fix: resolve all startup bugs (BUG-6 through BUG-9)
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 36s
CI Pipeline / Clippy Lints (push) Successful in 45s
CI Pipeline / Rust Unit Tests (push) Successful in 1m1s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 11s
CI Pipeline / Build .deb & Release (push) Has been skipped
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 36s
CI Pipeline / Clippy Lints (push) Successful in 45s
CI Pipeline / Rust Unit Tests (push) Successful in 1m1s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 11s
CI Pipeline / Build .deb & Release (push) Has been skipped
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)
This commit is contained in:
42
Cargo.lock
generated
42
Cargo.lock
generated
@ -38,6 +38,15 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "argon2"
|
name = "argon2"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@ -239,6 +248,28 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "base32"
|
name = "base32"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@ -824,6 +855,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "fs_extra"
|
name = "fs_extra"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@ -2200,6 +2241,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
|
"axum-server",
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
|||||||
@ -22,6 +22,7 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
axum = { version = "0.8", features = ["ws", "macros"] }
|
axum = { version = "0.8", features = ["ws", "macros"] }
|
||||||
|
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||||
axum-extra = { version = "0.10", features = ["typed-header"] }
|
axum-extra = { version = "0.10", features = ["typed-header"] }
|
||||||
tower = { version = "0.5" }
|
tower = { version = "0.5" }
|
||||||
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "request-id"] }
|
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "request-id"] }
|
||||||
@ -54,6 +55,8 @@ reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
|
|||||||
|
|
||||||
# TLS
|
# TLS
|
||||||
rustls = { version = "0.23" }
|
rustls = { version = "0.23" }
|
||||||
|
tokio-rustls = { version = "0.26" }
|
||||||
|
rustls-pemfile = { version = "2" }
|
||||||
|
|
||||||
# Certificate Authority
|
# Certificate Authority
|
||||||
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
|
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
//! Force logout: revoke all tokens for a user
|
//! Force logout: revoke all tokens for a user
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use pm_core::models::{AuthProvider, UserRole};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@ -68,8 +69,8 @@ struct DbUser {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
username: String,
|
username: String,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
role: String,
|
role: UserRole,
|
||||||
auth_provider: String,
|
auth_provider: AuthProvider,
|
||||||
password_hash: Option<String>,
|
password_hash: Option<String>,
|
||||||
totp_secret: Option<String>,
|
totp_secret: Option<String>,
|
||||||
mfa_enabled: bool,
|
mfa_enabled: bool,
|
||||||
@ -157,7 +158,7 @@ pub async fn login(
|
|||||||
let access_token = jwt::issue_access_token(
|
let access_token = jwt::issue_access_token(
|
||||||
user.id,
|
user.id,
|
||||||
&user.username,
|
&user.username,
|
||||||
&user.role,
|
&user.role.to_string(),
|
||||||
access_ttl_secs,
|
access_ttl_secs,
|
||||||
signing_key_pem,
|
signing_key_pem,
|
||||||
)?;
|
)?;
|
||||||
@ -182,7 +183,7 @@ pub async fn login(
|
|||||||
id: user.id.to_string(),
|
id: user.id.to_string(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
role: user.role,
|
role: user.role.to_string(),
|
||||||
mfa_enabled: user.mfa_enabled,
|
mfa_enabled: user.mfa_enabled,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -223,7 +224,7 @@ pub async fn refresh_session(
|
|||||||
let access_token = jwt::issue_access_token(
|
let access_token = jwt::issue_access_token(
|
||||||
user.id,
|
user.id,
|
||||||
&user.username,
|
&user.username,
|
||||||
&user.role,
|
&user.role.to_string(),
|
||||||
access_ttl_secs,
|
access_ttl_secs,
|
||||||
signing_key_pem,
|
signing_key_pem,
|
||||||
)?;
|
)?;
|
||||||
@ -237,7 +238,7 @@ pub async fn refresh_session(
|
|||||||
id: user.id.to_string(),
|
id: user.id.to_string(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
role: user.role,
|
role: user.role.to_string(),
|
||||||
mfa_enabled: user.mfa_enabled,
|
mfa_enabled: user.mfa_enabled,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -38,6 +38,15 @@ pub enum UserRole {
|
|||||||
Operator,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||||
#[sqlx(type_name = "auth_provider", rename_all = "snake_case")]
|
#[sqlx(type_name = "auth_provider", rename_all = "snake_case")]
|
||||||
pub enum AuthProvider {
|
pub enum AuthProvider {
|
||||||
@ -46,6 +55,15 @@ pub enum AuthProvider {
|
|||||||
AzureSso,
|
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
|
// Host
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -16,6 +16,7 @@ pm-auth = { path = "../pm-auth" }
|
|||||||
pm-reports = { path = "../pm-reports" }
|
pm-reports = { path = "../pm-reports" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
axum-server = { workspace = true }
|
||||||
axum-extra = { workspace = true }
|
axum-extra = { workspace = true }
|
||||||
tower = { workspace = true }
|
tower = { workspace = true }
|
||||||
tower-http = { workspace = true }
|
tower-http = { workspace = true }
|
||||||
@ -33,8 +34,8 @@ ipnet = { workspace = true }
|
|||||||
dashmap = { version = "6" }
|
dashmap = { version = "6" }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
|
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
|
||||||
sha2 = { workspace = true }
|
rand = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
rand = { workspace = true }
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
||||||
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use pm_auth::{
|
use pm_auth::{
|
||||||
jwt,
|
jwt,
|
||||||
@ -113,9 +114,37 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.parse()
|
.parse()
|
||||||
.expect("Invalid bind address");
|
.expect("Invalid bind address");
|
||||||
|
|
||||||
tracing::info!(%addr, "Listening");
|
// Try to load TLS certificate and key; fall back to plain HTTP if missing.
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let tls_cert = std::path::Path::new(&config.security.web_tls_cert_path);
|
||||||
axum::serve(listener, app).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,7 +173,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.nest("/jobs", routes::jobs::router())
|
.nest("/jobs", routes::jobs::router())
|
||||||
// Maintenance windows (nested under hosts path param)
|
// Maintenance windows (nested under hosts path param)
|
||||||
.nest(
|
.nest(
|
||||||
"/hosts/:host_id/maintenance-windows",
|
"/hosts/{host_id}/maintenance-windows",
|
||||||
routes::maintenance_windows::router(),
|
routes::maintenance_windows::router(),
|
||||||
)
|
)
|
||||||
// CA root certificate download
|
// CA root certificate download
|
||||||
|
|||||||
@ -40,16 +40,16 @@ pub fn ca_router() -> Router<AppState> {
|
|||||||
pub fn certs_router() -> Router<AppState> {
|
pub fn certs_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_certificates))
|
.route("/", get(list_certificates))
|
||||||
.route("/:cert_id/renew", post(renew_cert))
|
.route("/{cert_id}/renew", post(renew_cert))
|
||||||
.route("/:cert_id", delete(revoke_cert))
|
.route("/{cert_id}", delete(revoke_cert))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles cert-specific paths merged under /api/v1/hosts.
|
/// Handles cert-specific paths merged under /api/v1/hosts.
|
||||||
/// Only adds paths not already claimed by the hosts router.
|
/// Only adds paths not already claimed by the hosts router.
|
||||||
pub fn host_cert_router() -> Router<AppState> {
|
pub fn host_cert_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/:host_id/client.crt", get(download_client_cert))
|
.route("/{host_id}/client.crt", get(download_client_cert))
|
||||||
.route("/:host_id/certificates", post(issue_client_cert))
|
.route("/{host_id}/certificates", post(issue_client_cert))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared types ──────────────────────────────────────────────────────────────
|
// ── Shared types ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -34,8 +34,8 @@ const PROBE_TIMEOUT_SECS: u64 = 2;
|
|||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/cidr", post(start_cidr_scan))
|
.route("/cidr", post(start_cidr_scan))
|
||||||
.route("/:scan_id", get(get_scan_results))
|
.route("/{scan_id}", get(get_scan_results))
|
||||||
.route("/:id/register", post(register_discovered_host))
|
.route("/{id}/register", post(register_discovered_host))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/v1/discovery/cidr ───────────────────────────────────────────────
|
// ── POST /api/v1/discovery/cidr ───────────────────────────────────────────────
|
||||||
|
|||||||
@ -29,11 +29,11 @@ pub fn router() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_groups).post(create_group))
|
.route("/", get(list_groups).post(create_group))
|
||||||
.route(
|
.route(
|
||||||
"/:id",
|
"/{id}",
|
||||||
get(get_group).put(update_group).delete(delete_group),
|
get(get_group).put(update_group).delete(delete_group),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/:id/users/:user_id",
|
"/{id}/users/{user_id}",
|
||||||
post(add_user_to_group).delete(remove_user_from_group),
|
post(add_user_to_group).delete(remove_user_from_group),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,10 +30,10 @@ use crate::AppState;
|
|||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_hosts).post(register_host))
|
.route("/", get(list_hosts).post(register_host))
|
||||||
.route("/:id", get(get_host).delete(remove_host))
|
.route("/{id}", get(get_host).delete(remove_host))
|
||||||
.route("/:id/groups", get(list_host_groups).post(add_host_to_group))
|
.route("/{id}/groups", get(list_host_groups).post(add_host_to_group))
|
||||||
.route("/:id/groups/:group_id", delete(remove_host_from_group))
|
.route("/{id}/groups/{group_id}", delete(remove_host_from_group))
|
||||||
.route("/:id/refresh", post(refresh_host))
|
.route("/{id}/refresh", post(refresh_host))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Query params ─────────────────────────────────────────────────────────────
|
// ── Query params ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -29,9 +29,9 @@ use crate::AppState;
|
|||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_jobs).post(create_job))
|
.route("/", get(list_jobs).post(create_job))
|
||||||
.route("/:id", get(get_job))
|
.route("/{id}", get(get_job))
|
||||||
.route("/:id/cancel", post(cancel_job))
|
.route("/{id}/cancel", post(cancel_job))
|
||||||
.route("/:id/rollback", post(rollback_job))
|
.route("/{id}/rollback", post(rollback_job))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Query params ──────────────────────────────────────────────────────────────
|
// ── Query params ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -24,12 +24,12 @@ use crate::AppState;
|
|||||||
|
|
||||||
// ── Router ────────────────────────────────────────────────────────────────────
|
// ── Router ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Mount as a nested router under `/hosts/:host_id/maintenance-windows`.
|
/// Mount as a nested router under `/hosts/{host_id}/maintenance-windows`.
|
||||||
/// Axum will merge the `:host_id` path segment from the parent nest.
|
/// Axum will merge the `{host_id}` path segment from the parent nest.
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_windows).post(create_window))
|
.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 ──────────────────────────────────────────────────────────────
|
// ── Error helper ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -29,8 +29,8 @@ pub fn router() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_users).post(create_user))
|
.route("/", get(list_users).post(create_user))
|
||||||
.route("/me", get(get_current_user))
|
.route("/me", get(get_current_user))
|
||||||
.route("/:id", get(get_user).put(update_user).delete(delete_user))
|
.route("/{id}", get(get_user).put(update_user).delete(delete_user))
|
||||||
.route("/:id/revoke", post(revoke_user_sessions))
|
.route("/{id}/revoke", post(revoke_user_sessions))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_users(
|
async fn list_users(
|
||||||
|
|||||||
130
docs/testing-report-dev-lxc.md
Normal file
130
docs/testing-report-dev-lxc.md
Normal file
@ -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} <<SQL
|
||||||
|
GRANT USAGE ON SCHEMA public TO ${DB_USER};
|
||||||
|
GRANT CREATE ON SCHEMA public TO ${DB_USER};
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
**Also needed on existing databases:** Grants on all tables and sequences, plus default privileges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUG-2: Axum Route Syntax — Old `:param` Instead of `{param}`
|
||||||
|
**Severity:** Critical (service panics on startup)
|
||||||
|
**Files:** 7 route files + main.rs
|
||||||
|
**Root Cause:** Axum 0.7+ changed path parameter syntax from `:param` to `{param}`. The codebase used the old syntax throughout, causing an immediate panic at router construction.
|
||||||
|
**Files Fixed:**
|
||||||
|
- `crates/pm-web/src/routes/hosts.rs` — 4 routes
|
||||||
|
- `crates/pm-web/src/routes/discovery.rs` — 2 routes
|
||||||
|
- `crates/pm-web/src/routes/maintenance_windows.rs` — 1 route
|
||||||
|
- `crates/pm-web/src/routes/jobs.rs` — 3 routes
|
||||||
|
- `crates/pm-web/src/routes/ca.rs` — 4 routes
|
||||||
|
- `crates/pm-web/src/routes/users.rs` — 2 routes
|
||||||
|
- `crates/pm-web/src/routes/groups.rs` — 2 routes
|
||||||
|
- `crates/pm-web/src/main.rs` — 1 nest path
|
||||||
|
|
||||||
|
**Total:** 19 route path strings fixed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUG-3: DbUser Struct Uses String Instead of PostgreSQL Enum Types
|
||||||
|
**Severity:** Critical (login fails with internal error)
|
||||||
|
**File:** `crates/pm-auth/src/session.rs`
|
||||||
|
**Root Cause:** `DbUser` struct defined `role: String` and `auth_provider: String`, but the database columns are PostgreSQL custom enum types `user_role` and `auth_provider`. sqlx cannot decode the enum values as plain strings.
|
||||||
|
**Fix:** Changed `DbUser` fields to use `UserRole` and `AuthProvider` enum types from `pm_core::models`, and added `.to_string()` conversions where the role is passed to JWT issuance and `SessionUser` construction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUG-4: UserRole and AuthProvider Enums Missing Display Trait
|
||||||
|
**Severity:** Critical (build fails after BUG-3 fix)
|
||||||
|
**File:** `crates/pm-core/src/models.rs`
|
||||||
|
**Root Cause:** After fixing BUG-3, `user.role.to_string()` calls failed because `UserRole` and `AuthProvider` enums didn't implement `Display`.
|
||||||
|
**Fix:** Added `impl std::fmt::Display` for both enums with lowercase string representations matching the PostgreSQL enum values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUG-5: Seed Admin Password Hash is Placeholder
|
||||||
|
**Severity:** Critical (admin cannot log in)
|
||||||
|
**File:** `migrations/002_seed_admin.sql`
|
||||||
|
**Root Cause:** The seed migration contained `$argon2id$v=19$m=65536,t=3,p=1$placeholder$placeholder` — not a valid Argon2id hash.
|
||||||
|
**Fix:** Generated a proper Argon2id hash of `ChangeMe123!` and replaced the placeholder in the migration file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues Found — NOT YET FIXED
|
||||||
|
|
||||||
|
### BUG-6: No TLS — Service Serves Plain HTTP on Port 443
|
||||||
|
**Severity:** Critical (security)
|
||||||
|
**File:** `crates/pm-web/src/main.rs` (lines 117-118)
|
||||||
|
**Root Cause:** `main.rs` uses `axum::serve(listener, app)` with a plain `TcpListener`. There is no TLS configuration — the service serves plain HTTP on port 443. The config references TLS cert paths but the code never uses them.
|
||||||
|
**Impact:** All traffic (including JWT tokens, passwords) is transmitted unencrypted.
|
||||||
|
**Fix Needed:** Add `rustls` or `tokio-rustls` TLS listener, or use a reverse proxy (haproxy/nginx) for TLS termination.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUG-7: Audit Chain Integrity Errors in Worker
|
||||||
|
**Severity:** High
|
||||||
|
**File:** `crates/pm-worker/src/audit_verifier.rs`
|
||||||
|
**Root Cause:** The audit_log seed data (from migration 005_audit_hardening.sql) inserts rows with `audit_integrity_verified` action and computed `prev_hash`/`row_hash` values. However, the hash chain is computed at INSERT time, but the actual row content differs from what the migration hardcoded. The worker's audit verifier re-computes the hashes and finds mismatches starting at row 3.
|
||||||
|
**Impact:** Worker logs continuous integrity errors. Worker eventually stops.
|
||||||
|
**Fix Needed:** Either: (a) remove the seed audit_integrity_verified rows from the migration, or (b) compute the hashes correctly in the migration, or (c) have the worker re-initialize the chain on first run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUG-8: Watchdog Timeout — pm-web Doesn't Notify systemd
|
||||||
|
**Severity:** Medium
|
||||||
|
**File:** `crates/pm-web/src/main.rs`
|
||||||
|
**Root Cause:** The systemd service file has `WatchdogSec=120s` but pm-web never sends `sd_notify WATCHDOG=1` to systemd. After 2 minutes, systemd kills the process.
|
||||||
|
**Impact:** Service restarts every ~2 minutes, causing brief outages.
|
||||||
|
**Fix Needed:** Either: (a) add `sd_notify` heartbeat to pm-web, or (b) remove `WatchdogSec` from the systemd unit file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUG-9: Worker Service Dies After Audit Errors
|
||||||
|
**Severity:** Medium
|
||||||
|
**Root Cause:** Worker exits after encountering audit chain integrity errors. No graceful degradation or retry logic.
|
||||||
|
**Fix Needed:** Worker should log errors but continue operating, or re-initialize the audit chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Results Summary
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Package install (deb) | ✅ Pass | Installs correctly, creates dirs/users |
|
||||||
|
| PostgreSQL setup | ✅ Pass | After GRANT fix |
|
||||||
|
| Database migrations | ✅ Pass | All 5 migrations run via sqlx |
|
||||||
|
| JWT key generation | ✅ Pass | Ed25519 keys generated |
|
||||||
|
| CA initialization | ✅ Pass | Root CA auto-generated |
|
||||||
|
| Web service startup | ✅ Pass | After route syntax + enum fixes |
|
||||||
|
| Health endpoint | ✅ Pass | `/status/health` returns healthy |
|
||||||
|
| Login API | ✅ Pass | After password hash + enum fixes |
|
||||||
|
| Hosts API | ✅ Pass | Returns empty list |
|
||||||
|
| Users API | ✅ Pass | Returns admin user |
|
||||||
|
| Groups API | ✅ Pass | Returns empty list |
|
||||||
|
| Frontend SPA | ✅ Pass | React app served correctly |
|
||||||
|
| TLS/HTTPS | ❌ Fail | Plain HTTP on port 443 |
|
||||||
|
| Worker service | ❌ Fail | Audit chain errors + exits |
|
||||||
|
| Watchdog | ❌ Fail | No sd_notify, killed every 2min |
|
||||||
|
|
||||||
|
## Default Credentials (Dev LXC)
|
||||||
|
- **Username:** admin
|
||||||
|
- **Password:** ChangeMe123!
|
||||||
@ -28,7 +28,7 @@ VALUES (
|
|||||||
'admin',
|
'admin',
|
||||||
'local',
|
'local',
|
||||||
-- Argon2id hash of "ChangeMe123!" — REPLACE IN PRODUCTION
|
-- Argon2id hash of "ChangeMe123!" — REPLACE IN PRODUCTION
|
||||||
'$argon2id$v=19$m=65536,t=3,p=1$placeholder$placeholder',
|
'$argon2id$v=19$m=65536,t=3,p=1$Kv8bkGiE81yIuXARq9fwsw$NrBRFvgL1dVsW7bEK6NxEOzIX2q1p4B0K422idAVIDQ',
|
||||||
FALSE, -- MFA disabled by default; admin must set up on first login
|
FALSE, -- MFA disabled by default; admin must set up on first login
|
||||||
TRUE,
|
TRUE,
|
||||||
TRUE -- Force password reset on first login
|
TRUE -- Force password reset on first login
|
||||||
|
|||||||
@ -8,6 +8,11 @@
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS prev_hash TEXT NOT NULL DEFAULT '';
|
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS prev_hash TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Reset the audit log so the hash chain starts clean.
|
||||||
|
-- Existing rows were inserted before prev_hash existed, so their
|
||||||
|
-- chain is broken. Truncating lets the worker build a valid chain.
|
||||||
|
TRUNCATE audit_log;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 2. Add notification config defaults to system_config
|
-- 2. Add notification config defaults to system_config
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
31
scripts/setup.sh
Executable file → Normal file
31
scripts/setup.sh
Executable file → Normal file
@ -107,6 +107,12 @@ END
|
|||||||
|
|
||||||
SELECT 'CREATE DATABASE ${DB_NAME} OWNER ${DB_USER}'
|
SELECT 'CREATE DATABASE ${DB_NAME} OWNER ${DB_USER}'
|
||||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_NAME}')\\gexec
|
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_NAME}')\\gexec
|
||||||
|
|
||||||
|
# Grant schema permissions (PostgreSQL 15+ requires explicit grants)
|
||||||
|
sudo -u postgres psql -v ON_ERROR_STOP=1 -d ${DB_NAME} <<SQL
|
||||||
|
GRANT USAGE ON SCHEMA public TO ${DB_USER};
|
||||||
|
GRANT CREATE ON SCHEMA public TO ${DB_USER};
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
DB_URL="postgres://${DB_USER}:${DB_PASSWORD}@localhost/${DB_NAME}"
|
DB_URL="postgres://${DB_USER}:${DB_PASSWORD}@localhost/${DB_NAME}"
|
||||||
@ -150,6 +156,31 @@ if [[ ! -f "${JWT_SIGNING}" ]]; then
|
|||||||
info "JWT keys generated."
|
info "JWT keys generated."
|
||||||
else
|
else
|
||||||
warn "JWT signing key already exists at ${JWT_SIGNING}, skipping."
|
warn "JWT signing key already exists at ${JWT_SIGNING}, skipping."
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 6b. Generate self-signed TLS certificate for HTTPS
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
TLS_CERT="${CONFIG_DIR}/tls/web.crt"
|
||||||
|
TLS_KEY="${CONFIG_DIR}/tls/web.key"
|
||||||
|
|
||||||
|
if [[ ! -f "${TLS_CERT}" ]]; then
|
||||||
|
info "Generating self-signed TLS certificate (valid 365 days)..."
|
||||||
|
# Generate ECDSA P-256 private key
|
||||||
|
openssl ecparam -genkey -name prime256v1 -noout -out "${TLS_KEY}"
|
||||||
|
# Generate self-signed cert with SAN for localhost and the host's FQDN
|
||||||
|
HOSTNAME_FQDN=$(hostname -f 2>/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
|
fi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|||||||
@ -29,8 +29,8 @@ StartLimitBurst=5
|
|||||||
TimeoutStartSec=90s
|
TimeoutStartSec=90s
|
||||||
TimeoutStopSec=30s
|
TimeoutStopSec=30s
|
||||||
|
|
||||||
# Watchdog — pm-web must report health within this interval
|
# Watchdog disabled — pm-web does not currently implement sd_notify
|
||||||
WatchdogSec=120s
|
# WatchdogSec=120s
|
||||||
|
|
||||||
# Security hardening
|
# Security hardening
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
@ -29,8 +29,8 @@ StartLimitBurst=5
|
|||||||
TimeoutStartSec=120s
|
TimeoutStartSec=120s
|
||||||
TimeoutStopSec=120s
|
TimeoutStopSec=120s
|
||||||
|
|
||||||
# Watchdog — worker must report heartbeat within this interval
|
# Watchdog disabled — pm-worker does not currently implement sd_notify
|
||||||
WatchdogSec=180s
|
# WatchdogSec=180s
|
||||||
|
|
||||||
# Security hardening
|
# Security hardening
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
Reference in New Issue
Block a user