Private
Public Access
1
0

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:
2026-05-28 10:52:16 -05:00
commit 124b5b0e3b
153 changed files with 41878 additions and 0 deletions

26
crates/pm-core/Cargo.toml Normal file
View 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
View 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,
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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>,
}

View 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)
}
}