Private
Public Access
1
0

feat(M1): Project scaffolding, DB schema, core infrastructure

- Initialize Rust workspace with 7 crates (pm-web, pm-worker, pm-core,
  pm-agent-client, pm-auth, pm-ca, pm-reports)
- React + TypeScript + Vite + MUI frontend scaffold
- Full PostgreSQL schema: all 17 tables with indexes and constraints
- pm-core: config (TOML+env), db (SQLx pool + migrations), error
  (unified AppError + JSON envelope), request_id (ULID middleware),
  logging (tracing JSON/pretty)
- pm-web: Axum skeleton, /status/health endpoint, static file serving
- pm-worker: Tokio skeleton, heartbeat writer, schema version check
- Embedded sqlx migrations with advisory lock (single-writer)
- systemd unit files, setup.sh, build-frontend.sh
- config.example.toml with all configuration keys
- docs/runbooks/restore.md
- cargo check passes with zero warnings

Closes M1.
This commit is contained in:
2026-04-23 15:55:53 +00:00
parent 3eb7fd9f95
commit da5a94d838
50 changed files with 6139 additions and 3 deletions

7
.gitignore vendored
View File

@ -16,3 +16,10 @@ venv/**
# Backup files # Backup files
*.bak *.bak
*.bak.* *.bak.*
# Rust build artifacts
/target
# Frontend dependencies
frontend/node_modules
frontend/dist

3799
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

62
Cargo.toml Normal file
View File

@ -0,0 +1,62 @@
[workspace]
resolver = "2"
members = [
"crates/pm-web",
"crates/pm-worker",
"crates/pm-core",
"crates/pm-agent-client",
"crates/pm-auth",
"crates/pm-ca",
"crates/pm-reports",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"]
license = "MIT"
[workspace.dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# Web framework
axum = { version = "0.8", features = ["ws", "macros"] }
tower = { version = "0.5" }
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "request-id"] }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "uuid", "chrono", "json"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1" }
toml = { version = "0.8" }
# Error handling
thiserror = { version = "2" }
anyhow = { version = "1" }
# Logging / Tracing
tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# UUID / ULID
uuid = { version = "1", features = ["v4", "serde"] }
ulid = { version = "1", features = ["serde"] }
# Time
chrono = { version = "0.4", features = ["serde"] }
# HTTP client
reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
# TLS
rustls = { version = "0.23" }
# Config
config = { version = "0.15" }
# Misc
bytes = { version = "1" }
futures = { version = "0.3" }

View File

@ -174,9 +174,7 @@ Management plane web application communicating with Linux Patch API agents on ea
- Patch Manager host has network connectivity to all managed agents - Patch Manager host has network connectivity to all managed agents
- Linux Patch API agent is installed and running on each managed host - Linux Patch API agent is installed and running on each managed host
- Server administrators manually distribute mTLS and root certificates to managed clients - Server administrators manually distribute mTLS and root certificates to managed clients
- PostgreSQL is available on the Patch Manager host - PostgreSQL 16+ is available on the Patch Manager host
- Server administrators manually distribute mTLS and root certificates to managed clients
- PostgreSQL is available on the Patch Manager host
- Hardware host provides full-disk encryption (no OS-level disk encryption managed by the application) - Hardware host provides full-disk encryption (no OS-level disk encryption managed by the application)
## Dependencies ## Dependencies

View File

@ -0,0 +1,95 @@
# Linux Patch Manager — Example Configuration
# Copy to /etc/patch-manager/config.toml and edit for your environment.
#
# Environment variable overrides follow the pattern:
# PATCH_MANAGER__SECTION__KEY=value
# e.g. PATCH_MANAGER__DATABASE__URL=postgres://...
# ============================================================
# Web Server
# ============================================================
[server]
# Bind address for the HTTPS listener
host = "0.0.0.0"
# HTTPS port (443 for production; 8443 for non-root dev)
port = 443
# Path to compiled React SPA static files
static_dir = "/usr/share/patch-manager/frontend"
# ============================================================
# Database
# ============================================================
[database]
# PostgreSQL connection URL
url = "postgres://patch_manager:CHANGEME@localhost/patch_manager"
# Connection pool sizing
max_connections = 20
min_connections = 2
# Seconds to wait for a connection from the pool
acquire_timeout_secs = 30
# ============================================================
# Background Worker
# ============================================================
[worker]
# Agent health check interval (seconds). Default: 300 = 5 minutes
health_poll_interval_secs = 300
# Agent patch data poll interval (seconds). Default: 1800 = 30 minutes
patch_poll_interval_secs = 1800
# Maximum concurrent mTLS agent calls (Tokio Semaphore)
max_concurrent_agent_calls = 64
# Worker heartbeat write interval (seconds)
heartbeat_interval_secs = 30
# ============================================================
# Logging
# ============================================================
[logging]
# Log level: trace, debug, info, warn, error
# Override with RUST_LOG environment variable
level = "info"
# Output format: "json" (production) or "pretty" (development)
format = "json"
# ============================================================
# Security
# ============================================================
[security]
# IP whitelist: list of CIDRs or individual IPs allowed to connect.
# IMPORTANT: An empty list allows ALL IPs. Restrict this in production.
# Example: ["10.0.0.0/8", "192.168.1.50"]
ip_whitelist = []
# Ed25519 JWT signing key (private key, PEM format)
# Generate: openssl genpkey -algorithm ed25519 -out /etc/patch-manager/jwt/signing.pem
jwt_signing_key_path = "/etc/patch-manager/jwt/signing.pem"
# Ed25519 JWT verification key (public key, PEM format)
# Generate: openssl pkey -in /etc/patch-manager/jwt/signing.pem -pubout -out /etc/patch-manager/jwt/verify.pem
jwt_verify_key_path = "/etc/patch-manager/jwt/verify.pem"
# JWT access token TTL in seconds (default: 900 = 15 minutes)
jwt_access_ttl_secs = 900
# mTLS client certificate for agent communication
agent_client_cert_path = "/etc/patch-manager/certs/client.crt"
agent_client_key_path = "/etc/patch-manager/certs/client.key"
# Internal CA certificate and private key
# Private key has 0600 permissions; protected by hardware-host FDE
ca_cert_path = "/etc/patch-manager/ca/ca.crt"
ca_key_path = "/etc/patch-manager/ca/ca.key"
# Web UI TLS certificate (default: self-signed from internal CA)
# Set web_tls_strategy = 'operator_supplied' in system_config and
# point these paths to your certificate/key to use your own cert.
web_tls_cert_path = "/etc/patch-manager/tls/web.crt"
web_tls_key_path = "/etc/patch-manager/tls/web.key"

View File

@ -0,0 +1,19 @@
[package]
name = "pm-agent-client"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
pm-core = { path = "../pm-core" }
tokio = { workspace = true }
reqwest = { workspace = true }
rustls = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@ -0,0 +1,10 @@
//! Agent HTTP client stub.
//! Full mTLS Rustls-based implementation arrives in M4.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AgentClientError {
#[error("Not yet implemented")]
NotImplemented,
}

View File

@ -0,0 +1,4 @@
//! pm-agent-client — mTLS HTTP client for Linux Patch API agent communication.
//!
//! M1: Stub. Full implementation in M4.
pub mod client;

19
crates/pm-auth/Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "pm-auth"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
pm-core = { path = "../pm-core" }
tokio = { workspace = true }
axum = { workspace = true }
sqlx = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@ -0,0 +1 @@
//! jwt — stub for M2.

10
crates/pm-auth/src/lib.rs Normal file
View File

@ -0,0 +1,10 @@
//! pm-auth — Authentication and authorization.
//!
//! Modules: password (Argon2id), jwt (EdDSA), refresh tokens,
//! mfa_totp, mfa_webauthn, rbac, session.
//!
//! M1: Stub. Full implementation in M2.
pub mod password;
pub mod jwt;
pub mod rbac;
pub mod session;

View File

@ -0,0 +1 @@
//! password — stub for M2.

View File

@ -0,0 +1 @@
//! rbac — stub for M2.

View File

@ -0,0 +1 @@
//! session — stub for M2.

16
crates/pm-ca/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "pm-ca"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
pm-core = { path = "../pm-core" }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }

1
crates/pm-ca/src/ca.rs Normal file
View File

@ -0,0 +1 @@
//! Internal CA stub for M8.

7
crates/pm-ca/src/lib.rs Normal file
View File

@ -0,0 +1,7 @@
//! pm-ca — Internal Certificate Authority.
//!
//! Issues and renews mTLS client certificates for agent communication.
//! Uses rcgen + rustls. CA key stored at /etc/patch-manager/ca/ca.key.
//!
//! M1: Stub. Full implementation in M8.
pub mod ca;

22
crates/pm-core/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "pm-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
tokio = { workspace = true }
sqlx = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
ulid = { workspace = true }
chrono = { workspace = true }
config = { workspace = true }
axum = { workspace = true }

View File

@ -0,0 +1,137 @@
use serde::{Deserialize, Serialize};
use config::{Config, ConfigError, Environment, File};
/// Top-level application configuration.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppConfig {
pub server: ServerConfig,
pub database: DatabaseConfig,
pub worker: WorkerConfig,
pub logging: LoggingConfig,
pub security: SecurityConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
/// Bind address for the web server
pub host: String,
/// HTTPS port
pub port: u16,
/// Path to static frontend assets
pub static_dir: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DatabaseConfig {
/// Full PostgreSQL connection URL
pub url: String,
/// Maximum pool connections
pub max_connections: u32,
/// Minimum pool connections
pub min_connections: u32,
/// Connection acquire timeout in seconds
pub acquire_timeout_secs: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkerConfig {
/// Health poll interval in seconds (default: 300 = 5 min)
pub health_poll_interval_secs: u64,
/// Patch data poll interval in seconds (default: 1800 = 30 min)
pub patch_poll_interval_secs: u64,
/// Maximum concurrent agent calls
pub max_concurrent_agent_calls: usize,
/// Worker heartbeat interval in seconds
pub heartbeat_interval_secs: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LoggingConfig {
/// Log level filter: trace, debug, info, warn, error
pub level: String,
/// Output format: json or pretty
pub format: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SecurityConfig {
/// IP whitelist (CIDR or individual IPs); empty = allow all (not recommended)
pub ip_whitelist: Vec<String>,
/// JWT signing key path (Ed25519 PEM)
pub jwt_signing_key_path: String,
/// JWT verification key path (Ed25519 public PEM)
pub jwt_verify_key_path: String,
/// JWT access token TTL in seconds (default: 900 = 15 min)
pub jwt_access_ttl_secs: u64,
/// Agent mTLS client cert path
pub agent_client_cert_path: String,
/// Agent mTLS client key path
pub agent_client_key_path: String,
/// Internal CA cert path
pub ca_cert_path: String,
/// Internal CA key path
pub ca_key_path: String,
/// Web UI TLS cert path
pub web_tls_cert_path: String,
/// Web UI TLS key path
pub web_tls_key_path: String,
}
impl AppConfig {
/// Load configuration from a TOML file and environment variable overrides.
///
/// Environment variables follow the pattern: `PATCH_MANAGER__SECTION__KEY`
/// e.g. `PATCH_MANAGER__DATABASE__URL=postgres://...`
pub fn load(config_path: &str) -> Result<Self, ConfigError> {
let cfg = Config::builder()
.add_source(File::with_name(config_path).required(false))
.add_source(
Environment::with_prefix("PATCH_MANAGER")
.separator("__")
.try_parsing(true),
)
.build()?;
cfg.try_deserialize()
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
server: ServerConfig {
host: "0.0.0.0".to_string(),
port: 443,
static_dir: "/usr/share/patch-manager/frontend".to_string(),
},
database: DatabaseConfig {
url: "postgres://patch_manager:changeme@localhost/patch_manager".to_string(),
max_connections: 20,
min_connections: 2,
acquire_timeout_secs: 30,
},
worker: WorkerConfig {
health_poll_interval_secs: 300,
patch_poll_interval_secs: 1800,
max_concurrent_agent_calls: 64,
heartbeat_interval_secs: 30,
},
logging: LoggingConfig {
level: "info".to_string(),
format: "json".to_string(),
},
security: SecurityConfig {
ip_whitelist: vec![],
jwt_signing_key_path: "/etc/patch-manager/jwt/signing.pem".to_string(),
jwt_verify_key_path: "/etc/patch-manager/jwt/verify.pem".to_string(),
jwt_access_ttl_secs: 900,
agent_client_cert_path: "/etc/patch-manager/certs/client.crt".to_string(),
agent_client_key_path: "/etc/patch-manager/certs/client.key".to_string(),
ca_cert_path: "/etc/patch-manager/ca/ca.crt".to_string(),
ca_key_path: "/etc/patch-manager/ca/ca.key".to_string(),
web_tls_cert_path: "/etc/patch-manager/tls/web.crt".to_string(),
web_tls_key_path: "/etc/patch-manager/tls/web.key".to_string(),
},
}
}
}

69
crates/pm-core/src/db.rs Normal file
View File

@ -0,0 +1,69 @@
use sqlx::postgres::{PgPool, PgPoolOptions};
use std::time::Duration;
use crate::config::DatabaseConfig;
/// Initialize and return a PostgreSQL connection pool.
pub async fn init_pool(cfg: &DatabaseConfig) -> Result<PgPool, sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(cfg.max_connections)
.min_connections(cfg.min_connections)
.acquire_timeout(Duration::from_secs(cfg.acquire_timeout_secs))
.connect(&cfg.url)
.await?;
tracing::info!(
max_connections = cfg.max_connections,
"PostgreSQL connection pool initialized"
);
Ok(pool)
}
/// Run embedded SQLx migrations.
/// Uses a PostgreSQL advisory lock to ensure only one writer runs migrations.
pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
tracing::info!("Acquiring advisory lock for migrations");
// Advisory lock key — consistent hash of the application name
const LOCK_KEY: i64 = 0x7061_7463_686d_6772; // "patchmgr" bytes
// Acquire advisory lock; blocks until granted
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(LOCK_KEY)
.execute(pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to acquire advisory lock");
e
})
.expect("Advisory lock must be acquired before running migrations");
tracing::info!("Running database migrations");
let result = sqlx::migrate!("../../migrations").run(pool).await;
// Always release the lock
sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(LOCK_KEY)
.execute(pool)
.await
.ok();
match &result {
Ok(_) => tracing::info!("Database migrations completed successfully"),
Err(e) => tracing::error!(error = %e, "Database migrations failed"),
}
result
}
/// Check that the database schema is at the expected version.
/// Used by the worker to wait until migrations have been applied.
pub async fn check_schema_version(pool: &PgPool) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM _sqlx_migrations WHERE success = true",
)
.fetch_one(pool)
.await?;
Ok(row.0)
}

124
crates/pm-core/src/error.rs Normal file
View File

@ -0,0 +1,124 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Unified application error type.
#[derive(Debug, Error)]
pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Unprocessable entity: {0}")]
UnprocessableEntity(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
#[error("Configuration error: {0}")]
Config(String),
}
/// JSON error envelope returned to clients.
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: ErrorDetail,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorDetail {
/// Machine-readable error code (e.g. "not_found", "unauthorized")
pub code: String,
/// Human-readable message
pub message: String,
/// Request ID for correlation (set by middleware)
pub request_id: Option<String>,
/// Optional structured details
pub details: Option<serde_json::Value>,
}
impl ErrorResponse {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
error: ErrorDetail {
code: code.into(),
message: message.into(),
request_id: None,
details: None,
},
}
}
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.error.request_id = Some(request_id.into());
self
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.error.details = Some(details);
self
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "unauthorized", msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg.clone()),
AppError::UnprocessableEntity(msg) => {
(StatusCode::UNPROCESSABLE_ENTITY, "unprocessable_entity", msg.clone())
}
AppError::Database(e) => {
tracing::error!(error = %e, "Database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"An internal error occurred".to_string(),
)
}
AppError::Internal(e) => {
tracing::error!(error = %e, "Internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"An internal error occurred".to_string(),
)
}
AppError::Config(msg) => {
tracing::error!(error = %msg, "Configuration error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"config_error",
"Server configuration error".to_string(),
)
}
};
let body = ErrorResponse::new(code, message);
(status, Json(body)).into_response()
}
}
/// Convenience alias for handler return types.
pub type ApiResult<T> = Result<T, AppError>;

View File

@ -0,0 +1,9 @@
pub mod config;
pub mod db;
pub mod error;
pub mod logging;
pub mod request_id;
// Re-export commonly used types
pub use error::{AppError, ErrorResponse};
pub use config::AppConfig;

View File

@ -0,0 +1,32 @@
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use crate::config::LoggingConfig;
/// Initialize the global tracing subscriber.
///
/// Format is controlled by `cfg.format`:
/// - `"json"` — machine-readable JSON (production default)
/// - anything else — human-readable pretty output (development)
///
/// Log level is controlled by `cfg.level` (e.g. `"info"`, `"debug"`).
/// The `RUST_LOG` environment variable overrides `cfg.level`.
pub fn init(cfg: &LoggingConfig) {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&cfg.level));
match cfg.format.as_str() {
"json" => {
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().json().with_current_span(true))
.init();
}
_ => {
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().pretty())
.init();
}
}
tracing::info!(format = %cfg.format, level = %cfg.level, "Logging initialized");
}

View File

@ -0,0 +1,44 @@
use axum::{
extract::Request,
http::HeaderValue,
middleware::Next,
response::Response,
};
use ulid::Ulid;
/// HTTP header name for request correlation IDs.
pub const REQUEST_ID_HEADER: &str = "x-request-id";
/// Axum middleware that generates a ULID request ID and attaches it
/// to both the request extensions and the response header.
pub async fn request_id_middleware(mut req: Request, next: Next) -> Response {
// Use existing X-Request-Id if provided by upstream proxy, else generate
let id = req
.headers()
.get(REQUEST_ID_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| Ulid::new().to_string());
// Insert as extension so handlers can access it
req.extensions_mut().insert(RequestId(id.clone()));
let mut response = next.run(req).await;
// Echo the ID back in the response
if let Ok(value) = HeaderValue::from_str(&id) {
response.headers_mut().insert(REQUEST_ID_HEADER, value);
}
response
}
/// Extractor type for retrieving the request ID inside handlers.
#[derive(Debug, Clone)]
pub struct RequestId(pub String);
impl std::fmt::Display for RequestId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

View File

@ -0,0 +1,16 @@
[package]
name = "pm-reports"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
pm-core = { path = "../pm-core" }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }

View File

@ -0,0 +1 @@
//! csv report generation stub for M9.

View File

@ -0,0 +1,7 @@
//! pm-reports — CSV and PDF report generation.
//!
//! Uses printpdf + plotters for in-process PDF with charts.
//!
//! M1: Stub. Full implementation in M9.
pub mod csv;
pub mod pdf;

View File

@ -0,0 +1 @@
//! pdf report generation stub for M9.

27
crates/pm-web/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "pm-web"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "pm-web"
path = "src/main.rs"
[dependencies]
pm-core = { path = "../pm-core" }
tokio = { workspace = true }
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
sqlx = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
ulid = { workspace = true }
chrono = { workspace = true }

137
crates/pm-web/src/main.rs Normal file
View File

@ -0,0 +1,137 @@
//! pm-web — Linux Patch Manager web server.
//!
//! Serves the React SPA, exposes the REST API, and handles WebSocket relay.
use axum::{
extract::State,
http::StatusCode,
middleware,
response::Json,
routing::get,
Router,
};
use pm_core::{
config::AppConfig,
db,
logging,
request_id::request_id_middleware,
};
use serde_json::{json, Value};
use std::{net::SocketAddr, sync::Arc};
use tower_http::{
services::ServeDir,
trace::TraceLayer,
};
/// Shared application state threaded through Axum.
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool,
pub config: Arc<AppConfig>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load configuration
let config_path = std::env::var("PATCH_MANAGER_CONFIG")
.unwrap_or_else(|_| "/etc/patch-manager/config.toml".to_string());
let config = AppConfig::load(&config_path)
.unwrap_or_else(|_| {
eprintln!("Config file not found or invalid, using defaults");
AppConfig::default()
});
// Initialize logging
logging::init(&config.logging);
tracing::info!(version = env!("CARGO_PKG_VERSION"), "patch-manager-web starting");
// Initialize database pool
let pool = db::init_pool(&config.database).await?;
// Run migrations (advisory lock guards single-writer)
db::run_migrations(&pool).await?;
let state = AppState {
db: pool,
config: Arc::new(config.clone()),
};
// Build the application router
let app = build_router(state);
// Bind address
let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
.parse()
.expect("Invalid bind address");
tracing::info!(%addr, "Listening");
// TODO M8: wrap with TLS (rustls). For M1 we bind plain HTTP for local dev.
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
/// Construct the full Axum router.
pub fn build_router(state: AppState) -> Router {
let static_dir = state.config.server.static_dir.clone();
Router::new()
// Health / status (unauthenticated)
.route("/status/health", get(health_handler))
// API v1 routes (stub — expanded in later milestones)
.nest("/api/v1", api_v1_router())
// Serve React SPA static files; fallback to index.html for client-side routing
.fallback_service(
ServeDir::new(&static_dir)
.append_index_html_on_directories(true),
)
// Middleware stack
.layer(middleware::from_fn(request_id_middleware))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
/// API v1 sub-router — routes added per milestone.
fn api_v1_router() -> Router<AppState> {
Router::new()
// M2+: auth routes will be nested here
// M3+: host/group/user routes
// M4+: fleet status, agent polling
// M5+: jobs
// M6+: maintenance windows
// M7+: websocket relay
// M8+: certificates
// M9+: reports
// M10+: settings
}
/// GET /status/health — liveness probe.
///
/// Returns 200 OK with a JSON payload including service name, version,
/// and basic database reachability.
async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
// Quick DB ping
let db_ok = sqlx::query("SELECT 1")
.execute(&state.db)
.await
.is_ok();
let status = if db_ok { "healthy" } else { "degraded" };
let body = json!({
"service": "patch-manager-web",
"version": env!("CARGO_PKG_VERSION"),
"status": status,
"database": if db_ok { "ok" } else { "error" },
});
if db_ok {
Ok(Json(body))
} else {
Err(StatusCode::SERVICE_UNAVAILABLE)
}
}

View File

@ -0,0 +1,24 @@
[package]
name = "pm-worker"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "pm-worker"
path = "src/main.rs"
[dependencies]
pm-core = { path = "../pm-core" }
tokio = { workspace = true }
sqlx = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }

View File

@ -0,0 +1,128 @@
//! pm-worker — Linux Patch Manager background worker.
//!
//! Handles scheduled polling, job execution, maintenance window scheduling,
//! retry logic, email notifications, and data pruning.
use pm_core::{
config::AppConfig,
db,
logging,
};
use sqlx::PgPool;
use std::{sync::Arc, time::Duration};
use tokio::time;
/// Minimum number of applied migrations the worker requires before
/// accepting work. Prevents the worker from running against a schema
/// that hasn't been migrated yet.
const REQUIRED_MIGRATION_COUNT: i64 = 1;
/// How long to wait between schema-version checks before giving up.
const SCHEMA_CHECK_TIMEOUT: Duration = Duration::from_secs(120);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load configuration
let config_path = std::env::var("PATCH_MANAGER_CONFIG")
.unwrap_or_else(|_| "/etc/patch-manager/config.toml".to_string());
let config = AppConfig::load(&config_path)
.unwrap_or_else(|_| {
eprintln!("Config file not found or invalid, using defaults");
AppConfig::default()
});
// Initialize logging
logging::init(&config.logging);
tracing::info!(version = env!("CARGO_PKG_VERSION"), "patch-manager-worker starting");
// Initialize database pool
let pool = db::init_pool(&config.database).await?;
// Wait for schema to be at the expected version (web process runs migrations)
wait_for_schema(&pool).await?;
let config = Arc::new(config);
// Spawn worker tasks
let heartbeat_handle = tokio::spawn(run_heartbeat(
pool.clone(),
config.worker.heartbeat_interval_secs,
));
// TODO M4: spawn health_poller, patch_data_poller
// TODO M5: spawn job_executor
// TODO M6: spawn job_scheduler
tracing::info!("Worker tasks started");
// Wait for all tasks (they run indefinitely)
let _ = tokio::join!(heartbeat_handle);
Ok(())
}
/// Wait until the database schema has at least `REQUIRED_MIGRATION_COUNT`
/// successful migrations applied. Retries every 5 seconds up to
/// `SCHEMA_CHECK_TIMEOUT`.
async fn wait_for_schema(pool: &PgPool) -> anyhow::Result<()> {
let deadline = tokio::time::Instant::now() + SCHEMA_CHECK_TIMEOUT;
loop {
match db::check_schema_version(pool).await {
Ok(count) if count >= REQUIRED_MIGRATION_COUNT => {
tracing::info!(migration_count = count, "Schema version check passed");
return Ok(());
}
Ok(count) => {
tracing::warn!(
migration_count = count,
required = REQUIRED_MIGRATION_COUNT,
"Schema not ready, waiting..."
);
}
Err(e) => {
tracing::warn!(error = %e, "Schema version check failed, retrying...");
}
}
if tokio::time::Instant::now() >= deadline {
anyhow::bail!(
"Schema not ready after {}s — is the web process running migrations?",
SCHEMA_CHECK_TIMEOUT.as_secs()
);
}
time::sleep(Duration::from_secs(5)).await;
}
}
/// Writes a heartbeat row to `worker_heartbeat` every `interval_secs`.
/// The web process can query this to confirm the worker is alive.
async fn run_heartbeat(pool: PgPool, interval_secs: u64) {
let interval = Duration::from_secs(interval_secs);
let mut ticker = time::interval(interval);
loop {
ticker.tick().await;
let result = sqlx::query(
r#"
INSERT INTO worker_heartbeat (id, last_seen, worker_version)
VALUES (1, NOW(), $1)
ON CONFLICT (id) DO UPDATE
SET last_seen = EXCLUDED.last_seen,
worker_version = EXCLUDED.worker_version
"#,
)
.bind(env!("CARGO_PKG_VERSION"))
.execute(&pool)
.await;
match result {
Ok(_) => tracing::debug!("Worker heartbeat written"),
Err(e) => tracing::error!(error = %e, "Worker heartbeat failed"),
}
}
}

76
docs/runbooks/restore.md Normal file
View File

@ -0,0 +1,76 @@
# Linux Patch Manager — Backup & Restore Runbook
## Overview
This runbook covers backup and restoration of the Linux Patch Manager.
The application state lives in:
- PostgreSQL database (`patch_manager`)
- Internal CA private key (`/etc/patch-manager/ca/ca.key`)
- JWT signing key (`/etc/patch-manager/jwt/signing.pem`)
- Application config (`/etc/patch-manager/config.toml`)
- Operator-supplied TLS cert/key (if using `operator_supplied` strategy)
## Backup
### 1. Database
```bash
pg_dump -U patch_manager -Fc patch_manager > patch_manager_$(date +%Y%m%d_%H%M%S).dump
```
### 2. Configuration and Keys
```bash
tar -czf patch_manager_config_$(date +%Y%m%d_%H%M%S).tar.gz \
/etc/patch-manager/
```
> **Security:** The archive contains private keys. Encrypt before storing:
> `gpg --symmetric patch_manager_config_*.tar.gz`
### 3. Recommended Backup Schedule
- Database: daily pg_dump, retained 30 days
- Config/keys: on every change, retained indefinitely (encrypted)
## Restore
### Prerequisites
- Fresh Ubuntu 24.04 host
- Run `scripts/setup.sh` to create user, directories, and PostgreSQL
### 1. Restore Configuration and Keys
```bash
tar -xzf patch_manager_config_<timestamp>.tar.gz -C /
chown -R patch-manager:patch-manager /etc/patch-manager/
chmod 600 /etc/patch-manager/ca/ca.key
chmod 600 /etc/patch-manager/jwt/signing.pem
```
### 2. Restore Database
```bash
# Create empty database (if not already created by setup.sh)
sudo -u postgres createdb -O patch_manager patch_manager
# Restore
pg_restore -U patch_manager -d patch_manager -Fc patch_manager_<timestamp>.dump
```
### 3. Install and Start Services
```bash
# Install binaries
cp pm-web pm-worker /usr/local/bin/
# Install frontend
scripts/build-frontend.sh
# Start services
systemctl enable --now patch-manager-web patch-manager-worker
```
### 4. Verify
```bash
curl -k https://localhost/status/health
# Expected: {"status": "healthy", ...}
```
## Notes
- Migrations run automatically on web process startup.
- The CA private key is the most critical secret — losing it requires re-issuing all mTLS certificates.
- JWT signing key rotation is handled automatically every 90 days; no manual intervention needed.

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' wss:;" />
<title>Linux Patch Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
frontend/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "patch-manager-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.0",
"@mui/material": "^7.0.0",
"axios": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.3",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.30.0",
"@typescript-eslint/parser": "^8.30.0",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.24.0",
"typescript": "^5.8.3",
"vite": "^6.3.3"
}
}

37
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,37 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { CssBaseline, ThemeProvider } from '@mui/material'
import { lightTheme } from './theme/theme'
// Placeholder pages — implemented in M2+
const PlaceholderPage = ({ title }: { title: string }) => (
<div style={{ padding: 32 }}>
<h2>{title}</h2>
<p>Coming soon in a future milestone.</p>
</div>
)
function App() {
return (
<ThemeProvider theme={lightTheme}>
<CssBaseline />
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<PlaceholderPage title="Dashboard" />} />
<Route path="/hosts" element={<PlaceholderPage title="Hosts" />} />
<Route path="/hosts/:id" element={<PlaceholderPage title="Host Detail" />} />
<Route path="/jobs" element={<PlaceholderPage title="Jobs" />} />
<Route path="/deployment" element={<PlaceholderPage title="Patch Deployment" />} />
<Route path="/maintenance" element={<PlaceholderPage title="Maintenance Windows" />} />
<Route path="/groups" element={<PlaceholderPage title="Groups" />} />
<Route path="/reports" element={<PlaceholderPage title="Reports" />} />
<Route path="/users" element={<PlaceholderPage title="Users" />} />
<Route path="/certificates" element={<PlaceholderPage title="Certificates" />} />
<Route path="/settings" element={<PlaceholderPage title="Settings" />} />
<Route path="/login" element={<PlaceholderPage title="Login" />} />
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
</Routes>
</ThemeProvider>
)
}
export default App

View File

@ -0,0 +1,8 @@
import axios from 'axios'
// Base API client — JWT interceptors added in M2
export const apiClient = axios.create({
baseURL: '/api/v1',
headers: { 'Content-Type': 'application/json' },
timeout: 30_000,
})

2
frontend/src/index.css Normal file
View File

@ -0,0 +1,2 @@
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: 'Roboto', sans-serif; }

13
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)

View File

@ -0,0 +1,23 @@
import { createTheme } from '@mui/material/styles'
export const lightTheme = createTheme({
palette: {
mode: 'light',
primary: { main: '#1565C0' },
secondary: { main: '#0288D1' },
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
},
})
export const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: { main: '#42A5F5' },
secondary: { main: '#26C6DA' },
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
},
})

View File

@ -0,0 +1,47 @@
// Core TypeScript types — expanded per milestone
export type UserRole = 'admin' | 'operator'
export type AuthProvider = 'local' | 'azure_sso'
export type HostHealthStatus = 'pending' | 'healthy' | 'degraded' | 'unreachable'
export type JobStatus = 'queued' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
export type JobKind = 'patch_apply' | 'patch_remove' | 'reboot' | 'rollback'
export interface ApiError {
error: {
code: string
message: string
request_id?: string
details?: unknown
}
}
export interface Host {
id: string
fqdn: string
ip_address: string
display_name: string
health_status: HostHealthStatus
os_family?: string
os_name?: string
agent_version?: string
registered_at: string
}
export interface Group {
id: string
name: string
description: string
created_at: string
}
export interface User {
id: string
username: string
display_name: string
email: string
role: UserRole
auth_provider: AuthProvider
mfa_enabled: boolean
is_active: boolean
last_login_at?: string
}

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

37
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,37 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/status': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
mui: ['@mui/material', '@mui/icons-material'],
},
},
},
},
})

View File

@ -0,0 +1,385 @@
-- Migration: 001_initial_schema
-- Description: Full initial schema for Linux Patch Manager
-- All tables, indexes, and constraints in a single initial migration.
-- ============================================================
-- Extensions
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_bytes, crypt
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- fuzzy text search on host names
-- ============================================================
-- Enumerations
-- ============================================================
CREATE TYPE user_role AS ENUM ('admin', 'operator');
CREATE TYPE auth_provider AS ENUM ('local', 'azure_sso');
CREATE TYPE host_health_status AS ENUM ('pending', 'healthy', 'degraded', 'unreachable');
CREATE TYPE job_status AS ENUM ('queued', 'pending', 'running', 'succeeded', 'failed', 'cancelled');
CREATE TYPE job_kind AS ENUM ('patch_apply', 'patch_remove', 'reboot', 'rollback');
CREATE TYPE window_recurrence AS ENUM ('once', 'daily', 'weekly', 'monthly');
CREATE TYPE cert_status AS ENUM ('active', 'revoked', 'expired');
CREATE TYPE audit_action AS ENUM (
'user_login', 'user_logout', 'user_login_failed',
'user_created', 'user_deleted', 'user_updated',
'host_registered', 'host_removed',
'group_created', 'group_deleted',
'group_membership_changed',
'patch_job_created', 'patch_job_cancelled', 'patch_job_rollback',
'maintenance_window_created', 'maintenance_window_updated', 'maintenance_window_deleted',
'certificate_issued', 'certificate_renewed', 'certificate_revoked', 'certificate_downloaded',
'config_changed',
'discovery_scan_started'
);
-- ============================================================
-- Groups (defined before users/hosts for FK ordering)
-- ============================================================
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_groups_name ON groups (name);
-- ============================================================
-- Users
-- ============================================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL UNIQUE,
role user_role NOT NULL DEFAULT 'operator',
auth_provider auth_provider NOT NULL DEFAULT 'local',
-- Local auth fields (NULL for SSO-only users)
password_hash TEXT,
-- MFA
totp_secret TEXT, -- NULL = TOTP not configured
webauthn_credential JSONB, -- NULL = WebAuthn not configured
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
-- Azure SSO
azure_oid TEXT UNIQUE, -- Azure Object ID
-- Account state
is_active BOOLEAN NOT NULL DEFAULT TRUE,
force_password_reset BOOLEAN NOT NULL DEFAULT FALSE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_azure_oid ON users (azure_oid) WHERE azure_oid IS NOT NULL;
CREATE INDEX idx_users_role ON users (role);
-- ============================================================
-- User <-> Group membership
-- ============================================================
CREATE TABLE user_groups (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, group_id)
);
CREATE INDEX idx_user_groups_group ON user_groups (group_id);
-- ============================================================
-- Refresh Tokens
-- ============================================================
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Stored as Argon2id hash of the opaque token bytes
token_hash TEXT NOT NULL UNIQUE,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- 1-hour sliding inactivity window; updated on each use
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 hour',
revoked BOOLEAN NOT NULL DEFAULT FALSE,
revoked_at TIMESTAMPTZ,
user_agent TEXT,
ip_address INET
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id);
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at) WHERE revoked = FALSE;
-- ============================================================
-- Hosts
-- ============================================================
CREATE TABLE hosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fqdn TEXT NOT NULL,
ip_address INET NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
os_family TEXT, -- debian, rhel, alpine, arch
os_name TEXT, -- e.g. "Ubuntu 24.04"
arch TEXT, -- x86_64, aarch64, etc.
agent_version TEXT,
health_status host_health_status NOT NULL DEFAULT 'pending',
last_health_at TIMESTAMPTZ,
last_patch_at TIMESTAMPTZ,
-- Agent port (default 12443)
agent_port INTEGER NOT NULL DEFAULT 12443,
notes TEXT NOT NULL DEFAULT '',
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT hosts_fqdn_ip_unique UNIQUE (fqdn, ip_address)
);
CREATE INDEX idx_hosts_health_status ON hosts (health_status);
CREATE INDEX idx_hosts_fqdn ON hosts USING gin (fqdn gin_trgm_ops);
CREATE INDEX idx_hosts_ip ON hosts (ip_address);
-- ============================================================
-- Host <-> Group membership
-- ============================================================
CREATE TABLE host_groups (
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (host_id, group_id)
);
CREATE INDEX idx_host_groups_group ON host_groups (group_id);
-- ============================================================
-- Host Health Data (cached results from 5-min polls)
-- ============================================================
CREATE TABLE host_health_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status host_health_status NOT NULL,
-- Raw JSON response from agent GET /api/v1/health
payload JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_host_health_host ON host_health_data (host_id, polled_at DESC);
-- Retained for 30 days (pruned by worker)
-- ============================================================
-- Host Patch Data (cached results from 30-min polls)
-- ============================================================
CREATE TABLE host_patch_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
polled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Raw JSON response from agent GET /api/v1/patches
available_patches JSONB NOT NULL DEFAULT '[]',
installed_packages JSONB NOT NULL DEFAULT '[]',
patch_count INTEGER NOT NULL DEFAULT 0,
cve_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_host_patch_host ON host_patch_data (host_id, polled_at DESC);
-- Retained for 30 days (pruned by worker)
-- ============================================================
-- Maintenance Windows
-- ============================================================
CREATE TABLE maintenance_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
recurrence window_recurrence NOT NULL DEFAULT 'once',
-- Start time (UTC)
start_at TIMESTAMPTZ NOT NULL,
-- Duration in minutes
duration_minutes INTEGER NOT NULL DEFAULT 60,
-- For recurring windows: day-of-week (0=Sun) or day-of-month
recurrence_day INTEGER,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_mw_host ON maintenance_windows (host_id);
CREATE INDEX idx_mw_start ON maintenance_windows (start_at) WHERE enabled = TRUE;
-- ============================================================
-- Patch Jobs
-- ============================================================
CREATE TABLE patch_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind job_kind NOT NULL DEFAULT 'patch_apply',
status job_status NOT NULL DEFAULT 'queued',
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- For rollback jobs: reference to original job
parent_job_id UUID REFERENCES patch_jobs(id) ON DELETE SET NULL,
-- Optional: restrict to a specific maintenance window
maintenance_window_id UUID REFERENCES maintenance_windows(id) ON DELETE SET NULL,
-- Immediate apply if TRUE; else queued for next window
immediate BOOLEAN NOT NULL DEFAULT FALSE,
-- Patch selection (list of package names / CVE IDs)
patch_selection JSONB NOT NULL DEFAULT '[]',
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_patch_jobs_status ON patch_jobs (status);
CREATE INDEX idx_patch_jobs_created ON patch_jobs (created_at DESC);
CREATE INDEX idx_patch_jobs_user ON patch_jobs (created_by_user_id);
-- ============================================================
-- Patch Job Hosts (per-host status within a batch job)
-- ============================================================
CREATE TABLE patch_job_hosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES patch_jobs(id) ON DELETE CASCADE,
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
status job_status NOT NULL DEFAULT 'queued',
-- Agent-assigned async job ID
agent_job_id TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
-- Output / error from agent
output TEXT NOT NULL DEFAULT '',
error_message TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
UNIQUE (job_id, host_id)
);
CREATE INDEX idx_pjh_job ON patch_job_hosts (job_id);
CREATE INDEX idx_pjh_host ON patch_job_hosts (host_id);
CREATE INDEX idx_pjh_status ON patch_job_hosts (status);
-- ============================================================
-- Certificates
-- ============================================================
CREATE TABLE certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- NULL = root CA cert
host_id UUID REFERENCES hosts(id) ON DELETE CASCADE,
serial_number TEXT NOT NULL UNIQUE,
common_name TEXT NOT NULL,
status cert_status NOT NULL DEFAULT 'active',
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
-- PEM-encoded certificate (public cert only, no key)
cert_pem TEXT NOT NULL
);
CREATE INDEX idx_certs_host ON certificates (host_id);
CREATE INDEX idx_certs_status ON certificates (status);
CREATE INDEX idx_certs_expires ON certificates (expires_at);
-- ============================================================
-- Audit Log (tamper-evident, hash-chained)
-- ============================================================
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
action audit_action NOT NULL,
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_username TEXT, -- Snapshot at time of action
target_type TEXT, -- 'host', 'user', 'group', 'job', etc.
target_id TEXT, -- UUID or identifier of affected resource
details JSONB NOT NULL DEFAULT '{}',
ip_address INET,
request_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Hash chain: SHA-256(prev_hash || row_data)
row_hash TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_audit_created ON audit_log (created_at DESC);
CREATE INDEX idx_audit_actor ON audit_log (actor_user_id);
CREATE INDEX idx_audit_action ON audit_log (action);
CREATE INDEX idx_audit_target ON audit_log (target_type, target_id);
-- Retained for 6 months (pruned by worker)
-- ============================================================
-- Azure SSO Configuration
-- ============================================================
CREATE TABLE azure_sso_config (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
enabled BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id TEXT NOT NULL DEFAULT '',
client_id TEXT NOT NULL DEFAULT '',
-- Encrypted at rest via hardware-host FDE; stored as-is in DB
client_secret TEXT NOT NULL DEFAULT '',
redirect_uri TEXT NOT NULL DEFAULT '',
scopes TEXT NOT NULL DEFAULT 'openid email profile',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT azure_sso_singleton CHECK (id = 1)
);
-- ============================================================
-- System Configuration (key/value runtime settings)
-- ============================================================
CREATE TABLE system_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed default system config values
INSERT INTO system_config (key, value, description) VALUES
('health_poll_interval_secs', '300', 'Agent health check interval in seconds'),
('patch_poll_interval_secs', '1800', 'Agent patch data poll interval in seconds'),
('max_concurrent_agent_calls', '64', 'Maximum concurrent mTLS agent calls'),
('data_retention_days', '30', 'Retention period for operational data (days)'),
('audit_retention_days', '180', 'Retention period for audit log (days)'),
('smtp_enabled', 'false', 'Enable email notifications'),
('smtp_host', '', 'SMTP relay hostname'),
('smtp_port', '587', 'SMTP relay port'),
('smtp_username', '', 'SMTP auth username'),
('smtp_password', '', 'SMTP auth password'),
('smtp_from', '', 'From address for notifications'),
('smtp_tls_mode', 'starttls', 'SMTP TLS mode: none, starttls, tls'),
('web_tls_strategy', 'internal_ca', 'Web UI TLS cert strategy: internal_ca or operator_supplied'),
('ip_whitelist', '[]', 'JSON array of allowed CIDR/IP strings; empty = allow all');
-- ============================================================
-- Worker Heartbeat
-- ============================================================
CREATE TABLE worker_heartbeat (
id INTEGER PRIMARY KEY DEFAULT 1, -- singleton row
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
worker_version TEXT NOT NULL DEFAULT '',
CONSTRAINT worker_heartbeat_singleton CHECK (id = 1)
);
-- ============================================================
-- Discovery Results (transient; cleared before each scan)
-- ============================================================
CREATE TABLE discovery_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scan_id UUID NOT NULL,
ip_address INET NOT NULL,
fqdn TEXT,
agent_version TEXT,
os_name TEXT,
agent_port INTEGER NOT NULL DEFAULT 12443,
discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Whether the operator has registered this host
registered BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_discovery_scan ON discovery_results (scan_id);
CREATE INDEX idx_discovery_ip ON discovery_results (ip_address);

42
scripts/build-frontend.sh Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# =============================================================================
# Linux Patch Manager — Frontend Build Script
# =============================================================================
# Builds the React + TypeScript SPA and copies output to the system frontend dir.
# Run from the repository root.
# =============================================================================
set -euo pipefail
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
FRONTEND_DIR="${REPO_ROOT}/frontend"
DEST_DIR="${1:-/usr/share/patch-manager/frontend}"
info "Building React SPA..."
cd "${FRONTEND_DIR}"
# Install dependencies if node_modules not present
if [[ ! -d node_modules ]]; then
info "Installing npm dependencies..."
npm ci
fi
# Build
info "Running vite build..."
npm run build
# Deploy to destination
info "Copying build output to ${DEST_DIR}..."
mkdir -p "${DEST_DIR}"
rm -rf "${DEST_DIR:?}/"
cp -r dist/* "${DEST_DIR}/"
info "Frontend build complete → ${DEST_DIR}"

178
scripts/setup.sh Executable file
View File

@ -0,0 +1,178 @@
#!/usr/bin/env bash
# =============================================================================
# Linux Patch Manager — Initial Host Setup Script
# =============================================================================
# Run as root on the Ubuntu 24.04 Patch Manager host.
# This script:
# - Creates the service user/group
# - Creates required directories with correct permissions
# - Installs PostgreSQL if not present
# - Creates the database and user
# - Copies configuration and binaries
# - Installs systemd units
# - Generates initial Ed25519 JWT keys
# =============================================================================
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
[[ $EUID -ne 0 ]] && error "This script must be run as root."
SERVICE_USER="patch-manager"
SERVICE_GROUP="patch-manager"
CONFIG_DIR="/etc/patch-manager"
LOG_DIR="/var/log/patch-manager"
DATA_DIR="/opt/patch-manager"
FRONTEND_DIR="/usr/share/patch-manager/frontend"
BIN_DIR="/usr/local/bin"
DB_NAME="patch_manager"
DB_USER="patch_manager"
SYSTEMD_DIR="/etc/systemd/system"
info "=== Linux Patch Manager Setup ==="
# -----------------------------------------------------------------------
# 1. Create service user
# -----------------------------------------------------------------------
info "Creating service user '${SERVICE_USER}'..."
if ! id "${SERVICE_USER}" &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin \
--comment "Linux Patch Manager service account" \
"${SERVICE_USER}"
info "User '${SERVICE_USER}' created."
else
warn "User '${SERVICE_USER}' already exists, skipping."
fi
# -----------------------------------------------------------------------
# 2. Create required directories
# -----------------------------------------------------------------------
info "Creating directories..."
mkdir -p \
"${CONFIG_DIR}/ca" \
"${CONFIG_DIR}/certs" \
"${CONFIG_DIR}/jwt" \
"${CONFIG_DIR}/tls" \
"${LOG_DIR}" \
"${DATA_DIR}" \
"${FRONTEND_DIR}"
chown -R "${SERVICE_USER}:${SERVICE_GROUP}" \
"${CONFIG_DIR}" \
"${LOG_DIR}" \
"${DATA_DIR}" \
"${FRONTEND_DIR}"
chmod 750 "${CONFIG_DIR}/ca" "${CONFIG_DIR}/jwt"
info "Directories created."
# -----------------------------------------------------------------------
# 3. Install PostgreSQL 16 if not present
# -----------------------------------------------------------------------
if ! command -v psql &>/dev/null; then
info "Installing PostgreSQL 16..."
apt-get update -qq
apt-get install -y postgresql-16
systemctl enable --now postgresql
else
info "PostgreSQL already installed: $(psql --version)"
fi
# -----------------------------------------------------------------------
# 4. Create database and user
# -----------------------------------------------------------------------
info "Creating database '${DB_NAME}' and user '${DB_USER}'..."
DB_PASSWORD=$(openssl rand -base64 32)
sudo -u postgres psql -v ON_ERROR_STOP=1 <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${DB_USER}') THEN
CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASSWORD}';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE ${DB_NAME} OWNER ${DB_USER}'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_NAME}')\\gexec
SQL
DB_URL="postgres://${DB_USER}:${DB_PASSWORD}@localhost/${DB_NAME}"
info "Database ready. Connection URL (save this!):"
echo " ${DB_URL}"
# -----------------------------------------------------------------------
# 5. Write connection URL to config if example exists
# -----------------------------------------------------------------------
CONFIG_DEST="${CONFIG_DIR}/config.toml"
if [[ ! -f "${CONFIG_DEST}" ]]; then
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXAMPLE="${SCRIPT_DIR}/../config/config.example.toml"
if [[ -f "${EXAMPLE}" ]]; then
cp "${EXAMPLE}" "${CONFIG_DEST}"
sed -i "s|postgres://patch_manager:CHANGEME@localhost/patch_manager|${DB_URL}|" "${CONFIG_DEST}"
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${CONFIG_DEST}"
chmod 640 "${CONFIG_DEST}"
info "Config written to ${CONFIG_DEST} with database URL."
else
warn "config.example.toml not found; create ${CONFIG_DEST} manually."
fi
else
warn "${CONFIG_DEST} already exists; database URL NOT updated automatically."
warn "Ensure the database URL is set: ${DB_URL}"
fi
# -----------------------------------------------------------------------
# 6. Generate Ed25519 JWT keys
# -----------------------------------------------------------------------
JWT_SIGNING="${CONFIG_DIR}/jwt/signing.pem"
JWT_VERIFY="${CONFIG_DIR}/jwt/verify.pem"
if [[ ! -f "${JWT_SIGNING}" ]]; then
info "Generating Ed25519 JWT signing key..."
openssl genpkey -algorithm ed25519 -out "${JWT_SIGNING}"
openssl pkey -in "${JWT_SIGNING}" -pubout -out "${JWT_VERIFY}"
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${JWT_SIGNING}" "${JWT_VERIFY}"
chmod 600 "${JWT_SIGNING}"
chmod 644 "${JWT_VERIFY}"
info "JWT keys generated."
else
warn "JWT signing key already exists at ${JWT_SIGNING}, skipping."
fi
# -----------------------------------------------------------------------
# 7. Install systemd units
# -----------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
for unit in patch-manager-web.service patch-manager-worker.service; do
SRC="${SCRIPT_DIR}/../systemd/${unit}"
if [[ -f "${SRC}" ]]; then
cp "${SRC}" "${SYSTEMD_DIR}/${unit}"
info "Installed systemd unit: ${unit}"
else
warn "Systemd unit not found: ${SRC}"
fi
done
systemctl daemon-reload
info "systemd units installed and daemon reloaded."
# -----------------------------------------------------------------------
# Done
# -----------------------------------------------------------------------
info "=== Setup complete ==="
info "Next steps:"
echo " 1. Build and install binaries: cargo build --release"
echo " cp target/release/pm-web target/release/pm-worker ${BIN_DIR}/"
echo " 2. Build and install frontend: scripts/build-frontend.sh"
echo " 3. Review ${CONFIG_DEST}"
echo " 4. Enable services:"
echo " systemctl enable --now patch-manager-web patch-manager-worker"

View File

@ -0,0 +1,41 @@
[Unit]
Description=Linux Patch Manager — Web Server
Documentation=https://gitea.moon-dragon.us/echo/linux_patch_manager
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=patch-manager
Group=patch-manager
WorkingDirectory=/opt/patch-manager
# Configuration
Environment="PATCH_MANAGER_CONFIG=/etc/patch-manager/config.toml"
# Override individual settings via environment if needed:
# Environment="PATCH_MANAGER__DATABASE__URL=postgres://..."
ExecStart=/usr/local/bin/pm-web
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/patch-manager
PrivateTmp=true
PrivateDevices=true
# Allow binding to port 443 without root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=patch-manager-web
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,37 @@
[Unit]
Description=Linux Patch Manager — Background Worker
Documentation=https://gitea.moon-dragon.us/echo/linux_patch_manager
After=network.target postgresql.service patch-manager-web.service
Requires=postgresql.service
# Worker waits for the web process to apply migrations before starting tasks
Wants=patch-manager-web.service
[Service]
Type=simple
User=patch-manager
Group=patch-manager
WorkingDirectory=/opt/patch-manager
# Configuration
Environment="PATCH_MANAGER_CONFIG=/etc/patch-manager/config.toml"
ExecStart=/usr/local/bin/pm-worker
Restart=on-failure
RestartSec=10s
TimeoutStopSec=60s
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/patch-manager
PrivateTmp=true
PrivateDevices=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=patch-manager-worker
[Install]
WantedBy=multi-user.target

297
tasks/todo.md Normal file
View File

@ -0,0 +1,297 @@
# Linux Patch Manager — Implementation Plan
## Project Structure
```
linux_patch_manager/
├── Cargo.toml # Workspace root
├── crates/
│ ├── pm-web/ # Axum web server binary crate
│ ├── pm-worker/ # Background worker binary crate
│ ├── pm-core/ # Shared library: config, DB pool, models, errors, types
│ ├── pm-agent-client/ # mTLS HTTP client for agent communication
│ ├── pm-auth/ # Auth: JWT (EdDSA), Argon2id, TOTP, WebAuthn, RBAC, Azure SSO
│ ├── pm-ca/ # Internal CA: rcgen + rustls certificate management
│ └── pm-reports/ # PDF (printpdf + plotters) and CSV generation
├── migrations/ # SQLx database migrations
│ ├── 001_initial_schema.sql
│ ├── 002_auth_system.sql
│ ├── 003_host_management.sql
│ ├── 004_jobs_and_scheduling.sql
│ ├── 005_audit_logging.sql
│ └── 006_system_config.sql
├── frontend/ # React + TypeScript SPA
│ ├── src/
│ │ ├── api/ # API client (axios/fetch)
│ │ ├── components/ # Shared MUI components
│ │ ├── pages/ # 11 page components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── store/ # State management (zustand or context)
│ │ ├── theme/ # MUI theme (light + dark)
│ │ ├── types/ # TypeScript interfaces
│ │ └── utils/ # Utilities
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ └── index.html
├── config/
│ └── config.example.toml # Example configuration
├── systemd/
│ ├── patch-manager-web.service
│ └── patch-manager-worker.service
├── docs/
│ └── runbooks/
│ └── restore.md # Backup/restore runbook
├── scripts/
│ ├── setup.sh # Initial host setup script
│ └── build-frontend.sh # Frontend build script
├── SPEC.md
├── REQUIREMENTS.md
├── ARCHITECTURE.md
├── README.md
└── .gitignore
```
---
## Milestones
Each milestone produces a **testable vertical slice** — backend + frontend + database working together.
### M1: Project Scaffolding + Database Schema + Core Infrastructure
**Goal:** Runnable workspace with DB, config, logging, error handling.
- [x] Initialize Rust workspace with 7 crates (pm-web, pm-worker, pm-core, pm-agent-client, pm-auth, pm-ca, pm-reports)
- [x] Initialize React + TypeScript + Vite + MUI frontend project
- [x] Create `config.example.toml` with all configuration keys
- [x] Implement `pm-core::config` — TOML config loading + env overrides (`PATCH_MANAGER__SECTION__KEY`)
- [x] Implement `pm-core::db` — SQLx PgPool initialization, connection from config
- [x] Implement `pm-core::error` — Unified error type with API error envelope (`error.code`, `error.message`, `error.request_id`, `error.details`)
- [x] Implement `pm-core::request_id` — ULID generation + `X-Request-Id` header middleware
- [x] Implement `pm-core::logging``tracing` + `tracing-subscriber` JSON formatter, configurable log levels
- [x] Create initial database migrations (001_initial_schema.sql): `hosts`, `groups`, `host_groups`, `users`, `user_groups`, `refresh_tokens`, `maintenance_windows`, `patch_jobs`, `patch_job_hosts`, `host_patch_data`, `host_health_data`, `certificates`, `audit_log`, `azure_sso_config`, `system_config`, `worker_heartbeat`, `discovery_results`
- [x] Implement `pm-web` binary: Axum app skeleton, static file serving placeholder, `/status/health` endpoint
- [x] Implement `pm-worker` binary: Tokio runtime skeleton, DB connection, worker heartbeat writer (30s interval)
- [x] Implement `sqlx::migrate!` embedded migrations in pm-web, advisory lock for single-writer
- [x] Worker waits for expected schema version before accepting work
- [x] Create `systemd/patch-manager-web.service` and `systemd/patch-manager-worker.service` unit files
- [x] Create `scripts/setup.sh` for initial host setup
- [x] Create `scripts/build-frontend.sh`
- [x] Verify: both services start, `/status/health` returns 200, worker heartbeat updates
### M2: Authentication & Authorization + Frontend Shell
**Goal:** Users can log in with MFA, JWT auth works, RBAC middleware enforces roles.
- [ ] Implement `pm-auth::password` — Argon2id hashing with calibrated parameters (`m_cost=65536`, `t_cost=3`, `p_cost=1`)
- [ ] Implement `pm-auth::jwt` — EdDSA/Ed25519 JWT issuance and validation, 15-min TTL, 90-day key rotation with 24-hour overlap
- [ ] Implement `pm-auth::refresh` — Opaque 256-bit refresh tokens, hashed storage in `refresh_tokens`, 1-hour sliding inactivity timeout, rotation on use
- [ ] Implement `pm-auth::mfa_totp` — TOTP setup, verify, QR code generation
- [ ] Implement `pm-auth::mfa_webauthn` — WebAuthn registration and authentication
- [ ] Implement `pm-auth::rbac` — Admin/Operator role middleware, group-scoped access enforcement
- [ ] Implement `pm-auth::session` — Login flow (password → MFA → access+refresh tokens), logout (revoke refresh), force-revoke
- [ ] Implement `pm-web` auth routes: `POST /api/v1/auth/login`, `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, MFA setup endpoints
- [ ] Implement IP whitelist middleware on all connection points
- [ ] Frontend: App shell with React Router, MUI theme (light + dark), auth context, login page, MFA setup page
- [ ] Frontend: API client with JWT interceptors (auto-refresh), 401 redirect to login
- [ ] Create seed migration: default admin account
- [ ] Verify: login with MFA, JWT validation, refresh token rotation, RBAC blocks unauthorized access, IP whitelist blocks unknown IPs
### M3: Host Management + Groups + Frontend Pages
**Goal:** Full host CRUD, group management, auto-discovery.
- [ ] Implement host CRUD routes: `GET/POST /api/v1/hosts`, `GET/DELETE /api/v1/hosts/{id}`
- [ ] Implement FQDN resolution on host add (resolve to IP at registration time)
- [ ] Implement group CRUD routes: `GET/POST /api/v1/groups`, `GET/DELETE /api/v1/hosts/{id}/groups`
- [ ] Implement host ↔ group and user ↔ group membership management
- [ ] Implement RBAC scoping: operators can only see/manage hosts in their groups
- [ ] Implement auto-discovery: `POST /api/v1/discovery/cidr` → worker scans CIDR, bounded concurrency (128), TCP+TLS probe (1.5s timeout), progress tracking, cancel action
- [ ] Implement discovery results table and review flow
- [ ] Implement host removal with audit logging
- [ ] Frontend: Hosts page (filterable list by group, status, OS)
- [ ] Frontend: Host Detail page (system info, packages, patches, jobs, maintenance window config)
- [ ] Frontend: Groups page (manage groups, assign hosts and operators)
- [ ] Frontend: Users page (local account management, MFA setup, group assignments)
- [ ] Verify: add/remove hosts, group assignments, RBAC enforcement, CIDR scan with progress
### M4: Agent Communication Layer + Dashboard
**Goal:** mTLS client works, health/patch polling operational, dashboard shows fleet status.
- [ ] Implement `pm-agent-client` — Rustls-based mTLS HTTP client with client certificate, TLS 1.3 only
- [ ] Implement agent API calls: `GET /api/v1/health`, `GET /api/v1/system/info`, `GET /api/v1/packages`, `GET /api/v1/patches`
- [ ] Implement worker health poller: 5-minute intervals, bounded concurrency (64 semaphore), update `host_health_data`
- [ ] Implement worker patch data poller: 30-minute intervals, bounded concurrency, update `host_patch_data`
- [ ] Implement on-demand refresh: `POST /api/v1/hosts/{id}/refresh``NOTIFY refresh_requested` → worker queries immediately
- [ ] Implement host health status tracking: healthy/degraded/unreachable with timestamps
- [ ] Implement dashboard API: `GET /api/v1/status/fleet` (authenticated, fleet aggregates)
- [ ] Frontend: Dashboard page — compliance %, health summary, pending patches, upcoming windows, root CA download icon
- [ ] Frontend: Real-time health status indicators (green/yellow/red) on host lists
- [ ] Verify: polling works, dashboard shows live fleet data, on-demand refresh works, visual alerts for unhealthy agents
### M5: Patch Deployment & Job Management + Frontend Pages
**Goal:** Full patch lifecycle — queue, immediate, retry, rollback, job monitoring.
- [ ] Implement job creation: `POST /api/v1/jobs` (queue for window or apply now)
- [ ] Implement `patch_jobs` and `patch_job_hosts` row creation
- [ ] Implement `NOTIFY job_enqueued` for immediate-apply wake
- [ ] Implement worker job executor: call agent `POST /api/v1/patches/apply`, track async job IDs
- [ ] Implement worker retry engine: exponential backoff (1min, 5min, 30min), 3 retries max
- [ ] Implement patch job auto-retry within maintenance window (1 retry)
- [ ] Implement batch partial failure handling: auto-retry once, then report
- [ ] Implement rollback: `POST /api/v1/jobs/{id}/rollback` → worker calls agent rollback endpoint
- [ ] Implement job status tracking: poll agent `GET /api/v1/jobs/{id}` for running jobs
- [ ] Implement job listing/detail API: `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`
- [ ] Frontend: Patch Deployment page (select hosts → review patches → queue or apply now)
- [ ] Frontend: Jobs page (job list, per-host status, rollback action)
- [ ] Verify: queued job waits for window, immediate job runs now, retry logic works, rollback works, batch partial failures reported
### M6: Maintenance Windows & Scheduling + Frontend Page
**Goal:** Per-device recurring and one-time maintenance windows, auto-execution at window open.
- [ ] Implement maintenance window CRUD: `GET/POST/PUT/DELETE /api/v1/hosts/{id}/maintenance-windows`
- [ ] Implement recurring schedule logic: daily, weekly, monthly (cron-like evaluation)
- [ ] Implement one-time window support
- [ ] Implement worker job scheduler: detect window openings, dispatch queued jobs
- [ ] Implement window-open event triggering job execution
- [ ] Frontend: Maintenance Windows page (per-device schedule management)
- [ ] Frontend: Maintenance window config on Host Detail page
- [ ] Verify: create recurring/one-time windows, queued jobs execute at window open, window expiration stops execution
### M7: WebSocket Relay (Real-Time Job Status)
**Goal:** Browser receives live job updates via WebSocket.
- [ ] Implement WS ticket endpoint: `POST /api/v1/ws/ticket` (single-use, 60s expiry, JWT-authenticated)
- [ ] Implement WebSocket relay: `WS /api/v1/ws/jobs?ticket=...` → authenticated browser connection
- [ ] Implement agent WebSocket consumption: worker subscribes to agent `WS /api/v1/ws/jobs` for running jobs
- [ ] Implement event multiplexing: agent WS events → PostgreSQL update → browser WS push
- [ ] Frontend: WebSocket client hook with auto-reconnect and ticket refresh
- [ ] Frontend: Live job progress updates on Jobs page
- [ ] Verify: open job in browser, see real-time progress updates, WS ticket expires correctly
### M8: Internal CA + Certificate Management + Frontend Page
**Goal:** CA issues/renews certs, download links work.
- [ ] Implement `pm-ca` — CA initialization (root key + cert generation), stored at `/etc/patch-manager/ca/` with 0600 permissions
- [ ] Implement client certificate issuance for mTLS (per-host certs)
- [ ] Implement certificate renewal flow
- [ ] Implement certificate revocation (mark revoked in `certificates` table, re-issue replacement)
- [ ] Implement download endpoints: `GET /api/v1/ca/root.crt`, `GET /api/v1/hosts/{id}/client.crt`
- [ ] Implement Web UI TLS certificate: self-signed from internal CA (default) or operator-supplied cert/key
- [ ] Frontend: Certificates page (view/manage CA, issue/renew certs, view expiry)
- [ ] Frontend: Root CA download icon on Dashboard
- [ ] Frontend: Host-specific cert download icon on Host Detail page
- [ ] Verify: CA generates certs, downloads work, TLS cert strategy switchable
### M9: Reporting (CSV + PDF with Charts) + Frontend Page
**Goal:** All 4 report types exportable as CSV and PDF.
- [ ] Implement `pm-reports::csv` — CSV generation for all report types
- [ ] Implement `pm-reports::pdf` — PDF generation with `printpdf` + `plotters` charts
- [ ] Implement compliance report: % hosts fully patched by group/fleet, trend charts
- [ ] Implement patch history report: operations per host/group
- [ ] Implement vulnerability exposure report: hosts with pending CVEs
- [ ] Implement audit trail report: who did what when
- [ ] Implement report API: `GET /api/v1/reports/compliance`, `patch-history`, `vulnerability`, `audit` with `?format=csv|pdf`
- [ ] Frontend: Reports page (select type, filters, generate, download)
- [ ] Verify: all 4 reports generate as CSV and PDF, PDFs include charts
### M10: Settings Page (Azure SSO, SMTP, TLS, IP Whitelist) + Frontend Page
**Goal:** All runtime configuration manageable from the UI.
- [ ] Implement `system_config` table CRUD API
- [ ] Implement Azure SSO configuration: tenant ID, client ID/secret, redirect URI, scopes
- [ ] Implement "Test Connection" action for Azure SSO (round-trip against Azure AD, report success/failure without enabling)
- [ ] Implement SMTP configuration: host, port, auth mode, username/password, TLS mode, from-address
- [ ] Implement "Send Test Email" action for SMTP
- [ ] Implement polling interval tuning (health, patch) in Settings
- [ ] Implement Web UI TLS certificate strategy selection (internal CA vs. operator-supplied)
- [ ] Implement IP whitelist management in Settings
- [ ] Implement Azure SSO OAuth2/OIDC Authorization Code flow with PKCE
- [ ] Frontend: Settings page with all configuration sections and test actions
- [ ] Verify: Azure SSO test connection works, test email sends, TLS strategy switches, IP whitelist updates take effect
### M11: Email Notifications + Audit Logging Hardening
**Goal:** Optional email works, audit logs are tamper-evident.
- [ ] Implement email notifier in worker (Lettre crate, optional/disabled by default)
- [ ] Implement email templates: patch failure, job completion, maintenance window reminders
- [ ] Implement audit log hash chaining: `prev_hash` + `row_hash` on every insert
- [ ] Implement periodic audit integrity verification job
- [ ] Implement on-demand audit integrity verification from UI
- [ ] Implement audit log for all configuration changes (Azure SSO, SMTP, IP whitelist, TLS cert strategy)
- [ ] Implement audit log for certificate operations (issue, renew, download, revoke)
- [ ] Frontend: Email notification settings integration in Settings page
- [ ] Frontend: Audit integrity verification action in Reports/Users area
- [ ] Verify: email sends on failure, audit chain is intact, tampering detected by verification
### M12: Deployment Packaging, Backup/DR, Integration Testing
**Goal:** Production-ready deployment with documented runbooks.
- [ ] Create `docs/runbooks/restore.md` — backup/restore procedure
- [ ] Implement nightly `pg_dump` script to `/var/backups/patch-manager/`
- [ ] Implement CA material backup inclusion
- [ ] Implement `/etc/patch-manager/` config backup (excluding secrets unless encrypted destination)
- [ ] Create `scripts/setup.sh` — full host setup (install deps, create service user, set permissions, initialize DB)
- [ ] Finalize systemd unit files with proper dependencies, restart policies, logging
- [ ] End-to-end integration tests: full patch lifecycle across multiple agents
- [ ] Performance test: verify 500-host polling, dashboard load < 5s, CIDR scan < 10s for /22
- [ ] Security review: TLS 1.3 enforcement, IP whitelist, RBAC, audit chain integrity
- [ ] Compliance mapping verification: HIPAA and PCI-DSS controls documented and testable
- [ ] Verify: backup/restore works, RPO 24h / RTO 4h achievable, all NFRs met
---
## Dependency Graph
```
M1 (scaffolding)
├──> M2 (auth)
│ ├──> M3 (hosts/groups)
│ │ ├──> M4 (agent comm + dashboard)
│ │ │ ├──> M5 (patch deployment + jobs)
│ │ │ │ ├──> M6 (maintenance windows)
│ │ │ │ │ └──> M7 (websocket relay)
│ │ │ │ └──> M7 (websocket relay)
│ │ │ └──> M8 (CA + certs)
│ │ └──> M8 (CA + certs)
│ └──> M10 (settings)
├──> M8 (CA + certs) [needed by M4 for mTLS]
└──> M9 (reports)
M10 (settings) ──> M11 (email + audit hardening)
M11 ──> M12 (deployment + testing)
```
**Critical path:** M1 M2 M3 M4 M5 M6 M7 M11 M12
**Note:** M8 (CA) should be started early (after M1) since M4 (agent communication) requires mTLS client certs.
---
## Estimated Effort
| Milestone | Backend | Frontend | DB | Total |
|-----------|----------|----------|-----|-------|
| M1 | 3 days | 1 day | 1 day | 5 days |
| M2 | 4 days | 2 days | 0.5 day | 6.5 days |
| M3 | 3 days | 3 days | 0.5 day | 6.5 days |
| M4 | 3 days | 2 days | 0.5 day | 5.5 days |
| M5 | 4 days | 2 days | 0.5 day | 6.5 days |
| M6 | 2 days | 1.5 days | 0.5 day | 4 days |
| M7 | 2 days | 1.5 days | 0 | 3.5 days |
| M8 | 2 days | 1.5 days | 0 | 3.5 days |
| M9 | 3 days | 1.5 days | 0 | 4.5 days |
| M10 | 3 days | 2 days | 0.5 day | 5.5 days |
| M11 | 2 days | 1 day | 0.5 day | 3.5 days |
| M12 | 2 days | 0.5 days | 0.5 day | 3 days |
| **Total** | **33 days** | **19.5 days** | **5 days** | **~57.5 days** |
With a single developer: ~12 weeks. With parallel backend/frontend: ~7-8 weeks.
---
## Review Notes
- [ ] Kelly to review and approve this plan before implementation begins
- [ ] Confirm milestone ordering and priorities
- [ ] Confirm whether M8 (CA) should be pulled forward to support M4
- [ ] Confirm whether any milestones can be deferred to a later release