From 83c97aa2b122eef1f66d30224b9711a1782bf42a Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 28 Apr 2026 23:01:03 +0000 Subject: [PATCH] fix: resolve all startup bugs (BUG-6 through BUG-9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-6: Add TLS support via axum-server + rustls - Added axum-server with tls-rustls feature to workspace and pm-web - pm-web now serves HTTPS when TLS certs exist, falls back to HTTP with warning - setup.sh generates self-signed ECDSA P-256 TLS cert with SANs - Config already had web_tls_cert_path/web_tls_key_path fields BUG-7: Fix audit chain integrity errors - Migration 005 now TRUNCATEs audit_log after adding prev_hash column - Existing rows had broken hash chains (inserted before prev_hash existed) BUG-8: Disable WatchdogSec in patch-manager-web.service - pm-web does not implement sd_notify, causing systemd to kill the service BUG-9: Disable WatchdogSec in patch-manager-worker.service - Same issue as BUG-8, worker does not implement sd_notify Previous fixes (BUG-1 through BUG-5) also included: - setup.sh: PostgreSQL 15+ schema GRANTs - Axum route syntax :param → {param} (19 routes) - DbUser struct role: String → UserRole enum mapping - UserRole/AuthProvider Display trait implementations - Seed admin password hash (Argon2id) --- Cargo.lock | 42 ++++++ Cargo.toml | 3 + crates/pm-auth/src/session.rs | 13 +- crates/pm-core/src/models.rs | 18 +++ crates/pm-web/Cargo.toml | 5 +- crates/pm-web/src/main.rs | 37 ++++- crates/pm-web/src/routes/ca.rs | 8 +- crates/pm-web/src/routes/discovery.rs | 4 +- crates/pm-web/src/routes/groups.rs | 4 +- crates/pm-web/src/routes/hosts.rs | 8 +- crates/pm-web/src/routes/jobs.rs | 6 +- .../pm-web/src/routes/maintenance_windows.rs | 6 +- crates/pm-web/src/routes/users.rs | 4 +- docs/testing-report-dev-lxc.md | 130 ++++++++++++++++++ migrations/002_seed_admin.sql | 2 +- migrations/005_audit_hardening.sql | 5 + scripts/setup.sh | 31 +++++ systemd/patch-manager-web.service | 4 +- systemd/patch-manager-worker.service | 4 +- 19 files changed, 297 insertions(+), 37 deletions(-) create mode 100644 docs/testing-report-dev-lxc.md mode change 100755 => 100644 scripts/setup.sh diff --git a/Cargo.lock b/Cargo.lock index e7abdc7..c359975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.5.3" @@ -239,6 +248,28 @@ dependencies = [ "syn", ] +[[package]] +name = "axum-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +dependencies = [ + "arc-swap", + "bytes", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "base32" version = "0.5.1" @@ -824,6 +855,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2200,6 +2241,7 @@ dependencies = [ "anyhow", "axum", "axum-extra", + "axum-server", "base64", "chrono", "dashmap", diff --git a/Cargo.toml b/Cargo.toml index f14925e..dc4025c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ tokio = { version = "1", features = ["full"] } # Web framework axum = { version = "0.8", features = ["ws", "macros"] } +axum-server = { version = "0.7", features = ["tls-rustls"] } axum-extra = { version = "0.10", features = ["typed-header"] } tower = { version = "0.5" } tower-http = { version = "0.6", features = ["fs", "trace", "cors", "request-id"] } @@ -54,6 +55,8 @@ reqwest = { version = "0.12", features = ["rustls-tls", "json"] } # TLS rustls = { version = "0.23" } +tokio-rustls = { version = "0.26" } +rustls-pemfile = { version = "2" } # Certificate Authority rcgen = { version = "0.13", features = ["pem", "x509-parser"] } diff --git a/crates/pm-auth/src/session.rs b/crates/pm-auth/src/session.rs index 648ddf5..35c5b20 100644 --- a/crates/pm-auth/src/session.rs +++ b/crates/pm-auth/src/session.rs @@ -5,6 +5,7 @@ //! Force logout: revoke all tokens for a user use chrono::Utc; +use pm_core::models::{AuthProvider, UserRole}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use thiserror::Error; @@ -68,8 +69,8 @@ struct DbUser { id: Uuid, username: String, display_name: String, - role: String, - auth_provider: String, + role: UserRole, + auth_provider: AuthProvider, password_hash: Option, totp_secret: Option, mfa_enabled: bool, @@ -157,7 +158,7 @@ pub async fn login( let access_token = jwt::issue_access_token( user.id, &user.username, - &user.role, + &user.role.to_string(), access_ttl_secs, signing_key_pem, )?; @@ -182,7 +183,7 @@ pub async fn login( id: user.id.to_string(), username: user.username, display_name: user.display_name, - role: user.role, + role: user.role.to_string(), mfa_enabled: user.mfa_enabled, }, }) @@ -223,7 +224,7 @@ pub async fn refresh_session( let access_token = jwt::issue_access_token( user.id, &user.username, - &user.role, + &user.role.to_string(), access_ttl_secs, signing_key_pem, )?; @@ -237,7 +238,7 @@ pub async fn refresh_session( id: user.id.to_string(), username: user.username, display_name: user.display_name, - role: user.role, + role: user.role.to_string(), mfa_enabled: user.mfa_enabled, }, }) diff --git a/crates/pm-core/src/models.rs b/crates/pm-core/src/models.rs index bf7c799..1900ec7 100644 --- a/crates/pm-core/src/models.rs +++ b/crates/pm-core/src/models.rs @@ -38,6 +38,15 @@ pub enum UserRole { Operator, } +impl std::fmt::Display for UserRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Admin => write!(f, "admin"), + Self::Operator => write!(f, "operator"), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "auth_provider", rename_all = "snake_case")] pub enum AuthProvider { @@ -46,6 +55,15 @@ pub enum AuthProvider { AzureSso, } +impl std::fmt::Display for AuthProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Local => write!(f, "local"), + Self::AzureSso => write!(f, "azure_sso"), + } + } +} + // ============================================================ // Host // ============================================================ diff --git a/crates/pm-web/Cargo.toml b/crates/pm-web/Cargo.toml index 42cba49..f575fd6 100644 --- a/crates/pm-web/Cargo.toml +++ b/crates/pm-web/Cargo.toml @@ -16,6 +16,7 @@ pm-auth = { path = "../pm-auth" } pm-reports = { path = "../pm-reports" } tokio = { workspace = true } axum = { workspace = true } +axum-server = { workspace = true } axum-extra = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } @@ -33,8 +34,8 @@ ipnet = { workspace = true } dashmap = { version = "6" } reqwest = { workspace = true } lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] } -sha2 = { workspace = true } +rand = { workspace = true } base64 = { workspace = true } +sha2 = { workspace = true } url = { workspace = true } urlencoding = "2" -rand = { workspace = true } diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 81ffe8c..71402ae 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -3,6 +3,7 @@ mod routes; 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, @@ -113,9 +114,37 @@ async fn main() -> anyhow::Result<()> { .parse() .expect("Invalid bind address"); - tracing::info!(%addr, "Listening"); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; + // Try to load TLS certificate and key; fall back to plain HTTP if missing. + let tls_cert = std::path::Path::new(&config.security.web_tls_cert_path); + 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( + &config.security.web_tls_cert_path, + &config.security.web_tls_key_path, + ) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to load TLS certificates"); + e + })?; + + tracing::info!(%addr, "Listening (HTTPS)"); + axum_server::bind_rustls(addr, tls_config) + .serve(app.into_make_service()) + .await?; + } else { + tracing::warn!( + cert_path = %config.security.web_tls_cert_path, + key_path = %config.security.web_tls_key_path, + "TLS certificates not found — falling back to plain HTTP. \ + This is insecure for production!" + ); + tracing::info!(%addr, "Listening (HTTP — no TLS)"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + } + Ok(()) } @@ -144,7 +173,7 @@ pub fn build_router(state: AppState) -> Router { .nest("/jobs", routes::jobs::router()) // Maintenance windows (nested under hosts path param) .nest( - "/hosts/:host_id/maintenance-windows", + "/hosts/{host_id}/maintenance-windows", routes::maintenance_windows::router(), ) // CA root certificate download diff --git a/crates/pm-web/src/routes/ca.rs b/crates/pm-web/src/routes/ca.rs index 0074825..57dd4dd 100644 --- a/crates/pm-web/src/routes/ca.rs +++ b/crates/pm-web/src/routes/ca.rs @@ -40,16 +40,16 @@ pub fn ca_router() -> Router { pub fn certs_router() -> Router { Router::new() .route("/", get(list_certificates)) - .route("/:cert_id/renew", post(renew_cert)) - .route("/:cert_id", delete(revoke_cert)) + .route("/{cert_id}/renew", post(renew_cert)) + .route("/{cert_id}", delete(revoke_cert)) } /// Handles cert-specific paths merged under /api/v1/hosts. /// Only adds paths not already claimed by the hosts router. pub fn host_cert_router() -> Router { Router::new() - .route("/:host_id/client.crt", get(download_client_cert)) - .route("/:host_id/certificates", post(issue_client_cert)) + .route("/{host_id}/client.crt", get(download_client_cert)) + .route("/{host_id}/certificates", post(issue_client_cert)) } // ── Shared types ────────────────────────────────────────────────────────────── diff --git a/crates/pm-web/src/routes/discovery.rs b/crates/pm-web/src/routes/discovery.rs index 43f5c48..93d3edf 100644 --- a/crates/pm-web/src/routes/discovery.rs +++ b/crates/pm-web/src/routes/discovery.rs @@ -34,8 +34,8 @@ const PROBE_TIMEOUT_SECS: u64 = 2; pub fn router() -> Router { Router::new() .route("/cidr", post(start_cidr_scan)) - .route("/:scan_id", get(get_scan_results)) - .route("/:id/register", post(register_discovered_host)) + .route("/{scan_id}", get(get_scan_results)) + .route("/{id}/register", post(register_discovered_host)) } // ── POST /api/v1/discovery/cidr ─────────────────────────────────────────────── diff --git a/crates/pm-web/src/routes/groups.rs b/crates/pm-web/src/routes/groups.rs index b23a98f..b2ead1c 100644 --- a/crates/pm-web/src/routes/groups.rs +++ b/crates/pm-web/src/routes/groups.rs @@ -29,11 +29,11 @@ pub fn router() -> Router { Router::new() .route("/", get(list_groups).post(create_group)) .route( - "/:id", + "/{id}", get(get_group).put(update_group).delete(delete_group), ) .route( - "/:id/users/:user_id", + "/{id}/users/{user_id}", post(add_user_to_group).delete(remove_user_from_group), ) } diff --git a/crates/pm-web/src/routes/hosts.rs b/crates/pm-web/src/routes/hosts.rs index 2f77a70..1499538 100644 --- a/crates/pm-web/src/routes/hosts.rs +++ b/crates/pm-web/src/routes/hosts.rs @@ -30,10 +30,10 @@ use crate::AppState; pub fn router() -> Router { Router::new() .route("/", get(list_hosts).post(register_host)) - .route("/:id", get(get_host).delete(remove_host)) - .route("/:id/groups", get(list_host_groups).post(add_host_to_group)) - .route("/:id/groups/:group_id", delete(remove_host_from_group)) - .route("/:id/refresh", post(refresh_host)) + .route("/{id}", get(get_host).delete(remove_host)) + .route("/{id}/groups", get(list_host_groups).post(add_host_to_group)) + .route("/{id}/groups/{group_id}", delete(remove_host_from_group)) + .route("/{id}/refresh", post(refresh_host)) } // ── Query params ───────────────────────────────────────────────────────────── diff --git a/crates/pm-web/src/routes/jobs.rs b/crates/pm-web/src/routes/jobs.rs index 5a5a875..9a5ae2a 100644 --- a/crates/pm-web/src/routes/jobs.rs +++ b/crates/pm-web/src/routes/jobs.rs @@ -29,9 +29,9 @@ use crate::AppState; pub fn router() -> Router { Router::new() .route("/", get(list_jobs).post(create_job)) - .route("/:id", get(get_job)) - .route("/:id/cancel", post(cancel_job)) - .route("/:id/rollback", post(rollback_job)) + .route("/{id}", get(get_job)) + .route("/{id}/cancel", post(cancel_job)) + .route("/{id}/rollback", post(rollback_job)) } // ── Query params ────────────────────────────────────────────────────────────── diff --git a/crates/pm-web/src/routes/maintenance_windows.rs b/crates/pm-web/src/routes/maintenance_windows.rs index ce32b56..7127aed 100644 --- a/crates/pm-web/src/routes/maintenance_windows.rs +++ b/crates/pm-web/src/routes/maintenance_windows.rs @@ -24,12 +24,12 @@ use crate::AppState; // ── Router ──────────────────────────────────────────────────────────────────── -/// Mount as a nested router under `/hosts/:host_id/maintenance-windows`. -/// Axum will merge the `:host_id` path segment from the parent nest. +/// Mount as a nested router under `/hosts/{host_id}/maintenance-windows`. +/// Axum will merge the `{host_id}` path segment from the parent nest. pub fn router() -> Router { Router::new() .route("/", get(list_windows).post(create_window)) - .route("/:win_id", put(update_window).delete(delete_window)) + .route("/{win_id}", put(update_window).delete(delete_window)) } // ── Error helper ────────────────────────────────────────────────────────────── diff --git a/crates/pm-web/src/routes/users.rs b/crates/pm-web/src/routes/users.rs index cca15f2..3674ca5 100644 --- a/crates/pm-web/src/routes/users.rs +++ b/crates/pm-web/src/routes/users.rs @@ -29,8 +29,8 @@ pub fn router() -> Router { Router::new() .route("/", get(list_users).post(create_user)) .route("/me", get(get_current_user)) - .route("/:id", get(get_user).put(update_user).delete(delete_user)) - .route("/:id/revoke", post(revoke_user_sessions)) + .route("/{id}", get(get_user).put(update_user).delete(delete_user)) + .route("/{id}/revoke", post(revoke_user_sessions)) } async fn list_users( diff --git a/docs/testing-report-dev-lxc.md b/docs/testing-report-dev-lxc.md new file mode 100644 index 0000000..b5eba87 --- /dev/null +++ b/docs/testing-report-dev-lxc.md @@ -0,0 +1,130 @@ +# Linux Patch Manager — Dev LXC Testing Report + +**Date:** 2026-04-28 +**Environment:** LXC 131 (linux-patch-manager-dev) on MoonProx13 +**OS:** Ubuntu 24.04 LTS +**IP:** 192.168.0.247 +**Snapshot:** `pre-install` (clean Ubuntu 24.04 + echo user + SSH key + sudo) + +--- + +## Issues Found & Fixed + +### BUG-1: setup.sh Missing PostgreSQL Schema GRANT Statements +**Severity:** Critical (service cannot start) +**File:** `scripts/setup.sh` +**Root Cause:** PostgreSQL 15+ removed automatic CREATE/USAGE grants on the `public` schema. The setup script created the database and user but never granted schema permissions. +**Fix:** Added GRANT statements after database creation (lines 111-116): +```bash +# Grant schema permissions (PostgreSQL 15+ requires explicit grants) +sudo -u postgres psql -v ON_ERROR_STOP=1 -d ${DB_NAME} </dev/null || echo "localhost") + HOSTNAME_SHORT=$(hostname -s 2>/dev/null || echo "localhost") + openssl req -new -x509 -key "${TLS_KEY}" -out "${TLS_CERT}" \ + -days 365 \ + -subj "/CN=${HOSTNAME_FQDN}/O=Linux Patch Manager" \ + -addext "subjectAltName=DNS:${HOSTNAME_FQDN},DNS:${HOSTNAME_SHORT},DNS:localhost,IP:127.0.0.1,IP:::1" + chown "${SERVICE_USER}:${SERVICE_GROUP}" "${TLS_CERT}" "${TLS_KEY}" + chmod 644 "${TLS_CERT}" + chmod 600 "${TLS_KEY}" + info "TLS certificate generated for ${HOSTNAME_FQDN}." + warn "Self-signed certificate — replace with CA-signed cert for production!" +else + warn "TLS certificate already exists at ${TLS_CERT}, skipping." fi # ----------------------------------------------------------------------- diff --git a/systemd/patch-manager-web.service b/systemd/patch-manager-web.service index adee0c4..b92888a 100644 --- a/systemd/patch-manager-web.service +++ b/systemd/patch-manager-web.service @@ -29,8 +29,8 @@ StartLimitBurst=5 TimeoutStartSec=90s TimeoutStopSec=30s -# Watchdog — pm-web must report health within this interval -WatchdogSec=120s +# Watchdog disabled — pm-web does not currently implement sd_notify +# WatchdogSec=120s # Security hardening NoNewPrivileges=true diff --git a/systemd/patch-manager-worker.service b/systemd/patch-manager-worker.service index 6af7d52..b584d81 100644 --- a/systemd/patch-manager-worker.service +++ b/systemd/patch-manager-worker.service @@ -29,8 +29,8 @@ StartLimitBurst=5 TimeoutStartSec=120s TimeoutStopSec=120s -# Watchdog — worker must report heartbeat within this interval -WatchdogSec=180s +# Watchdog disabled — pm-worker does not currently implement sd_notify +# WatchdogSec=180s # Security hardening NoNewPrivileges=true