feat: health check configuration and worker engine (Phase 3+4)
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 4s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m1s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 4s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m1s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped
- Added health_check_poller.rs: periodic service/HTTP health checks - Added pre-patch health gate in job_executor.rs - Added waiting_health_check job status (migration 008) - Added health_check_status to HostSummary and hosts API - Added health check types and API functions to frontend - Added health check UI section to HostDetailPage - Added health check status indicators to HostsPage and PatchDeploymentPage - Added serde default for health_check_poll_interval_secs - Fixed missing AgentClient import in health_check_poller.rs - Fixed missing ws_relay import in main.rs - Fixed missing closing paren in retry_pending_jobs SQL - Added ReadWritePaths for /etc/patch-manager/keys in systemd services
This commit is contained in:
@ -47,6 +47,9 @@ pub enum AuditAction {
|
||||
PatchJobCompleted,
|
||||
PatchJobFailed,
|
||||
MaintenanceWindowReminder,
|
||||
HealthCheckCreated,
|
||||
HealthCheckUpdated,
|
||||
HealthCheckDeleted,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
@ -80,6 +83,9 @@ impl AuditAction {
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,9 @@ pub struct WorkerConfig {
|
||||
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
|
||||
@ -98,6 +101,8 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_health_check_poll_interval() -> u64 { 300 }
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@ -115,6 +120,7 @@ impl Default for AppConfig {
|
||||
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,
|
||||
|
||||
80
crates/pm-core/src/crypto.rs
Normal file
80
crates/pm-core/src/crypto.rs
Normal 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),
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
pub mod audit;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod logging;
|
||||
@ -8,11 +9,14 @@ pub mod request_id;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use config::AppConfig;
|
||||
pub use crypto::{CryptoError, KEY_PATH, decrypt, encrypt, load_or_create_key};
|
||||
pub use error::{AppError, ErrorResponse};
|
||||
pub use models::{
|
||||
AuthProvider, CreateGroupRequest, CreateHostRequest, CreateUserRequest, DiscoveryCidrRequest,
|
||||
DiscoveryResult, Group, Host, HostHealthStatus, HostSummary, RegisterDiscoveredRequest,
|
||||
UpdateGroupRequest, UpdateUserRequest, User, UserRole as DbUserRole,
|
||||
AuthProvider, 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
|
||||
|
||||
@ -113,9 +113,79 @@ pub struct HostSummary {
|
||||
pub health_status: HostHealthStatus,
|
||||
pub agent_version: Option<String>,
|
||||
pub patches_missing: i32,
|
||||
pub health_check_status: Option<String>,
|
||||
pub registered_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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>,
|
||||
// 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
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Group
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user