Private
Public Access
1
0
Files
linux_patch_manager/crates/pm-agent-client/src/client.rs
Echo 93828e1976
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
feat: health check configuration and worker engine (Phase 3+4)
- 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
2026-05-05 14:10:37 +00:00

272 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! mTLS HTTP client for communicating with Linux Patch API agents.
//!
//! # Example
//!
//! ```no_run
//! use pm_agent_client::client::AgentClient;
//!
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
//! let client = AgentClient::new(
//! "192.168.1.10",
//! 12443,
//! include_bytes!("../certs/client.crt"),
//! include_bytes!("../certs/client.key"),
//! include_bytes!("../certs/ca.crt"),
//! )?;
//!
//! let health = client.health().await?;
//! println!("Agent status: {}", health.status);
//! # Ok(())
//! # }
//! ```
use std::time::Duration;
use reqwest::{tls::Version, Certificate, ClientBuilder, Identity};
use serde::{de::DeserializeOwned, Serialize};
use tracing::{debug, instrument};
use crate::{
error::AgentClientError,
types::{
AgentEnvelope, AgentJobStatus, ApplyPatchesRequest, ApplyPatchesResponse, HealthData,
PackagesData, PatchesData, RollbackResponse, ServiceStatusData, SystemInfoData,
},
};
/// Default TCP port that the Linux Patch API agent listens on.
pub const DEFAULT_AGENT_PORT: u16 = 12443;
/// Request timeout applied to every agent API call.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
// ============================================================
// AgentClient
// ============================================================
/// Async HTTP client that speaks mTLS to a single Linux Patch API agent.
///
/// Construct once via [`AgentClient::new`] and reuse across calls;
/// the underlying [`reqwest::Client`] maintains a connection pool.
#[derive(Debug, Clone)]
pub struct AgentClient {
/// Underlying HTTP client (configured for mTLS + TLS 1.3).
inner: reqwest::Client,
/// Base URL of the agent, e.g. `https://10.0.0.5:12443/api/v1`.
base_url: String,
}
impl AgentClient {
/// Create a new [`AgentClient`] configured for mTLS.
///
/// # Arguments
///
/// * `host_ip` IP address (or hostname) of the agent.
/// * `port` TCP port the agent listens on (default [`DEFAULT_AGENT_PORT`]).
/// * `client_cert_pem` PEM-encoded client certificate presented during the TLS handshake.
/// * `client_key_pem` PEM-encoded private key matching `client_cert_pem`.
/// * `ca_cert_pem` PEM-encoded CA certificate used to verify the agent's server cert.
///
/// # Errors
///
/// Returns [`AgentClientError::Tls`] when certificate parsing fails, or
/// [`AgentClientError::Request`] when `reqwest` client construction fails.
pub fn new(
host_ip: &str,
port: u16,
client_cert_pem: &[u8],
client_key_pem: &[u8],
ca_cert_pem: &[u8],
) -> Result<Self, AgentClientError> {
// Build client identity: reqwest expects cert + key concatenated as PEM.
let mut identity_pem = Vec::with_capacity(client_cert_pem.len() + client_key_pem.len());
identity_pem.extend_from_slice(client_cert_pem);
identity_pem.extend_from_slice(client_key_pem);
let identity = Identity::from_pem(&identity_pem)
.map_err(|e| AgentClientError::Tls(format!("invalid client identity PEM: {e}")))?;
// Parse the CA certificate used to verify the agent's server certificate.
let ca_cert = Certificate::from_pem(ca_cert_pem)
.map_err(|e| AgentClientError::Tls(format!("invalid CA certificate PEM: {e}")))?;
// Build the reqwest client:
// - force rustls TLS backend
// - disable built-in OS/system trust roots (only trust our internal CA)
// - enforce TLS 1.3 minimum
// - attach client identity (mTLS)
// - add our CA as a trusted root
// - apply a global request timeout
let inner = ClientBuilder::new()
.use_rustls_tls()
.tls_built_in_root_certs(false)
.min_tls_version(Version::TLS_1_3)
.identity(identity)
.add_root_certificate(ca_cert)
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|e| AgentClientError::Request(e))?;
let clean_ip = host_ip.split('/').next().unwrap_or(host_ip);
let base_url = format!("https://{}:{}/api/v1", clean_ip, port);
Ok(Self { inner, base_url })
}
// --------------------------------------------------------
// Public API methods
// --------------------------------------------------------
/// `GET /api/v1/health` — check agent liveness and retrieve uptime.
#[instrument(skip(self), fields(base_url = %self.base_url))]
pub async fn health(&self) -> Result<HealthData, AgentClientError> {
self.get("health", &[]).await
}
/// `GET /api/v1/system/info` — retrieve host system information.
#[instrument(skip(self), fields(base_url = %self.base_url))]
pub async fn system_info(&self) -> Result<SystemInfoData, AgentClientError> {
self.get("system/info", &[]).await
}
/// `GET /api/v1/packages?status=upgradable` — list packages with available upgrades.
#[instrument(skip(self), fields(base_url = %self.base_url))]
pub async fn packages_upgradable(&self) -> Result<PackagesData, AgentClientError> {
self.get("packages", &[("status", "upgradable")]).await
}
/// `GET /api/v1/patches` — list available patches with severity and CVE data.
#[instrument(skip(self), fields(base_url = %self.base_url))]
pub async fn patches(&self) -> Result<PatchesData, AgentClientError> {
self.get("patches", &[]).await
}
// --------------------------------------------------------
// Private helpers
// --------------------------------------------------------
/// Execute a GET request against `{base_url}/{path}` with optional query
/// parameters, deserialize the [`AgentEnvelope`], and extract the `data`
/// field — or propagate an [`AgentClientError::ApiError`].
async fn get<T>(&self, path: &str, query: &[(&str, &str)]) -> Result<T, AgentClientError>
where
T: DeserializeOwned,
{
let url = format!("{}/{}", self.base_url, path);
debug!(url = %url, ?query, "Sending GET request to agent");
let mut request = self.inner.get(&url);
if !query.is_empty() {
request = request.query(query);
}
let response = request.send().await?;
let status = response.status();
debug!(url = %url, status = %status, "Received response from agent");
// Capture body text so we can attempt to deserialise the error envelope
// even for non-2xx responses.
let body = response.text().await?;
// Attempt to parse the standard agent envelope regardless of HTTP status.
// The agent may embed a structured error body on 4xx/5xx responses.
let envelope: AgentEnvelope<T> = serde_json::from_str(&body)?;
if !status.is_success() || !envelope.success {
// Prefer the structured error from the envelope when present.
if let Some(err) = envelope.error {
return Err(AgentClientError::ApiError {
code: err.code,
message: err.message,
});
}
// Fallback: use the HTTP status as the error indicator.
return Err(AgentClientError::ApiError {
code: status.as_str().to_string(),
message: format!("Agent returned HTTP {} for {}", status.as_u16(), url),
});
}
// On success the `data` field must be present.
envelope.data.ok_or_else(|| AgentClientError::ApiError {
code: "MISSING_DATA".to_string(),
message: "Agent response success=true but data field is absent".to_string(),
})
}
// --------------------------------------------------------
// Patch apply / job management methods
// --------------------------------------------------------
/// `POST /api/v1/patches/apply` — trigger patch application on the agent.
#[instrument(skip(self, req), fields(base_url = %self.base_url))]
pub async fn apply_patches(
&self,
req: &ApplyPatchesRequest,
) -> Result<ApplyPatchesResponse, AgentClientError> {
self.post("patches/apply", req).await
}
/// `GET /api/v1/jobs/{id}` — poll an async agent job for status.
#[instrument(skip(self), fields(base_url = %self.base_url, job_id = %job_id))]
pub async fn job_status(&self, job_id: &str) -> Result<AgentJobStatus, AgentClientError> {
self.get(&format!("jobs/{}", job_id), &[]).await
}
/// `POST /api/v1/jobs/{id}/rollback` — trigger rollback on the agent.
#[instrument(skip(self), fields(base_url = %self.base_url, job_id = %job_id))]
pub async fn rollback_job(&self, job_id: &str) -> Result<RollbackResponse, AgentClientError> {
let empty: serde_json::Value = serde_json::json!({});
self.post(&format!("jobs/{}/rollback", job_id), &empty)
.await
}
/// `GET /api/v1/system/services/{name}` — check status of a specific service on the agent.
#[instrument(skip(self), fields(base_url = %self.base_url, service_name = %service_name))]
pub async fn service_status(&self, service_name: &str) -> Result<ServiceStatusData, AgentClientError> {
self.get(&format!("system/services/{}", service_name), &[]).await
}
// --------------------------------------------------------
// Private POST helper
// --------------------------------------------------------
/// Execute a POST request against `{base_url}/{path}`, serialize `body` as
/// JSON, deserialize the [`AgentEnvelope`], and extract the `data` field —
/// or propagate an [`AgentClientError::ApiError`].
async fn post<Req, Resp>(&self, path: &str, body: &Req) -> Result<Resp, AgentClientError>
where
Req: Serialize,
Resp: DeserializeOwned,
{
let url = format!("{}/{}", self.base_url, path);
debug!(url = %url, "Sending POST request to agent");
let response = self.inner.post(&url).json(body).send().await?;
let status = response.status();
debug!(url = %url, status = %status, "Received POST response from agent");
let body_text = response.text().await?;
let envelope: AgentEnvelope<Resp> = serde_json::from_str(&body_text)?;
if !status.is_success() || !envelope.success {
if let Some(err) = envelope.error {
return Err(AgentClientError::ApiError {
code: err.code,
message: err.message,
});
}
return Err(AgentClientError::ApiError {
code: status.as_str().to_string(),
message: format!("Agent returned HTTP {} for {}", status.as_u16(), url),
});
}
envelope.data.ok_or_else(|| AgentClientError::ApiError {
code: "MISSING_DATA".to_string(),
message: "Agent response success=true but data field is absent".to_string(),
})
}
}