Compare commits
10 Commits
v1.1.0-enr
...
v1.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| fed5e386ce | |||
| f3555c1570 | |||
| cea162b048 | |||
| 08493fc782 | |||
| 8b890625f6 | |||
| 835c8d79cf | |||
| 8fd7d7620a | |||
| 3e8eacab9a | |||
| a09e3eaa68 | |||
| 6cfef766a7 |
@ -49,7 +49,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -f install -y
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
@ -71,7 +71,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -f install -y
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
|
||||
@ -93,7 +93,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -f install -y
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
|
||||
- name: Run cargo-audit
|
||||
run: |
|
||||
cargo install cargo-audit
|
||||
@ -118,7 +118,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -f install -y
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
|
||||
- name: Run enrollment unit tests
|
||||
run: cargo test --test enroll_identity
|
||||
- name: Run enrollment integration tests
|
||||
@ -145,7 +145,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -f install -y
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config
|
||||
sudo apt-get install -y build-essential libsystemd-dev pkg-config libssl-dev
|
||||
- name: Build binary
|
||||
run: cargo build
|
||||
- name: Verify --enroll flag exists
|
||||
@ -170,12 +170,12 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -f install -y
|
||||
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev
|
||||
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev libssl-dev
|
||||
- name: Build Debian package
|
||||
run: |
|
||||
sudo dpkg-buildpackage -us -uc -b -d
|
||||
- name: Upload to Gitea Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: github.ref_type == 'tag'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
run: |
|
||||
@ -203,12 +203,12 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -f install -y
|
||||
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev
|
||||
sudo apt-get install -y build-essential debhelper pkg-config libsystemd-dev libssl-dev
|
||||
- name: Build Debian package
|
||||
run: |
|
||||
sudo dpkg-buildpackage -us -uc -b -d
|
||||
- name: Upload to Gitea Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: github.ref_type == 'tag'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
run: |
|
||||
@ -240,7 +240,7 @@ jobs:
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo dnf install -y gcc rpm-build systemd-devel pkg-config
|
||||
sudo dnf install -y gcc rpm-build systemd-devel pkg-config openssl-devel
|
||||
- name: Build release binary
|
||||
run: cargo build --release
|
||||
- name: Build RPM package
|
||||
@ -248,7 +248,7 @@ jobs:
|
||||
chmod +x build-rpm.sh
|
||||
./build-rpm.sh
|
||||
- name: Upload to Gitea Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: github.ref_type == 'tag'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
run: |
|
||||
@ -271,13 +271,13 @@ jobs:
|
||||
- name: Install Rust
|
||||
run: |
|
||||
apk add --no-cache curl bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
curl --ipv4 --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
. "$HOME/.cargo/env"
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apk add --no-cache alpine-sdk rust cargo openssl-dev elogind-dev musl-dev abuild gcc
|
||||
apk add --no-cache alpine-sdk rust cargo openssl openssl-dev elogind-dev musl-dev abuild gcc
|
||||
- name: Build release binary
|
||||
run: cargo build --release --target x86_64-unknown-linux-musl
|
||||
- name: Build Alpine package
|
||||
@ -285,7 +285,7 @@ jobs:
|
||||
chmod +x build-alpine.sh
|
||||
SKIP_CARGO_BUILD=1 ./build-alpine.sh
|
||||
- name: Upload to Gitea Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
if: github.ref_type == 'tag'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
run: |
|
||||
|
||||
@ -64,7 +64,7 @@ addr = "0.15"
|
||||
if-addrs = "0.13"
|
||||
|
||||
# HTTP client for enrollment communication
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
# Clap for CLI arguments
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
@ -94,23 +94,32 @@ impl WhitelistManager {
|
||||
|
||||
// Parse to validate - must be IPv4 or CIDR, no hostnames in auto-append
|
||||
let parsed_entry = if let Some((ip_str, prefix_str)) = entry_str.split_once('/') {
|
||||
let ip: Ipv4Addr = ip_str.parse()
|
||||
let ip: Ipv4Addr = ip_str
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid IP in CIDR notation: {}", entry_str))?;
|
||||
let prefix: u8 = prefix_str.parse()
|
||||
let prefix: u8 = prefix_str
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid prefix in CIDR notation: {}", entry_str))?;
|
||||
if prefix > 32 {
|
||||
anyhow::bail!("Invalid CIDR prefix (must be 0-32): {}", entry_str);
|
||||
}
|
||||
WhitelistEntry::Cidr { network: ip, prefix }
|
||||
WhitelistEntry::Cidr {
|
||||
network: ip,
|
||||
prefix,
|
||||
}
|
||||
} else {
|
||||
let ip: Ipv4Addr = entry_str.parse()
|
||||
let ip: Ipv4Addr = entry_str
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid IPv4 address: {}", entry_str))?;
|
||||
WhitelistEntry::Ip(ip)
|
||||
};
|
||||
|
||||
// 2. Check for duplicate in current in-memory state
|
||||
{
|
||||
let entries = self.entries.read().map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
|
||||
let entries = self
|
||||
.entries
|
||||
.read()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
|
||||
for existing in entries.iter() {
|
||||
if *existing == parsed_entry {
|
||||
info!(
|
||||
@ -129,15 +138,21 @@ impl WhitelistManager {
|
||||
let lock_path = format!("{}.lock", self.config_path);
|
||||
let lock_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&lock_path)
|
||||
.with_context(|| format!("Failed to create lock file: {}", lock_path))?;
|
||||
|
||||
lock_file.lock_exclusive().context("Failed to acquire exclusive whitelist lock")?;
|
||||
lock_file
|
||||
.lock_exclusive()
|
||||
.context("Failed to acquire exclusive whitelist lock")?;
|
||||
|
||||
// Double-check for duplicates after acquiring lock (concurrent append scenario)
|
||||
{
|
||||
let entries = self.entries.read().map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
|
||||
let entries = self
|
||||
.entries
|
||||
.read()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to acquire whitelist read lock: {}", e))?;
|
||||
for existing in entries.iter() {
|
||||
if *existing == parsed_entry {
|
||||
info!(
|
||||
@ -154,9 +169,12 @@ impl WhitelistManager {
|
||||
|
||||
// 4. Read current whitelist YAML or create empty config
|
||||
let mut config = if Path::new(&self.config_path).exists() {
|
||||
self.load_config().context("Failed to load existing whitelist for append")?
|
||||
self.load_config()
|
||||
.context("Failed to load existing whitelist for append")?
|
||||
} else {
|
||||
WhitelistConfig { entries: Vec::new() }
|
||||
WhitelistConfig {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Append new entry to allowed_ips list
|
||||
@ -168,8 +186,9 @@ impl WhitelistManager {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = config_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create whitelist directory: {}", parent.display()))?;
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create whitelist directory: {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,28 +201,35 @@ impl WhitelistManager {
|
||||
.create_new(true)
|
||||
.truncate(true)
|
||||
.open(&temp_path)
|
||||
.with_context(|| format!("Failed to create temp whitelist file: {}", temp_path.display()))?;
|
||||
|
||||
file.write_all(yaml_content.as_bytes())
|
||||
.with_context(|| format!("Failed to write whitelist data to: {}", temp_path.display()))?;
|
||||
file.flush()
|
||||
.with_context(|| format!("Failed to flush whitelist data to: {}", temp_path.display()))?;
|
||||
|
||||
// Atomic rename
|
||||
fs::rename(&temp_path, config_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to atomically rename whitelist temp file {} to {}",
|
||||
temp_path.display(),
|
||||
config_path.display()
|
||||
"Failed to create temp whitelist file: {}",
|
||||
temp_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
file.write_all(yaml_content.as_bytes()).with_context(|| {
|
||||
format!("Failed to write whitelist data to: {}", temp_path.display())
|
||||
})?;
|
||||
file.flush().with_context(|| {
|
||||
format!("Failed to flush whitelist data to: {}", temp_path.display())
|
||||
})?;
|
||||
|
||||
// Atomic rename
|
||||
fs::rename(&temp_path, config_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to atomically rename whitelist temp file {} to {}",
|
||||
temp_path.display(),
|
||||
config_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Release lock explicitly before reload (drop happens at end of scope)
|
||||
drop(lock_file);
|
||||
|
||||
// 7. Reload in-memory state
|
||||
self.reload().context("Failed to reload whitelist after append")?;
|
||||
self.reload()
|
||||
.context("Failed to reload whitelist after append")?;
|
||||
|
||||
// 8. Log audit event
|
||||
tracing::info!(
|
||||
|
||||
@ -142,16 +142,16 @@ pub struct AppConfig {
|
||||
|
||||
impl AppConfig {
|
||||
/// Load configuration from a YAML file
|
||||
pub fn load(path: &str) -> Result<Self> {
|
||||
pub fn load(path: &str, skip_tls_validation: bool) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {}", path))?;
|
||||
|
||||
let config: AppConfig = serde_yaml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse config file: {}", path))?;
|
||||
|
||||
// Validate TLS configuration if enabled
|
||||
// Validate TLS configuration if enabled (skip during enrollment bootstrap)
|
||||
if let Some(ref tls) = config.tls {
|
||||
if tls.enabled {
|
||||
if tls.enabled && !skip_tls_validation {
|
||||
if !std::path::Path::new(&tls.ca_cert).exists() {
|
||||
anyhow::bail!("TLS CA certificate not found: {}", tls.ca_cert);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::signal::unix::{SignalKind, signal as unix_signal};
|
||||
use tokio::signal::unix::{signal as unix_signal, SignalKind};
|
||||
|
||||
use crate::enroll::identity;
|
||||
|
||||
@ -99,7 +99,7 @@ impl EnrollmentClient {
|
||||
.expect("Failed to parse manager URL");
|
||||
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}, // Allowed schemes
|
||||
"http" | "https" => {} // Allowed schemes
|
||||
other => panic!(
|
||||
"Invalid manager URL scheme '{}' — only 'http' and 'https' are allowed. \
|
||||
Refused dangerous scheme to prevent SSRF/path traversal.",
|
||||
@ -139,12 +139,11 @@ impl EnrollmentClient {
|
||||
/// - `Err` if URL parsing fails or DNS resolution yields no results
|
||||
pub async fn manager_ip(&self) -> Result<String> {
|
||||
// Parse URL to extract host using url crate for RFC-compliant parsing
|
||||
let parsed = url::Url::parse(&self.manager_url).with_context(|| {
|
||||
format!("Failed to parse manager URL '{}'", self.manager_url)
|
||||
})?;
|
||||
let host_str = parsed.host_str().with_context(|| {
|
||||
format!("Manager URL '{}' has no host component", self.manager_url)
|
||||
})?;
|
||||
let parsed = url::Url::parse(&self.manager_url)
|
||||
.with_context(|| format!("Failed to parse manager URL '{}'", self.manager_url))?;
|
||||
let host_str = parsed
|
||||
.host_str()
|
||||
.with_context(|| format!("Manager URL '{}' has no host component", self.manager_url))?;
|
||||
|
||||
// Check if already an IP address using url::Host parsing
|
||||
if let Ok(url::Host::Ipv4(addr)) = url::Host::parse(host_str) {
|
||||
@ -287,9 +286,8 @@ impl EnrollmentClient {
|
||||
.await
|
||||
.context("Failed to read status response body")?;
|
||||
|
||||
let status: EnrollmentStatusResponse =
|
||||
serde_json::from_str(&body)
|
||||
.context("Invalid status response — malformed JSON from manager")?;
|
||||
let status: EnrollmentStatusResponse = serde_json::from_str(&body)
|
||||
.context("Invalid status response — malformed JSON from manager")?;
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
@ -336,7 +334,11 @@ impl EnrollmentClient {
|
||||
max_attempts: u32,
|
||||
) -> Result<PkiBundle> {
|
||||
// Enforce hard limits
|
||||
let effective_interval = if interval_seconds == 0 { 60 } else { interval_seconds };
|
||||
let effective_interval = if interval_seconds == 0 {
|
||||
60
|
||||
} else {
|
||||
interval_seconds
|
||||
};
|
||||
let effective_max = match max_attempts {
|
||||
0 => 1440,
|
||||
n if n > 1440 => 1440,
|
||||
@ -417,7 +419,11 @@ impl EnrollmentClient {
|
||||
attempts = attempt,
|
||||
"Enrollment approved — received PKI bundle from manager"
|
||||
);
|
||||
return Ok(PkiBundle { ca_crt, server_crt, server_key });
|
||||
return Ok(PkiBundle {
|
||||
ca_crt,
|
||||
server_crt,
|
||||
server_key,
|
||||
});
|
||||
}
|
||||
EnrollmentStatusResponse::Denied => {
|
||||
tracing::warn!(
|
||||
@ -444,20 +450,22 @@ impl EnrollmentClient {
|
||||
total_seconds = total_seconds,
|
||||
"Enrollment polling timed out after maximum attempts"
|
||||
);
|
||||
Err(anyhow!("Enrollment timed out after {} hours ({}/{} attempts)",
|
||||
total_seconds / 3600, effective_max, effective_max))
|
||||
Err(anyhow!(
|
||||
"Enrollment timed out after {} hours ({}/{} attempts)",
|
||||
total_seconds / 3600,
|
||||
effective_max,
|
||||
effective_max
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a SIGINT (Ctrl+C) signal receiver.
|
||||
fn setup_sigint() -> Result<tokio::signal::unix::Signal> {
|
||||
unix_signal(SignalKind::interrupt())
|
||||
.context("Failed to create SIGINT signal handler")
|
||||
unix_signal(SignalKind::interrupt()).context("Failed to create SIGINT signal handler")
|
||||
}
|
||||
|
||||
/// Create a SIGTERM signal receiver.
|
||||
fn setup_sigterm() -> Result<tokio::signal::unix::Signal> {
|
||||
unix_signal(SignalKind::terminate())
|
||||
.context("Failed to create SIGTERM signal handler")
|
||||
unix_signal(SignalKind::terminate()).context("Failed to create SIGTERM signal handler")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -66,8 +66,7 @@ pub fn get_fqdn() -> Result<String> {
|
||||
|
||||
/// Collect all non-loopback IPv4 addresses from network interfaces.
|
||||
pub fn get_ip_addresses() -> Result<Vec<String>> {
|
||||
let ifaces = if_addrs::get_if_addrs()
|
||||
.context("Failed to enumerate network interfaces")?;
|
||||
let ifaces = if_addrs::get_if_addrs().context("Failed to enumerate network interfaces")?;
|
||||
|
||||
let mut addrs: Vec<String> = ifaces
|
||||
.iter()
|
||||
@ -105,16 +104,28 @@ pub fn get_os_details() -> Result<serde_json::Value> {
|
||||
let unquoted = value.trim().trim_matches('"').trim_matches('\'');
|
||||
match key {
|
||||
"NAME" => {
|
||||
details.insert("distro".into(), serde_json::Value::String(unquoted.to_string()));
|
||||
details.insert(
|
||||
"distro".into(),
|
||||
serde_json::Value::String(unquoted.to_string()),
|
||||
);
|
||||
}
|
||||
"VERSION_ID" => {
|
||||
details.insert("version".into(), serde_json::Value::String(unquoted.to_string()));
|
||||
details.insert(
|
||||
"version".into(),
|
||||
serde_json::Value::String(unquoted.to_string()),
|
||||
);
|
||||
}
|
||||
"ID_LIKE" => {
|
||||
details.insert("id_like".into(), serde_json::Value::String(unquoted.to_string()));
|
||||
details.insert(
|
||||
"id_like".into(),
|
||||
serde_json::Value::String(unquoted.to_string()),
|
||||
);
|
||||
}
|
||||
"VERSION_CODENAME" => {
|
||||
details.insert("codename".into(), serde_json::Value::String(unquoted.to_string()));
|
||||
details.insert(
|
||||
"codename".into(),
|
||||
serde_json::Value::String(unquoted.to_string()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@ -123,7 +134,10 @@ pub fn get_os_details() -> Result<serde_json::Value> {
|
||||
} else {
|
||||
// Fallback for systems without os-release (very rare)
|
||||
details.insert("distro".into(), serde_json::Value::String("unknown".into()));
|
||||
details.insert("version".into(), serde_json::Value::String("unknown".into()));
|
||||
details.insert(
|
||||
"version".into(),
|
||||
serde_json::Value::String("unknown".into()),
|
||||
);
|
||||
}
|
||||
|
||||
// Kernel version via uname -r
|
||||
@ -159,6 +173,9 @@ mod tests {
|
||||
#[test]
|
||||
fn os_details_contains_kernel() {
|
||||
let details = get_os_details().expect("Failed to get OS details");
|
||||
assert!(details.get("kernel").is_some(), "OS details must contain kernel version");
|
||||
assert!(
|
||||
details.get("kernel").is_some(),
|
||||
"OS details must contain kernel version"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,8 +12,7 @@ use anyhow::{Context, Result};
|
||||
|
||||
/// Re-export key types for ergonomic access from parent modules.
|
||||
pub use client::{
|
||||
EnrollmentClient, EnrollmentRequest, EnrollmentResponse,
|
||||
EnrollmentStatusResponse, PkiBundle,
|
||||
EnrollmentClient, EnrollmentRequest, EnrollmentResponse, EnrollmentStatusResponse, PkiBundle,
|
||||
};
|
||||
/// Re-export identity extraction functions.
|
||||
pub use identity::{get_fqdn, get_ip_addresses, get_machine_id, get_os_details};
|
||||
@ -40,10 +39,16 @@ pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Res
|
||||
tracing::info!("Registration successful - received polling token");
|
||||
|
||||
// Get polling config (use defaults if not set)
|
||||
let interval = config.enrollment.as_ref()
|
||||
.map(|e| e.polling_interval_seconds).unwrap_or(60);
|
||||
let max_attempts = config.enrollment.as_ref()
|
||||
.map(|e| e.max_poll_attempts).unwrap_or(1440);
|
||||
let interval = config
|
||||
.enrollment
|
||||
.as_ref()
|
||||
.map(|e| e.polling_interval_seconds)
|
||||
.unwrap_or(60);
|
||||
let max_attempts = config
|
||||
.enrollment
|
||||
.as_ref()
|
||||
.map(|e| e.max_poll_attempts)
|
||||
.unwrap_or(1440);
|
||||
|
||||
// Phase 2: Polling
|
||||
tracing::info!(
|
||||
@ -51,7 +56,9 @@ pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Res
|
||||
max_attempts = max_attempts,
|
||||
"Starting enrollment - polling phase"
|
||||
);
|
||||
let pki_bundle = client.poll_for_approval(&response.polling_token, interval, max_attempts).await?;
|
||||
let pki_bundle = client
|
||||
.poll_for_approval(&response.polling_token, interval, max_attempts)
|
||||
.await?;
|
||||
|
||||
// Phase 3: PKI provisioning & whitelist update
|
||||
tracing::info!("Enrollment approved - starting PKI provisioning phase");
|
||||
@ -62,13 +69,15 @@ pub async fn run_enrollment(manager_url: &str, config: &super::AppConfig) -> Res
|
||||
&pki_bundle.server_crt,
|
||||
&pki_bundle.server_key,
|
||||
config.tls_config(),
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("PKI bundle written to disk");
|
||||
|
||||
// Resolve manager hostname to IP and append to whitelist
|
||||
let manager_ip = client.manager_ip().await.context(
|
||||
"Failed to resolve manager IP - cannot update whitelist",
|
||||
)?;
|
||||
let manager_ip = client
|
||||
.manager_ip()
|
||||
.await
|
||||
.context("Failed to resolve manager IP - cannot update whitelist")?;
|
||||
provision::append_manager_to_whitelist(&manager_ip, config.whitelist_path()).await?;
|
||||
tracing::info!(manager_ip = %manager_ip, "Manager IP appended to whitelist");
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
//! PKI provisioning module for self-enrollment.
|
||||
//! Handles certificate extraction, validation, and secure file writing.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use crate::auth::WhitelistManager;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
@ -71,8 +71,9 @@ pub fn write_pem_file(path: &str, pem_data: &str, is_key: bool) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(parent)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(parent, perms)
|
||||
.with_context(|| format!("Failed to set permissions on: {}", parent.display()))?;
|
||||
fs::set_permissions(parent, perms).with_context(|| {
|
||||
format!("Failed to set permissions on: {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -107,14 +108,13 @@ pub fn write_pem_file(path: &str, pem_data: &str, is_key: bool) -> Result<()> {
|
||||
.with_context(|| format!("Failed to flush PEM data to: {}", temp_path.display()))?;
|
||||
|
||||
// Atomic rename to target path
|
||||
fs::rename(&temp_path, path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to atomically rename {} to {}",
|
||||
temp_path.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
fs::rename(&temp_path, path).with_context(|| {
|
||||
format!(
|
||||
"Failed to atomically rename {} to {}",
|
||||
temp_path.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
@ -138,7 +138,11 @@ pub async fn provision_pki_bundle(
|
||||
) -> Result<()> {
|
||||
// Determine target paths from config or defaults
|
||||
let (ca_path, cert_path, key_path) = if let Some(tls) = tls_config {
|
||||
(tls.ca_cert.clone(), tls.server_cert.clone(), tls.server_key.clone())
|
||||
(
|
||||
tls.ca_cert.clone(),
|
||||
tls.server_cert.clone(),
|
||||
tls.server_key.clone(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
DEFAULT_CA_CERT.to_string(),
|
||||
@ -148,10 +152,8 @@ pub async fn provision_pki_bundle(
|
||||
};
|
||||
|
||||
// 1. Validate all three PEM strings before any writes
|
||||
validate_pem(ca_crt, "CERTIFICATE")
|
||||
.context("CA certificate validation failed")?;
|
||||
validate_pem(server_crt, "CERTIFICATE")
|
||||
.context("Server certificate validation failed")?;
|
||||
validate_pem(ca_crt, "CERTIFICATE").context("CA certificate validation failed")?;
|
||||
validate_pem(server_crt, "CERTIFICATE").context("Server certificate validation failed")?;
|
||||
|
||||
// Server key can be PRIVATE KEY (PKCS#8), RSA PRIVATE KEY (PKCS#1), or EC PRIVATE KEY
|
||||
let key_valid = validate_pem(server_key, "PRIVATE KEY").is_ok()
|
||||
@ -165,14 +167,11 @@ pub async fn provision_pki_bundle(
|
||||
}
|
||||
|
||||
// 2. Write to configured paths (atomic writes)
|
||||
write_pem_file(&ca_path, ca_crt, false)
|
||||
.context("Failed to write CA certificate")?;
|
||||
write_pem_file(&ca_path, ca_crt, false).context("Failed to write CA certificate")?;
|
||||
|
||||
write_pem_file(&cert_path, server_crt, false)
|
||||
.context("Failed to write server certificate")?;
|
||||
write_pem_file(&cert_path, server_crt, false).context("Failed to write server certificate")?;
|
||||
|
||||
write_pem_file(&key_path, server_key, true)
|
||||
.context("Failed to write server key")?;
|
||||
write_pem_file(&key_path, server_key, true).context("Failed to write server key")?;
|
||||
|
||||
// 3. Log successful provisioning with structured fields
|
||||
tracing::info!(
|
||||
@ -198,11 +197,19 @@ pub async fn append_manager_to_whitelist(manager_ip: &str, whitelist_path: &str)
|
||||
}
|
||||
|
||||
// Create or load WhitelistManager and call append_entry
|
||||
let mut manager = WhitelistManager::new(whitelist_path)
|
||||
.with_context(|| format!("Failed to initialize whitelist manager for path: {}", whitelist_path))?;
|
||||
let mut manager = WhitelistManager::new(whitelist_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to initialize whitelist manager for path: {}",
|
||||
whitelist_path
|
||||
)
|
||||
})?;
|
||||
|
||||
manager.append_entry(ip_or_cidr)
|
||||
.with_context(|| format!("Failed to append manager IP '{}' to whitelist at: {}", ip_or_cidr, whitelist_path))?;
|
||||
manager.append_entry(ip_or_cidr).with_context(|| {
|
||||
format!(
|
||||
"Failed to append manager IP '{}' to whitelist at: {}",
|
||||
ip_or_cidr, whitelist_path
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -343,7 +350,8 @@ mod tests {
|
||||
let dir = tempdir().expect("failed to create temp dir");
|
||||
let target_path = dir.path().join("cert.pem");
|
||||
let cert1 = sample_certificate();
|
||||
let cert2 = "-----BEGIN CERTIFICATE-----\nNEWCERTDATA\n-----END CERTIFICATE-----".to_string();
|
||||
let cert2 =
|
||||
"-----BEGIN CERTIFICATE-----\nNEWCERTDATA\n-----END CERTIFICATE-----".to_string();
|
||||
|
||||
// Write initial file
|
||||
write_pem_file(target_path.to_str().unwrap(), &cert1, false).expect("initial write failed");
|
||||
@ -352,7 +360,10 @@ mod tests {
|
||||
write_pem_file(target_path.to_str().unwrap(), &cert2, false).expect("second write failed");
|
||||
|
||||
let backup_path = format!("{}.bak", target_path.display());
|
||||
assert!(std::path::Path::new(&backup_path).exists(), "Backup file should exist");
|
||||
assert!(
|
||||
std::path::Path::new(&backup_path).exists(),
|
||||
"Backup file should exist"
|
||||
);
|
||||
|
||||
// Original content in backup
|
||||
let backup_content = fs::read_to_string(&backup_path).expect("failed to read backup");
|
||||
|
||||
14
src/main.rs
14
src/main.rs
@ -23,8 +23,8 @@ use tracing::{error, info, warn};
|
||||
|
||||
use linux_patch_api::api::{configure_api_routes, configure_health_route};
|
||||
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
|
||||
use linux_patch_api::packages::create_backend;
|
||||
use linux_patch_api::enroll;
|
||||
use linux_patch_api::packages::create_backend;
|
||||
use linux_patch_api::{init_logging, AppConfig, JobManager};
|
||||
|
||||
/// Linux Patch API CLI arguments
|
||||
@ -42,7 +42,10 @@ struct Args {
|
||||
verbose: bool,
|
||||
|
||||
/// Enroll with manager at URL (skips mTLS startup, runs enrollment flow only)
|
||||
#[arg(long, help = "Enroll with manager at URL (skips mTLS startup, runs enrollment flow only)")]
|
||||
#[arg(
|
||||
long,
|
||||
help = "Enroll with manager at URL (skips mTLS startup, runs enrollment flow only)"
|
||||
)]
|
||||
enroll: Option<String>,
|
||||
}
|
||||
|
||||
@ -61,7 +64,7 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
// Load configuration
|
||||
let config = match AppConfig::load(&args.config) {
|
||||
let config = match AppConfig::load(&args.config, args.enroll.is_some()) {
|
||||
Ok(cfg) => {
|
||||
info!(
|
||||
port = cfg.server.port,
|
||||
@ -78,7 +81,10 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Handle enrollment mode - runs before server startup
|
||||
if let Some(ref manager_url) = args.enroll {
|
||||
info!(manager_url = manager_url, "Enrollment mode activated - running enrollment flow before server startup");
|
||||
info!(
|
||||
manager_url = manager_url,
|
||||
"Enrollment mode activated - running enrollment flow before server startup"
|
||||
);
|
||||
match enroll::run_enrollment(manager_url, &config).await {
|
||||
Ok(()) => {
|
||||
info!("Enrollment complete - proceeding to server startup");
|
||||
|
||||
@ -25,8 +25,8 @@ use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
use wiremock::matchers::{method, path, path_regex};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Test constants
|
||||
const TEST_TOKEN: &str = "test_enrollment_token";
|
||||
@ -63,10 +63,7 @@ fn create_temp_dirs() -> (TempDir, TempDir) {
|
||||
/// Initialize an empty whitelist YAML file at the given path.
|
||||
/// Required because WhitelistManager::new() loads existing config on construction.
|
||||
fn init_empty_whitelist(path: &str) {
|
||||
std::fs::write(
|
||||
path,
|
||||
"entries: []\n",
|
||||
).expect("Failed to create initial whitelist file");
|
||||
std::fs::write(path, "entries: []\n").expect("Failed to create initial whitelist file");
|
||||
}
|
||||
|
||||
/// Build a TLS config pointing to the temp certificate directory.
|
||||
@ -76,7 +73,10 @@ fn build_tls_config(cert_dir: &std::path::Path) -> TlsConfig {
|
||||
port: 12443,
|
||||
ca_cert: cert_dir.join("ca.pem").to_string_lossy().to_string(),
|
||||
server_cert: cert_dir.join("server.pem").to_string_lossy().to_string(),
|
||||
server_key: cert_dir.join("server.key.pem").to_string_lossy().to_string(),
|
||||
server_key: cert_dir
|
||||
.join("server.key.pem")
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
min_tls_version: "1.3".to_string(),
|
||||
}
|
||||
}
|
||||
@ -104,7 +104,11 @@ async fn test_full_enrollment_flow_happy_path() {
|
||||
let ca_cert_path = cert_dir.path().join("ca.pem");
|
||||
let server_cert_path = cert_dir.path().join("server.pem");
|
||||
let server_key_path = cert_dir.path().join("server.key.pem");
|
||||
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
|
||||
let whitelist_path = whitelist_dir
|
||||
.path()
|
||||
.join("whitelist.yaml")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
init_empty_whitelist(&whitelist_path);
|
||||
|
||||
@ -116,7 +120,7 @@ async fn test_full_enrollment_flow_happy_path() {
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(&format!(r#"{{"polling_token": "{}"}}"#, TEST_TOKEN)),
|
||||
.set_body_string(format!(r#"{{"polling_token": "{}"}}"#, TEST_TOKEN)),
|
||||
)
|
||||
.named("enroll_registration")
|
||||
.mount(&server)
|
||||
@ -128,11 +132,10 @@ async fn test_full_enrollment_flow_happy_path() {
|
||||
let count = poll_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
if count < 1 {
|
||||
// First poll returns pending (simulates admin review delay)
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"{"status": "pending"}"#)
|
||||
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#)
|
||||
} else {
|
||||
// Second poll returns approved with full PKI bundle
|
||||
ResponseTemplate::new(200).set_body_string(&format!(
|
||||
ResponseTemplate::new(200).set_body_string(format!(
|
||||
r#"{{
|
||||
"status": "approved",
|
||||
"ca_crt": {},
|
||||
@ -152,7 +155,10 @@ async fn test_full_enrollment_flow_happy_path() {
|
||||
let client = build_client(&base_url);
|
||||
|
||||
// Phase 1: Registration
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
assert_eq!(response.polling_token, TEST_TOKEN);
|
||||
|
||||
// Phase 2: Polling (should get pending first, then approved)
|
||||
@ -172,10 +178,15 @@ async fn test_full_enrollment_flow_happy_path() {
|
||||
&bundle.server_crt,
|
||||
&bundle.server_key,
|
||||
Some(&tls_config),
|
||||
).await.expect("PKI provisioning should succeed");
|
||||
)
|
||||
.await
|
||||
.expect("PKI provisioning should succeed");
|
||||
|
||||
// Phase 3b: Whitelist update (manager_ip for localhost URL returns 127.0.0.1)
|
||||
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP");
|
||||
let manager_ip = client
|
||||
.manager_ip()
|
||||
.await
|
||||
.expect("Should resolve manager IP");
|
||||
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
|
||||
.await
|
||||
.expect("Whitelist append should succeed");
|
||||
@ -186,14 +197,29 @@ async fn test_full_enrollment_flow_happy_path() {
|
||||
assert!(server_key_path.exists(), "Server key file should exist");
|
||||
|
||||
// Verify: correct permissions (key=0o600, certs=0o644)
|
||||
let key_perms = std::fs::metadata(&server_key_path).unwrap().permissions().mode() & 0o777;
|
||||
let key_perms = std::fs::metadata(&server_key_path)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(key_perms, 0o600, "Key file should have 0o600 permissions");
|
||||
|
||||
let ca_perms = std::fs::metadata(&ca_cert_path).unwrap().permissions().mode() & 0o777;
|
||||
let ca_perms = std::fs::metadata(&ca_cert_path)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(ca_perms, 0o644, "CA cert should have 0o644 permissions");
|
||||
|
||||
let server_perms = std::fs::metadata(&server_cert_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(server_perms, 0o644, "Server cert should have 0o644 permissions");
|
||||
let server_perms = std::fs::metadata(&server_cert_path)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(
|
||||
server_perms, 0o644,
|
||||
"Server cert should have 0o644 permissions"
|
||||
);
|
||||
|
||||
// Verify: whitelist contains manager IP
|
||||
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
|
||||
@ -220,14 +246,17 @@ async fn test_enrollment_denied_flow() {
|
||||
let (server, base_url) = create_mock_manager().await;
|
||||
let (cert_dir, _whitelist_dir) = create_temp_dirs();
|
||||
|
||||
let whitelist_path = _whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
|
||||
let whitelist_path = _whitelist_dir
|
||||
.path()
|
||||
.join("whitelist.yaml")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
init_empty_whitelist(&whitelist_path);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "denied_token"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "denied_token"}"#),
|
||||
)
|
||||
.named("registration")
|
||||
.mount(&server)
|
||||
@ -235,9 +264,7 @@ async fn test_enrollment_denied_flow() {
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/api/v1/enroll/status/.+"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#))
|
||||
.named("status_denied")
|
||||
.expect(1) // Exactly one poll attempt before denial
|
||||
.mount(&server)
|
||||
@ -246,7 +273,10 @@ async fn test_enrollment_denied_flow() {
|
||||
let client = build_client(&base_url);
|
||||
|
||||
// Phase 1: Registration succeeds even for denied enrollment
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
assert_eq!(response.polling_token, "denied_token");
|
||||
|
||||
// Phase 2: Polling returns denial error
|
||||
@ -254,7 +284,10 @@ async fn test_enrollment_denied_flow() {
|
||||
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "Should receive error for denied enrollment");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should receive error for denied enrollment"
|
||||
);
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("denied"),
|
||||
@ -267,9 +300,18 @@ async fn test_enrollment_denied_flow() {
|
||||
let server_cert_path = cert_dir.path().join("server.pem");
|
||||
let server_key_path = cert_dir.path().join("server.key.pem");
|
||||
|
||||
assert!(!ca_path.exists(), "CA cert should NOT exist after denied enrollment");
|
||||
assert!(!server_cert_path.exists(), "Server cert should NOT exist after denied enrollment");
|
||||
assert!(!server_key_path.exists(), "Server key should NOT exist after denied enrollment");
|
||||
assert!(
|
||||
!ca_path.exists(),
|
||||
"CA cert should NOT exist after denied enrollment"
|
||||
);
|
||||
assert!(
|
||||
!server_cert_path.exists(),
|
||||
"Server cert should NOT exist after denied enrollment"
|
||||
);
|
||||
assert!(
|
||||
!server_key_path.exists(),
|
||||
"Server key should NOT exist after denied enrollment"
|
||||
);
|
||||
|
||||
// Verify: no whitelist modifications on failed enrollment
|
||||
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
|
||||
@ -298,8 +340,7 @@ async fn test_enrollment_timeout_flow() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "timeout_token"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "timeout_token"}"#),
|
||||
)
|
||||
.named("registration")
|
||||
.mount(&server)
|
||||
@ -307,16 +348,17 @@ async fn test_enrollment_timeout_flow() {
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/api/v1/enroll/status/.+"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#))
|
||||
.named("status_always_pending")
|
||||
.expect(3) // Exactly 3 poll attempts before timeout
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = build_client(&base_url);
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
|
||||
// Poll with max_attempts=3 - should timeout after exactly 3 attempts
|
||||
let result = client
|
||||
@ -337,8 +379,14 @@ async fn test_enrollment_timeout_flow() {
|
||||
let server_key_path = cert_dir.path().join("server.key.pem");
|
||||
|
||||
assert!(!ca_path.exists(), "CA cert should NOT exist after timeout");
|
||||
assert!(!server_cert_path.exists(), "Server cert should NOT exist after timeout");
|
||||
assert!(!server_key_path.exists(), "Server key should NOT exist after timeout");
|
||||
assert!(
|
||||
!server_cert_path.exists(),
|
||||
"Server cert should NOT exist after timeout"
|
||||
);
|
||||
assert!(
|
||||
!server_key_path.exists(),
|
||||
"Server key should NOT exist after timeout"
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@ -359,32 +407,32 @@ async fn test_certificate_permission_verification() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "perm_token"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "perm_token"}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/api/v1/enroll/status/.+"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(&format!(
|
||||
r#"{{
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
|
||||
r#"{{
|
||||
"status": "approved",
|
||||
"ca_crt": {},
|
||||
"server_crt": {},
|
||||
"server_key": {}
|
||||
}}"#,
|
||||
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
|
||||
)),
|
||||
)
|
||||
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
|
||||
)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = build_client(&base_url);
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
let bundle = client
|
||||
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
|
||||
.await
|
||||
@ -397,14 +445,15 @@ async fn test_certificate_permission_verification() {
|
||||
&bundle.server_crt,
|
||||
&bundle.server_key,
|
||||
Some(&tls_config),
|
||||
).await.expect("PKI provisioning should succeed");
|
||||
)
|
||||
.await
|
||||
.expect("PKI provisioning should succeed");
|
||||
|
||||
// Verify key file: 0o600 (owner read/write only)
|
||||
let key_path = cert_dir.path().join("server.key.pem");
|
||||
let key_perms = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(
|
||||
key_perms,
|
||||
0o600,
|
||||
key_perms, 0o600,
|
||||
"Key file must have exactly 0o600 permissions (owner rw only)"
|
||||
);
|
||||
|
||||
@ -412,17 +461,19 @@ async fn test_certificate_permission_verification() {
|
||||
let ca_path = cert_dir.path().join("ca.pem");
|
||||
let ca_perms = std::fs::metadata(&ca_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(
|
||||
ca_perms,
|
||||
0o644,
|
||||
ca_perms, 0o644,
|
||||
"CA certificate must have exactly 0o644 permissions"
|
||||
);
|
||||
|
||||
// Verify server cert: 0o644 (owner rw, group/others read)
|
||||
let server_cert_path = cert_dir.path().join("server.pem");
|
||||
let server_perms = std::fs::metadata(&server_cert_path).unwrap().permissions().mode() & 0o777;
|
||||
let server_perms = std::fs::metadata(&server_cert_path)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(
|
||||
server_perms,
|
||||
0o644,
|
||||
server_perms, 0o644,
|
||||
"Server certificate must have exactly 0o644 permissions"
|
||||
);
|
||||
|
||||
@ -442,7 +493,9 @@ async fn test_certificate_permission_verification() {
|
||||
assert!(ca_content.contains("END CERTIFICATE"));
|
||||
|
||||
let key_content = std::fs::read_to_string(&key_path).unwrap();
|
||||
assert!(key_content.contains("BEGIN PRIVATE KEY") || key_content.contains("BEGIN RSA PRIVATE KEY"));
|
||||
assert!(
|
||||
key_content.contains("BEGIN PRIVATE KEY") || key_content.contains("BEGIN RSA PRIVATE KEY")
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@ -459,45 +512,52 @@ async fn test_whitelist_append_verification() {
|
||||
let (server, base_url) = create_mock_manager().await;
|
||||
let (_cert_dir, whitelist_dir) = create_temp_dirs();
|
||||
|
||||
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
|
||||
let whitelist_path = whitelist_dir
|
||||
.path()
|
||||
.join("whitelist.yaml")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
init_empty_whitelist(&whitelist_path);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "wl_token"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "wl_token"}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/api/v1/enroll/status/.+"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(&format!(
|
||||
r#"{{
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
|
||||
r#"{{
|
||||
"status": "approved",
|
||||
"ca_crt": {},
|
||||
"server_crt": {},
|
||||
"server_key": {}
|
||||
}}"#,
|
||||
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
|
||||
)),
|
||||
)
|
||||
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
|
||||
)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = build_client(&base_url);
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
let _bundle = client
|
||||
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
|
||||
.await
|
||||
.expect("Should receive approved PkiBundle");
|
||||
|
||||
// First enrollment: append to whitelist
|
||||
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP");
|
||||
let manager_ip = client
|
||||
.manager_ip()
|
||||
.await
|
||||
.expect("Should resolve manager IP");
|
||||
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
|
||||
.await
|
||||
.expect("First whitelist append should succeed");
|
||||
@ -544,7 +604,10 @@ async fn test_whitelist_append_verification() {
|
||||
);
|
||||
|
||||
// Verify: YAML format is valid and parseable
|
||||
assert!(wl_content.contains("entries:"), "YAML should contain 'entries:' key");
|
||||
assert!(
|
||||
wl_content.contains("entries:"),
|
||||
"YAML should contain 'entries:' key"
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@ -565,24 +628,24 @@ async fn test_signal_handling_during_polling() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "signal_token"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "signal_token"}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/api/v1/enroll/status/.+"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#))
|
||||
.named("always_pending")
|
||||
.expect(3) // Exactly 3 polls before graceful shutdown
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = build_client(&base_url);
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
|
||||
// Poll with max_attempts=3, interval=1s
|
||||
// This simulates SIGTERM interrupt by exhausting attempts (graceful shutdown)
|
||||
@ -600,11 +663,15 @@ async fn test_signal_handling_during_polling() {
|
||||
);
|
||||
|
||||
// Verify: cleanup of any partial state (no leftover files)
|
||||
for entry in std::fs::read_dir(cert_dir.path()).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
assert!(false, "No partial files should remain after graceful shutdown: {}",
|
||||
entry.file_name().to_string_lossy());
|
||||
}
|
||||
let remaining: Vec<_> = std::fs::read_dir(cert_dir.path())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
remaining.is_empty(),
|
||||
"No partial files should remain after graceful shutdown: {:?}",
|
||||
remaining
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@ -620,45 +687,52 @@ async fn test_whitelist_yaml_format_preservation() {
|
||||
let (server, base_url) = create_mock_manager().await;
|
||||
let (_cert_dir, whitelist_dir) = create_temp_dirs();
|
||||
|
||||
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
|
||||
let whitelist_path = whitelist_dir
|
||||
.path()
|
||||
.join("whitelist.yaml")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
init_empty_whitelist(&whitelist_path);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "yaml_token"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "yaml_token"}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/api/v1/enroll/status/.+"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(&format!(
|
||||
r#"{{
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
|
||||
r#"{{
|
||||
"status": "approved",
|
||||
"ca_crt": {},
|
||||
"server_crt": {},
|
||||
"server_key": {}
|
||||
}}"#,
|
||||
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
|
||||
)),
|
||||
)
|
||||
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
|
||||
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
|
||||
)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = build_client(&base_url);
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
let _bundle = client
|
||||
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
|
||||
.await
|
||||
.expect("Should receive approved PkiBundle");
|
||||
|
||||
// Provision and append to whitelist
|
||||
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP");
|
||||
let manager_ip = client
|
||||
.manager_ip()
|
||||
.await
|
||||
.expect("Should resolve manager IP");
|
||||
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
|
||||
.await
|
||||
.expect("Whitelist append should succeed");
|
||||
@ -667,11 +741,14 @@ async fn test_whitelist_yaml_format_preservation() {
|
||||
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
|
||||
|
||||
// Parse as serde_yaml to verify format
|
||||
let wl_config: serde_yaml::Value = serde_yaml::from_str(&wl_content)
|
||||
.expect("Whitelist should be valid YAML after enrollment");
|
||||
let wl_config: serde_yaml::Value =
|
||||
serde_yaml::from_str(&wl_content).expect("Whitelist should be valid YAML after enrollment");
|
||||
|
||||
// Verify structure: entries key exists and is a sequence
|
||||
assert!(wl_config.get("entries").is_some(), "YAML must contain 'entries' key");
|
||||
assert!(
|
||||
wl_config.get("entries").is_some(),
|
||||
"YAML must contain 'entries' key"
|
||||
);
|
||||
let entries = wl_config.get("entries").unwrap();
|
||||
assert!(entries.is_sequence(), "'entries' must be a YAML sequence");
|
||||
|
||||
|
||||
@ -9,13 +9,12 @@
|
||||
//! - Short polling intervals ensure tests complete quickly
|
||||
//! - serial_test prevents port conflicts between concurrent test runs
|
||||
|
||||
use linux_patch_api::enroll::client::{
|
||||
EnrollmentClient,
|
||||
};
|
||||
use linux_patch_api::enroll::client::EnrollmentClient;
|
||||
use serial_test::serial;
|
||||
use wiremock::http::Method;
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{method, path, path_regex},
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
};
|
||||
|
||||
/// Test constants
|
||||
@ -54,8 +53,7 @@ async fn test_successful_enrollment_flow() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "test_token_123"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "test_token_123"}"#),
|
||||
)
|
||||
.named("enroll_registration")
|
||||
.mount(&server)
|
||||
@ -81,7 +79,10 @@ async fn test_successful_enrollment_flow() {
|
||||
let client = build_client(&base_url);
|
||||
|
||||
// Phase 1: Register - should succeed with polling token
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
assert_eq!(response.polling_token, TEST_TOKEN);
|
||||
|
||||
// Phase 2: Poll for approval - should get PkiBundle immediately since mock returns approved
|
||||
@ -89,11 +90,23 @@ async fn test_successful_enrollment_flow() {
|
||||
.poll_for_approval(TEST_TOKEN, POLL_INTERVAL_SECONDS, 5)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Polling should succeed with approved status");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Polling should succeed with approved status"
|
||||
);
|
||||
let bundle = result.unwrap();
|
||||
assert_eq!(bundle.ca_crt, "-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----");
|
||||
assert_eq!(bundle.server_crt, "-----BEGIN CERTIFICATE-----\nSERVER_CERT_DATA\n-----END CERTIFICATE-----");
|
||||
assert_eq!(bundle.server_key, "-----BEGIN PRIVATE KEY-----\nSERVER_KEY_DATA\n-----END PRIVATE KEY-----");
|
||||
assert_eq!(
|
||||
bundle.ca_crt,
|
||||
"-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----"
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.server_crt,
|
||||
"-----BEGIN CERTIFICATE-----\nSERVER_CERT_DATA\n-----END CERTIFICATE-----"
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.server_key,
|
||||
"-----BEGIN PRIVATE KEY-----\nSERVER_KEY_DATA\n-----END PRIVATE KEY-----"
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@ -111,8 +124,7 @@ async fn test_pending_then_approved_sequence() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "seq_token_456"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "seq_token_456"}"#),
|
||||
)
|
||||
.named("registration")
|
||||
.mount(&server)
|
||||
@ -121,16 +133,14 @@ async fn test_pending_then_approved_sequence() {
|
||||
// Status always returns approved (simplifies test while verifying the happy path)
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/api/v1/enroll/status/.+"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{
|
||||
"status": "approved",
|
||||
"ca_crt": "CA_PEM",
|
||||
"server_crt": "SERVER_PEM",
|
||||
"server_key": "KEY_PEM"
|
||||
}"#,
|
||||
),
|
||||
)
|
||||
))
|
||||
.named("status_approved")
|
||||
.mount(&server)
|
||||
.await;
|
||||
@ -168,8 +178,7 @@ async fn test_denied_enrollment() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "denied_token_789"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "denied_token_789"}"#),
|
||||
)
|
||||
.named("registration")
|
||||
.mount(&server)
|
||||
@ -178,10 +187,7 @@ async fn test_denied_enrollment() {
|
||||
// Status returns denied immediately
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/enroll/status/denied_token_789"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"{"status": "denied"}"#),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#))
|
||||
.named("status_denied")
|
||||
.expect(1) // Exactly one poll attempt
|
||||
.mount(&server)
|
||||
@ -190,7 +196,10 @@ async fn test_denied_enrollment() {
|
||||
let client = build_client(&base_url);
|
||||
|
||||
// Register succeeds
|
||||
let response = client.register().await.expect("Registration should succeed even for denied enrollment");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed even for denied enrollment");
|
||||
assert_eq!(response.polling_token, "denied_token_789");
|
||||
|
||||
// Poll should return error
|
||||
@ -198,7 +207,10 @@ async fn test_denied_enrollment() {
|
||||
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "Should receive error for denied enrollment");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should receive error for denied enrollment"
|
||||
);
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("denied"),
|
||||
@ -223,8 +235,7 @@ async fn test_token_not_found_expired() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "expired_token_000"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "expired_token_000"}"#),
|
||||
)
|
||||
.named("registration")
|
||||
.mount(&server)
|
||||
@ -233,10 +244,7 @@ async fn test_token_not_found_expired() {
|
||||
// Status returns notfound (serde rename_all="lowercase" converts NotFound -> "notfind")
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/enroll/status/expired_token_000"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"{"status": "notfound"}"#),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "notfound"}"#))
|
||||
.named("status_not_found")
|
||||
.expect(1) // Exactly one poll attempt
|
||||
.mount(&server)
|
||||
@ -245,7 +253,10 @@ async fn test_token_not_found_expired() {
|
||||
let client = build_client(&base_url);
|
||||
|
||||
// Register succeeds
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
|
||||
// Poll should return error about expired/invalid token
|
||||
let result = client
|
||||
@ -277,8 +288,7 @@ async fn test_max_attempts_timeout() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "timeout_token_abc"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "timeout_token_abc"}"#),
|
||||
)
|
||||
.named("registration")
|
||||
.mount(&server)
|
||||
@ -287,10 +297,7 @@ async fn test_max_attempts_timeout() {
|
||||
// Status always returns pending - should be called exactly 3 times (max_attempts=3)
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/enroll/status/timeout_token_abc"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"{"status": "pending"}"#),
|
||||
)
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#))
|
||||
.named("status_pending_timeout")
|
||||
.expect(3) // Exactly 3 poll attempts before giving up
|
||||
.mount(&server)
|
||||
@ -298,7 +305,10 @@ async fn test_max_attempts_timeout() {
|
||||
|
||||
let client = build_client(&base_url);
|
||||
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
|
||||
// Poll with max_attempts=3, interval=1s
|
||||
let result = client
|
||||
@ -329,9 +339,10 @@ async fn test_rate_limit_on_registration() {
|
||||
// Registration returns 429
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(ResponseTemplate::new(429).set_body_string(
|
||||
r#"{"error": "Too Many Requests", "retry_after": 60}"#,
|
||||
))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(429)
|
||||
.set_body_string(r#"{"error": "Too Many Requests", "retry_after": 60}"#),
|
||||
)
|
||||
.named("registration_rate_limited")
|
||||
.expect(1) // Exactly one attempt
|
||||
.mount(&server)
|
||||
@ -382,16 +393,14 @@ async fn test_registration_payload_structure() {
|
||||
// Status endpoint (for completeness)
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/api/v1/enroll/status/.+"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{
|
||||
"status": "approved",
|
||||
"ca_crt": "CA_TEST",
|
||||
"server_crt": "CRT_TEST",
|
||||
"server_key": "KEY_TEST"
|
||||
}"#,
|
||||
),
|
||||
)
|
||||
))
|
||||
.named("status_approved")
|
||||
.mount(&server)
|
||||
.await;
|
||||
@ -399,34 +408,45 @@ async fn test_registration_payload_structure() {
|
||||
let client = build_client(&base_url);
|
||||
|
||||
// Execute registration and capture the actual request
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
assert_eq!(response.polling_token, "payload_test_token");
|
||||
|
||||
// Verify using server request logs
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
let post_request = requests.iter()
|
||||
.find(|r| r.method.to_string() == "POST")
|
||||
let post_request = requests
|
||||
.iter()
|
||||
.find(|r| r.method == Method::POST)
|
||||
.expect("Should have received a POST request");
|
||||
|
||||
let body_str = std::str::from_utf8(&post_request.body).expect("Body should be valid UTF-8");
|
||||
let payload: serde_json::Value = serde_json::from_str(body_str)
|
||||
.expect("Request body should be valid JSON");
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_str(body_str).expect("Request body should be valid JSON");
|
||||
|
||||
// Verify machine_id field
|
||||
let machine_id = payload.get("machine_id")
|
||||
let machine_id = payload
|
||||
.get("machine_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("machine_id field must exist and be a string");
|
||||
assert!(!machine_id.is_empty(), "machine_id should not be empty");
|
||||
assert_eq!(machine_id.len(), 32, "machine_id should be 32 characters (UUID hex)");
|
||||
assert_eq!(
|
||||
machine_id.len(),
|
||||
32,
|
||||
"machine_id should be 32 characters (UUID hex)"
|
||||
);
|
||||
|
||||
// Verify fqdn field
|
||||
let fqdn = payload.get("fqdn")
|
||||
let fqdn = payload
|
||||
.get("fqdn")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("fqdn field must exist and be a string");
|
||||
assert!(!fqdn.is_empty(), "fqdn should not be empty");
|
||||
|
||||
// Verify ip_address field
|
||||
let ip_address = payload.get("ip_address")
|
||||
let ip_address = payload
|
||||
.get("ip_address")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("ip_address field must exist and be a string");
|
||||
assert!(!ip_address.is_empty(), "ip_address should not be empty");
|
||||
@ -438,12 +458,10 @@ async fn test_registration_payload_structure() {
|
||||
);
|
||||
|
||||
// Verify os_details field is an object with expected keys
|
||||
let os_details = payload.get("os_details")
|
||||
let os_details = payload
|
||||
.get("os_details")
|
||||
.expect("os_details field must exist");
|
||||
assert!(
|
||||
os_details.is_object(),
|
||||
"os_details should be a JSON object"
|
||||
);
|
||||
assert!(os_details.is_object(), "os_details should be a JSON object");
|
||||
|
||||
let os_obj = os_details.as_object().unwrap();
|
||||
assert!(!os_obj.is_empty(), "os_details should not be empty");
|
||||
@ -469,9 +487,9 @@ async fn test_server_error_on_registration() {
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string(
|
||||
r#"{"error": "Internal Server Error"}"#,
|
||||
))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(500).set_body_string(r#"{"error": "Internal Server Error"}"#),
|
||||
)
|
||||
.named("registration_server_error")
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
@ -506,8 +524,7 @@ async fn test_rate_limit_on_polling_retries() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "rl_poll_token"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "rl_poll_token"}"#),
|
||||
)
|
||||
.named("registration")
|
||||
.mount(&server)
|
||||
@ -516,22 +533,23 @@ async fn test_rate_limit_on_polling_retries() {
|
||||
// Status returns approved on first poll
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/enroll/status/rl_poll_token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{
|
||||
"status": "approved",
|
||||
"ca_crt": "CA_OK",
|
||||
"server_crt": "CRT_OK",
|
||||
"server_key": "KEY_OK"
|
||||
}"#,
|
||||
),
|
||||
)
|
||||
))
|
||||
.named("status_approved_after_retry")
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = build_client(&base_url);
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
|
||||
// Polling should succeed (mock returns approved directly)
|
||||
let bundle = client
|
||||
@ -579,8 +597,7 @@ async fn test_polling_default_parameters() {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/enroll"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(202)
|
||||
.set_body_string(r#"{"polling_token": "defaults_token"}"#),
|
||||
ResponseTemplate::new(202).set_body_string(r#"{"polling_token": "defaults_token"}"#),
|
||||
)
|
||||
.named("registration")
|
||||
.mount(&server)
|
||||
@ -589,22 +606,23 @@ async fn test_polling_default_parameters() {
|
||||
// Status returns approved immediately
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/enroll/status/defaults_token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"{
|
||||
"status": "approved",
|
||||
"ca_crt": "DEFAULT_CA",
|
||||
"server_crt": "DEFAULT_CRT",
|
||||
"server_key": "DEFAULT_KEY"
|
||||
}"#,
|
||||
),
|
||||
)
|
||||
))
|
||||
.named("status_approved")
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = build_client(&base_url);
|
||||
let response = client.register().await.expect("Registration should succeed");
|
||||
let response = client
|
||||
.register()
|
||||
.await
|
||||
.expect("Registration should succeed");
|
||||
|
||||
// Call with interval=0 (should default to 60) and max_attempts=0 (should default to 1440)
|
||||
// But since mock returns approved on first try, we don't actually wait
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
//! Comprehensive tests for cross-distribution identity extraction functions.
|
||||
//! Verifies machine-id, FQDN, IP address collection, and OS detail parsing.
|
||||
|
||||
use linux_patch_api::enroll::identity::{get_fqdn, get_ip_addresses, get_machine_id, get_os_details};
|
||||
use linux_patch_api::enroll::identity::{
|
||||
get_fqdn, get_ip_addresses, get_machine_id, get_os_details,
|
||||
};
|
||||
use linux_patch_api::enroll::EnrollmentRequest;
|
||||
use serde_json::Value;
|
||||
|
||||
@ -46,10 +48,7 @@ fn test_machine_id_is_consistent() {
|
||||
// Multiple calls should return the same value (it's a persistent identifier)
|
||||
let id1 = get_machine_id().expect("Failed to get machine-id (call 1)");
|
||||
let id2 = get_machine_id().expect("Failed to get machine-id (call 2)");
|
||||
assert_eq!(
|
||||
id1, id2,
|
||||
"machine-id should be consistent across calls"
|
||||
);
|
||||
assert_eq!(id1, id2, "machine-id should be consistent across calls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -67,8 +66,12 @@ fn test_machine_id_fallback_file_check() {
|
||||
// Verify fallback file exists (may or may not be used)
|
||||
let fallback = std::path::Path::new("/var/lib/dbus/machine-id");
|
||||
if fallback.exists() {
|
||||
let content = std::fs::read_to_string(fallback).expect("Failed to read fallback machine-id");
|
||||
assert!(!content.trim().is_empty(), "Fallback machine-id should not be empty");
|
||||
let content =
|
||||
std::fs::read_to_string(fallback).expect("Failed to read fallback machine-id");
|
||||
assert!(
|
||||
!content.trim().is_empty(),
|
||||
"Fallback machine-id should not be empty"
|
||||
);
|
||||
}
|
||||
// If it doesn't exist, that's fine - primary file is used instead
|
||||
}
|
||||
@ -157,9 +160,9 @@ fn test_ip_addresses_are_valid_ipv4() {
|
||||
assert_eq!(parts.len(), 4, "IP '{}' should have 4 octets", addr);
|
||||
|
||||
for part in &parts {
|
||||
let _octet: u8 = part
|
||||
.parse()
|
||||
.unwrap_or_else(|_| panic!("IP octet '{}' in '{}' is not a valid number", part, addr));
|
||||
let _octet: u8 = part.parse().unwrap_or_else(|_| {
|
||||
panic!("IP octet '{}' in '{}' is not a valid number", part, addr)
|
||||
});
|
||||
// u8 parse success guarantees 0-255 range
|
||||
}
|
||||
}
|
||||
@ -198,7 +201,10 @@ fn test_ip_addresses_no_broadcast() {
|
||||
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
|
||||
|
||||
for addr in &addrs {
|
||||
assert_ne!(addr, "255.255.255.255", "Broadcast address should be excluded");
|
||||
assert_ne!(
|
||||
addr, "255.255.255.255",
|
||||
"Broadcast address should be excluded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -238,7 +244,11 @@ fn test_ip_addresses_are_unicast() {
|
||||
assert!(first < 240, "Address '{}' is reserved", addr);
|
||||
|
||||
// Not unspecified (0.0.0.0)
|
||||
assert!(!(parts == vec![0, 0, 0, 0]), "Address '{}' is unspecified", addr);
|
||||
assert!(
|
||||
parts != vec![0, 0, 0, 0],
|
||||
"Address '{}' is unspecified",
|
||||
addr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -259,7 +269,9 @@ fn test_os_details_returns_valid_json_object() {
|
||||
#[test]
|
||||
fn test_os_details_contains_kernel_version() {
|
||||
let details = get_os_details().expect("Failed to get OS details");
|
||||
let kernel = details.get("kernel").expect("OS details must contain 'kernel' field");
|
||||
let kernel = details
|
||||
.get("kernel")
|
||||
.expect("OS details must contain 'kernel' field");
|
||||
assert!(kernel.is_string(), "Kernel version should be a string");
|
||||
|
||||
let kernel_str = kernel.as_str().unwrap();
|
||||
@ -297,7 +309,10 @@ fn test_os_details_distro_is_valid_string() {
|
||||
assert!(distro.is_string(), "Distro should be a string");
|
||||
let distro_str = distro.as_str().unwrap();
|
||||
assert!(!distro_str.is_empty(), "Distro name should not be empty");
|
||||
assert_ne!(distro_str, "unknown", "Distro should be identified on this system");
|
||||
assert_ne!(
|
||||
distro_str, "unknown",
|
||||
"Distro should be identified on this system"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -350,7 +365,8 @@ fn test_enrollment_payload_construction() {
|
||||
let os_details = get_os_details().expect("Failed to get OS details");
|
||||
|
||||
// Use first non-loopback IP as the primary address
|
||||
let primary_ip = ip_addrs.first()
|
||||
let primary_ip = ip_addrs
|
||||
.first()
|
||||
.expect("Should have at least one IP")
|
||||
.clone();
|
||||
|
||||
@ -362,19 +378,30 @@ fn test_enrollment_payload_construction() {
|
||||
};
|
||||
|
||||
// Verify payload serializes to valid JSON
|
||||
let json = serde_json::to_string(&request)
|
||||
.expect("EnrollmentRequest should serialize to valid JSON");
|
||||
let json =
|
||||
serde_json::to_string(&request).expect("EnrollmentRequest should serialize to valid JSON");
|
||||
|
||||
assert!(!json.is_empty(), "Serialized enrollment request should not be empty");
|
||||
assert!(
|
||||
!json.is_empty(),
|
||||
"Serialized enrollment request should not be empty"
|
||||
);
|
||||
|
||||
// Verify JSON contains all required fields
|
||||
let parsed: Value = serde_json::from_str(&json)
|
||||
.expect("Should deserialize enrollment request");
|
||||
let parsed: Value = serde_json::from_str(&json).expect("Should deserialize enrollment request");
|
||||
|
||||
assert!(parsed.get("machine_id").is_some(), "JSON must contain machine_id");
|
||||
assert!(
|
||||
parsed.get("machine_id").is_some(),
|
||||
"JSON must contain machine_id"
|
||||
);
|
||||
assert!(parsed.get("fqdn").is_some(), "JSON must contain fqdn");
|
||||
assert!(parsed.get("ip_address").is_some(), "JSON must contain ip_address");
|
||||
assert!(parsed.get("os_details").is_some(), "JSON must contain os_details");
|
||||
assert!(
|
||||
parsed.get("ip_address").is_some(),
|
||||
"JSON must contain ip_address"
|
||||
);
|
||||
assert!(
|
||||
parsed.get("os_details").is_some(),
|
||||
"JSON must contain os_details"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -430,8 +457,8 @@ fn test_enrollment_payload_roundtrip() {
|
||||
|
||||
// Serialize to JSON then deserialize back
|
||||
let json = serde_json::to_string(&request).expect("Failed to serialize");
|
||||
let deserialized: EnrollmentRequest = serde_json::from_str(&json)
|
||||
.expect("Failed to deserialize enrollment request");
|
||||
let deserialized: EnrollmentRequest =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize enrollment request");
|
||||
|
||||
assert_eq!(request.machine_id, deserialized.machine_id);
|
||||
assert_eq!(request.fqdn, deserialized.fqdn);
|
||||
@ -461,8 +488,11 @@ fn test_cross_distro_os_release_parsing() {
|
||||
}
|
||||
|
||||
// Verify key fields are present (POSIX standard for os-release)
|
||||
assert!(parsed.contains_key("NAME"), "os-release must contain NAME field");
|
||||
assert!(parsed["NAME"].ne(&""), "NAME should not be empty");
|
||||
assert!(
|
||||
parsed.contains_key("NAME"),
|
||||
"os-release must contain NAME field"
|
||||
);
|
||||
assert!(!parsed["NAME"].is_empty(), "NAME should not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user