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:
7
.gitignore
vendored
7
.gitignore
vendored
@ -16,3 +16,10 @@ venv/**
|
||||
# Backup files
|
||||
*.bak
|
||||
*.bak.*
|
||||
|
||||
# Rust build artifacts
|
||||
/target
|
||||
|
||||
# Frontend dependencies
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
||||
3799
Cargo.lock
generated
Normal file
3799
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
Normal file
62
Cargo.toml
Normal 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" }
|
||||
4
SPEC.md
4
SPEC.md
@ -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
|
||||
- Linux Patch API agent is installed and running on each managed host
|
||||
- Server administrators manually distribute mTLS and root certificates to managed clients
|
||||
- PostgreSQL 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
|
||||
- PostgreSQL 16+ is available on the Patch Manager host
|
||||
- Hardware host provides full-disk encryption (no OS-level disk encryption managed by the application)
|
||||
|
||||
## Dependencies
|
||||
|
||||
95
config/config.example.toml
Normal file
95
config/config.example.toml
Normal 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"
|
||||
19
crates/pm-agent-client/Cargo.toml
Normal file
19
crates/pm-agent-client/Cargo.toml
Normal 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 }
|
||||
10
crates/pm-agent-client/src/client.rs
Normal file
10
crates/pm-agent-client/src/client.rs
Normal 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,
|
||||
}
|
||||
4
crates/pm-agent-client/src/lib.rs
Normal file
4
crates/pm-agent-client/src/lib.rs
Normal 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
19
crates/pm-auth/Cargo.toml
Normal 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 }
|
||||
1
crates/pm-auth/src/jwt.rs
Normal file
1
crates/pm-auth/src/jwt.rs
Normal file
@ -0,0 +1 @@
|
||||
//! jwt — stub for M2.
|
||||
10
crates/pm-auth/src/lib.rs
Normal file
10
crates/pm-auth/src/lib.rs
Normal 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;
|
||||
1
crates/pm-auth/src/password.rs
Normal file
1
crates/pm-auth/src/password.rs
Normal file
@ -0,0 +1 @@
|
||||
//! password — stub for M2.
|
||||
1
crates/pm-auth/src/rbac.rs
Normal file
1
crates/pm-auth/src/rbac.rs
Normal file
@ -0,0 +1 @@
|
||||
//! rbac — stub for M2.
|
||||
1
crates/pm-auth/src/session.rs
Normal file
1
crates/pm-auth/src/session.rs
Normal file
@ -0,0 +1 @@
|
||||
//! session — stub for M2.
|
||||
16
crates/pm-ca/Cargo.toml
Normal file
16
crates/pm-ca/Cargo.toml
Normal 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
1
crates/pm-ca/src/ca.rs
Normal file
@ -0,0 +1 @@
|
||||
//! Internal CA stub for M8.
|
||||
7
crates/pm-ca/src/lib.rs
Normal file
7
crates/pm-ca/src/lib.rs
Normal 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
22
crates/pm-core/Cargo.toml
Normal 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 }
|
||||
137
crates/pm-core/src/config.rs
Normal file
137
crates/pm-core/src/config.rs
Normal 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
69
crates/pm-core/src/db.rs
Normal 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
124
crates/pm-core/src/error.rs
Normal 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>;
|
||||
9
crates/pm-core/src/lib.rs
Normal file
9
crates/pm-core/src/lib.rs
Normal 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;
|
||||
32
crates/pm-core/src/logging.rs
Normal file
32
crates/pm-core/src/logging.rs
Normal 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");
|
||||
}
|
||||
44
crates/pm-core/src/request_id.rs
Normal file
44
crates/pm-core/src/request_id.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
16
crates/pm-reports/Cargo.toml
Normal file
16
crates/pm-reports/Cargo.toml
Normal 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 }
|
||||
1
crates/pm-reports/src/csv.rs
Normal file
1
crates/pm-reports/src/csv.rs
Normal file
@ -0,0 +1 @@
|
||||
//! csv report generation stub for M9.
|
||||
7
crates/pm-reports/src/lib.rs
Normal file
7
crates/pm-reports/src/lib.rs
Normal 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;
|
||||
1
crates/pm-reports/src/pdf.rs
Normal file
1
crates/pm-reports/src/pdf.rs
Normal file
@ -0,0 +1 @@
|
||||
//! pdf report generation stub for M9.
|
||||
27
crates/pm-web/Cargo.toml
Normal file
27
crates/pm-web/Cargo.toml
Normal 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
137
crates/pm-web/src/main.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
24
crates/pm-worker/Cargo.toml
Normal file
24
crates/pm-worker/Cargo.toml
Normal 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 }
|
||||
128
crates/pm-worker/src/main.rs
Normal file
128
crates/pm-worker/src/main.rs
Normal 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
76
docs/runbooks/restore.md
Normal 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
15
frontend/index.html
Normal 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
34
frontend/package.json
Normal 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
37
frontend/src/App.tsx
Normal 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
|
||||
8
frontend/src/api/client.ts
Normal file
8
frontend/src/api/client.ts
Normal 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
2
frontend/src/index.css
Normal 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
13
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
23
frontend/src/theme/theme.ts
Normal file
23
frontend/src/theme/theme.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
47
frontend/src/types/index.ts
Normal file
47
frontend/src/types/index.ts
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
37
frontend/vite.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
385
migrations/001_initial_schema.sql
Normal file
385
migrations/001_initial_schema.sql
Normal 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
42
scripts/build-frontend.sh
Executable 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
178
scripts/setup.sh
Executable 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"
|
||||
41
systemd/patch-manager-web.service
Normal file
41
systemd/patch-manager-web.service
Normal 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
|
||||
37
systemd/patch-manager-worker.service
Normal file
37
systemd/patch-manager-worker.service
Normal 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
297
tasks/todo.md
Normal 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
|
||||
Reference in New Issue
Block a user