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:
@ -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<String>,
|
||||
totp_secret: Option<String>,
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
@ -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
|
||||
// ============================================================
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -40,16 +40,16 @@ pub fn ca_router() -> Router<AppState> {
|
||||
pub fn certs_router() -> Router<AppState> {
|
||||
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<AppState> {
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -34,8 +34,8 @@ const PROBE_TIMEOUT_SECS: u64 = 2;
|
||||
pub fn router() -> Router<AppState> {
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
@ -29,11 +29,11 @@ pub fn router() -> Router<AppState> {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@ -30,10 +30,10 @@ use crate::AppState;
|
||||
pub fn router() -> Router<AppState> {
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@ -29,9 +29,9 @@ use crate::AppState;
|
||||
pub fn router() -> Router<AppState> {
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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<AppState> {
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -29,8 +29,8 @@ pub fn router() -> Router<AppState> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user