use crate::error::RegentError;
use crate::hosts::managed_host::InternalApiCallOutcome;
use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
use crate::hosts::properties::{HostProperties, InitSystem, LinuxFlavor, LinuxSpecifics, OsKind};
use crate::secrets::SecretProvidersPool;
use crate::state::Check;
use crate::state::attribute::HostHandler;
use crate::state::attribute::Privilege;
use crate::state::attribute::Remediation;
use crate::state::attribute::RemediationsList;
use crate::state::compliance::AttributeComplianceAssessment;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum HostnameMethod {
Systemd,
Generic,
#[cfg(feature = "windows")]
Windows,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "PascalCase")]
pub struct HostnameBlockExpectedState {
name: String,
method: Option<HostnameMethod>,
}
impl Timeout for HostnameBlockExpectedState {
fn default_timeout(&self) -> Duration {
Duration::from_secs(5)
}
}
impl HostnameBlockExpectedState {
pub fn new(hostname: &str, method: Option<HostnameMethod>) -> HostnameBlockExpectedState {
HostnameBlockExpectedState {
name: hostname.to_string(),
method,
}
}
}
impl Check for HostnameBlockExpectedState {
fn check(&self) -> Result<(), RegentError> {
if self.name.is_empty() {
return Err(RegentError::IncoherentExpectedState(
"Hostname cannot be empty.".to_string(),
));
} else if let Err(details) = is_valid_hostname(&self.name) {
return Err(RegentError::IncoherentExpectedState(details));
}
Ok(())
}
fn check_host_compatibility(
&self,
host_properties: &HostProperties,
) -> Result<(), RegentError> {
match host_properties.os_kind() {
OsKind::Linux(_) | OsKind::MacOs(_) | OsKind::FreeBsd(_) => Ok(()),
#[cfg(feature = "windows")]
OsKind::Windows(_) => Ok(()),
incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
"Host is {:?} but hostname management is only supported on Unix-like systems or Windows (with windows feature)",
incompatible_os_kind
))),
}
}
}
impl<Handler: HostHandler> AssessCompliance<Handler> for HostnameBlockExpectedState {
async fn assess_compliance(
&self,
host_handler: &mut Handler,
host_properties: &Option<HostProperties>,
privilege: &Privilege,
_optional_secret_provider: &Option<SecretProvidersPool>,
) -> Result<AttributeComplianceAssessment, RegentError> {
if let Some(props) = host_properties {
self.check_host_compatibility(props)?;
}
if let Err(details) = self.check() {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Runtime check failed : {}",
details
)));
}
let os_kind = host_properties
.as_ref()
.map(|props| props.os_kind())
.unwrap_or(&OsKind::Linux(LinuxSpecifics {
linux_flavor: LinuxFlavor::Debian,
init_system: InitSystem::Systemd,
}));
let current_hostname = match os_kind {
#[cfg(feature = "windows")]
OsKind::Windows(_) => match host_handler.run_windows_command("hostname").await {
Ok(result) => {
if result.return_code != 0 {
return Err(RegentError::FailedDryRunEvaluation(
"Failed to get current hostname on Windows".to_string(),
));
}
result.stdout.trim().to_string()
}
Err(e) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Unable to get hostname on Windows: {:?}",
e
)));
}
},
OsKind::Linux(_) | OsKind::MacOs(_) | OsKind::FreeBsd(_) => {
match host_handler
.run_command("cat /etc/hostname", &Privilege::None)
.await
{
Ok(result) => {
if result.return_code != 0 {
return Err(RegentError::FailedDryRunEvaluation(
"Failed to get current hostname".to_string(),
));
}
result.stdout.trim().to_string()
}
Err(e) => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"Unable to get hostname: {:?}",
e
)));
}
}
}
OsKind::Unknown => {
return Err(RegentError::FailedDryRunEvaluation(
"Cannot determine hostname on unknown OS".to_string(),
));
}
};
if current_hostname == self.name {
return Ok(AttributeComplianceAssessment::Compliant);
}
let method = match os_kind {
#[cfg(feature = "windows")]
OsKind::Windows(_) => {
self.method.clone().unwrap_or(HostnameMethod::Windows)
}
OsKind::Linux(_) => {
self.method.clone().unwrap_or(HostnameMethod::Systemd)
}
OsKind::MacOs(_) | OsKind::FreeBsd(_) => {
self.method.clone().unwrap_or(HostnameMethod::Generic)
}
OsKind::Unknown => {
return Err(RegentError::FailedDryRunEvaluation(
"Cannot set hostname on unknown OS".to_string(),
));
}
};
Ok(AttributeComplianceAssessment::NonCompliant(
RemediationsList::from(vec![Remediation::Hostname(HostnameApiCall::from(
HostnameModuleInternalApiCall::SetHostname {
name: self.name.clone(),
method,
},
privilege.clone(),
))])
.unwrap(),
))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum HostnameModuleInternalApiCall {
SetHostname {
name: String,
method: HostnameMethod,
},
}
impl std::fmt::Display for HostnameModuleInternalApiCall {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HostnameModuleInternalApiCall::SetHostname { name, .. } => {
write!(f, "set hostname to {}", name)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HostnameApiCall {
pub api_call: HostnameModuleInternalApiCall,
privilege: Privilege,
}
impl HostnameApiCall {
pub fn display(&self) -> String {
match &self.api_call {
HostnameModuleInternalApiCall::SetHostname { name, .. } => {
format!("Set hostname to {}", name)
}
}
}
fn from(api_call: HostnameModuleInternalApiCall, privilege: Privilege) -> HostnameApiCall {
HostnameApiCall {
api_call,
privilege,
}
}
}
impl Check for HostnameApiCall {
fn check(&self) -> Result<(), RegentError> {
Ok(())
}
fn check_host_compatibility(
&self,
host_properties: &HostProperties,
) -> Result<(), RegentError> {
match host_properties.os_kind() {
OsKind::Linux(_) | OsKind::MacOs(_) | OsKind::FreeBsd(_) => Ok(()),
#[cfg(feature = "windows")]
OsKind::Windows(_) => Ok(()),
incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
"Host is {:?} but hostname management is only supported on Unix-like systems or Windows (with windows feature)",
incompatible_os_kind
))),
}
}
}
impl<Handler: HostHandler> ReachCompliance<Handler> for HostnameApiCall {
async fn call(
&self,
host_handler: &mut Handler,
host_properties: &Option<HostProperties>,
_optional_secret_provider: &Option<SecretProvidersPool>,
) -> Result<InternalApiCallOutcome, RegentError> {
if let Some(props) = host_properties {
self.check_host_compatibility(props)?;
}
let os_kind = host_properties
.as_ref()
.map(|props| props.os_kind())
.unwrap_or(&OsKind::Linux(LinuxSpecifics {
linux_flavor: LinuxFlavor::Debian,
init_system: InitSystem::Systemd,
}));
match os_kind {
#[cfg(feature = "windows")]
OsKind::Windows(_) => {
let (name, method) = match &self.api_call {
HostnameModuleInternalApiCall::SetHostname { name, method } => (name, method),
};
let cmd = match method {
HostnameMethod::Windows => format!("hostname {}", name),
HostnameMethod::Systemd | HostnameMethod::Generic => {
format!("hostname {}", name)
}
};
let cmd_result = host_handler.run_windows_command(&cmd).await;
match cmd_result {
Ok(result) => {
if result.return_code == 0 {
Ok(InternalApiCallOutcome::Success(None))
} else {
Ok(InternalApiCallOutcome::Failure(format!(
"RC: {}, STDOUT: {}, STDERR: {}",
result.return_code, result.stdout, result.stderr
)))
}
}
Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
"Command execution failed: {:?}",
e
))),
}
}
OsKind::Linux(_) | OsKind::MacOs(_) | OsKind::FreeBsd(_) => {
let (name, method) = match &self.api_call {
HostnameModuleInternalApiCall::SetHostname { name, method } => (name, method),
};
let cmd = match method {
HostnameMethod::Systemd => format!("hostnamectl set-hostname {}", name),
HostnameMethod::Generic => {
format!("hostname {} && echo {} > /etc/hostname", name, name)
}
#[cfg(feature = "windows")]
HostnameMethod::Windows => {
format!("hostnamectl set-hostname {}", name)
}
};
let cmd_result = host_handler.run_command(&cmd, &self.privilege).await;
match cmd_result {
Ok(result) => {
if result.return_code == 0 {
Ok(InternalApiCallOutcome::Success(None))
} else {
Ok(InternalApiCallOutcome::Failure(format!(
"RC: {}, STDOUT: {}, STDERR: {}",
result.return_code, result.stdout, result.stderr
)))
}
}
Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
"Command execution failed: {:?}",
e
))),
}
}
OsKind::Unknown => Err(RegentError::FailedDryRunEvaluation(
"Cannot set hostname on unknown OS".to_string(),
)),
}
}
}
fn is_valid_hostname(hostname: &str) -> Result<(), String> {
if hostname.is_empty() {
return Err("hostname is empty".to_string());
}
if hostname.len() > 253 {
return Err("hostname too long (max 253 characters)".to_string());
}
if hostname.contains("--") {
return Err("hostname forbidden to have --".to_string());
}
for element in hostname.split('.') {
if element.is_empty() {
return Err("one empty element between 2 points".to_string());
} else if element.len() > 63 {
return Err("element too long (max 63 characters)".to_string());
}
for (i, c) in element.chars().enumerate() {
match c {
'a'..='z' | '0'..='9' => (),
'-' => {
if i == 0 {
return Err("element forbidden to start with -".to_string());
} else if i == element.len() - 1 {
return Err("element forbidden to end with -".to_string());
}
}
forbidden_character => {
return Err(format!("forbidden character : {forbidden_character}"));
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsing_hostname_module_block_from_yaml_str() {
let raw_attributes = "---
- Name: myserver.example.com
- Name: webserver
Method: !Systemd
- Name: oldbox
Method: !Generic
";
let _attributes: Vec<HostnameBlockExpectedState> =
yaml_serde::from_str(raw_attributes).unwrap();
}
#[test]
fn is_valid_hostname_rejects_empty_hostname() {
assert!(is_valid_hostname("").is_err());
}
#[test]
fn is_valid_hostname_rejects_hostname_longer_than_253_chars() {
let long_hostname = "a".repeat(254);
assert!(is_valid_hostname(&long_hostname).is_err());
}
#[test]
fn is_valid_hostname_rejects_hostname_with_consecutive_dashes() {
assert!(is_valid_hostname("my--server").is_err());
}
#[test]
fn is_valid_hostname_rejects_hostname_with_leading_or_trailing_dashes() {
assert!(is_valid_hostname("-myserver").is_err());
assert!(is_valid_hostname("myserver-").is_err());
}
#[test]
fn is_valid_hostname_rejects_hostname_with_invalid_chars() {
assert!(is_valid_hostname("my!server").is_err());
assert!(is_valid_hostname("my@server").is_err());
}
#[test]
fn is_valid_hostname_accepts_valid_hostname() {
assert!(is_valid_hostname("myserver").is_ok());
assert!(is_valid_hostname("myserver.example.com").is_ok());
}
#[test]
fn is_valid_hostname_rejects_hostname_with_labels_longer_than_63_chars() {
let long_label = format!("element-1.{}.element-2", "a".repeat(64));
assert!(is_valid_hostname(&format!("{}.example.com", long_label)).is_err());
}
}