Private
Public Access
1
0

Compare commits

..

24 Commits

Author SHA1 Message Date
268b01efa5 chore: bump version to 1.1.9 2026-06-09 13:05:59 -05:00
4cac290502 fix: enable services, fix config parsing, make migrations idempotent (#62) 2026-06-09 13:04:11 -05:00
ec41091721 ci: update actions for Node.js 24 compatibility (#61)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 7s
CI Pipeline / Clippy Lints (push) Successful in 50s
CI Pipeline / Rust Unit Tests (push) Failing after 1m28s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 12:49:37 -05:00
26f87ebc20 chore: bump version to 1.1.8 (#60)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m25s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 12:02:50 -05:00
a1a8eab41a fix(postinst): surgical upgrade/fresh-install handling (#59)
- write_config(): Replace CHANGEME placeholder on upgrade instead of
  skipping entirely; preserve existing real passwords unchanged
- setup_database(): When DB user already exists, recover password from
  existing config and sync to PostgreSQL, or generate a fresh password;
  fixes crash-loop when config password diverges from PostgreSQL
- generate_jwt_keys(): Regenerate missing verify.pem from existing
  signing.pem instead of silently skipping
- Password extraction uses @localhost anchor to correctly handle
  passwords containing @ characters
2026-06-09 11:47:22 -05:00
b2ea6b1f7a chore: bump version to 1.1.7 (#58)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 2s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m29s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 09:27:34 -05:00
592ff6a7ee fix(postinst): thorough audit - fix argon2 salt and verify all password generation logic (#57) 2026-06-09 09:10:31 -05:00
0c0f952f7f chore: bump version to 1.1.6 (#56)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Failing after 1m27s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-09 08:21:20 -05:00
2a18276884 fix(postinst): correct argon2 -m parameter from raw KiB to log2 value (#55)
* chore: bump version to 1.1.5

* fix(postinst): correct argon2 -m parameter from raw KiB to log2 value

* trigger CI
2026-06-09 08:10:00 -05:00
2bdbc8af5a fix(ci): remove arm64 from Docker platforms and add timeout (#53)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Failing after 1m58s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-08 20:15:13 -05:00
87bd5d2162 fix: remove duplicate version display from sidebar toolbar (#52)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 2s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m53s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-08 17:54:25 -05:00
836d409e3b feat: add version display to sidebar and bump to v1.1.4 (#51)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 7s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m40s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-08 17:44:20 -05:00
e17b740415 fix(docker): complete Dockerfile audit - migrations, deps, openssl (#49)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m46s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
Three issues fixed in the multi-stage Docker build:

1. CRITICAL: Add COPY migrations/ to rust-builder stage
   - sqlx::migrate!(../../migrations) is a compile-time proc macro
   - Without migrations/ present, cargo build fails with 'no such file or directory'
   - Previously migrations/ was only copied in runtime stage (too late)

2. Copy individual crate Cargo.toml files for dependency caching
   - The dummy-build caching step only copied workspace Cargo.toml/Cargo.lock
   - Without crate-level manifests, cargo couldn't resolve the workspace
   - This meant the cache layer was ineffective (rebuilt everything on code changes)

3. Add openssl package to runtime stage
   - entrypoint.sh uses openssl rand, openssl genpkey, openssl pkey
   - Only libssl3t64 (shared library) was installed, not the CLI tool
   - Runtime would fail on first-run key generation

All stages verified: Ubuntu 24.04   Rust via rustup (1.85+) 
2026-06-07 22:59:32 -05:00
0d151d36b9 chore: bump version to 1.1.2 (#48)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m22s
CI Pipeline / Security Audit (push) Successful in 6s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-07 22:08:55 -05:00
4fbcf3d35a fix(docker): use Ubuntu 24.04 throughout all Dockerfile stages (#47)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m21s
CI Pipeline / Security Audit (push) Successful in 6s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 16s
CI Pipeline / Build .deb & Release (push) Has been skipped
- Stage 1 (Rust): Replace rust:1.85-bookworm with ubuntu:24.04 + rustup stable
- Stage 2 (Frontend): Replace node:20-bookworm-slim with ubuntu:24.04 + NodeSource Node.js 20
- Stage 3 (Runtime): Already ubuntu:24.04 with libssl3t64 (verified correct)
- docker-compose: Change postgres:16-bookworm to postgres:16 (standard image)

This aligns Docker builds with the project's target OS (Ubuntu 24.04) and
matches the CI environment which runs on ubuntu-latest (24.04).
2026-06-07 21:19:07 -05:00
6d4ec8c9ac chore: bump version to 1.1.1 (#45)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m58s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-07 20:14:45 -05:00
bf91b3c6d2 fix(docker): use ubuntu:24.04 runtime instead of debian:bookworm-slim (#44)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m54s
CI Pipeline / Security Audit (push) Successful in 7s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 17s
CI Pipeline / Build .deb & Release (push) Has been skipped
* fix(docker): add PostgreSQL APT repo for postgresql-client-16

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)

* fix(docker): use ubuntu:24.04 runtime instead of debian:bookworm-slim

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 18:55:45 -05:00
2d3be0955b chore: bump version to 1.1.0 (#43)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Failing after 1m25s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
2026-06-07 17:01:01 -05:00
a5343760e1 feat: Automated install, Docker deployment, and CI Docker job (#42)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m20s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
- debian/control: add Pre-Depends and Depends on postgresql-16, argon2
- debian/postinst: idempotent automation for PostgreSQL setup,
  DB/user creation, migration tracking, admin password generation,
  config write, and service enable/start
- Dockerfile: multi-stage build (Rust + frontend + slim runtime)
- docker/entrypoint.sh: first-run DB wait, migrations, admin password
- docker-compose.yml: split db/app architecture with healthcheck
- .env.example: template for DB_PASSWORD and TAG
- .dockerignore: exclude build artifacts from Docker context
- .github/workflows/ci.yml: add Docker job for multi-arch
  (amd64/arm64) GHCR push on tag releases with layer caching
- .gitignore: add .env entry
2026-06-07 16:20:08 -05:00
209480dd43 Release v1.0.0 (#41)
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m26s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
* chore: bump version to 1.0.0

* fix: BusyBox-compatible timing and set -e safety in shell scripts
2026-06-07 13:27:21 -05:00
5fa1fef6c8 fix: remove committed private keys and add gitleaks CI
Some checks failed
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Failing after 1m31s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
- Remove all cert files from git tracking (git rm --cached)
  - crates/pm-agent-client/certs/client.key (private key)
  - crates/pm-agent-client/certs/client.crt (public cert)
  - crates/pm-agent-client/certs/ca.crt (public cert)
- Add .gitignore patterns for *.key, *.key.pem, certs/*.crt, certs/*.pem
- Update pm-agent-client doc examples to use std::fs::read() instead of include_bytes!
- Add gitleaks secret scanning job to CI workflow
- Update security-review.md with critical finding for Issue #12
- Add README.md to crates/pm-agent-client/certs/ explaining runtime cert generation

Private keys were dev/test only - no production key rotation needed.
Git history purge with filter-repo will follow after PR merge.

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-06 13:20:52 -05:00
e6dd1b8489 test: add authz gate integration tests (closes #15)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Successful in 1m56s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
* test: add authz gate integration tests (closes #15)

* fix: separate authz gate 403 tests from DB-dependent tests

---------

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-06 11:18:11 -05:00
dd6961265d chore: bump version to 0.2.4
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 19s
CI Pipeline / Build .deb & Release (push) Successful in 3m41s
CI Pipeline / Security Audit (push) Successful in 5s
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-06 00:04:08 -05:00
40ba483d35 fix: add ca_chain and crl_pem to EnrollmentStatusResponse
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 23:57:23 -05:00
37 changed files with 2111 additions and 558 deletions

40
.dockerignore Normal file
View File

@ -0,0 +1,40 @@
# Build artifacts
target/
*.deb
package-build/
# Frontend build output (rebuilt in Docker)
frontend/dist/
frontend/node_modules/
# Git
.git/
.gitignore
# IDE
.vscode/
.idea/
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Environment
.env
.env.*
# Documentation
docs/
# Agent Zero project data
.a0proj/
# Python
venv/
__pycache__/
# Misc
*.md
!README.md
LICENSE

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
# Linux Patch Manager — Docker Environment Variables
# Copy this file to .env and edit the values before running docker compose up.
# Required: PostgreSQL password for the patch_manager user
DB_PASSWORD=changeme-to-a-strong-password
# Optional: Docker image tag (defaults to 'latest' if not set)
TAG=latest

View File

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

11
.gitignore vendored
View File

@ -28,5 +28,12 @@ frontend/dist
*.deb
package-build/
# TLS certificates - generated on first run
crates/pm-agent-client/certs/
# Docker environment
.env
# Private key material - NEVER commit
*.key
*.key.pem
crates/pm-agent-client/certs/*.crt
crates/pm-agent-client/certs/*.key
crates/pm-agent-client/certs/*.pem

82
Cargo.lock generated
View File

@ -139,6 +139,16 @@ dependencies = [
"syn",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@ -475,6 +485,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -2043,7 +2062,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "migrate-secrets"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"hex",
@ -2096,6 +2115,31 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mockito"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
dependencies = [
"assert-json-diff",
"bytes",
"colored",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"log",
"pin-project-lite",
"rand 0.9.4",
"regex",
"serde_json",
"serde_urlencoded",
"similar",
"tokio",
]
[[package]]
name = "moxcms"
version = "0.8.1"
@ -2548,7 +2592,7 @@ dependencies = [
[[package]]
name = "pm-agent-client"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -2565,7 +2609,7 @@ dependencies = [
[[package]]
name = "pm-auth"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"argon2",
@ -2593,7 +2637,7 @@ dependencies = [
[[package]]
name = "pm-ca"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -2617,7 +2661,7 @@ dependencies = [
[[package]]
name = "pm-core"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"aes-gcm",
"anyhow",
@ -2641,7 +2685,7 @@ dependencies = [
[[package]]
name = "pm-reports"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -2661,7 +2705,7 @@ dependencies = [
[[package]]
name = "pm-web"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"axum",
@ -2672,14 +2716,17 @@ dependencies = [
"dashmap 6.1.0",
"governor 0.6.3",
"hex",
"http-body-util",
"ipnet",
"jsonwebtoken",
"lettre",
"mockito",
"pm-auth",
"pm-ca",
"pm-core",
"pm-reports",
"rand 0.8.6",
"rcgen",
"reqwest",
"rustls",
"serde",
@ -2688,6 +2735,7 @@ dependencies = [
"sqlx",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"tower",
"tower-http",
@ -2702,7 +2750,7 @@ dependencies = [
[[package]]
name = "pm-worker"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -3082,6 +3130,18 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
@ -3521,6 +3581,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simple_asn1"
version = "0.6.4"

View File

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

141
Dockerfile Normal file
View File

@ -0,0 +1,141 @@
# =============================================================================
# Linux Patch Manager — Multi-stage Docker Build
# =============================================================================
# Build: docker build -t linux-patch-manager .
# Run: docker compose up
# =============================================================================
# ---------------------------------------------------------------------------
# Stage 1: Rust build (Ubuntu 24.04 + rustup)
# ---------------------------------------------------------------------------
FROM ubuntu:24.04 AS rust-builder
ENV DEBIAN_FRONTEND=noninteractive
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
RUN echo 'fn main(){}' > crates/pm-web/src/main.rs \
&& echo 'fn main(){}' > crates/pm-worker/src/main.rs \
&& echo '' > crates/pm-core/src/lib.rs \
&& echo '' > crates/pm-agent-client/src/lib.rs \
&& echo '' > crates/pm-auth/src/lib.rs \
&& echo '' > crates/pm-ca/src/lib.rs \
&& echo '' > crates/pm-reports/src/lib.rs \
&& echo 'fn main(){}' > crates/migrate-secrets/src/main.rs
RUN cargo build --release 2>/dev/null || true
# Now build the real project
COPY crates/ crates/
COPY migrations/ migrations/
RUN cargo build --release
# Verify binaries exist
RUN ls -la target/release/pm-web target/release/pm-worker
# Strip debug symbols
RUN strip target/release/pm-web target/release/pm-worker
# ---------------------------------------------------------------------------
# Stage 2: Frontend build (Ubuntu 24.04 + Node.js 20)
# ---------------------------------------------------------------------------
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/*
WORKDIR /usr/src/app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --production=false
COPY frontend/ ./
RUN npm run build
# ---------------------------------------------------------------------------
# Stage 3: Runtime
# ---------------------------------------------------------------------------
FROM ubuntu:24.04 AS runtime
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3t64 \
libfontconfig1 \
openssl \
postgresql-client-16 \
argon2 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create service user
RUN useradd --system --no-create-home --shell /usr/sbin/nologin \
--comment "Linux Patch Manager service account" patch-manager
# Create directories
RUN mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \
/etc/patch-manager/jwt /etc/patch-manager/tls \
/var/log/patch-manager /opt/patch-manager \
/usr/share/patch-manager/frontend \
/usr/share/patch-manager/migrations
# Copy binaries
COPY --from=rust-builder /usr/src/app/target/release/pm-web /usr/local/bin/pm-web
COPY --from=rust-builder /usr/src/app/target/release/pm-worker /usr/local/bin/pm-worker
# Copy frontend
COPY --from=frontend-builder /usr/src/app/frontend/dist/ /usr/share/patch-manager/frontend/
# Copy migrations
COPY migrations/ /usr/share/patch-manager/migrations/
# Copy entrypoint
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 755 /usr/local/bin/entrypoint.sh
# Copy config template
COPY config/config.example.toml /usr/share/patch-manager/config.example.toml
# Set ownership
RUN chown -R patch-manager:patch-manager \
/etc/patch-manager /var/log/patch-manager \
/opt/patch-manager /usr/share/patch-manager
# Expose HTTPS port
EXPOSE 443
# Volume for persistent data
VOLUME ["/etc/patch-manager", "/var/log/patch-manager", "/opt/patch-manager"]
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

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

@ -0,0 +1,31 @@
# Agent Client Certificates
**⚠️ Private keys are NOT committed to version control.**
This directory holds mTLS certificates used by `pm-agent-client` for testing.
The entire directory is excluded from git via `.gitignore`.
## Generating Test Certificates
Certificates are generated automatically on first run by the `pm-ca` service,
or you can generate them manually for development:
```bash
# Create certs directory if it doesn't exist
mkdir -p crates/pm-agent-client/certs
# Generate using the pm-ca service (preferred)
# Or copy from /etc/patch-manager/certs/ on a deployed host
```
## Production Deployment
Production certificates are managed by `pm-ca` at `/etc/patch-manager/certs/`.
The `pm-agent-client` reads certificates from file paths configured in
`config.toml` (`agent_client_cert_path`, `agent_client_key_path`, `ca_cert_path`).
## Security
- **Never commit private keys** (`*.key`, `*.key.pem`) to version control
- The `gitleaks` CI check scans for accidentally committed secrets
- See `SECURITY.md` and `docs/security-review.md` for full details

View File

@ -1,12 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
-----END CERTIFICATE-----

View File

@ -1,12 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
-----END CERTIFICATE-----

View File

@ -1,19 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAuTfH0/Z2HT49DfHT
49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49Df
HT49DfHT49DfHT49DfHT49DfHQIDAQABAkEArWvK64P1/x9P2dh0+PQ3x0+PQ3x0
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ
3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x
0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
-----END PRIVATE KEY-----

11
crates/pm-agent-client/src/client.rs Executable file → Normal file
View File

@ -6,12 +6,17 @@
//! use pm_agent_client::client::AgentClient;
//!
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
//! // Load certificates from files (never hardcode or include_bytes! private keys)
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
//!
//! let client = AgentClient::new(
//! "192.168.1.10",
//! 12443,
//! include_bytes!("../certs/client.crt"),
//! include_bytes!("../certs/client.key"),
//! include_bytes!("../certs/ca.crt"),
//! &client_cert,
//! &client_key,
//! &ca_cert,
//! )?;
//!
//! let health = client.health().await?;

11
crates/pm-agent-client/src/lib.rs Executable file → Normal file
View File

@ -10,12 +10,17 @@
//! use pm_agent_client::AgentClient;
//!
//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> {
//! // Load certificates from files (never hardcode or include_bytes! private keys)
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
//!
//! let client = AgentClient::new(
//! "10.0.1.5",
//! 12443,
//! include_bytes!("../certs/client.crt"),
//! include_bytes!("../certs/client.key"),
//! include_bytes!("../certs/ca.crt"),
//! &client_cert,
//! &client_key,
//! &ca_cert,
//! )?;
//!
//! let health = client.health().await?;

View File

@ -101,7 +101,8 @@ 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
/// Worker heartbeat interval in seconds (default: 300 = 5 min)
#[serde(default = "default_heartbeat_interval")]
pub heartbeat_interval_secs: u64,
/// WS relay HTTP polling fallback interval in seconds (default: 10)
pub ws_relay_poll_interval_secs: u64,
@ -255,6 +256,10 @@ 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()
}

View File

@ -178,8 +178,10 @@ pub enum EnrollmentStatusResponse {
Pending,
Approved {
ca_crt: String,
ca_chain: String,
server_crt: String,
server_key: String,
crl_pem: String,
},
Denied,
NotFound,

View File

@ -5,6 +5,10 @@ edition.workspace = true
authors.workspace = true
license.workspace = true
[lib]
name = "pm_web"
path = "src/lib.rs"
[[bin]]
name = "pm-web"
path = "src/main.rs"
@ -46,4 +50,9 @@ url = { workspace = true }
urlencoding = "2"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
mockito = "1"
tempfile = "3"
rcgen = { workspace = true }
time = { workspace = true }

248
crates/pm-web/src/lib.rs Normal file
View File

@ -0,0 +1,248 @@
//! pm-web — Linux Patch Manager web server (library crate).
//!
//! Re-exports [`AppState`], [`build_router`], and [`health_handler`] so that
//! integration tests can construct a test application without depending on
//! the binary entry-point.
pub mod routes;
pub mod secret_key;
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
use dashmap::DashMap;
use pm_auth::{
password::hash_password,
rbac::{require_auth, AuthConfig},
};
use pm_core::{config::AppConfig, models::ApprovedEntry, request_id::request_id_middleware};
use rand::Rng;
use routes::sso::{OidcCache, SsoHandoff, SsoSession};
use routes::ws::WsTicket;
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_governor::{
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
};
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
/// Placeholder Argon2id hash prefix used in the seed admin migration (issue #8).
/// Detecting this prefix means the admin password has not been bootstrapped yet.
const ADMIN_PLACEHOLDER_HASH_PREFIX: &str = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA";
/// Bootstrap the default admin account with a random password.
///
/// On first startup after a fresh install, the `users` table contains the seed
/// admin row with a clearly-invalid placeholder hash (cannot validate any password).
/// This function detects that placeholder, generates a cryptographically random
/// 24-character password, hashes it with Argon2id, and UPDATEs the admin row.
///
/// The plaintext password is printed **once** to stderr (visible in `systemctl status`
/// or `journalctl`) and is never stored on disk.
///
/// If the admin row already has a real hash, this function is a no-op.
pub async fn bootstrap_admin_password(pool: &sqlx::PgPool) {
let result: Option<String> = sqlx::query_scalar(
"SELECT password_hash FROM users WHERE username = 'admin' AND auth_provider = 'local'",
)
.fetch_optional(pool)
.await
.unwrap_or(None);
let current_hash = match result {
Some(h) => h,
None => return,
};
if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) {
return;
}
let password: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(24)
.map(char::from)
.collect();
let new_hash = match hash_password(&password) {
Ok(h) => h,
Err(e) => {
tracing::error!(error = %e, "Failed to hash bootstrap admin password");
return;
},
};
let rows = sqlx::query(
r#"UPDATE users
SET password_hash = $1
WHERE username = 'admin'
AND auth_provider = 'local'
AND password_hash LIKE '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'"#,
)
.bind(&new_hash)
.execute(pool)
.await;
match rows {
Ok(result) if result.rows_affected() == 1 => {
eprintln!();
eprintln!("========================================");
eprintln!(" INITIAL ADMIN PASSWORD (shown once)");
eprintln!(" Username: admin");
eprintln!(" Password: {}", password);
eprintln!();
eprintln!(" You will be forced to change this on first login.");
eprintln!(" If lost, restart the service to generate a new one.");
eprintln!("========================================");
eprintln!();
tracing::info!("Bootstrap admin password generated and set");
},
Ok(_) => {
tracing::info!("Admin password already bootstrapped (concurrent or prior)");
},
Err(e) => {
tracing::error!(error = %e, "Failed to update admin password hash");
},
}
}
/// Shared application state threaded through Axum.
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool,
pub config: Arc<AppConfig>,
pub signing_key_pem: String,
pub auth_config: Arc<AuthConfig>,
/// In-memory store for single-use WebSocket authentication tickets.
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// In-memory store for SSO PKCE sessions (state → code_verifier).
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
/// In-memory store for SSO handoff codes (single-use, 60s TTL).
/// See `tasks/sso-token-handoff-spec.md` §4.1.
pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
pub oidc_cache: Arc<Mutex<OidcCache>>,
/// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>,
/// Short-lived cache for approved enrollment PKI bundles.
///
/// Entries are single-use (removed on retrieval) and expire after
/// [`ENROLLMENT_BUNDLE_TTL_SECS`](pm_core::models::ENROLLMENT_BUNDLE_TTL_SECS).
pub approved_enrollments: Arc<DashMap<String, ApprovedEntry>>,
}
/// Construct the full Axum router.
pub fn build_router(state: AppState) -> Router {
let static_dir = state.config.server.static_dir.clone();
let auth_config = state.auth_config.clone();
let rl = &state.config.rate_limit;
// Enrollment rate limiting: strict (5 req/min per IP, burst 3)
let enrollment_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(12_000)
.burst_size(rl.enrollment_burst)
.finish()
.expect("Invalid enrollment governor config"),
);
// Auth rate limiting: moderate (20 req/min per IP, burst 10)
let auth_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(3_000)
.burst_size(rl.auth_burst)
.finish()
.expect("Invalid auth governor config"),
);
// API rate limiting: normal (120 req/min per IP, burst 30)
let api_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(500)
.burst_size(rl.api_burst)
.finish()
.expect("Invalid API governor config"),
);
// Enrollment routes with strict per-IP rate limiting
let enrollment_router =
routes::enrollment::router().layer(GovernorLayer::new(enrollment_governor));
// Public auth routes with moderate per-IP rate limiting
let auth_public_router =
routes::auth::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
// SSO routes with moderate per-IP rate limiting
let sso_public_router =
routes::sso::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
let sso_azure_router =
routes::sso::azure_compat_router().layer(GovernorLayer::new(auth_governor));
// All protected API routes — require valid JWT, with normal per-IP rate limiting
let protected_api = Router::new()
.nest("/auth", routes::auth::protected_router())
.nest("/hosts", routes::hosts::router())
.nest("/hosts", routes::ca::host_cert_router())
.nest("/groups", routes::groups::router())
.nest("/users", routes::users::router())
.nest("/discovery", routes::discovery::router())
.nest("/status", routes::status::router())
.nest("/jobs", routes::jobs::router())
.nest(
"/hosts/{host_id}/maintenance-windows",
routes::maintenance_windows::router(),
)
.nest(
"/maintenance-windows",
routes::maintenance_windows::all_windows_router(),
)
.nest("/ca", routes::ca::ca_router())
.nest("/certificates", routes::ca::certs_router())
.merge(routes::ws::ticket_router())
.nest("/reports", routes::reports::router())
.nest(
"/hosts/{host_id}/health-checks",
routes::health_checks::router(),
)
.nest("/settings", routes::settings::router())
.nest("/admin", routes::enrollment::admin_router())
.layer(GovernorLayer::new(api_governor))
.route_layer(middleware::from_fn(move |req, next| {
let auth_config = auth_config.clone();
require_auth(auth_config, req, next)
}));
Router::new()
.route("/status/health", get(health_handler))
.nest("/api/v1/auth", auth_public_router)
.nest("/api/v1", enrollment_router)
.nest("/api/v1", routes::pki::router())
.nest("/api/v1/auth/sso", sso_public_router)
.nest("/api/v1/auth/azure", sso_azure_router)
.nest("/api/v1", protected_api)
.merge(routes::ws::ws_router())
.fallback_service(
ServeDir::new(&static_dir)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(format!("{}/index.html", static_dir))),
)
.layer(middleware::from_fn(request_id_middleware))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
pub async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
let status = if db_ok { "healthy" } else { "degraded" };
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
if db_ok {
Ok(Json(body))
} else {
Err(StatusCode::SERVICE_UNAVAILABLE)
}
}

View File

@ -1,145 +1,13 @@
//! pm-web — Linux Patch Manager web server.
//! pm-web — Linux Patch Manager web server (binary entry-point).
mod routes;
mod secret_key;
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
use axum_server::tls_rustls::RustlsConfig;
use dashmap::DashMap;
use pm_auth::{
jwt,
password::hash_password,
rbac::{require_auth, AuthConfig},
};
use pm_core::{
config::AppConfig, db, logging, models::ApprovedEntry, request_id::request_id_middleware,
};
use rand::Rng;
use routes::sso::{OidcCache, SsoHandoff, SsoSession};
use routes::ws::WsTicket;
use serde_json::{json, Value};
use pm_auth::{jwt, rbac::AuthConfig};
use pm_core::{config::AppConfig, db, models::ApprovedEntry};
use pm_web::routes::sso::{OidcCache, SsoHandoff, SsoSession};
use pm_web::routes::ws::WsTicket;
use pm_web::{bootstrap_admin_password, build_router, AppState};
use std::{net::SocketAddr, sync::Arc, time::Duration};
use tokio::sync::Mutex;
use tower_governor::{
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
};
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
/// Placeholder Argon2id hash prefix used in the seed admin migration (issue #8).
/// Detecting this prefix means the admin password has not been bootstrapped yet.
const ADMIN_PLACEHOLDER_HASH_PREFIX: &str = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA";
/// Bootstrap the default admin account with a random password.
///
/// On first startup after a fresh install, the `users` table contains the seed
/// admin row with a clearly-invalid placeholder hash (cannot validate any password).
/// This function detects that placeholder, generates a cryptographically random
/// 24-character password, hashes it with Argon2id, and UPDATEs the admin row.
///
/// The plaintext password is printed **once** to stderr (visible in `systemctl status`
/// or `journalctl`) and is never stored on disk.
///
/// If the admin row already has a real hash, this function is a no-op.
async fn bootstrap_admin_password(pool: &sqlx::PgPool) {
// Check if the admin account still has the placeholder hash.
let result: Option<String> = sqlx::query_scalar(
"SELECT password_hash FROM users WHERE username = 'admin' AND auth_provider = 'local'",
)
.fetch_optional(pool)
.await
.unwrap_or(None);
let current_hash = match result {
Some(h) => h,
None => return, // No admin row — nothing to bootstrap.
};
if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) {
// Admin already has a real password — nothing to do.
return;
}
// Generate a 24-character random alphanumeric password.
let password: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(24)
.map(char::from)
.collect();
// Hash it with the application's Argon2id parameters.
let new_hash = match hash_password(&password) {
Ok(h) => h,
Err(e) => {
tracing::error!(error = %e, "Failed to hash bootstrap admin password");
return;
},
};
// Replace the placeholder hash with the real one.
// The WHERE clause matches the placeholder prefix to ensure idempotency.
let rows = sqlx::query(
r#"UPDATE users
SET password_hash = $1
WHERE username = 'admin'
AND auth_provider = 'local'
AND password_hash LIKE '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'"#,
)
.bind(&new_hash)
.execute(pool)
.await;
match rows {
Ok(result) if result.rows_affected() == 1 => {
eprintln!();
eprintln!("========================================");
eprintln!(" INITIAL ADMIN PASSWORD (shown once)");
eprintln!(" Username: admin");
eprintln!(" Password: {}", password);
eprintln!();
eprintln!(" You will be forced to change this on first login.");
eprintln!(" If lost, restart the service to generate a new one.");
eprintln!("========================================");
eprintln!();
tracing::info!("Bootstrap admin password generated and set");
},
Ok(_) => {
// Rows affected != 1 — concurrent bootstrap or already replaced.
tracing::info!("Admin password already bootstrapped (concurrent or prior)");
},
Err(e) => {
tracing::error!(error = %e, "Failed to update admin password hash");
},
}
}
/// Shared application state threaded through Axum.
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool,
pub config: Arc<AppConfig>,
pub signing_key_pem: String,
pub auth_config: Arc<AuthConfig>,
/// In-memory store for single-use WebSocket authentication tickets.
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// In-memory store for SSO PKCE sessions (state → code_verifier).
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
/// In-memory store for SSO handoff codes (single-use, 60s TTL).
/// See `tasks/sso-token-handoff-spec.md` §4.1.
pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
pub oidc_cache: Arc<Mutex<OidcCache>>,
/// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>,
/// Short-lived cache for approved enrollment PKI bundles.
///
/// Entries are single-use (removed on retrieval) and expire after
/// [`ENROLLMENT_BUNDLE_TTL_SECS`](pm_core::models::ENROLLMENT_BUNDLE_TTL_SECS).
pub approved_enrollments: Arc<DashMap<String, ApprovedEntry>>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@ -156,7 +24,7 @@ async fn main() -> anyhow::Result<()> {
AppConfig::default()
});
logging::init(&config.logging);
pm_core::logging::init(&config.logging);
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
"patch-manager-web starting"
@ -183,12 +51,10 @@ async fn main() -> anyhow::Result<()> {
let pool = db::init_pool(&config.database).await?;
db::run_migrations(&pool).await?;
// Bootstrap admin password if the seed admin still has the placeholder hash (issue #8).
// Bootstrap admin password if the seed admin still has the placeholder hash.
bootstrap_admin_password(&pool).await;
// Initialise the internal CA using the configured certificate paths.
// The CA certificate and key must exist at the configured locations and be
// unencrypted PEM. If absent, a new CA is generated in that directory.
let ca_base = std::path::Path::new(&config.security.ca_cert_path)
.parent()
.expect("CA certificate path must have a parent directory");
@ -223,7 +89,7 @@ async fn main() -> anyhow::Result<()> {
});
}
// Background task: purge expired SSO sessions every 60 seconds (sessions older than 10 minutes).
// Background task: purge expired SSO sessions every 60 seconds.
{
let sessions = sso_sessions.clone();
tokio::spawn(async move {
@ -243,8 +109,6 @@ async fn main() -> anyhow::Result<()> {
}
// Background task: purge expired approved enrollment PKI bundles.
// Entries are also removed on first retrieval (single-use), so this
// task only cleans up bundles that were never picked up by the agent.
{
let approved = approved_enrollments.clone();
tokio::spawn(async move {
@ -262,9 +126,6 @@ async fn main() -> anyhow::Result<()> {
}
// Background task: purge expired SSO handoff codes every 60 seconds.
// See `tasks/sso-token-handoff-spec.md` §4.3. Handoffs are also
// atomically removed on exchange (single-use), so this task only
// cleans up codes that the SPA never POSTed back for.
{
let handoffs = sso_handoffs.clone();
tokio::spawn(async move {
@ -306,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
let tls_key = std::path::Path::new(&config.security.web_tls_key_path);
if tls_cert.exists() && tls_key.exists() {
let tls_config = RustlsConfig::from_pem_file(
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
&config.security.web_tls_cert_path,
&config.security.web_tls_key_path,
)
@ -338,149 +199,3 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
/// Construct the full Axum router.
pub fn build_router(state: AppState) -> Router {
let static_dir = state.config.server.static_dir.clone();
let auth_config = state.auth_config.clone();
let rl = &state.config.rate_limit;
// Enrollment rate limiting: strict (5 req/min per IP, burst 3)
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
// governor quota: 1 request per 12_000ms = ~5/min sustained
let enrollment_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(12_000)
.burst_size(rl.enrollment_burst)
.finish()
.expect("Invalid enrollment governor config"),
);
// Auth rate limiting: moderate (20 req/min per IP, burst 10)
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
// governor quota: 1 request per 3_000ms = ~20/min sustained
let auth_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(3_000)
.burst_size(rl.auth_burst)
.finish()
.expect("Invalid auth governor config"),
);
// API rate limiting: normal (120 req/min per IP, burst 30)
// Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy.
// governor quota: 1 request per 500ms = ~120/min sustained
let api_governor = Arc::new(
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_millisecond(500)
.burst_size(rl.api_burst)
.finish()
.expect("Invalid API governor config"),
);
// Enrollment routes with strict per-IP rate limiting
let enrollment_router =
routes::enrollment::router().layer(GovernorLayer::new(enrollment_governor));
// Public auth routes with moderate per-IP rate limiting
let auth_public_router =
routes::auth::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
// SSO routes with moderate per-IP rate limiting
let sso_public_router =
routes::sso::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor)));
let sso_azure_router =
routes::sso::azure_compat_router().layer(GovernorLayer::new(auth_governor));
// All protected API routes — require valid JWT, with normal per-IP rate limiting
let protected_api = Router::new()
// Auth: MFA setup/verify
// Auth: MFA setup/verify/disable (nested under /auth so paths are /api/v1/auth/mfa/*)
.nest("/auth", routes::auth::protected_router())
// Hosts
.nest("/hosts", routes::hosts::router())
// Host-scoped certificate endpoints (merged separately to avoid conflict)
.nest("/hosts", routes::ca::host_cert_router())
// Groups
.nest("/groups", routes::groups::router())
// Users
.nest("/users", routes::users::router())
// Discovery
.nest("/discovery", routes::discovery::router())
// Fleet status
.nest("/status", routes::status::router())
// Patch jobs
.nest("/jobs", routes::jobs::router())
// Maintenance windows (nested under hosts path param)
.nest(
"/hosts/{host_id}/maintenance-windows",
routes::maintenance_windows::router(),
)
// Maintenance windows — bulk list-all endpoint
.nest(
"/maintenance-windows",
routes::maintenance_windows::all_windows_router(),
)
// CA root certificate download
.nest("/ca", routes::ca::ca_router())
// Certificate list / renew / revoke
.nest("/certificates", routes::ca::certs_router())
// WS ticket issuance (JWT-protected — ticket returned to browser, then used for WS upgrade)
.merge(routes::ws::ticket_router())
// Reports
.nest("/reports", routes::reports::router())
.nest(
"/hosts/{host_id}/health-checks",
routes::health_checks::router(),
)
// Settings (admin-only)
.nest("/settings", routes::settings::router())
// Admin enrollment routes (JWT protected, Admin role enforced)
.nest("/admin", routes::enrollment::admin_router())
// Apply rate limiting then auth middleware
.layer(GovernorLayer::new(api_governor))
.route_layer(middleware::from_fn(move |req, next| {
let auth_config = auth_config.clone();
require_auth(auth_config, req, next)
}));
Router::new()
.route("/status/health", get(health_handler))
// Public auth routes (rate-limited, no JWT)
.nest("/api/v1/auth", auth_public_router)
// Public enrollment endpoints (rate-limited, no JWT)
.nest("/api/v1", enrollment_router)
// Public PKI endpoints (CRL distribution, no JWT — CRLs are self-authenticating)
.nest("/api/v1", routes::pki::router())
// Public SSO routes (rate-limited, no JWT)
.nest("/api/v1/auth/sso", sso_public_router)
// Public Azure SSO routes (rate-limited, no JWT)
.nest("/api/v1/auth/azure", sso_azure_router)
// Protected API routes (JWT required, rate-limited)
.nest("/api/v1", protected_api)
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware
.merge(routes::ws::ws_router())
// Serve React SPA
.fallback_service(
ServeDir::new(&static_dir)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(format!("{}/index.html", static_dir))),
)
.layer(middleware::from_fn(request_id_middleware))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
let status = if db_ok { "healthy" } else { "degraded" };
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
if db_ok {
Ok(Json(body))
} else {
Err(StatusCode::SERVICE_UNAVAILABLE)
}
}

View File

@ -111,8 +111,10 @@ async fn enroll_status(
}
return Ok(Json(EnrollmentStatusResponse::Approved {
ca_crt: entry.pki.ca_crt.clone(),
ca_chain: entry.pki.ca_chain.clone(),
server_crt: entry.pki.server_crt.clone(),
server_key: entry.pki.server_key.clone(),
crl_pem: entry.pki.crl_pem.clone(),
}));
}

View File

@ -0,0 +1,530 @@
//! Integration tests for the authz gate that restricts auth config mutations
//! (OIDC, SMTP, IP whitelist) to the Admin role only.
//!
//! See Issue #15 for the full specification.
//!
//! ## Test organization
//!
//! The 403 (forbidden_role) tests verify that the authorization middleware
//! rejects non-admin roles BEFORE any handler or database logic runs. These
//! tests use a lazy PgPool (no live database required) and pre-generated CA
//! files, so they always pass in CI.
//!
//! The 200 (admin allowed) tests verify the full handler path including audit
//! logging. They require a live PostgreSQL database and are marked `#[ignore]`
//! so they only run when `DATABASE_URL` is set and `--ignored` is passed.
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Request, StatusCode};
use dashmap::DashMap;
use http_body_util::BodyExt;
use pm_auth::jwt;
use pm_auth::rbac::AuthConfig;
use pm_core::config::AppConfig;
use pm_web::routes::sso::OidcCache;
use pm_web::{build_router, AppState};
use serde_json::json;
use sqlx::PgPool;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower::ServiceExt;
use uuid::Uuid;
// ── Ed25519 test key pair ────────────────────────────────────────────────────
const TEST_SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIBrWiMMcgpPXwtGDSSBl01fcQyb5Vh4CMzEmxcSXvcrJ
-----END PRIVATE KEY-----
";
const TEST_VERIFY_KEY: &str = "-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEACgE6fMDCcG11NOpPKSO/ASpPUSntB7XsF5sBFBYDjFo=
-----END PUBLIC KEY-----
";
// ── Fixed test user IDs (so we can seed matching rows in the DB) ─────────────
const ADMIN_USER_ID: &str = "00000000-0000-4000-8000-000000000001";
const OPERATOR_USER_ID: &str = "00000000-0000-4000-8000-000000000002";
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Generate a valid JWT authorization header for the given role.
fn auth_header(role: &str) -> String {
let user_id = match role {
"admin" => Uuid::parse_str(ADMIN_USER_ID).unwrap(),
_ => Uuid::parse_str(OPERATOR_USER_ID).unwrap(),
};
let username = format!("test-{}", role);
let token = jwt::issue_access_token(user_id, &username, role, 900, TEST_SIGNING_KEY)
.expect("failed to issue test JWT");
format!("Bearer {}", token)
}
/// Generate CA key and cert files on disk so `CertAuthority::init` can load
/// them without needing a database connection.
fn generate_ca_files(ca_dir: &std::path::Path) {
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256,
};
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("generate CA key");
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params
.distinguished_name
.push(DnType::CommonName, "Test Root CA");
let cert = params.self_signed(&key).expect("self-sign CA cert");
std::fs::create_dir_all(ca_dir).expect("create CA dir");
std::fs::write(ca_dir.join("ca.key"), key.serialize_pem()).expect("write ca.key");
std::fs::write(ca_dir.join("ca.crt"), cert.pem()).expect("write ca.crt");
}
/// Build a minimal `AppState` suitable for 403 authz gate tests.
///
/// Uses a lazy PgPool (no live database connection required) and pre-generated
/// CA files. This works because the authorization middleware rejects non-admin
/// requests BEFORE any handler or database logic runs.
async fn setup_state_no_db() -> AppState {
let pool = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://test:test@localhost:5432/test")
.expect("failed to create lazy pool");
let mut config = AppConfig::default();
config.server.static_dir = "/tmp".to_string();
let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[]));
let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA");
let ca_dir_path = ca_dir.path().to_path_buf();
generate_ca_files(&ca_dir_path);
std::mem::forget(ca_dir);
let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool)
.await
.expect("CA init failed");
AppState {
db: pool,
config: Arc::new(config),
signing_key_pem: TEST_SIGNING_KEY.to_string(),
auth_config,
ws_tickets: Arc::new(DashMap::new()),
sso_sessions: Arc::new(DashMap::new()),
sso_handoffs: Arc::new(DashMap::new()),
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
ca: Arc::new(ca),
approved_enrollments: Arc::new(DashMap::new()),
}
}
/// Seed test users into the database so that audit_log foreign-key
/// constraints on `actor_user_id` are satisfied.
async fn seed_test_users(pool: &PgPool) {
let placeholder_hash = "$argon2id$v=19$m=65536,t=3,p=1$placeholder$placeholder";
for (user_id, username, role) in [
(ADMIN_USER_ID, "test-admin", "admin"),
(OPERATOR_USER_ID, "test-operator", "operator"),
] {
sqlx::query(
r#"INSERT INTO users (id, username, display_name, email, role, auth_provider, password_hash)
VALUES ($1, $2, $3, $4, $5::user_role, 'local', $6)
ON CONFLICT (id) DO NOTHING"#,
)
.bind(Uuid::parse_str(user_id).unwrap())
.bind(username)
.bind(username)
.bind(format!("{}@test.example.com", username))
.bind(role)
.bind(placeholder_hash)
.execute(pool)
.await
.expect("failed to seed test user");
}
}
/// Build a full `AppState` with a live database connection.
async fn setup_state(pool: PgPool) -> AppState {
seed_test_users(&pool).await;
let mut config = AppConfig::default();
config.server.static_dir = "/tmp".to_string();
let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[]));
let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA");
let ca_dir_path = ca_dir.path().to_path_buf();
std::mem::forget(ca_dir);
let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool)
.await
.expect("CA init failed");
AppState {
db: pool,
config: Arc::new(config),
signing_key_pem: TEST_SIGNING_KEY.to_string(),
auth_config,
ws_tickets: Arc::new(DashMap::new()),
sso_sessions: Arc::new(DashMap::new()),
sso_handoffs: Arc::new(DashMap::new()),
oidc_cache: Arc::new(Mutex::new(OidcCache::default())),
ca: Arc::new(ca),
approved_enrollments: Arc::new(DashMap::new()),
}
}
/// Send a request through the full Axum router and return the response.
async fn send_request(
state: AppState,
method: axum::http::Method,
uri: &str,
auth_header: Option<&str>,
body: Option<serde_json::Value>,
) -> (StatusCode, serde_json::Value) {
let router = build_router(state);
let mut builder = Request::builder().method(method).uri(uri);
if let Some(auth) = auth_header {
builder = builder.header("authorization", auth);
}
builder = builder.header("content-type", "application/json");
let req = if let Some(b) = body {
builder.body(Body::from(b.to_string())).unwrap()
} else {
builder.body(Body::empty()).unwrap()
};
// Insert ConnectInfo so tower_governor's SmartIpKeyExtractor can resolve the client IP.
let (mut parts, body) = req.into_parts();
parts
.extensions
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
let req = Request::from_parts(parts, body);
let resp = router.oneshot(req).await.unwrap();
let status = resp.status();
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_else(|_| {
let raw = String::from_utf8_lossy(&body_bytes);
json!({ "_raw": raw.to_string() })
});
(status, body_json)
}
// ═══════════════════════════════════════════════════════════════════════════
// 403 Forbidden Role tests — no database required
// ═══════════════════════════════════════════════════════════════════════════
//
// These tests verify that the authorization middleware rejects non-admin roles
// BEFORE any handler or database logic runs. They use a lazy PgPool and
// pre-generated CA files, so they always pass in CI.
/// 1. PUT /api/v1/settings with operator role → 403 forbidden_role
#[tokio::test]
async fn update_settings_operator_denied() {
let state = setup_state_no_db().await;
let auth = auth_header("operator");
let (status, body) = send_request(
state,
axum::http::Method::PUT,
"/api/v1/settings",
Some(&auth),
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"expected 403, got {}: {:?}",
status,
body
);
assert_eq!(body["error"]["code"], "forbidden_role");
}
/// 3. PUT /api/v1/settings/ip-whitelist with operator role → 403 forbidden_role
#[tokio::test]
async fn update_ip_whitelist_operator_denied() {
let state = setup_state_no_db().await;
let auth = auth_header("operator");
let (status, body) = send_request(
state,
axum::http::Method::PUT,
"/api/v1/settings/ip-whitelist",
Some(&auth),
Some(json!({ "entries": ["10.0.0.0/8"] })),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"expected 403, got {}: {:?}",
status,
body
);
assert_eq!(body["error"]["code"], "forbidden_role");
}
/// 5. POST /api/v1/settings/sso/discover with operator role → 403 forbidden_role
#[tokio::test]
async fn discover_oidc_operator_denied() {
let state = setup_state_no_db().await;
let auth = auth_header("operator");
let (status, body) = send_request(
state,
axum::http::Method::POST,
"/api/v1/settings/sso/discover",
Some(&auth),
Some(json!({ "discovery_url": "https://example.com/.well-known/openid-configuration" })),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"expected 403, got {}: {:?}",
status,
body
);
assert_eq!(body["error"]["code"], "forbidden_role");
}
/// 7. POST /api/v1/settings/sso/test with operator role → 403 forbidden_role
#[tokio::test]
async fn test_oidc_operator_denied() {
let state = setup_state_no_db().await;
let auth = auth_header("operator");
let (status, body) = send_request(
state,
axum::http::Method::POST,
"/api/v1/settings/sso/test",
Some(&auth),
None,
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"expected 403, got {}: {:?}",
status,
body
);
assert_eq!(body["error"]["code"], "forbidden_role");
}
// ═══════════════════════════════════════════════════════════════════════════
// 200 Admin Allowed tests — require live database
// ═══════════════════════════════════════════════════════════════════════════
//
// These tests verify the full handler path including audit logging.
// They require a live PostgreSQL database and are marked `#[ignore]` so they
// only run when DATABASE_URL is set and `--ignored` is passed.
/// 2. PUT /api/v1/settings with admin role → 200 + audit log
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
async fn update_settings_admin_allowed(pool: PgPool) {
let state = setup_state(pool).await;
let pool = state.db.clone();
let auth = auth_header("admin");
let (status, body) = send_request(
state,
axum::http::Method::PUT,
"/api/v1/settings",
Some(&auth),
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"expected 200, got {}: {:?}",
status,
body
);
let row: Option<(String,)> = sqlx::query_as(
"SELECT action::text FROM audit_log WHERE action::text = 'config_changed' ORDER BY created_at DESC LIMIT 1",
)
.fetch_optional(&pool)
.await
.expect("audit log query failed");
assert!(row.is_some(), "expected audit log entry for config_changed");
}
/// 4. PUT /api/v1/settings/ip-whitelist with admin role → 200 + audit log
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
async fn update_ip_whitelist_admin_allowed(pool: PgPool) {
let state = setup_state(pool).await;
let pool = state.db.clone();
let auth = auth_header("admin");
let (status, body) = send_request(
state,
axum::http::Method::PUT,
"/api/v1/settings/ip-whitelist",
Some(&auth),
Some(json!({ "entries": ["10.0.0.0/8"] })),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"expected 200, got {}: {:?}",
status,
body
);
let row: Option<(String,)> = sqlx::query_as(
"SELECT action::text FROM audit_log WHERE action::text = 'ip_whitelist_updated' ORDER BY created_at DESC LIMIT 1",
)
.fetch_optional(&pool)
.await
.expect("audit log query failed");
assert!(
row.is_some(),
"expected audit log entry for ip_whitelist_updated"
);
}
/// 6. POST /api/v1/settings/sso/discover with admin role → 200 + audit log
/// Uses mockito to simulate an OIDC discovery endpoint.
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
async fn discover_oidc_admin_allowed(pool: PgPool) {
let state = setup_state(pool).await;
let pool = state.db.clone();
let auth = auth_header("admin");
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/.well-known/openid-configuration")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"issuer": "https://mock-oidc.example.com",
"authorization_endpoint": "https://mock-oidc.example.com/auth",
"token_endpoint": "https://mock-oidc.example.com/token",
"jwks_uri": "https://mock-oidc.example.com/jwks",
"userinfo_endpoint": "https://mock-oidc.example.com/userinfo"
})
.to_string(),
)
.create_async()
.await;
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
let (status, body) = send_request(
state,
axum::http::Method::POST,
"/api/v1/settings/sso/discover",
Some(&auth),
Some(json!({ "discovery_url": discovery_url })),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"expected 200, got {}: {:?}",
status,
body
);
assert_eq!(body["success"], true);
mock.assert_async().await;
let row: Option<(String,)> = sqlx::query_as(
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_discover_performed' ORDER BY created_at DESC LIMIT 1",
)
.fetch_optional(&pool)
.await
.expect("audit log query failed");
assert!(
row.is_some(),
"expected audit log entry for oidc_discover_performed"
);
}
/// 8. POST /api/v1/settings/sso/test with admin role → 200 + audit log
/// Uses mockito to simulate an OIDC discovery endpoint.
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
async fn test_oidc_admin_allowed(pool: PgPool) {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/.well-known/openid-configuration")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"issuer": "https://mock-oidc.example.com",
"authorization_endpoint": "https://mock-oidc.example.com/auth",
"token_endpoint": "https://mock-oidc.example.com/token",
"jwks_uri": "https://mock-oidc.example.com/jwks"
})
.to_string(),
)
.create_async()
.await;
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
// Seed the oidc_config table with an enabled provider pointing to mockito.
sqlx::query("UPDATE oidc_config SET enabled = true, discovery_url = $1 WHERE id = 1")
.bind(&discovery_url)
.execute(&pool)
.await
.expect("failed to seed oidc_config");
let state = setup_state(pool).await;
let pool = state.db.clone();
let auth = auth_header("admin");
let (status, body) = send_request(
state,
axum::http::Method::POST,
"/api/v1/settings/sso/test",
Some(&auth),
None,
)
.await;
assert_eq!(
status,
StatusCode::OK,
"expected 200, got {}: {:?}",
status,
body
);
assert_eq!(body["success"], true);
mock.assert_async().await;
let row: Option<(String,)> = sqlx::query_as(
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_test_performed' ORDER BY created_at DESC LIMIT 1",
)
.fetch_optional(&pool)
.await
.expect("audit log query failed");
assert!(
row.is_some(),
"expected audit log entry for oidc_test_performed"
);
}

View File

@ -0,0 +1 @@
mod authz_gate;

60
debian/changelog vendored
View File

@ -1,3 +1,63 @@
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
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 16:47:03 -0500
linux-patch-manager (1.0.0-1) unstable; urgency=low
* Release v1.0.0
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 12:58:46 -0500
linux-patch-manager (0.1.9-1) noble; urgency=medium
* Fix: Replace broken DashMap rate limiting with tower-governor middleware

5
debian/control vendored
View File

@ -1,9 +1,10 @@
Package: linux-patch-manager
Version: 1.0.0-1
Version: 1.1.9-1
Architecture: amd64
Maintainer: Moon Dragon <echo@moon-dragon.us>
Installed-Size: 45000
Depends: postgresql-16, libssl3, libc6 (>= 2.39), libfontconfig1
Pre-Depends: postgresql-16
Depends: postgresql-16, argon2, libssl3, libc6 (>= 2.39), libfontconfig1
Recommends: postgresql-client-16, fonts-dejavu-core
Suggests: gpg
Section: admin

425
debian/postinst vendored
View File

@ -4,91 +4,394 @@ set -e
# =============================================================================
# Linux Patch Manager — Post-install script
# =============================================================================
# Fully automated: apt install ./linux-patch-manager_X.X.X-1_amd64.deb
# results in a running service with a printed admin password.
# All steps are idempotent (safe to re-run on upgrade).
# =============================================================================
case "$1" in
configure)
# Create service user if not exists
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
DB_NAME="patch_manager"
DB_USER="patch_manager"
CONFIG_DIR="/etc/patch-manager"
MIGRATION_DIR="/usr/share/patch-manager/migrations"
ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt"
# ---------------------------------------------------------------------------
# PostgreSQL helpers
# ---------------------------------------------------------------------------
psql_run() {
# Run SQL as the postgres superuser
sudo -u postgres psql -v ON_ERROR_STOP=1 "$@" 2>/dev/null
}
psql_run_db() {
# Run SQL against the patch_manager database
sudo -u postgres psql -v ON_ERROR_STOP=1 -d "${DB_NAME}" "$@" 2>/dev/null
}
# ---------------------------------------------------------------------------
# 1. Create service user (idempotent)
# ---------------------------------------------------------------------------
create_service_user() {
if ! id patch-manager &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin \
--comment "Linux Patch Manager service account" patch-manager
info "Service user 'patch-manager' created."
else
info "Service user 'patch-manager' already exists."
fi
}
# Create required directories
mkdir -p /etc/patch-manager/ca /etc/patch-manager/certs \
/etc/patch-manager/jwt /etc/patch-manager/tls \
# ---------------------------------------------------------------------------
# 2. Create required directories (idempotent)
# ---------------------------------------------------------------------------
create_directories() {
mkdir -p "${CONFIG_DIR}/ca" "${CONFIG_DIR}/certs" \
"${CONFIG_DIR}/jwt" "${CONFIG_DIR}/tls" \
/var/log/patch-manager /opt/patch-manager \
/var/backups/patch-manager
chown -R patch-manager:patch-manager \
/etc/patch-manager /var/log/patch-manager \
"${CONFIG_DIR}" /var/log/patch-manager \
/opt/patch-manager /usr/share/patch-manager/frontend
chmod 750 /etc/patch-manager/ca /etc/patch-manager/jwt
chmod 750 "${CONFIG_DIR}/ca" "${CONFIG_DIR}/jwt"
chmod 700 /var/backups/patch-manager
}
# Generate JWT signing key if not present
if [[ ! -f /etc/patch-manager/jwt/signing.pem ]]; then
openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem 2>/dev/null
openssl pkey -in /etc/patch-manager/jwt/signing.pem -pubout -out /etc/patch-manager/jwt/verify.pem 2>/dev/null
chown patch-manager:patch-manager /etc/patch-manager/jwt/signing.pem /etc/patch-manager/jwt/verify.pem
chmod 600 /etc/patch-manager/jwt/signing.pem
chmod 644 /etc/patch-manager/jwt/verify.pem
# ---------------------------------------------------------------------------
# 3. Wait for PostgreSQL to be ready
# ---------------------------------------------------------------------------
wait_for_postgresql() {
info "Waiting for PostgreSQL to be ready..."
local retries=30
local delay=2
local i
for ((i = 1; i <= retries; i++)); do
if pg_isready -q 2>/dev/null; then
info "PostgreSQL is ready."
return 0
fi
warn "PostgreSQL not ready yet (attempt ${i}/${retries}), waiting ${delay}s..."
sleep "${delay}"
done
error "PostgreSQL did not become ready after $((retries * delay)) seconds."
return 1
}
# ---------------------------------------------------------------------------
# 4. Create PostgreSQL user and database (idempotent)
# ---------------------------------------------------------------------------
setup_database() {
info "Setting up PostgreSQL database and user..."
# Generate a random password for the DB user
local db_password
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
# Create role if not exists
local role_exists
role_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" 2>/dev/null || echo "")
if [[ "${role_exists}" != "1" ]]; then
psql_run -c "CREATE ROLE ${DB_USER} LOGIN PASSWORD '${db_password}';"
info "PostgreSQL user '${DB_USER}' created."
# Store password for config generation
echo "${db_password}" > /tmp/.pm-db-password-new
else
info "PostgreSQL user '${DB_USER}' already exists."
# Recover the DB password: try from existing config, or generate new.
local config_file="${CONFIG_DIR}/config.toml"
local existing_pw=""
if [[ -f "${config_file}" ]]; then
# Extract password from URL: postgres://user:PASSWORD@host/db
# Use @localhost anchor so passwords containing @ are extracted correctly.
existing_pw=$(sed -n 's|^url = "postgres://[^:]*:\(.*\)@localhost.*"|\1|p' "${config_file}" | head -1)
fi
if [[ -n "${existing_pw}" && "${existing_pw}" != "CHANGEME" ]]; then
# Config has a real password — sync it to PostgreSQL so the app can connect.
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${existing_pw}';" 2>/dev/null || true
echo "${existing_pw}" > /tmp/.pm-db-password-new
info "Synced DB password from existing config to PostgreSQL."
else
# No config or CHANGEME — generate a fresh password and update PostgreSQL.
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
echo "${db_password}" > /tmp/.pm-db-password-new
info "Generated new DB password for existing user."
fi
fi
# Write default config if not present
if [[ ! -f /etc/patch-manager/config.toml ]]; then
cp /usr/share/patch-manager/config.example.toml /etc/patch-manager/config.toml
chown patch-manager:patch-manager /etc/patch-manager/config.toml
chmod 640 /etc/patch-manager/config.toml
# Create database if not exists
local db_exists
db_exists=$(psql_run -t -A -c "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" 2>/dev/null || echo "")
if [[ "${db_exists}" != "1" ]]; then
psql_run -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};"
info "Database '${DB_NAME}' created."
else
info "Database '${DB_NAME}' already exists, skipping creation."
fi
# Install backup cron if not present
if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then
(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/backup.sh >> /var/log/patch-manager/backup.log 2>&1") | crontab -
# Grant permissions (idempotent)
psql_run_db -c "GRANT USAGE ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true
psql_run_db -c "GRANT CREATE ON SCHEMA public TO ${DB_USER};" 2>/dev/null || true
psql_run_db -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# 5. Apply database migrations (idempotent)
# ---------------------------------------------------------------------------
apply_migrations() {
info "Applying database migrations..."
# Ensure pgcrypto extension is available
psql_run_db -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
# Create migration tracking table if not exists
psql_run_db <<'MIGSQL'
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
MIGSQL
# Handle upgrade from pre-migration-tracking versions:
# If tables exist but _migrations is empty, mark all existing migrations as applied.
local migration_count
migration_count=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
migration_count="${migration_count// /}"
local tables_exist
tables_exist=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" 2>/dev/null || echo "0")
tables_exist="${tables_exist// /}"
if [[ "${migration_count}" == "0" && "${tables_exist}" -gt 0 ]]; then
info "Existing database detected — marking all shipped migrations as already applied."
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
local fname
fname=$(basename "${sql_file}")
psql_run_db -c "INSERT INTO _migrations (filename) VALUES ('${fname}') ON CONFLICT (filename) DO NOTHING;" 2>/dev/null || true
done
fi
# Reload systemd
# Apply each migration in sorted order, skipping already-applied ones
local applied=0
local skipped=0
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
local fname
fname=$(basename "${sql_file}")
local already_applied
already_applied=$(psql_run_db -t -A -c "SELECT COUNT(*) FROM _migrations WHERE filename='${fname}';" 2>/dev/null || echo "0")
already_applied="${already_applied// /}"
if [[ "${already_applied}" -gt 0 ]]; then
skipped=$((skipped + 1))
continue
fi
info " Applying migration: ${fname}"
if psql_run_db -f "${sql_file}"; then
psql_run_db -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true
applied=$((applied + 1))
else
error " Failed to apply migration: ${fname}"
return 1
fi
done
if [[ "${applied}" -gt 0 ]]; then
info "Applied ${applied} new migration(s), skipped ${skipped} already applied."
else
info "All migrations up to date (${skipped} already applied)."
fi
}
# ---------------------------------------------------------------------------
# 6. Generate admin password and update database
# ---------------------------------------------------------------------------
generate_admin_password() {
info "Generating admin password..."
# Generate a random 24-character password
local admin_password
admin_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#%^&*' | head -c 24)
# Hash with argon2 (PHC format, compatible with the application)
# Generate a random 16-character salt (argon2 requires minimum 8 characters)
local admin_salt
admin_salt=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 16)
local password_hash
password_hash=$(echo -n "${admin_password}" | argon2 "${admin_salt}" -id -t 3 -m 16 -p 1 -l 32 -e)
# Update admin user password in database
# Only update if the placeholder hash is still present
# The placeholder starts with: $argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA
# Using single-quoted variable to preserve $ signs in SQL LIKE pattern
local placeholder_pattern
placeholder_pattern='$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'
local updated
updated=$(psql_run_db -t -A -c \
"UPDATE users SET password_hash = '${password_hash}', force_password_reset = TRUE \
WHERE username = 'admin' AND password_hash LIKE '${placeholder_pattern}' \
RETURNING id;" 2>/dev/null || echo "")
if [[ -n "${updated}" ]]; then
# Write admin password to file (mode 600, owned by root)
echo "${admin_password}" > "${ADMIN_PASSWORD_FILE}"
chmod 600 "${ADMIN_PASSWORD_FILE}"
chown root:root "${ADMIN_PASSWORD_FILE}"
echo ""
echo -e "${CYAN}=============================================${NC}"
echo -e "${CYAN} Linux Patch Manager — Admin Credentials${NC}"
echo -e "${CYAN}=============================================${NC}"
echo -e " Username: ${GREEN}admin${NC}"
echo -e " Password: ${GREEN}${admin_password}${NC}"
echo ""
echo -e " ${YELLOW}IMPORTANT: Save this password! It will not be shown again.${NC}"
echo -e " Password also saved to: ${ADMIN_PASSWORD_FILE}"
echo -e "${CYAN}=============================================${NC}"
echo ""
else
info "Admin password already set (not a fresh install). Password file not regenerated."
fi
}
# ---------------------------------------------------------------------------
# 7. Write config.toml with DB URL
# ---------------------------------------------------------------------------
# Handles three scenarios:
# 1. No config file → create from example with real DB password
# 2. Config exists with CHANGEME → replace CHANGEME with real DB password
# 3. Config exists with real password → leave it alone (upgrade)
# ---------------------------------------------------------------------------
write_config() {
local config_file="${CONFIG_DIR}/config.toml"
# Resolve the DB password to use: from setup_database() or generate fresh.
local db_password=""
if [[ -f /tmp/.pm-db-password-new ]]; then
db_password=$(cat /tmp/.pm-db-password-new)
fi
if [[ -f "${config_file}" ]]; then
# Check if the config still has the CHANGEME placeholder
if grep -q 'CHANGEME' "${config_file}"; then
if [[ -z "${db_password}" ]]; then
# No password from setup_database() — generate a fresh one
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
fi
info "Replacing CHANGEME placeholder in existing config with real DB password."
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|postgres://${DB_USER}:${db_password}@localhost/${DB_NAME}|" "${config_file}"
else
info "Config file ${config_file} already exists with a real password, leaving it unchanged."
return 0
fi
else
# No config file — create from example
if [[ -z "${db_password}" ]]; then
db_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | head -c 32)
psql_run -c "ALTER ROLE ${DB_USER} WITH PASSWORD '${db_password}';" 2>/dev/null || true
fi
info "Writing configuration file..."
cp /usr/share/patch-manager/config.example.toml "${config_file}"
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|postgres://${DB_USER}:${db_password}@localhost/${DB_NAME}|" "${config_file}"
fi
chown patch-manager:patch-manager "${config_file}"
chmod 640 "${config_file}"
info "Configuration written to ${config_file}"
}
# ---------------------------------------------------------------------------
# 8. Generate JWT keys (idempotent)
# Only generates if missing; regenerates verify.pem from signing.pem if lost.
# ---------------------------------------------------------------------------
generate_jwt_keys() {
if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then
info "Generating Ed25519 JWT signing key..."
openssl genpkey -algorithm ed25519 -out "${CONFIG_DIR}/jwt/signing.pem" 2>/dev/null
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem"
chmod 600 "${CONFIG_DIR}/jwt/signing.pem"
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
info "JWT keys generated."
elif [[ ! -f "${CONFIG_DIR}/jwt/verify.pem" ]]; then
info "Regenerating missing JWT verification key from existing signing key..."
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/verify.pem"
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
info "JWT verification key regenerated."
else
info "JWT keys already exist, skipping."
fi
}
# ---------------------------------------------------------------------------
# 9. Enable and start services
# ---------------------------------------------------------------------------
enable_and_start_services() {
systemctl daemon-reload
# Restart services if this is an upgrade (not a fresh install)
if systemctl is-active --quiet patch-manager-web 2>/dev/null; then
systemctl restart patch-manager-web || true
fi
if systemctl is-active --quiet patch-manager-worker 2>/dev/null; then
systemctl restart patch-manager-worker || true
fi
# Enable the target (which pulls in web + worker)
systemctl enable patch-manager.target 2>/dev/null || true
# Run pending database migrations
MIGRATION_DIR="/usr/share/patch-manager/migrations"
if [[ -d "$MIGRATION_DIR" ]]; then
echo "Applying database migrations..."
for sql_file in $(ls "$MIGRATION_DIR"/*.sql 2>/dev/null | sort); do
echo " Applying: $(basename "$sql_file")"
done
echo "Note: Migrations must be applied manually: sudo -u patch_manager psql -d patch_manager -f <migration_file>"
fi
# Enable individual services so they survive a reboot
systemctl enable patch-manager-web.service patch-manager-worker.service 2>/dev/null || true
echo ""
echo "Linux Patch Manager installed successfully!"
echo "==========================================="
echo ""
echo "Next steps:"
echo " 1. Install and configure PostgreSQL:"
echo " apt install postgresql-16"
echo " 2. Create the database:"
echo " sudo -u postgres createdb -O patch_manager patch_manager"
echo " 3. Edit /etc/patch-manager/config.toml with your database URL"
echo " 4. Enable and start services:"
echo " systemctl enable --now patch-manager.target"
echo " 5. Access the web UI at https://localhost"
echo " Default admin credentials are set via the seed migration."
echo ""
echo "IMPORTANT: Change the default admin password immediately after first login!"
echo ""
echo "If this is an upgrade, services have been restarted automatically."
echo "Apply any new database migrations:"
echo " sudo -u patch_manager psql -d patch_manager -f /usr/share/patch-manager/migrations/<NNN_migration>.sql"
echo ""
# Start or restart services
if systemctl is-active --quiet patch-manager.target 2>/dev/null; then
info "Restarting patch-manager services (upgrade)..."
systemctl restart patch-manager.target 2>/dev/null || true
else
info "Starting patch-manager services..."
systemctl start patch-manager.target 2>/dev/null || true
fi
}
# ---------------------------------------------------------------------------
# 10. Install backup cron (idempotent)
# ---------------------------------------------------------------------------
install_backup_cron() {
if ! crontab -l 2>/dev/null | grep -qF "backup.sh"; then
(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/backup.sh >> /var/log/patch-manager/backup.log 2>&1") | crontab -
info "Nightly backup cron installed."
fi
}
# =============================================================================
# Main
# =============================================================================
case "$1" in
configure)
create_service_user
create_directories
wait_for_postgresql
setup_database
apply_migrations
generate_admin_password
write_config
generate_jwt_keys
enable_and_start_services
install_backup_cron
# Clean up temp file
rm -f /tmp/.pm-db-password-new
info "Linux Patch Manager installation complete."
;;
abort-upgrade|abort-remove|abort-deconfigure)

58
docker-compose.yml Normal file
View File

@ -0,0 +1,58 @@
# =============================================================================
# Linux Patch Manager — Docker Compose Deployment
# =============================================================================
# Usage:
# cp .env.example .env # Edit DB_PASSWORD
# docker compose up -d
# =============================================================================
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: patch_manager
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: patch_manager
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U patch_manager -d patch_manager"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
networks:
- patch-manager-net
app:
image: ghcr.io/draco-lunaris/linux-patch-manager:${TAG:-latest}
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "443:443"
environment:
DATABASE_URL: postgres://patch_manager:${DB_PASSWORD}@db:5432/patch_manager
PATCH_MANAGER_CONFIG: /etc/patch-manager/config.toml
volumes:
- pm-config:/etc/patch-manager
- pm-logs:/var/log/patch-manager
- pm-data:/opt/patch-manager
networks:
- patch-manager-net
volumes:
pgdata:
driver: local
pm-config:
driver: local
pm-logs:
driver: local
pm-data:
driver: local
networks:
patch-manager-net:
driver: bridge

232
docker/entrypoint.sh Executable file
View File

@ -0,0 +1,232 @@
#!/bin/bash
set -e
# =============================================================================
# Linux Patch Manager — Docker Entrypoint
# =============================================================================
# Handles first-run: wait for DB, run migrations, generate admin password,
# start pm-web and pm-worker services.
# =============================================================================
MIGRATION_DIR="/usr/share/patch-manager/migrations"
CONFIG_DIR="/etc/patch-manager"
ADMIN_PASSWORD_FILE="/etc/patch-manager/admin-password.txt"
# ---------------------------------------------------------------------------
# Parse DATABASE_URL into PG* env vars for psql compatibility
# ---------------------------------------------------------------------------
parse_database_url() {
# DATABASE_URL format: postgres://user:password@host:port/dbname
local url="${DATABASE_URL}"
# Extract components
DB_PASS=$(echo "$url" | sed -n 's|postgres://[^:]*:\([^@]*\)@.*|\1|p')
DB_HOST=$(echo "$url" | sed -n 's|.*@\([^:/]*\).*|\1|p')
DB_PORT=$(echo "$url" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
DB_USER=$(echo "$url" | sed -n 's|postgres://\([^:]*\):.*|\1|p')
DB_NAME=$(echo "$url" | sed -n 's|.*/\([^?]*\).*|\1|p')
# Default port
DB_PORT="${DB_PORT:-5432}"
export PGHOST="${DB_HOST}"
export PGPORT="${DB_PORT}"
export PGUSER="${DB_USER}"
export PGPASSWORD="${DB_PASS}"
export PGDATABASE="${DB_NAME}"
}
# ---------------------------------------------------------------------------
# Wait for PostgreSQL to be ready
# ---------------------------------------------------------------------------
wait_for_db() {
echo "[entrypoint] Waiting for PostgreSQL at ${PGHOST}:${DB_PORT}..."
local retries=60
local delay=2
local i
for ((i = 1; i <= retries; i++)); do
if pg_isready -q -h "${PGHOST}" -p "${DB_PORT}" -U "${DB_USER}" 2>/dev/null; then
echo "[entrypoint] PostgreSQL is ready."
return 0
fi
echo "[entrypoint] PostgreSQL not ready (attempt ${i}/${retries}), waiting ${delay}s..."
sleep "${delay}"
done
echo "[entrypoint] ERROR: PostgreSQL did not become ready after $((retries * delay)) seconds." >&2
return 1
}
# ---------------------------------------------------------------------------
# Run database migrations (idempotent)
# ---------------------------------------------------------------------------
run_migrations() {
echo "[entrypoint] Applying database migrations..."
# Ensure pgcrypto extension
psql -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;" 2>/dev/null || true
# Create migration tracking table
psql -v ON_ERROR_STOP=1 <<'EOSQL'
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
EOSQL
# Handle upgrade from pre-migration-tracking versions
local migration_count
migration_count=$(psql -t -A -c "SELECT COUNT(*) FROM _migrations;" 2>/dev/null || echo "0")
migration_count="${migration_count// /}"
local tables_exist
tables_exist=$(psql -t -A -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" 2>/dev/null || echo "0")
tables_exist="${tables_exist// /}"
if [[ "${migration_count}" == "0" && "${tables_exist}" -gt 0 ]]; then
echo "[entrypoint] Existing database detected — marking all shipped migrations as already applied."
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
local fname
fname=$(basename "${sql_file}")
psql -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}') ON CONFLICT (filename) DO NOTHING;" 2>/dev/null || true
done
fi
# Apply each migration in sorted order
local applied=0
local skipped=0
for sql_file in $(ls "${MIGRATION_DIR}"/*.sql 2>/dev/null | sort); do
local fname
fname=$(basename "${sql_file}")
local already_applied
already_applied=$(psql -t -A -c "SELECT COUNT(*) FROM _migrations WHERE filename='${fname}';" 2>/dev/null || echo "0")
already_applied="${already_applied// /}"
if [[ "${already_applied}" -gt 0 ]]; then
skipped=$((skipped + 1))
continue
fi
echo "[entrypoint] Applying migration: ${fname}"
if psql -v ON_ERROR_STOP=1 -f "${sql_file}"; then
psql -v ON_ERROR_STOP=1 -c "INSERT INTO _migrations (filename) VALUES ('${fname}');" 2>/dev/null || true
applied=$((applied + 1))
else
echo "[entrypoint] ERROR: Failed to apply migration: ${fname}" >&2
return 1
fi
done
if [[ "${applied}" -gt 0 ]]; then
echo "[entrypoint] Applied ${applied} new migration(s), skipped ${skipped}."
else
echo "[entrypoint] All migrations up to date (${skipped} already applied)."
fi
}
# ---------------------------------------------------------------------------
# Generate admin password on first run
# ---------------------------------------------------------------------------
generate_admin_password() {
echo "[entrypoint] Checking admin password status..."
# Generate a random 24-character password
local admin_password
admin_password=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#%^&*' | head -c 24)
# Hash with argon2 (PHC format)
local password_hash
password_hash=$(echo -n "${admin_password}" | argon2 salt -id -t 3 -m 65536 -p 1 -l 32 -e)
# Update admin user — only if placeholder hash is still present
# The placeholder starts with: $argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA
# Using single-quoted variable to preserve $ signs in the SQL LIKE pattern
local placeholder_pattern
placeholder_pattern='$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'
local updated
updated=$(psql -t -A -c \
"UPDATE users SET password_hash = '${password_hash}', force_password_reset = TRUE \
WHERE username = 'admin' AND password_hash LIKE '${placeholder_pattern}' \
RETURNING id;" 2>/dev/null || echo "")
if [[ -n "${updated}" ]]; then
echo "${admin_password}" > "${ADMIN_PASSWORD_FILE}"
chmod 600 "${ADMIN_PASSWORD_FILE}"
chown root:root "${ADMIN_PASSWORD_FILE}"
echo ""
echo "============================================="
echo " Linux Patch Manager — Admin Credentials"
echo "============================================="
echo " Username: admin"
echo " Password: ${admin_password}"
echo ""
echo " IMPORTANT: Save this password! It will not be shown again."
echo " Password also saved to: ${ADMIN_PASSWORD_FILE}"
echo "============================================="
echo ""
else
echo "[entrypoint] Admin password already set (not a fresh install)."
fi
}
# ---------------------------------------------------------------------------
# Generate JWT keys if not present
# ---------------------------------------------------------------------------
generate_jwt_keys() {
if [[ ! -f "${CONFIG_DIR}/jwt/signing.pem" ]]; then
echo "[entrypoint] Generating Ed25519 JWT signing key..."
openssl genpkey -algorithm ed25519 -out "${CONFIG_DIR}/jwt/signing.pem" 2>/dev/null
openssl pkey -in "${CONFIG_DIR}/jwt/signing.pem" -pubout -out "${CONFIG_DIR}/jwt/verify.pem" 2>/dev/null
chown patch-manager:patch-manager "${CONFIG_DIR}/jwt/signing.pem" "${CONFIG_DIR}/jwt/verify.pem"
chmod 600 "${CONFIG_DIR}/jwt/signing.pem"
chmod 644 "${CONFIG_DIR}/jwt/verify.pem"
echo "[entrypoint] JWT keys generated."
else
echo "[entrypoint] JWT signing key already exists."
fi
}
# ---------------------------------------------------------------------------
# Write config.toml if not present
# ---------------------------------------------------------------------------
write_config() {
local config_file="${CONFIG_DIR}/config.toml"
if [[ -f "${config_file}" ]]; then
echo "[entrypoint] Config file already exists, not overwriting."
return 0
fi
echo "[entrypoint] Writing configuration file..."
cp /usr/share/patch-manager/config.example.toml "${config_file}"
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|${DATABASE_URL}|" "${config_file}"
chown patch-manager:patch-manager "${config_file}"
chmod 640 "${config_file}"
echo "[entrypoint] Configuration written."
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
echo "[entrypoint] Linux Patch Manager Docker entrypoint starting..."
parse_database_url
wait_for_db
run_migrations
generate_admin_password
generate_jwt_keys
write_config
echo "[entrypoint] Starting pm-web and pm-worker..."
# Start pm-worker in background
pm-worker &
WORKER_PID=$!
# Start pm-web in foreground (main process)
export PATCH_MANAGER_CONFIG="${CONFIG_DIR}/config.toml"
exec pm-web

View File

@ -160,9 +160,30 @@ verifying that all mandated security controls are implemented and operational.
## 6. Findings & Recommendations
### No Critical or High Findings
### 🔴 CRITICAL: Committed Private Key Material (Issue #12) — RESOLVED
All security controls are implemented as specified in the system requirements.
**Description:**
Private key file `client.key` and public certificates (`client.crt`, `ca.crt`) were committed
to version control in `crates/pm-agent-client/certs/`. Committed private keys are a critical
security risk: anyone with repository access can impersonate agents or decrypt captured TLS traffic.
**Status:** ✅ RESOLVED
**Remediation Applied:**
1. Removed all cert files from git tracking (`git rm --cached`)
2. Added `*.key`, `*.key.pem` and `crates/pm-agent-client/certs/` to `.gitignore`
3. Updated `pm-agent-client` doc examples to use `std::fs::read()` instead of `include_bytes!`
4. Added `gitleaks` secret scanning to CI pipeline
5. Added README to `crates/pm-agent-client/certs/` explaining runtime cert generation
6. Git history will be purged with `git filter-repo` after PR merge
**Key Rotation:**
These keys were dev/test only. No production key rotation is needed. All committed keys
should be considered compromised and must not be used in production.
### No Other Critical or High Findings
All other security controls are implemented as specified in the system requirements.
### Recommendations (Low Priority)
@ -192,3 +213,4 @@ All security controls are implemented as specified in the system requirements.
- [x] Backup encryption supported (GPG)
- [x] Azure SSO with PKCE flow
- [x] No plaintext credential storage
- [x] Committed private key material removed from repository (Issue #12)

View File

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

View File

@ -12,14 +12,44 @@ CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- fuzzy text search on host names
-- Enumerations
-- ============================================================
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 (
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',
@ -30,13 +60,15 @@ CREATE TYPE audit_action AS ENUM (
'certificate_issued', 'certificate_renewed', 'certificate_revoked', 'certificate_downloaded',
'config_changed',
'discovery_scan_started'
);
);
END IF;
END $$;
-- ============================================================
-- Groups (defined before users/hosts for FK ordering)
-- ============================================================
CREATE TABLE groups (
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
@ -44,13 +76,13 @@ CREATE TABLE groups (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_groups_name ON groups (name);
CREATE INDEX IF NOT EXISTS idx_groups_name ON groups (name);
-- ============================================================
-- Users
-- ============================================================
CREATE TABLE users (
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
@ -73,28 +105,28 @@ CREATE TABLE users (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
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);
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);
-- ============================================================
-- User <-> Group membership
-- ============================================================
CREATE TABLE user_groups (
CREATE TABLE IF NOT EXISTS 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 idx_user_groups_group ON user_groups (group_id);
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups (group_id);
-- ============================================================
-- Refresh Tokens
-- ============================================================
CREATE TABLE refresh_tokens (
CREATE TABLE IF NOT EXISTS 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
@ -109,14 +141,14 @@ CREATE TABLE refresh_tokens (
ip_address INET
);
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;
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;
-- ============================================================
-- Hosts
-- ============================================================
CREATE TABLE hosts (
CREATE TABLE IF NOT EXISTS hosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fqdn TEXT NOT NULL,
ip_address INET NOT NULL,
@ -136,28 +168,28 @@ CREATE TABLE hosts (
CONSTRAINT hosts_fqdn_ip_unique UNIQUE (fqdn, 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);
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);
-- ============================================================
-- Host <-> Group membership
-- ============================================================
CREATE TABLE host_groups (
CREATE TABLE IF NOT EXISTS 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 idx_host_groups_group ON host_groups (group_id);
CREATE INDEX IF NOT EXISTS idx_host_groups_group ON host_groups (group_id);
-- ============================================================
-- Host Health Data (cached results from 5-min polls)
-- ============================================================
CREATE TABLE host_health_data (
CREATE TABLE IF NOT EXISTS 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(),
@ -166,14 +198,14 @@ CREATE TABLE host_health_data (
payload JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_host_health_host ON host_health_data (host_id, polled_at DESC);
CREATE INDEX IF NOT EXISTS 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 host_patch_data (
CREATE TABLE IF NOT EXISTS 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(),
@ -184,14 +216,14 @@ CREATE TABLE host_patch_data (
cve_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_host_patch_host ON host_patch_data (host_id, polled_at DESC);
CREATE INDEX IF NOT EXISTS idx_host_patch_host ON host_patch_data (host_id, polled_at DESC);
-- Retained for 30 days (pruned by worker)
-- ============================================================
-- Maintenance Windows
-- ============================================================
CREATE TABLE maintenance_windows (
CREATE TABLE IF NOT EXISTS 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 '',
@ -207,14 +239,14 @@ CREATE TABLE maintenance_windows (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_mw_host ON maintenance_windows (host_id);
CREATE INDEX idx_mw_start ON maintenance_windows (start_at) WHERE enabled = TRUE;
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;
-- ============================================================
-- Patch Jobs
-- ============================================================
CREATE TABLE patch_jobs (
CREATE TABLE IF NOT EXISTS 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',
@ -233,15 +265,15 @@ CREATE TABLE patch_jobs (
completed_at TIMESTAMPTZ
);
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);
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);
-- ============================================================
-- Patch Job Hosts (per-host status within a batch job)
-- ============================================================
CREATE TABLE patch_job_hosts (
CREATE TABLE IF NOT EXISTS 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,
@ -257,15 +289,15 @@ CREATE TABLE patch_job_hosts (
UNIQUE (job_id, host_id)
);
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);
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);
-- ============================================================
-- Certificates
-- ============================================================
CREATE TABLE certificates (
CREATE TABLE IF NOT EXISTS certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- NULL = root CA cert
host_id UUID REFERENCES hosts(id) ON DELETE CASCADE,
@ -279,15 +311,15 @@ CREATE TABLE certificates (
cert_pem TEXT NOT NULL
);
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);
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);
-- ============================================================
-- Audit Log (tamper-evident, hash-chained)
-- ============================================================
CREATE TABLE audit_log (
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
action audit_action NOT NULL,
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
@ -302,17 +334,17 @@ CREATE TABLE audit_log (
row_hash TEXT NOT NULL DEFAULT ''
);
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);
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);
-- Retained for 6 months (pruned by worker)
-- ============================================================
-- Azure SSO Configuration
-- ============================================================
CREATE TABLE azure_sso_config (
CREATE TABLE IF NOT EXISTS azure_sso_config (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
enabled BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id TEXT NOT NULL DEFAULT '',
@ -329,7 +361,7 @@ CREATE TABLE azure_sso_config (
-- System Configuration (key/value runtime settings)
-- ============================================================
CREATE TABLE system_config (
CREATE TABLE IF NOT EXISTS system_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
@ -351,13 +383,14 @@ 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');
('ip_whitelist', '[]', 'JSON array of allowed CIDR/IP strings; empty = allow all')
ON CONFLICT (key) DO NOTHING;
-- ============================================================
-- Worker Heartbeat
-- ============================================================
CREATE TABLE worker_heartbeat (
CREATE TABLE IF NOT EXISTS worker_heartbeat (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
worker_version TEXT NOT NULL DEFAULT '',
@ -368,7 +401,7 @@ CREATE TABLE worker_heartbeat (
-- Discovery Results (transient; cleared before each scan)
-- ============================================================
CREATE TABLE discovery_results (
CREATE TABLE IF NOT EXISTS discovery_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scan_id UUID NOT NULL,
ip_address INET NOT NULL,
@ -381,5 +414,5 @@ CREATE TABLE discovery_results (
registered BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_discovery_scan ON discovery_results (scan_id);
CREATE INDEX idx_discovery_ip ON discovery_results (ip_address);
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);

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 retry_next_at TIMESTAMPTZ;
ADD COLUMN IF NOT EXISTS retry_next_at TIMESTAMPTZ;
-- Last failure reason captured by the worker for display in the UI
ALTER TABLE patch_job_hosts
ADD COLUMN last_error TEXT;
ADD COLUMN IF NOT EXISTS last_error TEXT;
-- ============================================================
-- pg_notify trigger: fires when an immediate job is inserted
@ -30,15 +30,21 @@ BEGIN
END;
$$;
CREATE TRIGGER trg_job_enqueued
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 $$;
-- ============================================================
-- Index: efficiently find hosts due for retry
-- ============================================================
CREATE INDEX idx_pjh_retry
CREATE INDEX IF NOT EXISTS 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 host_health_checks (
CREATE TABLE IF NOT EXISTS 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 host_health_checks (
)
);
CREATE INDEX idx_health_checks_host ON host_health_checks (host_id);
CREATE INDEX IF NOT EXISTS idx_health_checks_host ON host_health_checks (host_id);
-- Health check poll results (4-day retention, pruned by worker)
CREATE TABLE host_health_check_results (
CREATE TABLE IF NOT EXISTS 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 host_health_check_results (
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_health_results_check ON host_health_check_results (check_id, checked_at DESC);
CREATE INDEX IF NOT EXISTS 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 target_host_id UUID REFERENCES hosts(id) ON DELETE SET NULL;
ADD COLUMN IF NOT EXISTS target_host_id UUID REFERENCES hosts(id) ON DELETE SET NULL;
CREATE INDEX idx_health_checks_target_host ON host_health_checks (target_host_id)
CREATE INDEX IF NOT EXISTS 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 enrollment_requests (
CREATE TABLE IF NOT EXISTS 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 enrollment_requests (
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX idx_enrollment_requests_token ON enrollment_requests (polling_token);
CREATE INDEX idx_enrollment_requests_expires ON enrollment_requests (expires_at);
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);

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="0.1.9"
VERSION="1.1.9"
RELEASE="1"
PKG_NAME="linux-patch-manager"
DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"

View File

@ -30,7 +30,7 @@ mv "$TEMP_CHANGELOG" debian/changelog
echo "[2/5] debian/changelog: Added entry for $NEW_VERSION"
# 3. debian/control - Update Version field
if grep -q "^Version:" debian/control 2>/dev/null; then
if grep -q "^Version:" debian/control 2>/dev/null || true; then
sed -i "s/^Version: .*/Version: $NEW_VERSION-1/" debian/control
echo "[3/5] debian/control: -> $NEW_VERSION-1"
else
@ -71,7 +71,7 @@ fi
echo ""
echo "Stale references check:"
grep -r "$OLD_VERSION" --include='*.toml' --include='*.sh' --include='*.json' --include='control' . 2>/dev/null | grep -v 'target/' | grep -v '.git/' | grep -v 'node_modules/' | grep -v 'bump-version.sh' || echo " No stale references found"
grep -r "$OLD_VERSION" --include='*.toml' --include='*.sh' --include='*.json' --include='control' . 2>/dev/null || true | grep -v 'target/' | grep -v '.git/' | grep -v 'node_modules/' | grep -v 'bump-version.sh' || echo " No stale references found"
echo ""
echo "Next steps:"

View File

@ -15,6 +15,13 @@
set -euo pipefail
# ---------------------------------------------------------------------------
# BusyBox-compatible millisecond timing (_now_ms not available)
# ---------------------------------------------------------------------------
_now_ms() {
python3 -c "import time; print(int(time.time()*1000))"
}
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@ -72,10 +79,10 @@ api_call() {
time_api_call() {
local method="$1" endpoint="$2" shift; shift
local start end elapsed
start=$(date +%s%N)
start=$(_now_ms)
api_call "${method}" "${endpoint}" -o /dev/null "$@" 2>/dev/null || true
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 )) # milliseconds
end=$(_now_ms)
elapsed=$(( end - start )) # milliseconds
echo "$(echo "scale=3; ${elapsed}/1000" | bc)"
}
@ -97,10 +104,10 @@ test_dashboard_load() {
# Also measure frontend static asset load
info "Measuring frontend index.html load time..."
start=$(date +%s%N)
start=$(_now_ms)
curl -sk -o /dev/null "${BASE_URL}/" 2>/dev/null || true
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
end=$(_now_ms)
elapsed=$(( end - start ))
FRONTEND_TIME=$(echo "scale=3; ${elapsed}/1000" | bc)
info "Frontend load time: ${FRONTEND_TIME}s"
pass "Frontend static load: ${FRONTEND_TIME}s"
@ -169,14 +176,14 @@ test_bulk_host_operations() {
# 4.2 Sequential host creation (measure throughput)
info "4.2 Sequential host creation (10 hosts)"
local start=$(date +%s%N)
local start=$(_now_ms)
for i in $(seq 1 10); do
api_call POST /api/v1/hosts \
-d "{\"fqdn\": \"perf-test-${i}.example.com\", \"ip_address\": \"10.99.0.${i}\"}" \
-o /dev/null 2>/dev/null || true
done
local end=$(date +%s%N)
local total_ms=$(( (end - start) / 1000000 ))
local end=$(_now_ms)
local total_ms=$(( end - start ))
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
local per_host=$(echo "scale=3; ${total_s}/10" | bc)
info "10 hosts created in ${total_s}s (${per_host}s per host)"
@ -199,11 +206,11 @@ test_cidr_scan() {
# Note: This test initiates a real CIDR scan which may not complete quickly
# without reachable hosts. We measure the API response time for initiating.
info "5.1 CIDR scan initiation time"
local start=$(date +%s%N)
local start=$(_now_ms)
SCAN_RESP=$(api_call POST /api/v1/discovery/cidr \
-d '{"cidr": "10.0.0.0/30", "timeout": 1.5}' 2>/dev/null || true)
local end=$(date +%s%N)
local elapsed_ms=$(( (end - start) / 1000000 ))
local end=$(_now_ms)
local elapsed_ms=$(( end - start ))
local elapsed_s=$(echo "scale=3; ${elapsed_ms}/1000" | bc)
info "CIDR scan initiation: ${elapsed_s}s"
@ -240,13 +247,13 @@ test_concurrent_load() {
# Fire 20 concurrent requests and measure total time
info "6.1 20 concurrent fleet status requests"
local start=$(date +%s%N)
local start=$(_now_ms)
for i in $(seq 1 20); do
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
done
wait
local end=$(date +%s%N)
local total_ms=$(( (end - start) / 1000000 ))
local end=$(_now_ms)
local total_ms=$(( end - start ))
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
local per_req=$(echo "scale=3; ${total_s}/20" | bc)
@ -259,7 +266,7 @@ test_concurrent_load() {
# 6.2 Mixed endpoint concurrent load
info "6.2 20 concurrent mixed-endpoint requests"
start=$(date +%s%N)
start=$(_now_ms)
for i in $(seq 1 5); do
api_call GET /api/v1/hosts -o /dev/null 2>/dev/null &
api_call GET /api/v1/groups -o /dev/null 2>/dev/null &
@ -267,8 +274,8 @@ test_concurrent_load() {
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
done
wait
end=$(date +%s%N)
total_ms=$(( (end - start) / 1000000 ))
end=$(_now_ms)
total_ms=$(( end - start ))
total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
per_req=$(echo "scale=3; ${total_s}/20" | bc)
info "Mixed concurrent: ${total_s}s total, ${per_req}s avg"
@ -282,12 +289,12 @@ test_ws_ticket_performance() {
echo -e "\n${CYAN}=== Test 7: WebSocket Ticket Issuance ===${NC}"
info "7.1 Sequential ticket creation (10 tickets)"
local start=$(date +%s%N)
local start=$(_now_ms)
for i in $(seq 1 10); do
api_call POST /api/v1/ws/ticket -o /dev/null 2>/dev/null || true
done
local end=$(date +%s%N)
local total_ms=$(( (end - start) / 1000000 ))
local end=$(_now_ms)
local total_ms=$(( end - start ))
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
local per_ticket=$(echo "scale=3; ${total_s}/10" | bc)
info "10 tickets in ${total_s}s (${per_ticket}s per ticket)"