use crate::error::RegentError;
use crate::hosts::handlers::shell_quote;
use crate::hosts::managed_host::InternalApiCallOutcome;
use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
use crate::hosts::properties::HostProperties;
use crate::hosts::properties::OsKind;
use crate::secrets::SecretProvidersPool;
use crate::state::Check;
use crate::state::attribute::HostHandler;
use crate::state::attribute::Privilege;
use crate::state::compliance::AttributeComplianceAssessment;
use nanoid::nanoid;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::net::IpAddr;
use std::time::Duration;
const PROBE_DIR_PREFIX: &str = "/tmp/regent-dhcp-";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum DhcpCheckBehavior {
#[serde(rename_all = "PascalCase")]
CheckResponseAndServer {
response: ExpectedDhcpResponse,
server: IpAddr,
rogue_server_allowed: bool,
},
#[serde(rename_all = "PascalCase")]
CheckResponse { response: ExpectedDhcpResponse },
#[serde(rename_all = "PascalCase")]
CheckServer {
server: IpAddr,
rogue_server_allowed: bool,
},
#[serde(rename_all = "PascalCase")]
SimpleCheck,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct DhcpExpectedState {
parent_interface: String,
mac_address: Option<String>,
check: DhcpCheckBehavior,
}
impl DhcpExpectedState {
pub fn check_response_and_server(
parent_interface: &str,
client_mac_address: Option<&str>,
expected_ip: IpAddr,
expected_mask: IpAddr,
expected_dns: Vec<IpAddr>,
server: IpAddr,
rogue_server_allowed: bool,
) -> DhcpExpectedState {
DhcpExpectedState {
parent_interface: parent_interface.to_string(),
mac_address: client_mac_address.map(|m| m.to_string()),
check: DhcpCheckBehavior::CheckResponseAndServer {
response: ExpectedDhcpResponse::new(expected_ip, expected_mask, expected_dns),
server,
rogue_server_allowed,
},
}
}
pub fn check_response(
parent_interface: &str,
client_mac_address: Option<&str>,
expected_ip: IpAddr,
expected_mask: IpAddr,
expected_dns: Vec<IpAddr>,
) -> DhcpExpectedState {
DhcpExpectedState {
parent_interface: parent_interface.to_string(),
mac_address: client_mac_address.map(|m| m.to_string()),
check: DhcpCheckBehavior::CheckResponse {
response: ExpectedDhcpResponse::new(expected_ip, expected_mask, expected_dns),
},
}
}
pub fn check_server(
parent_interface: &str,
client_mac_address: Option<&str>,
server: IpAddr,
rogue_server_allowed: bool,
) -> DhcpExpectedState {
DhcpExpectedState {
parent_interface: parent_interface.to_string(),
mac_address: client_mac_address.map(|m| m.to_string()),
check: DhcpCheckBehavior::CheckServer {
server,
rogue_server_allowed,
},
}
}
pub fn simple_check(
parent_interface: &str,
client_mac_address: Option<&str>,
) -> DhcpExpectedState {
DhcpExpectedState {
parent_interface: parent_interface.to_string(),
mac_address: client_mac_address.map(|m| m.to_string()),
check: DhcpCheckBehavior::SimpleCheck,
}
}
}
impl Check for DhcpExpectedState {
fn check(&self) -> Result<(), RegentError> {
if self.parent_interface.is_empty() {
return Err(RegentError::IncoherentExpectedState(
"ParentInterface is empty".to_string(),
));
}
if let Some(mac) = &self.mac_address {
if mac.is_empty() {
return Err(RegentError::IncoherentExpectedState(
"MacAddress is present but empty".to_string(),
));
}
if !is_valid_mac_address(mac) {
return Err(RegentError::IncoherentExpectedState(format!(
"MacAddress '{mac}' is not a valid MAC address (expected 6 hex octets \
separated by ':', e.g. aa:bb:cc:dd:ee:ff)"
)));
}
}
match &self.check {
DhcpCheckBehavior::CheckResponseAndServer {
response,
server: _,
rogue_server_allowed: _,
} => {
check_expected_response(response)?;
}
DhcpCheckBehavior::CheckResponse { response } => {
check_expected_response(response)?;
}
DhcpCheckBehavior::CheckServer {
server: _,
rogue_server_allowed: _,
} => {}
DhcpCheckBehavior::SimpleCheck => {}
}
Ok(())
}
fn check_host_compatibility(
&self,
host_properties: &HostProperties,
) -> Result<(), RegentError> {
match host_properties.os_kind() {
OsKind::Linux(_) => Ok(()),
incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
"Host is {:?} but the DHCP attribute is only supported on Linux",
incompatible_os_kind
))),
}
}
}
impl Timeout for DhcpExpectedState {
fn default_timeout(&self) -> Duration {
Duration::from_secs(20)
}
}
impl<Handler: HostHandler> AssessCompliance<Handler> for DhcpExpectedState {
async fn assess_compliance(
&self,
host_handler: &mut Handler,
host_properties: &Option<HostProperties>,
privilege: &Privilege,
_optional_secret_provider: &Option<SecretProvidersPool>,
) -> Result<AttributeComplianceAssessment, RegentError> {
if matches!(privilege, Privilege::None) {
return Err(RegentError::WrongInitialization(
"DHCP assessment needs sudo/sudo-rs privileges".to_string(),
));
}
if let Some(properties) = host_properties {
self.check_host_compatibility(properties)?;
}
for required_command in ["dhclient", "ip", "timeout", "mktemp"] {
let command_available = host_handler
.is_this_command_available(required_command, privilege)
.await
.unwrap_or(false);
if !command_available {
return Err(RegentError::FailedDryRunEvaluation(format!(
"command {required_command} not available on this host"
)));
}
}
let parent_interface = self.parent_interface.as_str();
let mac_address = self.mac_address.as_deref();
match host_handler
.run_command(
&is_wireless_interface_cmd(parent_interface),
&Privilege::None,
)
.await
{
Ok(result) if result.return_code == 0 => {
return Err(RegentError::IncompatibleHost(format!(
"{parent_interface} is a wireless interface (not supported)"
)));
}
Ok(_) => { }
Err(details) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Unable to determine if {parent_interface} is wireless: {:?}",
details
)));
}
}
if let Some(mac) = mac_address {
match host_handler.run_command("ip -o link show", privilege).await {
Ok(result) if result.return_code == 0 => {
if result.stdout.to_lowercase().contains(&mac.to_lowercase()) {
return Err(RegentError::IncoherentExpectedState(format!(
"MAC address {mac} is already used by an interface on this host, \
this might create conflicts"
)));
}
}
Ok(result) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Unable to check whether MAC address {mac} is already in use: {:?}",
result
)));
}
Err(details) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Unable to check whether MAC address {mac} is already in use: {:?}",
details
)));
}
}
}
let probe_dir = match host_handler
.run_command(&create_probe_dir_cmd(), privilege)
.await
{
Ok(result) if result.return_code == 0 => extract_probe_dir(&result.stdout)?,
Ok(result) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Failed to create the temporary directory for the DHCP probe: {:?}",
result
)));
}
Err(details) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Unable to create the temporary directory for the DHCP probe: {:?}",
details
)));
}
};
let virtual_interface = virtual_interface_name();
let lease_file = format!("{probe_dir}/probe.leases");
let pid_file = format!("{probe_dir}/probe.pid");
let probe_timeout_secs = self.default_timeout().as_secs();
let probe_outcome = probe_dhcp_lease(
host_handler,
privilege,
parent_interface,
mac_address,
&virtual_interface,
&lease_file,
&pid_file,
probe_timeout_secs,
)
.await;
cleanup_probe(
host_handler,
privilege,
&virtual_interface,
&probe_dir,
&lease_file,
&pid_file,
)
.await;
let dhcp_response = probe_outcome?;
match &self.check {
DhcpCheckBehavior::CheckResponseAndServer {
response,
server,
rogue_server_allowed,
} => {
if let Some(why) =
assess_responding_servers(&dhcp_response, server, *rogue_server_allowed)
{
return Ok(AttributeComplianceAssessment::NonCompliantFatal(why));
}
match dhcp_response.matches_expected_response(response) {
ComparisonOutcome::Matches => Ok(AttributeComplianceAssessment::Compliant),
ComparisonOutcome::Different(why) => {
Ok(AttributeComplianceAssessment::NonCompliantFatal(format!(
"Unexpected response. {}",
why
)))
}
}
}
DhcpCheckBehavior::CheckResponse { response } => {
match dhcp_response.matches_expected_response(response) {
ComparisonOutcome::Matches => Ok(AttributeComplianceAssessment::Compliant),
ComparisonOutcome::Different(why) => {
Ok(AttributeComplianceAssessment::NonCompliantFatal(format!(
"Unexpected response. {}",
why
)))
}
}
}
DhcpCheckBehavior::CheckServer {
server,
rogue_server_allowed,
} => match assess_responding_servers(&dhcp_response, server, *rogue_server_allowed) {
Some(why) => Ok(AttributeComplianceAssessment::NonCompliantFatal(why)),
None => Ok(AttributeComplianceAssessment::Compliant),
},
DhcpCheckBehavior::SimpleCheck => {
Ok(AttributeComplianceAssessment::Compliant)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DhcpApiCall {}
impl DhcpApiCall {
pub fn display(&self) -> String {
"Should not have been called".to_string()
}
}
impl Check for DhcpApiCall {
fn check(&self) -> Result<(), RegentError> {
Err(RegentError::InternalLogicError(
"(check) DhcpApiCall should not have been called as we cannot remediate automatically a wrong DHCP configuration".to_string()
))
}
fn check_host_compatibility(
&self,
_host_properties: &HostProperties,
) -> Result<(), RegentError> {
Err(RegentError::InternalLogicError(
"(check_host_compatibility) DhcpApiCall should not have been called as we cannot remediate automatically a wrong DHCP configuration".to_string()
))
}
}
impl<Handler: HostHandler> ReachCompliance<Handler> for DhcpApiCall {
async fn call(
&self,
_host_handler: &mut Handler,
_host_properties: &Option<HostProperties>,
_optional_secret_provider: &Option<SecretProvidersPool>,
) -> Result<InternalApiCallOutcome, RegentError> {
Err(RegentError::InternalLogicError(
"(call) DhcpApiCall should not have been called as we cannot remediate automatically a wrong DHCP configuration".to_string()
))
}
}
#[allow(clippy::too_many_arguments)]
async fn probe_dhcp_lease<Handler: HostHandler>(
host_handler: &mut Handler,
privilege: &Privilege,
parent_interface: &str,
mac_address: Option<&str>,
virtual_interface: &str,
lease_file: &str,
pid_file: &str,
timeout_secs: u64,
) -> Result<DhcpResponse, RegentError> {
match host_handler
.run_command(
&create_virtual_interface_cmd(parent_interface, virtual_interface, mac_address),
privilege,
)
.await
{
Ok(result) if result.return_code == 0 => {}
Ok(result) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Failed to create virtual interface {virtual_interface} on {parent_interface} for DHCP probing: {:?}",
result
)));
}
Err(details) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Unable to create virtual interface {virtual_interface} on {parent_interface} for DHCP probing: {:?}",
details
)));
}
}
match host_handler
.run_command(&bring_up_interface_cmd(virtual_interface), privilege)
.await
{
Ok(result) if result.return_code == 0 => {}
Ok(result) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Failed to bring up virtual interface {virtual_interface} for DHCP probing: {:?}",
result
)));
}
Err(details) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Unable to bring up virtual interface {virtual_interface} for DHCP probing: {:?}",
details
)));
}
}
let probe_result = match host_handler
.run_command(
&dhcp_probe_cmd(virtual_interface, timeout_secs, lease_file, pid_file),
privilege,
)
.await
{
Ok(result) => result,
Err(details) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Unable to run dhclient command: {:?}",
details
)));
}
};
if probe_result.return_code == 124 {
return Err(RegentError::TimeOutReached(format!(
"dhclient on {virtual_interface} did not finish within {timeout_secs}s"
)));
}
if probe_result.return_code != 0 {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Failed dhclient command: {:?}",
probe_result
)));
}
let raw_stdout_and_stderr = probe_result.stdout + &probe_result.stderr;
let responding_servers = parse_responding_servers(&raw_stdout_and_stderr);
let raw_lease = match host_handler
.run_command(&read_lease_file_cmd(lease_file), privilege)
.await
{
Ok(result) if result.return_code == 0 => result.stdout,
Ok(result) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"dhclient acquired a lease on {virtual_interface} but its lease file could not be read: {:?}",
result
)));
}
Err(details) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"dhclient acquired a lease on {virtual_interface} but its lease file could not be read: {:?}",
details
)));
}
};
let lease = parse_lease_file(&raw_lease);
if lease.ip.is_none() {
return Err(RegentError::FailedDryRunEvaluation(format!(
"dhclient acquired a lease on {virtual_interface} but no address could be parsed \
out of its lease file"
)));
}
Ok(DhcpResponse {
ip: lease.ip,
mask: lease.mask,
dns: lease.dns,
responding_servers,
})
}
async fn cleanup_probe<Handler: HostHandler>(
host_handler: &mut Handler,
privilege: &Privilege,
virtual_interface: &str,
probe_dir: &str,
lease_file: &str,
pid_file: &str,
) {
let _ = host_handler
.run_command(
&release_lease_cmd(virtual_interface, lease_file, pid_file),
privilege,
)
.await;
let _ = host_handler
.run_command(&stop_dhclient_cmd(pid_file), privilege)
.await;
let _ = host_handler
.run_command(&delete_interface_cmd(virtual_interface), privilege)
.await;
let _ = host_handler
.run_command(&remove_probe_dir_cmd(probe_dir), privilege)
.await;
}
#[derive(Debug)]
struct DhcpResponse {
ip: Option<IpAddr>,
mask: Option<IpAddr>,
dns: Vec<IpAddr>,
responding_servers: HashSet<IpAddr>,
}
impl DhcpResponse {
fn matches_expected_response(
&self,
expected_response: &ExpectedDhcpResponse,
) -> ComparisonOutcome {
if self.ip != Some(expected_response.ip) {
return ComparisonOutcome::Different(format!(
"Different IP : expected {}, got {:?}",
expected_response.ip, self.ip
));
}
if self.mask != Some(expected_response.mask) {
return ComparisonOutcome::Different(format!(
"Different mask : expected {}, got {:?}",
expected_response.mask, self.mask
));
}
let mut got_dns = self.dns.clone();
let mut expected_dns = expected_response.dns.clone();
got_dns.sort();
expected_dns.sort();
if got_dns != expected_dns {
return ComparisonOutcome::Different(format!(
"Different DNS : expected {:?}, got {:?}",
expected_response.dns, self.dns
));
}
ComparisonOutcome::Matches
}
}
fn assess_responding_servers(
dhcp_response: &DhcpResponse,
expected_server: &IpAddr,
rogue_server_allowed: bool,
) -> Option<String> {
if dhcp_response.responding_servers.is_empty() {
return Some("No responding server at all".to_string());
}
if !dhcp_response.responding_servers.contains(expected_server) {
return Some(format!(
"Expected server {expected_server} not among responding servers ({:?})",
dhcp_response.responding_servers
));
}
if !rogue_server_allowed && dhcp_response.responding_servers.len() > 1 {
return Some(format!(
"Expected server {expected_server} answered but there are also rogue DHCP servers ({:?})",
dhcp_response.responding_servers
));
}
None
}
fn dhcp_probe_cmd(interface: &str, timeout_secs: u64, lease_file: &str, pid_file: &str) -> String {
format!(
"timeout {timeout_secs} dhclient -1 -v -sf /bin/true -cf /dev/null -lf {} -pf {} {}",
shell_quote(lease_file),
shell_quote(pid_file),
shell_quote(interface)
)
}
fn release_lease_cmd(interface: &str, lease_file: &str, pid_file: &str) -> String {
format!(
"timeout 5 dhclient -r -sf /bin/true -cf /dev/null -lf {} -pf {} {}",
shell_quote(lease_file),
shell_quote(pid_file),
shell_quote(interface)
)
}
fn stop_dhclient_cmd(pid_file: &str) -> String {
format!("timeout 5 dhclient -x -pf {}", shell_quote(pid_file))
}
fn read_lease_file_cmd(lease_file: &str) -> String {
format!("cat {}", shell_quote(lease_file))
}
fn create_probe_dir_cmd() -> String {
format!("mktemp -d {PROBE_DIR_PREFIX}XXXXXX")
}
fn remove_probe_dir_cmd(probe_dir: &str) -> String {
format!("rm -rf {}", shell_quote(probe_dir))
}
fn extract_probe_dir(raw_output: &str) -> Result<String, RegentError> {
raw_output
.lines()
.map(str::trim)
.rfind(|line| line.starts_with(PROBE_DIR_PREFIX) && !line.contains(char::is_whitespace))
.map(str::to_string)
.ok_or_else(|| {
RegentError::FailedDryRunEvaluation(format!(
"Could not find the temporary directory for the DHCP probe in mktemp output: {:?}",
raw_output
))
})
}
fn virtual_interface_name() -> String {
format!(
"rgt{}",
nanoid!(
6,
&[
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
]
)
)
}
fn create_virtual_interface_cmd(
parent: &str,
virtual_interface: &str,
mac_address: Option<&str>,
) -> String {
match mac_address {
Some(mac) => format!(
"ip link add link {} name {} address {} type macvlan mode bridge",
shell_quote(parent),
shell_quote(virtual_interface),
shell_quote(mac)
),
None => format!(
"ip link add link {} name {} type macvlan mode bridge",
shell_quote(parent),
shell_quote(virtual_interface)
),
}
}
fn bring_up_interface_cmd(interface: &str) -> String {
format!("ip link set {} up", shell_quote(interface))
}
fn delete_interface_cmd(interface: &str) -> String {
format!("ip link delete {}", shell_quote(interface))
}
fn is_wireless_interface_cmd(interface: &str) -> String {
format!(
"test -d {}",
shell_quote(&format!("/sys/class/net/{interface}/wireless"))
)
}
fn check_expected_response(response: &ExpectedDhcpResponse) -> Result<(), RegentError> {
let mut seen = HashSet::new();
for entry in &response.dns {
if !seen.insert(*entry) {
return Err(RegentError::IncoherentExpectedState(format!(
"Duplicate DNS server in expected response: {entry}"
)));
}
}
Ok(())
}
fn is_valid_mac_address(mac: &str) -> bool {
let octets: Vec<&str> = mac.split(':').collect();
octets.len() == 6
&& octets
.iter()
.all(|octet| octet.len() == 2 && octet.chars().all(|c| c.is_ascii_hexdigit()))
}
fn parse_responding_servers(raw_output: &str) -> HashSet<IpAddr> {
let mut responding_servers = HashSet::new();
for line in raw_output.lines() {
if !line.contains("DHCPOFFER") && !line.contains("DHCPACK") {
continue;
}
if let Some(pos) = line.find("from ") {
let ip_part = &line[pos + "from ".len()..];
let ip_str: String = ip_part
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if let Ok(ip) = ip_str.parse() {
responding_servers.insert(ip);
continue;
}
}
let tokens: Vec<&str> = line.split_whitespace().collect();
if let Some(last) = tokens.last() {
let cleaned = last.trim_matches(|c| c == '(' || c == ')' || c == ';');
if let Ok(ip) = cleaned.parse() {
responding_servers.insert(ip);
}
}
}
responding_servers
}
#[derive(Debug, Default, PartialEq)]
struct DhcpLease {
ip: Option<IpAddr>,
mask: Option<IpAddr>,
dns: Vec<IpAddr>,
}
fn parse_lease_file(raw_lease: &str) -> DhcpLease {
let mut lease = DhcpLease::default();
for line in raw_lease.lines() {
let statement = line.trim().trim_end_matches(';').trim();
let statement = statement.strip_prefix("option ").unwrap_or(statement);
if let Some(value) = statement.strip_prefix("fixed-address ") {
if let Ok(ip) = value.trim().parse() {
lease.ip = Some(ip);
}
} else if let Some(value) = statement.strip_prefix("subnet-mask ") {
if let Ok(mask) = value.trim().parse() {
lease.mask = Some(mask);
}
} else if let Some(value) = statement.strip_prefix("domain-name-servers ") {
let servers: Vec<IpAddr> = value
.split(',')
.filter_map(|entry| entry.trim().parse().ok())
.collect();
if !servers.is_empty() {
lease.dns = servers;
}
}
}
lease
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ExpectedDhcpResponse {
ip: IpAddr,
mask: IpAddr,
dns: Vec<IpAddr>,
}
impl ExpectedDhcpResponse {
pub fn new(ip: IpAddr, mask: IpAddr, dns: Vec<IpAddr>) -> ExpectedDhcpResponse {
ExpectedDhcpResponse { ip, mask, dns }
}
}
enum ComparisonOutcome {
Matches,
Different(String),
}