Private
Public Access
1
0

Compare commits

..

7 Commits

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

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

@ -12,6 +12,7 @@ env:
permissions:
contents: write
packages: write
jobs:
rust-format:
@ -57,6 +58,18 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-audit && cargo audit
gitleaks:
name: Secret scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
frontend-lint:
name: Frontend Lint
runs-on: ubuntu-latest
@ -116,3 +129,46 @@ jobs:
with:
body: ${{ steps.release_notes.outputs.notes }}
files: linux-patch-manager_*.deb
docker:
name: Docker Build & Push
needs: [rust-format, clippy, rust-test, security-audit, frontend-lint]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/draco-lunaris/linux-patch-manager
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

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

View File

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

111
Dockerfile Normal file
View File

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

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?;

12
debian/changelog vendored
View File

@ -1,3 +1,15 @@
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.0-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

417
debian/postinst vendored Normal file → Executable file
View File

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

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.0",
"type": "module",
"scripts": {
"dev": "vite",

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.0"
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)"