feat: add bump-version.sh script for version management
Automates version bumps across all version source files: - Cargo.toml (PRIMARY - workspace.package.version) - debian/changelog (prepend new entry) - debian/control (update Version field) - scripts/build-package.sh (update VERSION variable) - frontend/package.json (update version field) - Stale references check after bump Usage: ./scripts/bump-version.sh <new_version> <old_version>
This commit is contained in:
26
crates/pm-core/Cargo.toml
Normal file
26
crates/pm-core/Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
||||
[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 }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
330
crates/pm-core/src/audit.rs
Executable file
330
crates/pm-core/src/audit.rs
Executable file
@ -0,0 +1,330 @@
|
||||
//! Audit log helper functions.
|
||||
//!
|
||||
//! Writes tamper-evident, hash-chained audit events to the `audit_log` table.
|
||||
//! The hash chain: each row's `row_hash` = SHA-256(
|
||||
//! prev_hash || action || actor_user_id || actor_username ||
|
||||
//! target_type || target_id || details_json || ip_address ||
|
||||
//! request_id || created_at
|
||||
//! ).
|
||||
//!
|
||||
//! The `prev_hash` column stores the previous row's `row_hash` for chain
|
||||
//! verification. The first row has `prev_hash = ''`.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use std::net::IpAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Audit event categories (must match the `audit_action` PostgreSQL ENUM).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditAction {
|
||||
UserLogin,
|
||||
UserLogout,
|
||||
UserLoginFailed,
|
||||
UserCreated,
|
||||
UserDeleted,
|
||||
UserUpdated,
|
||||
HostRegistered,
|
||||
HostRemoved,
|
||||
GroupCreated,
|
||||
GroupDeleted,
|
||||
GroupMembershipChanged,
|
||||
PatchJobCreated,
|
||||
PatchJobCancelled,
|
||||
PatchJobRollback,
|
||||
MaintenanceWindowCreated,
|
||||
MaintenanceWindowUpdated,
|
||||
MaintenanceWindowDeleted,
|
||||
CertificateIssued,
|
||||
CertificateRenewed,
|
||||
CertificateRevoked,
|
||||
CertificateDownloaded,
|
||||
ConfigChanged,
|
||||
DiscoveryScanStarted,
|
||||
// M11 additions
|
||||
AuditIntegrityVerified,
|
||||
EmailNotificationSent,
|
||||
PatchJobCompleted,
|
||||
PatchJobFailed,
|
||||
MaintenanceWindowReminder,
|
||||
HealthCheckCreated,
|
||||
HealthCheckUpdated,
|
||||
HealthCheckDeleted,
|
||||
CertificateReissued,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UserLogin => "user_login",
|
||||
Self::UserLogout => "user_logout",
|
||||
Self::UserLoginFailed => "user_login_failed",
|
||||
Self::UserCreated => "user_created",
|
||||
Self::UserDeleted => "user_deleted",
|
||||
Self::UserUpdated => "user_updated",
|
||||
Self::HostRegistered => "host_registered",
|
||||
Self::HostRemoved => "host_removed",
|
||||
Self::GroupCreated => "group_created",
|
||||
Self::GroupDeleted => "group_deleted",
|
||||
Self::GroupMembershipChanged => "group_membership_changed",
|
||||
Self::PatchJobCreated => "patch_job_created",
|
||||
Self::PatchJobCancelled => "patch_job_cancelled",
|
||||
Self::PatchJobRollback => "patch_job_rollback",
|
||||
Self::MaintenanceWindowCreated => "maintenance_window_created",
|
||||
Self::MaintenanceWindowUpdated => "maintenance_window_updated",
|
||||
Self::MaintenanceWindowDeleted => "maintenance_window_deleted",
|
||||
Self::CertificateIssued => "certificate_issued",
|
||||
Self::CertificateRenewed => "certificate_renewed",
|
||||
Self::CertificateRevoked => "certificate_revoked",
|
||||
Self::CertificateDownloaded => "certificate_downloaded",
|
||||
Self::ConfigChanged => "config_changed",
|
||||
Self::DiscoveryScanStarted => "discovery_scan_started",
|
||||
Self::AuditIntegrityVerified => "audit_integrity_verified",
|
||||
Self::EmailNotificationSent => "email_notification_sent",
|
||||
Self::PatchJobCompleted => "patch_job_completed",
|
||||
Self::PatchJobFailed => "patch_job_failed",
|
||||
Self::MaintenanceWindowReminder => "maintenance_window_reminder",
|
||||
Self::HealthCheckCreated => "health_check_created",
|
||||
Self::HealthCheckUpdated => "health_check_updated",
|
||||
Self::HealthCheckDeleted => "health_check_deleted",
|
||||
Self::CertificateReissued => "certificate_reissued",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an audit event to the database.
|
||||
///
|
||||
/// Computes a hash chain entry using the previous row's hash.
|
||||
/// Non-fatal: logs errors but does not propagate them to avoid
|
||||
/// disrupting the primary operation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn log_event(
|
||||
pool: &PgPool,
|
||||
action: AuditAction,
|
||||
actor_user_id: Option<Uuid>,
|
||||
actor_username: Option<&str>,
|
||||
target_type: Option<&str>,
|
||||
target_id: Option<&str>,
|
||||
details: serde_json::Value,
|
||||
ip_address: Option<IpAddr>,
|
||||
request_id: Option<&str>,
|
||||
) {
|
||||
let result = write_audit_row(
|
||||
pool,
|
||||
action,
|
||||
actor_user_id,
|
||||
actor_username,
|
||||
target_type,
|
||||
target_id,
|
||||
details,
|
||||
ip_address,
|
||||
request_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::error!(error = %e, action = action.as_str(), "Failed to write audit log");
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn write_audit_row(
|
||||
pool: &PgPool,
|
||||
action: AuditAction,
|
||||
actor_user_id: Option<Uuid>,
|
||||
actor_username: Option<&str>,
|
||||
target_type: Option<&str>,
|
||||
target_id: Option<&str>,
|
||||
details: serde_json::Value,
|
||||
ip_address: Option<IpAddr>,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
// Fetch previous hash for chain
|
||||
let prev_hash: Option<String> =
|
||||
sqlx::query_scalar("SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1")
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let prev = prev_hash.unwrap_or_default();
|
||||
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
|
||||
let action_str = action.as_str();
|
||||
let uid_str = actor_user_id.map(|u| u.to_string()).unwrap_or_default();
|
||||
let uname = actor_username.unwrap_or("");
|
||||
let ttype = target_type.unwrap_or("");
|
||||
let tid = target_id.unwrap_or("");
|
||||
let details_str = serde_json::to_string(&details).unwrap_or_default();
|
||||
let ip_str = ip_address.map(|ip| ip.to_string()).unwrap_or_default();
|
||||
let rid = request_id.unwrap_or("");
|
||||
|
||||
// Hash: SHA-256(prev_hash + action + actor_user_id + actor_username +
|
||||
// target_type + target_id + details_json + ip_address +
|
||||
// request_id + created_at)
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(prev.as_bytes());
|
||||
hasher.update(action_str.as_bytes());
|
||||
hasher.update(uid_str.as_bytes());
|
||||
hasher.update(uname.as_bytes());
|
||||
hasher.update(ttype.as_bytes());
|
||||
hasher.update(tid.as_bytes());
|
||||
hasher.update(details_str.as_bytes());
|
||||
hasher.update(ip_str.as_bytes());
|
||||
hasher.update(rid.as_bytes());
|
||||
hasher.update(now.as_bytes());
|
||||
let row_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let ip_for_db = ip_address.map(|ip| ip.to_string());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO audit_log
|
||||
(action, actor_user_id, actor_username, target_type, target_id,
|
||||
details, ip_address, request_id, created_at, row_hash, prev_hash)
|
||||
VALUES
|
||||
($1::audit_action, $2, $3, $4, $5, $6, $7::inet, $8, $9::timestamptz, $10, $11)
|
||||
"#,
|
||||
)
|
||||
.bind(action_str)
|
||||
.bind(actor_user_id)
|
||||
.bind(actor_username)
|
||||
.bind(target_type)
|
||||
.bind(target_id)
|
||||
.bind(details)
|
||||
.bind(ip_for_db)
|
||||
.bind(request_id)
|
||||
.bind(&now)
|
||||
.bind(&row_hash)
|
||||
.bind(&prev)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Result of an audit integrity verification pass.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct IntegrityResult {
|
||||
/// Whether the chain is intact (no tampering detected).
|
||||
pub intact: bool,
|
||||
/// Total number of rows checked.
|
||||
pub rows_checked: i64,
|
||||
/// List of errors found (row id, expected hash, actual hash).
|
||||
pub errors: Vec<IntegrityError>,
|
||||
}
|
||||
|
||||
/// A single integrity error detected in the audit chain.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct IntegrityError {
|
||||
pub row_id: i64,
|
||||
pub expected_hash: String,
|
||||
pub actual_hash: String,
|
||||
}
|
||||
|
||||
/// Row read from audit_log for integrity verification.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct AuditRow {
|
||||
id: i64,
|
||||
action: String,
|
||||
actor_user_id: Option<uuid::Uuid>,
|
||||
actor_username: Option<String>,
|
||||
target_type: Option<String>,
|
||||
target_id: Option<String>,
|
||||
details: Option<serde_json::Value>,
|
||||
ip_address: Option<String>,
|
||||
request_id: Option<String>,
|
||||
created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
row_hash: String,
|
||||
prev_hash: String,
|
||||
}
|
||||
|
||||
/// Walk the audit_log rows ordered by id and verify each row_hash matches
|
||||
/// the recomputed hash. Returns an [`IntegrityResult`] describing any
|
||||
/// tampering detected.
|
||||
pub async fn verify_integrity(pool: &PgPool) -> IntegrityResult {
|
||||
let rows: Vec<AuditRow> = match sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, action::text AS action, actor_user_id, actor_username,
|
||||
target_type, target_id, details,
|
||||
host(ip_address) AS ip_address,
|
||||
request_id, created_at, row_hash, prev_hash
|
||||
FROM audit_log
|
||||
ORDER BY id ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "verify_integrity: failed to fetch audit rows");
|
||||
return IntegrityResult {
|
||||
intact: false,
|
||||
rows_checked: 0,
|
||||
errors: vec![],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut expected_prev_hash = String::new();
|
||||
|
||||
for row in &rows {
|
||||
// Verify prev_hash linkage
|
||||
if row.prev_hash != expected_prev_hash {
|
||||
errors.push(IntegrityError {
|
||||
row_id: row.id,
|
||||
expected_hash: expected_prev_hash.clone(),
|
||||
actual_hash: row.prev_hash.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Recompute the row hash from all fields
|
||||
let uid_str = row.actor_user_id.map(|u| u.to_string()).unwrap_or_default();
|
||||
let uname = row.actor_username.as_deref().unwrap_or("");
|
||||
let ttype = row.target_type.as_deref().unwrap_or("");
|
||||
let tid = row.target_id.as_deref().unwrap_or("");
|
||||
let details_str = row
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::to_string(v).ok())
|
||||
.unwrap_or_default();
|
||||
let ip_str = row.ip_address.as_deref().unwrap_or("");
|
||||
let rid = row.request_id.as_deref().unwrap_or("");
|
||||
let created_str = row
|
||||
.created_at
|
||||
.map(|c| c.to_rfc3339_opts(chrono::SecondsFormat::Micros, true))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(row.prev_hash.as_bytes());
|
||||
hasher.update(row.action.as_bytes());
|
||||
hasher.update(uid_str.as_bytes());
|
||||
hasher.update(uname.as_bytes());
|
||||
hasher.update(ttype.as_bytes());
|
||||
hasher.update(tid.as_bytes());
|
||||
hasher.update(details_str.as_bytes());
|
||||
hasher.update(ip_str.as_bytes());
|
||||
hasher.update(rid.as_bytes());
|
||||
hasher.update(created_str.as_bytes());
|
||||
let computed_hash = hex::encode(hasher.finalize());
|
||||
|
||||
if row.row_hash != computed_hash {
|
||||
errors.push(IntegrityError {
|
||||
row_id: row.id,
|
||||
expected_hash: computed_hash,
|
||||
actual_hash: row.row_hash.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Next row should have this row's hash as prev_hash
|
||||
expected_prev_hash = row.row_hash.clone();
|
||||
}
|
||||
|
||||
let intact = errors.is_empty();
|
||||
let rows_checked = rows.len() as i64;
|
||||
|
||||
IntegrityResult {
|
||||
intact,
|
||||
rows_checked,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
214
crates/pm-core/src/config.rs
Normal file
214
crates/pm-core/src/config.rs
Normal file
@ -0,0 +1,214 @@
|
||||
use config::{Config, ConfigError, Environment, File};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Rate limiting configuration per route group.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RateLimitConfig {
|
||||
/// Enrollment endpoint: requests per minute per IP (default: 5)
|
||||
#[serde(default = "default_enrollment_rpm")]
|
||||
pub enrollment_rpm: u32,
|
||||
/// Enrollment burst allowance (default: 3)
|
||||
#[serde(default = "default_enrollment_burst")]
|
||||
pub enrollment_burst: u32,
|
||||
/// Public auth endpoints: requests per minute per IP (default: 20)
|
||||
#[serde(default = "default_auth_rpm")]
|
||||
pub auth_rpm: u32,
|
||||
/// Auth burst allowance (default: 10)
|
||||
#[serde(default = "default_auth_burst")]
|
||||
pub auth_burst: u32,
|
||||
/// Authenticated API: requests per minute per IP (default: 120)
|
||||
#[serde(default = "default_api_rpm")]
|
||||
pub api_rpm: u32,
|
||||
/// API burst allowance (default: 30)
|
||||
#[serde(default = "default_api_burst")]
|
||||
pub api_burst: u32,
|
||||
}
|
||||
|
||||
fn default_enrollment_rpm() -> u32 {
|
||||
5
|
||||
}
|
||||
fn default_enrollment_burst() -> u32 {
|
||||
3
|
||||
}
|
||||
fn default_auth_rpm() -> u32 {
|
||||
20
|
||||
}
|
||||
fn default_auth_burst() -> u32 {
|
||||
10
|
||||
}
|
||||
fn default_api_rpm() -> u32 {
|
||||
120
|
||||
}
|
||||
fn default_api_burst() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enrollment_rpm: default_enrollment_rpm(),
|
||||
enrollment_burst: default_enrollment_burst(),
|
||||
auth_rpm: default_auth_rpm(),
|
||||
auth_burst: default_auth_burst(),
|
||||
api_rpm: default_api_rpm(),
|
||||
api_burst: default_api_burst(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
#[serde(default)]
|
||||
pub rate_limit: RateLimitConfig,
|
||||
}
|
||||
|
||||
#[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,
|
||||
/// Health check poll interval in seconds (default: 300 = 5 min)
|
||||
#[serde(default = "default_health_check_poll_interval")]
|
||||
pub health_check_poll_interval_secs: u64,
|
||||
/// Maximum concurrent agent calls
|
||||
pub max_concurrent_agent_calls: usize,
|
||||
/// Worker heartbeat interval in seconds
|
||||
pub heartbeat_interval_secs: u64,
|
||||
/// WS relay HTTP polling fallback interval in seconds (default: 10)
|
||||
pub ws_relay_poll_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,
|
||||
/// Frontend URL to redirect to after SSO callback (default: http://localhost:5173/auth/sso/callback)
|
||||
#[serde(default = "default_sso_callback_url")]
|
||||
pub sso_callback_url: 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()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_health_check_poll_interval() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_sso_callback_url() -> String {
|
||||
"http://localhost:5173/auth/sso/callback".to_string()
|
||||
}
|
||||
|
||||
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,
|
||||
health_check_poll_interval_secs: 300,
|
||||
max_concurrent_agent_calls: 64,
|
||||
heartbeat_interval_secs: 30,
|
||||
ws_relay_poll_interval_secs: 10,
|
||||
},
|
||||
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(),
|
||||
sso_callback_url: default_sso_callback_url(),
|
||||
},
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
80
crates/pm-core/src/crypto.rs
Executable file
80
crates/pm-core/src/crypto.rs
Executable file
@ -0,0 +1,80 @@
|
||||
//! AES-256-GCM encryption for sensitive health check credentials.
|
||||
//!
|
||||
//! Uses a per-install key stored at `/etc/patch-manager/keys/health-check.key`.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub const KEY_PATH: &str = "/etc/patch-manager/keys/health-check.key";
|
||||
|
||||
/// Load or create the per-install encryption key.
|
||||
/// If the key file doesn't exist, generates a new 256-bit key and saves it.
|
||||
pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> {
|
||||
if path.exists() {
|
||||
let key_bytes = fs::read(path).map_err(CryptoError::Io)?;
|
||||
if key_bytes.len() != 32 {
|
||||
return Err(CryptoError::InvalidKeyLength(key_bytes.len()));
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
Ok(key)
|
||||
} else {
|
||||
let mut key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut key);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(CryptoError::Io)?;
|
||||
}
|
||||
fs::write(path, key).map_err(CryptoError::Io)?;
|
||||
// Set permissions to 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(CryptoError::Io)?;
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt plaintext with AES-256-GCM. Returns (ciphertext, nonce).
|
||||
pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
|
||||
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| CryptoError::KeyInit(e.to_string()))?;
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext.as_bytes())
|
||||
.map_err(|_| CryptoError::EncryptionFailed)?;
|
||||
Ok((ciphertext, nonce_bytes.to_vec()))
|
||||
}
|
||||
|
||||
/// Decrypt AES-256-GCM ciphertext with the given nonce.
|
||||
pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result<String, CryptoError> {
|
||||
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| CryptoError::KeyInit(e.to_string()))?;
|
||||
let nonce = Nonce::from_slice(nonce);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||
String::from_utf8(plaintext).map_err(CryptoError::Utf8)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CryptoError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Invalid key length: expected 32 bytes, got {0}")]
|
||||
InvalidKeyLength(usize),
|
||||
#[error("Key init error: {0}")]
|
||||
KeyInit(String),
|
||||
#[error("Encryption failed")]
|
||||
EncryptionFailed,
|
||||
#[error("Decryption failed")]
|
||||
DecryptionFailed,
|
||||
#[error("UTF-8 error: {0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
}
|
||||
117
crates/pm-core/src/db.rs
Executable file
117
crates/pm-core/src/db.rs
Executable file
@ -0,0 +1,117 @@
|
||||
use crate::config::DatabaseConfig;
|
||||
use crate::models::{CreateEnrollmentRequest, EnrollmentRequest};
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Enrollment Requests
|
||||
// ============================================================
|
||||
|
||||
pub async fn create_enrollment_request(
|
||||
pool: &PgPool,
|
||||
req: CreateEnrollmentRequest,
|
||||
token_hash: String,
|
||||
) -> Result<EnrollmentRequest, sqlx::Error> {
|
||||
sqlx::query_as::<
|
||||
_,
|
||||
EnrollmentRequest,
|
||||
>(
|
||||
r#"
|
||||
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token, hostname)
|
||||
VALUES ($1, $2, $3::inet, $4, $5, $6)
|
||||
RETURNING id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at
|
||||
"#,
|
||||
)
|
||||
.bind(req.machine_id)
|
||||
.bind(req.fqdn)
|
||||
.bind(req.ip_address)
|
||||
.bind(req.os_details)
|
||||
.bind(token_hash)
|
||||
.bind(&req.hostname)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_enrollment_requests(
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<EnrollmentRequest>, sqlx::Error> {
|
||||
sqlx::query_as::<_, EnrollmentRequest>(
|
||||
"SELECT id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_enrollment_request(pool: &PgPool, id: Uuid) -> Result<u64, sqlx::Error> {
|
||||
let result = sqlx::query("DELETE FROM enrollment_requests WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
126
crates/pm-core/src/error.rs
Executable file
126
crates/pm-core/src/error.rs
Executable file
@ -0,0 +1,126 @@
|
||||
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>;
|
||||
23
crates/pm-core/src/lib.rs
Executable file
23
crates/pm-core/src/lib.rs
Executable file
@ -0,0 +1,23 @@
|
||||
pub mod audit;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod logging;
|
||||
pub mod models;
|
||||
pub mod request_id;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use config::AppConfig;
|
||||
pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH};
|
||||
pub use error::{AppError, ErrorResponse};
|
||||
pub use models::{
|
||||
AdminResetPasswordRequest, AuthProvider, ChangePasswordRequest, CreateGroupRequest,
|
||||
CreateHealthCheckRequest, CreateHostRequest, CreateUserRequest, DiscoveryCidrRequest,
|
||||
DiscoveryResult, Group, HealthCheck, HealthCheckResult, HealthCheckWithResult, Host,
|
||||
HostHealthStatus, HostSummary, RegisterDiscoveredRequest, UpdateGroupRequest,
|
||||
UpdateHealthCheckRequest, UpdateUserRequest, User, UserRole as DbUserRole,
|
||||
};
|
||||
|
||||
// Re-export audit integrity types
|
||||
pub use audit::{verify_integrity, IntegrityError, IntegrityResult};
|
||||
31
crates/pm-core/src/logging.rs
Executable file
31
crates/pm-core/src/logging.rs
Executable file
@ -0,0 +1,31 @@
|
||||
use crate::config::LoggingConfig;
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
/// 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");
|
||||
}
|
||||
555
crates/pm-core/src/models.rs
Executable file
555
crates/pm-core/src/models.rs
Executable file
@ -0,0 +1,555 @@
|
||||
//! Shared database model types used across pm-web and pm-worker.
|
||||
//!
|
||||
//! These match the database schema defined in migrations/.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ============================================================
|
||||
// Enumerations (matching PostgreSQL ENUM types)
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[sqlx(type_name = "host_health_status", rename_all = "lowercase")]
|
||||
pub enum HostHealthStatus {
|
||||
Pending,
|
||||
Healthy,
|
||||
Degraded,
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HostHealthStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Pending => write!(f, "pending"),
|
||||
Self::Healthy => write!(f, "healthy"),
|
||||
Self::Degraded => write!(f, "degraded"),
|
||||
Self::Unreachable => write!(f, "unreachable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
|
||||
pub enum UserRole {
|
||||
Admin,
|
||||
Operator,
|
||||
Reporter,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserRole {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Admin => write!(f, "admin"),
|
||||
Self::Operator => write!(f, "operator"),
|
||||
Self::Reporter => write!(f, "reporter"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "auth_provider", rename_all = "snake_case")]
|
||||
pub enum AuthProvider {
|
||||
Local,
|
||||
#[sqlx(rename = "azure_sso")]
|
||||
AzureSso,
|
||||
Keycloak,
|
||||
Oidc,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuthProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Local => write!(f, "local"),
|
||||
Self::AzureSso => write!(f, "azure_sso"),
|
||||
Self::Keycloak => write!(f, "keycloak"),
|
||||
Self::Oidc => write!(f, "oidc"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Host
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Host {
|
||||
pub id: Uuid,
|
||||
pub fqdn: String,
|
||||
pub ip_address: String, // stored as INET, returned as text
|
||||
pub display_name: String,
|
||||
pub os_family: Option<String>,
|
||||
pub os_name: Option<String>,
|
||||
pub arch: Option<String>,
|
||||
pub agent_version: Option<String>,
|
||||
pub health_status: HostHealthStatus,
|
||||
pub last_health_at: Option<DateTime<Utc>>,
|
||||
pub last_patch_at: Option<DateTime<Utc>>,
|
||||
pub agent_port: i32,
|
||||
pub notes: String,
|
||||
pub registered_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Payload for registering a new host.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateHostRequest {
|
||||
/// FQDN or IP address of the managed host
|
||||
pub fqdn: String,
|
||||
pub display_name: Option<String>,
|
||||
pub agent_port: Option<i32>,
|
||||
pub notes: Option<String>,
|
||||
pub group_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Payload for updating an existing host.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateHostRequest {
|
||||
pub fqdn: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Host list item (lighter projection for list views)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct HostSummary {
|
||||
pub id: Uuid,
|
||||
pub fqdn: String,
|
||||
pub ip_address: String,
|
||||
pub display_name: String,
|
||||
pub os_family: Option<String>,
|
||||
pub os_name: Option<String>,
|
||||
pub health_status: HostHealthStatus,
|
||||
pub agent_version: Option<String>,
|
||||
pub patches_missing: i32,
|
||||
pub health_check_status: Option<String>,
|
||||
pub registered_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Host Enrollment
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct EnrollmentRequest {
|
||||
pub id: Uuid,
|
||||
pub machine_id: String,
|
||||
pub fqdn: String,
|
||||
pub ip_address: String,
|
||||
pub os_details: serde_json::Value,
|
||||
pub polling_token: String,
|
||||
/// Short hostname provided during enrollment (optional).
|
||||
pub hostname: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Payload for initial host enrollment request.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CreateEnrollmentRequest {
|
||||
pub machine_id: String,
|
||||
pub fqdn: String,
|
||||
pub ip_address: String,
|
||||
pub os_details: serde_json::Value,
|
||||
/// Short hostname (from /etc/hostname, optional).
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "lowercase")]
|
||||
pub enum EnrollmentStatusResponse {
|
||||
Pending,
|
||||
Approved {
|
||||
ca_crt: String,
|
||||
server_crt: String,
|
||||
server_key: String,
|
||||
},
|
||||
Denied,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PkiBundle {
|
||||
pub ca_crt: String,
|
||||
pub server_crt: String,
|
||||
pub server_key: String,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Health Checks
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct HealthCheck {
|
||||
pub id: Uuid,
|
||||
pub host_id: Uuid,
|
||||
pub name: String,
|
||||
pub check_type: String, // "service" or "http"
|
||||
pub enabled: bool,
|
||||
// Service check fields
|
||||
pub service_name: Option<String>,
|
||||
// HTTP check fields
|
||||
pub url: Option<String>,
|
||||
pub expected_body: Option<String>,
|
||||
pub ignore_cert_errors: bool,
|
||||
pub basic_auth_user: Option<String>,
|
||||
pub target_host_id: Option<Uuid>,
|
||||
// basic_auth_pass_encrypted and nonce NOT exposed in API responses
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthCheckWithResult {
|
||||
#[serde(flatten)]
|
||||
pub check: HealthCheck,
|
||||
pub last_result: Option<HealthCheckResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct HealthCheckResult {
|
||||
pub id: Uuid,
|
||||
pub check_id: Uuid,
|
||||
pub healthy: bool,
|
||||
pub detail: Option<String>,
|
||||
pub latency_ms: Option<i32>,
|
||||
pub checked_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateHealthCheckRequest {
|
||||
pub name: String,
|
||||
pub check_type: String, // "service" or "http"
|
||||
pub service_name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub expected_body: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub ignore_cert_errors: bool,
|
||||
pub basic_auth_user: Option<String>,
|
||||
pub basic_auth_pass: Option<String>, // plaintext in request, encrypted before storage
|
||||
pub target_host_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateHealthCheckRequest {
|
||||
pub name: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub service_name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub expected_body: Option<String>,
|
||||
pub ignore_cert_errors: Option<bool>,
|
||||
pub basic_auth_user: Option<String>,
|
||||
pub basic_auth_pass: Option<String>, // if provided, re-encrypt
|
||||
pub target_host_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Group
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Group {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGroupRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateGroupRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// User
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email: String,
|
||||
pub role: UserRole,
|
||||
pub auth_provider: AuthProvider,
|
||||
pub mfa_enabled: bool,
|
||||
pub is_active: bool,
|
||||
pub force_password_reset: bool,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// User create payload (admin-only)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// User update payload
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub display_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub is_active: Option<bool>,
|
||||
pub force_password_reset: Option<bool>,
|
||||
}
|
||||
|
||||
/// Self-service password change payload
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Admin password reset payload
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdminResetPasswordRequest {
|
||||
pub new_password: String,
|
||||
#[serde(default)]
|
||||
pub force_password_reset: bool,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Discovery
|
||||
// ============================================================
|
||||
|
||||
/// Request body for CIDR auto-discovery scan.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DiscoveryCidrRequest {
|
||||
/// CIDR range to scan (e.g. "10.0.0.0/24")
|
||||
pub cidr: String,
|
||||
/// Agent port to probe (default 12443)
|
||||
pub agent_port: Option<i32>,
|
||||
}
|
||||
|
||||
/// A single discovered host result.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct DiscoveryResult {
|
||||
pub id: Uuid,
|
||||
pub scan_id: Uuid,
|
||||
pub ip_address: String,
|
||||
pub fqdn: Option<String>,
|
||||
pub agent_version: Option<String>,
|
||||
pub os_name: Option<String>,
|
||||
pub agent_port: i32,
|
||||
pub discovered_at: DateTime<Utc>,
|
||||
pub registered: bool,
|
||||
}
|
||||
|
||||
/// Payload for registering a host from a discovery result.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterDiscoveredRequest {
|
||||
pub discovery_id: Uuid,
|
||||
pub display_name: Option<String>,
|
||||
pub group_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Patch Jobs
|
||||
// ============================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[sqlx(type_name = "job_status", rename_all = "lowercase")]
|
||||
pub enum JobStatus {
|
||||
Queued,
|
||||
Pending,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for JobStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Queued => write!(f, "queued"),
|
||||
Self::Pending => write!(f, "pending"),
|
||||
Self::Running => write!(f, "running"),
|
||||
Self::Succeeded => write!(f, "succeeded"),
|
||||
Self::Failed => write!(f, "failed"),
|
||||
Self::Cancelled => write!(f, "cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(type_name = "job_kind", rename_all = "snake_case")]
|
||||
pub enum JobKind {
|
||||
#[sqlx(rename = "patch_apply")]
|
||||
PatchApply,
|
||||
#[sqlx(rename = "patch_remove")]
|
||||
PatchRemove,
|
||||
Reboot,
|
||||
Rollback,
|
||||
}
|
||||
|
||||
/// Full `patch_jobs` row.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct PatchJob {
|
||||
pub id: Uuid,
|
||||
pub kind: JobKind,
|
||||
pub status: JobStatus,
|
||||
pub created_by_user_id: Option<Uuid>,
|
||||
pub parent_job_id: Option<Uuid>,
|
||||
pub maintenance_window_id: Option<Uuid>,
|
||||
pub immediate: bool,
|
||||
pub patch_selection: serde_json::Value,
|
||||
pub notes: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Full `patch_job_hosts` row (includes columns added in migration 003).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct PatchJobHost {
|
||||
pub id: Uuid,
|
||||
pub job_id: Uuid,
|
||||
pub host_id: Uuid,
|
||||
pub status: JobStatus,
|
||||
pub agent_job_id: Option<String>,
|
||||
pub retry_count: i32,
|
||||
pub output: String,
|
||||
pub error_message: Option<String>,
|
||||
pub retry_next_at: Option<DateTime<Utc>>,
|
||||
pub last_error: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Request payload for creating a patch job via `POST /api/v1/jobs`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateJobRequest {
|
||||
/// Host IDs to patch.
|
||||
pub host_ids: Vec<Uuid>,
|
||||
/// Package names to apply (empty = all available patches).
|
||||
pub packages: Vec<String>,
|
||||
/// If true: apply immediately. If false: queue for next maintenance window.
|
||||
pub immediate: bool,
|
||||
/// Optional maintenance window to bind to.
|
||||
pub maintenance_window_id: Option<Uuid>,
|
||||
/// Allow reboot if required by patches.
|
||||
pub allow_reboot: Option<bool>,
|
||||
/// Optional operator notes.
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Summary row for job list view (aggregates per-host counts).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct PatchJobSummary {
|
||||
pub id: Uuid,
|
||||
pub kind: JobKind,
|
||||
pub status: JobStatus,
|
||||
pub immediate: bool,
|
||||
pub host_count: i64,
|
||||
pub succeeded_count: i64,
|
||||
pub failed_count: i64,
|
||||
pub notes: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Maintenance Windows
|
||||
// ============================================================
|
||||
|
||||
/// Recurrence type for a maintenance window.
|
||||
/// Mirrors the `window_recurrence` PostgreSQL ENUM.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[sqlx(type_name = "window_recurrence", rename_all = "lowercase")]
|
||||
pub enum WindowRecurrence {
|
||||
/// Single one-time window (at `start_at` for `duration_minutes` minutes).
|
||||
Once,
|
||||
/// Repeats every day at the time portion of `start_at`.
|
||||
Daily,
|
||||
/// Repeats on the day-of-week in `recurrence_day` (0 = Sunday).
|
||||
Weekly,
|
||||
/// Repeats on the day-of-month in `recurrence_day` (1-31).
|
||||
Monthly,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WindowRecurrence {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Once => write!(f, "once"),
|
||||
Self::Daily => write!(f, "daily"),
|
||||
Self::Weekly => write!(f, "weekly"),
|
||||
Self::Monthly => write!(f, "monthly"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full row from `maintenance_windows`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct MaintenanceWindow {
|
||||
pub id: Uuid,
|
||||
pub host_id: Uuid,
|
||||
pub label: String,
|
||||
pub recurrence: WindowRecurrence,
|
||||
/// Absolute start time (one-time) or time-of-day reference (recurring).
|
||||
pub start_at: DateTime<Utc>,
|
||||
/// Duration of the window in minutes.
|
||||
pub duration_minutes: i32,
|
||||
/// Day-of-week (0=Sun, weekly) or day-of-month (1-31, monthly); NULL for once/daily.
|
||||
pub recurrence_day: Option<i32>,
|
||||
pub enabled: bool,
|
||||
pub auto_apply: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Payload for `POST /api/v1/hosts/{id}/maintenance-windows`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateMaintenanceWindowRequest {
|
||||
pub label: String,
|
||||
pub recurrence: WindowRecurrence,
|
||||
/// RFC 3339 / ISO 8601 timestamp (UTC recommended).
|
||||
pub start_at: DateTime<Utc>,
|
||||
/// How many minutes the window is open (default 60).
|
||||
pub duration_minutes: Option<i32>,
|
||||
/// Required for `weekly` (0-6) and `monthly` (1-31).
|
||||
pub recurrence_day: Option<i32>,
|
||||
/// Whether the window is active (default true).
|
||||
pub enabled: Option<bool>,
|
||||
/// Whether to auto-create a patch_apply job when this window opens and patches are pending (default true).
|
||||
pub auto_apply: Option<bool>,
|
||||
}
|
||||
|
||||
/// Payload for `PUT /api/v1/hosts/{id}/maintenance-windows/{window_id}`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateMaintenanceWindowRequest {
|
||||
pub label: Option<String>,
|
||||
pub recurrence: Option<WindowRecurrence>,
|
||||
pub start_at: Option<DateTime<Utc>>,
|
||||
pub duration_minutes: Option<i32>,
|
||||
pub recurrence_day: Option<i32>,
|
||||
pub enabled: Option<bool>,
|
||||
pub auto_apply: Option<bool>,
|
||||
}
|
||||
39
crates/pm-core/src/request_id.rs
Executable file
39
crates/pm-core/src/request_id.rs
Executable file
@ -0,0 +1,39 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user