use std::collections::{
HashMap,
HashSet,
};
use std::sync::LazyLock;
use kftray_commons::models::hostfile::HostEntry;
use kftray_commons::utils::db_mode::DatabaseMode;
use log::{
debug,
warn,
};
use crate::hostfile_direct::DirectHostfileManager;
use crate::hostfile_helper::HostfileHelperClient;
static HOSTFILE_MANAGER: LazyLock<HostfileManager> = LazyLock::new(HostfileManager::new);
pub struct HostfileManager {
helper_client: Option<HostfileHelperClient>,
direct_manager: DirectHostfileManager,
handed_to_helper: std::sync::Mutex<HashSet<(String, HostEntry)>>,
ssl_ids_written: std::sync::Mutex<HashSet<String>>,
}
struct SslWriteError {
error: std::io::Error,
left_on_disk: bool,
}
impl HostfileManager {
pub fn new() -> Self {
Self {
helper_client: HostfileHelperClient::new().ok(),
direct_manager: DirectHostfileManager::new(),
handed_to_helper: std::sync::Mutex::new(HashSet::new()),
ssl_ids_written: std::sync::Mutex::new(HashSet::new()),
}
}
#[cfg(test)]
fn without_helper() -> Self {
Self {
helper_client: None,
direct_manager: DirectHostfileManager::new(),
handed_to_helper: std::sync::Mutex::new(HashSet::new()),
ssl_ids_written: std::sync::Mutex::new(HashSet::new()),
}
}
fn helper(&self) -> Option<&HostfileHelperClient> {
self.helper_client
.as_ref()
.filter(|helper| helper.is_available())
}
fn wrote_any(&self, ids: &[String]) -> bool {
let handed = self
.handed_to_helper
.lock()
.unwrap_or_else(|e| e.into_inner());
let written = self
.ssl_ids_written
.lock()
.unwrap_or_else(|e| e.into_inner());
handed.iter().any(|(owner, _)| ids.contains(owner))
|| ids.iter().any(|id| written.contains(id))
}
pub fn add_host_entry(&self, id: String, entry: HostEntry) -> std::io::Result<()> {
if let Some(helper) = self.helper() {
match helper.add_host_entry(id.clone(), entry.clone()) {
Ok(_) => {
debug!("Successfully added host entry via helper for ID: {id}");
self.handed_to_helper
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert((id, entry));
return Ok(());
}
Err(e) => {
warn!("Helper hostfile add failed: {e}, falling back to direct");
}
}
}
self.direct_manager.add_host_entry(id, entry)
}
fn add_ssl_host_entry(
&self, config_id: &str, alias: &str, ip: std::net::IpAddr, protected: &[HostEntry],
) -> Result<(String, String), SslWriteError> {
let https_id = format!("{config_id}-https");
let https_local_id = format!("{config_id}-https-local");
self.add_host_entry(
https_id.clone(),
HostEntry {
ip,
hostname: alias.to_string(),
},
)
.map_err(|error| SslWriteError {
error,
left_on_disk: false,
})?;
if let Err(error) = self.add_host_entry(
https_local_id.clone(),
HostEntry {
ip,
hostname: format!("{alias}.local"),
},
) {
let rollback = self.remove_host_entries(
&[https_id.as_str(), https_local_id.as_str()],
&[],
protected,
);
let left_on_disk = rollback.is_err();
if let Err(cleanup_error) = rollback {
warn!(
"Failed to roll back {https_id}/{https_local_id} after SSL write failure: {cleanup_error}"
);
}
return Err(SslWriteError {
error,
left_on_disk,
});
}
{
let mut written = self
.ssl_ids_written
.lock()
.unwrap_or_else(|e| e.into_inner());
written.insert(https_id.clone());
written.insert(https_local_id.clone());
}
Ok((https_id, https_local_id))
}
pub fn remove_host_entries(
&self, ids: &[&str], expected: &[(String, HostEntry)], protected: &[HostEntry],
) -> std::io::Result<()> {
let mut asked_helper = false;
if let Some(helper) = self.helper() {
asked_helper = true;
for id in ids {
if let Err(e) = helper.remove_host_entry(id) {
warn!("Helper hostfile remove failed for {id}: {e}");
}
}
}
let mut recorded: Vec<(String, HostEntry)> = DirectHostfileManager::direct_section()?
.into_iter()
.filter_map(|entry| {
let owner = entry.owner?;
if !ids.contains(&owner.as_str()) {
return None;
}
Some((
owner,
HostEntry {
ip: entry.ip,
hostname: entry.hostname,
},
))
})
.collect();
recorded.extend(expected.iter().cloned());
let expected = recorded;
match self.direct_manager.remove_host_entries(ids, protected) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
let Some(helper) = self.helper() else {
return Err(error);
};
let legacy: Vec<HostEntry> = expected
.iter()
.filter(|(_, entry)| !protected.contains(entry))
.map(|(_, entry)| entry.clone())
.collect();
helper
.remove_direct_owned_entries(ids, legacy)
.map_err(|helper_error| {
std::io::Error::other(format!(
"{error}; the helper could not remove them either: {helper_error}"
))
})?;
let section = DirectHostfileManager::direct_section()?;
let left: Vec<&str> = ids
.iter()
.copied()
.filter(|id| {
section
.iter()
.any(|entry| entry.owner.as_deref() == Some(*id))
})
.collect();
if !left.is_empty() {
return Err(std::io::Error::other(format!(
"Host entries for {} are still on disk after the helper removed them",
left.join(", ")
)));
}
}
Err(error) => return Err(error),
}
let handed = attributable_lines(
&self
.handed_to_helper
.lock()
.unwrap_or_else(|e| e.into_inner()),
&expected,
protected,
);
const HELPER_SETTLE: std::time::Duration = std::time::Duration::from_millis(100);
const HELPER_SETTLE_ATTEMPTS: usize = 3;
let mut stranded = Vec::new();
for attempt in 0..HELPER_SETTLE_ATTEMPTS {
let section = DirectHostfileManager::helper_section()?;
stranded = DirectHostfileManager::stranded_in_helper_section(§ion, ids, &handed);
if stranded.is_empty() || !asked_helper {
break;
}
if attempt + 1 < HELPER_SETTLE_ATTEMPTS {
std::thread::sleep(HELPER_SETTLE);
}
}
if !stranded.is_empty() {
if let Some(helper) = self.helper() {
let legacy: Vec<HostEntry> = handed
.iter()
.filter(|(id, _)| stranded.contains(&id.as_str()))
.map(|(_, entry)| entry.clone())
.collect();
if let Err(e) = helper.remove_unowned_host_entries(legacy) {
warn!("Could not remove unmarked host entries via helper: {e}");
}
let section = DirectHostfileManager::helper_section()?;
stranded =
DirectHostfileManager::stranded_in_helper_section(§ion, ids, &handed);
}
if !stranded.is_empty() {
let legacy: Vec<HostEntry> = handed
.iter()
.filter(|(id, _)| stranded.contains(&id.as_str()))
.map(|(_, entry)| entry.clone())
.collect();
let mappings: Vec<(std::net::IpAddr, String)> = legacy
.into_iter()
.map(|entry| (entry.ip, entry.hostname))
.collect();
let direct_result = self
.direct_manager
.prune_legacy_entries(&mappings)
.and_then(|_| {
self.direct_manager
.remove_owned_from_helper_section(&stranded)
});
match stranded_fallback_outcome(&direct_result) {
StrandedFallbackOutcome::Resolved => {
let section = DirectHostfileManager::helper_section()?;
stranded = DirectHostfileManager::stranded_in_helper_section(
§ion, ids, &handed,
);
}
StrandedFallbackOutcome::Sticky => {}
StrandedFallbackOutcome::Retryable(e) => {
warn!("Could not remove unmarked host entries directly: {e}");
}
}
}
}
if !stranded.is_empty() {
return Err(std::io::Error::other(format!(
"Host entries for {} are still on disk and could not be removed",
stranded.join(", ")
)));
}
self.handed_to_helper
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|(owner, _)| !ids.contains(&owner.as_str()));
self.ssl_ids_written
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|owner| !ids.contains(&owner.as_str()));
Ok(())
}
async fn ssl_ids_written_including_persisted(&self, mode: DatabaseMode) -> HashSet<String> {
let mut ids = self
.ssl_ids_written
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let persisted = persisted_ssl_ids_written(mode).await;
if persisted.is_empty() {
return ids;
}
let sections = tokio::task::spawn_blocking(|| {
(
DirectHostfileManager::helper_section(),
DirectHostfileManager::direct_section(),
)
})
.await;
match sections {
Ok((Ok(helper_section), Ok(direct_section))) => {
let expected = expected_ssl_entries(&persisted, mode).await;
let (verified, stale) = persisted_ids_verified_on_disk(
persisted,
&helper_section,
&direct_section,
&expected,
);
for full_id in stale {
forget_ssl_id_written(&full_id, mode).await;
}
ids.extend(verified);
}
_ => ids.extend(persisted),
}
ids
}
}
impl Default for HostfileManager {
fn default() -> Self {
Self::new()
}
}
pub fn add_host_entry(id: String, entry: HostEntry) -> std::io::Result<()> {
HOSTFILE_MANAGER.add_host_entry(id, entry)
}
#[derive(Debug, PartialEq, Eq)]
enum StrandedFallbackOutcome {
Resolved,
Sticky,
Retryable(String),
}
fn stranded_fallback_outcome(result: &std::io::Result<()>) -> StrandedFallbackOutcome {
match result {
Ok(()) => StrandedFallbackOutcome::Resolved,
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
StrandedFallbackOutcome::Sticky
}
Err(e) => StrandedFallbackOutcome::Retryable(e.to_string()),
}
}
fn persisted_ids_verified_on_disk(
persisted: HashSet<String>, helper_section: &[kftray_commons::utils::hostsfile::SectionEntry],
direct_section: &[kftray_commons::utils::hostsfile::SectionEntry],
expected: &HashMap<String, HostEntry>,
) -> (HashSet<String>, HashSet<String>) {
persisted.into_iter().partition(|full_id| {
let lines = || helper_section.iter().chain(direct_section);
lines().any(|entry| entry.owner.as_deref() == Some(full_id.as_str()))
|| expected.get(full_id).is_some_and(|expected_entry| {
lines().any(|entry| {
entry.owner.is_none()
&& entry.ip == expected_entry.ip
&& entry.hostname == expected_entry.hostname
})
})
})
}
fn attributable_lines(
handed: &HashSet<(String, HostEntry)>, expected: &[(String, HostEntry)],
protected: &[HostEntry],
) -> HashSet<(String, HostEntry)> {
handed
.iter()
.chain(expected)
.filter(|(_, entry)| !protected.contains(entry))
.cloned()
.collect()
}
fn names_domain_alias(config: &kftray_commons::models::config_model::Config) -> bool {
config.domain_enabled.unwrap_or_default()
&& config.service.is_some()
&& config
.alias
.as_deref()
.is_some_and(|alias| !alias.is_empty())
}
fn config_host_entries(
id: i64, config: Option<&kftray_commons::models::config_model::Config>,
ssl_ids_written: &HashSet<String>,
) -> Vec<(String, HostEntry)> {
let Some(config) = config else {
return Vec::new();
};
let Some(alias) = config.alias.as_deref().filter(|alias| !alias.is_empty()) else {
return Vec::new();
};
let Some(ip) = listener_ip(config) else {
return Vec::new();
};
let mut entries = Vec::new();
if names_domain_alias(config) {
entries.push((
id.to_string(),
HostEntry {
ip,
hostname: alias.to_owned(),
},
));
}
if config.protocol == "tcp" {
let https_id = format!("{id}-https");
if ssl_ids_written.contains(&https_id) {
entries.push((
https_id,
HostEntry {
ip,
hostname: alias.to_owned(),
},
));
}
let https_local_id = format!("{id}-https-local");
if ssl_ids_written.contains(&https_local_id) {
entries.push((
https_local_id,
HostEntry {
ip,
hostname: format!("{alias}.local"),
},
));
}
}
entries
}
pub(crate) fn listener_ip(
config: &kftray_commons::models::config_model::Config,
) -> Option<std::net::IpAddr> {
config
.local_address
.as_deref()
.map_or(Some(std::net::Ipv4Addr::LOCALHOST.into()), |address| {
address.parse().ok()
})
}
fn base_config_id(full_id: &str) -> Option<i64> {
full_id
.strip_suffix("-https-local")
.or_else(|| full_id.strip_suffix("-https"))
.and_then(|id| id.parse().ok())
}
async fn expected_ssl_entries(
persisted: &HashSet<String>, mode: DatabaseMode,
) -> HashMap<String, HostEntry> {
let base_ids: HashSet<i64> = persisted
.iter()
.filter_map(|id| base_config_id(id))
.collect();
let mut expected = HashMap::new();
for id in base_ids {
let config = kftray_commons::utils::config::get_config_with_mode(id, mode)
.await
.ok();
for (full_id, entry) in config_host_entries(id, config.as_ref(), persisted) {
if persisted.contains(&full_id) {
expected.insert(full_id, entry);
}
}
}
expected
}
const SSL_HOSTS_WRITTEN_PREFIX: &str = "ssl_hosts_written";
fn ssl_hosts_written_key(full_id: &str, mode: DatabaseMode) -> String {
format!(
"{SSL_HOSTS_WRITTEN_PREFIX}:{}:{full_id}",
kftray_commons::utils::settings::mode_scope(mode)
)
}
async fn persist_ssl_id_written(full_id: &str, mode: DatabaseMode) {
let key = ssl_hosts_written_key(full_id, mode);
if let Err(error) =
kftray_commons::utils::settings::set_setting_with_mode(&key, "1", mode).await
{
warn!("Failed to persist SSL host id {full_id} as written: {error}");
}
}
async fn forget_ssl_id_written(full_id: &str, mode: DatabaseMode) {
let key = ssl_hosts_written_key(full_id, mode);
if let Err(error) = kftray_commons::utils::settings::delete_setting_with_mode(&key, mode).await
{
warn!("Failed to remove persisted SSL host id {full_id}: {error}");
}
}
async fn settle_ssl_write_failure(
https_id: &str, https_local_id: &str, left_on_disk: bool, mode: DatabaseMode,
) {
if left_on_disk {
return;
}
forget_ssl_id_written(https_id, mode).await;
forget_ssl_id_written(https_local_id, mode).await;
}
async fn persisted_ssl_ids_written(mode: DatabaseMode) -> HashSet<String> {
let prefix = format!(
"{SSL_HOSTS_WRITTEN_PREFIX}:{}:",
kftray_commons::utils::settings::mode_scope(mode)
);
match kftray_commons::utils::settings::get_settings_with_prefix_and_mode(&prefix, mode).await {
Ok(entries) => entries
.into_iter()
.filter_map(|(key, _)| key.strip_prefix(&prefix).map(str::to_owned))
.collect(),
Err(error) => {
warn!("Failed to read persisted SSL host ids: {error}");
HashSet::new()
}
}
}
pub async fn remove_config_host_entries(
id: i64, config: Option<&kftray_commons::models::config_model::Config>,
in_use: &[kftray_commons::models::config_model::Config], mode: DatabaseMode,
) -> std::io::Result<()> {
let https_id = format!("{id}-https");
let https_local_id = format!("{id}-https-local");
let ids = [id.to_string(), https_id.clone(), https_local_id.clone()];
let ssl_ids_written = HOSTFILE_MANAGER
.ssl_ids_written_including_persisted(mode)
.await;
let protected: Vec<HostEntry> = in_use
.iter()
.filter(|other| other.id != Some(id))
.flat_map(|other| {
config_host_entries(other.id.unwrap_or_default(), Some(other), &ssl_ids_written)
})
.map(|(_, entry)| entry)
.collect();
let expected = config_host_entries(id, config, &ssl_ids_written);
tokio::task::spawn_blocking(move || {
let ids: Vec<&str> = ids.iter().map(String::as_str).collect();
HOSTFILE_MANAGER.remove_host_entries(&ids, &expected, &protected)
})
.await
.map_err(|e| {
std::io::Error::other(format!("remove_config_host_entries task panicked: {e}"))
})??;
forget_ssl_id_written(&https_id, mode).await;
forget_ssl_id_written(&https_local_id, mode).await;
Ok(())
}
pub async fn rollback_config_host_entries(
id: i64, config: Option<&kftray_commons::models::config_model::Config>,
in_use: &[kftray_commons::models::config_model::Config], mode: DatabaseMode,
) -> std::io::Result<()> {
if !alias_may_exist(id, config, mode).await {
return Ok(());
}
remove_config_host_entries(id, config, in_use, mode).await
}
async fn alias_may_exist(
id: i64, config: Option<&kftray_commons::models::config_model::Config>, mode: DatabaseMode,
) -> bool {
let Some(config) = config else {
return true;
};
if names_domain_alias(config) {
return true;
}
let ids = [
id.to_string(),
format!("{id}-https"),
format!("{id}-https-local"),
];
if HOSTFILE_MANAGER.wrote_any(&ids) {
return true;
}
for full_id in &ids[1..] {
match kftray_commons::utils::settings::get_setting_with_mode(
&ssl_hosts_written_key(full_id, mode),
mode,
)
.await
{
Ok(None) => {}
Ok(Some(_)) | Err(_) => return true,
}
}
match crate::kube::stop::recorded_configs(id, mode).await {
Some(recorded) => recorded.iter().any(names_domain_alias),
None => true,
}
}
pub async fn add_ssl_host_entry(
config_id: &str, alias: &str, ip: std::net::IpAddr, mode: DatabaseMode,
) -> std::io::Result<()> {
let https_id = format!("{config_id}-https");
let https_local_id = format!("{config_id}-https-local");
persist_ssl_id_written(&https_id, mode).await;
persist_ssl_id_written(&https_local_id, mode).await;
let this_id: Option<i64> = config_id.parse().ok();
let ssl_ids_written = HOSTFILE_MANAGER
.ssl_ids_written_including_persisted(mode)
.await;
let protected: Vec<HostEntry> = crate::kube::stop::forwarding_configs(mode)
.await
.iter()
.filter(|other| other.id != this_id)
.flat_map(|other| {
config_host_entries(other.id.unwrap_or_default(), Some(other), &ssl_ids_written)
})
.map(|(_, entry)| entry)
.collect();
let config_id_owned = config_id.to_string();
let alias_owned = alias.to_string();
let result = tokio::task::spawn_blocking(move || {
HOSTFILE_MANAGER.add_ssl_host_entry(&config_id_owned, &alias_owned, ip, &protected)
})
.await
.map_err(|e| std::io::Error::other(format!("add_ssl_host_entry task panicked: {e}")))?;
match result {
Ok(_) => Ok(()),
Err(failure) => {
settle_ssl_write_failure(&https_id, &https_local_id, failure.left_on_disk, mode).await;
Err(failure.error)
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Once;
use super::*;
static INIT: Once = Once::new();
fn init() {
INIT.call_once(|| {
let _ = env_logger::builder().is_test(true).try_init();
});
}
#[test]
fn history_handed_to_the_helper_never_claims_a_line_a_running_forward_needs() {
let shared = HostEntry {
ip: "127.0.0.1".parse().unwrap(),
hostname: "shared.local".to_owned(),
};
let section = vec![kftray_commons::utils::hostsfile::SectionEntry {
ip: shared.ip,
hostname: shared.hostname.clone(),
owner: None,
}];
let mut history: HashSet<(String, HostEntry)> = HashSet::new();
history.insert(("7".to_owned(), shared.clone()));
let handed = attributable_lines(&history, &[], &[]);
assert_eq!(
DirectHostfileManager::stranded_in_helper_section(§ion, &["7"], &handed),
vec!["7"]
);
let handed = attributable_lines(&history, &[], std::slice::from_ref(&shared));
assert!(
DirectHostfileManager::stranded_in_helper_section(§ion, &["7"], &handed).is_empty()
);
}
#[test]
fn a_configuration_names_the_aliases_its_removal_must_verify() {
use kftray_commons::models::config_model::Config;
let config = Config {
id: Some(41),
alias: Some("app.local".to_owned()),
local_address: Some("127.0.0.7".to_owned()),
domain_enabled: Some(true),
service: Some("app".to_owned()),
protocol: "tcp".to_owned(),
..Config::default()
};
let ssl_written = HashSet::from(["41-https".to_owned(), "41-https-local".to_owned()]);
let entries = config_host_entries(41, Some(&config), &ssl_written);
let ids: Vec<String> = entries.iter().map(|(id, _)| id.clone()).collect();
let ids: Vec<&str> = ids.iter().map(String::as_str).collect();
assert_eq!(ids, vec!["41", "41-https", "41-https-local"]);
let plain = Config {
domain_enabled: Some(false),
protocol: "udp".to_owned(),
..config.clone()
};
assert!(config_host_entries(41, Some(&plain), &ssl_written).is_empty());
assert_eq!(
entries[0].1.ip,
"127.0.0.7".parse::<std::net::IpAddr>().unwrap()
);
assert_eq!(entries[2].1.hostname, "app.local.local");
let section = vec![kftray_commons::utils::hostsfile::SectionEntry {
ip: "127.0.0.7".parse().unwrap(),
hostname: "app.local".to_owned(),
owner: None,
}];
let handed: HashSet<(String, HostEntry)> = entries.into_iter().collect();
assert_eq!(
DirectHostfileManager::stranded_in_helper_section(§ion, &ids, &handed),
vec!["41", "41-https"]
);
assert!(
DirectHostfileManager::stranded_in_helper_section(§ion, &ids, &HashSet::new())
.is_empty(),
"without the configuration the same line is unattributable"
);
}
#[test]
fn https_aliases_are_claimed_only_for_ids_this_run_actually_wrote() {
use kftray_commons::models::config_model::Config;
let config = Config {
id: Some(44),
alias: Some("plain.local".to_owned()),
protocol: "tcp".to_owned(),
..Config::default()
};
assert!(
config_host_entries(44, Some(&config), &HashSet::new()).is_empty(),
"an id whose HTTPS aliases were never written must not claim any lines"
);
let written = HashSet::from(["44-https".to_owned()]);
let entries = config_host_entries(44, Some(&config), &written);
let ids: Vec<&str> = entries.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, vec!["44-https"]);
}
#[test]
fn https_aliases_point_at_the_address_the_forward_listens_on() {
use kftray_commons::models::config_model::Config;
let config = Config {
id: Some(7),
alias: Some("api".to_owned()),
service: Some("api".to_owned()),
protocol: "tcp".to_owned(),
local_address: Some("127.0.0.2".to_owned()),
..Config::default()
};
let written = HashSet::from(["7-https".to_owned(), "7-https-local".to_owned()]);
let entries = config_host_entries(7, Some(&config), &written);
let listener: std::net::IpAddr = "127.0.0.2".parse().unwrap();
assert_eq!(
entries
.iter()
.map(|(id, entry)| (id.as_str(), entry.ip, entry.hostname.as_str()))
.collect::<Vec<_>>(),
vec![
("7-https", listener, "api"),
("7-https-local", listener, "api.local"),
]
);
let default_address = Config {
local_address: None,
..config
};
let entries = config_host_entries(7, Some(&default_address), &written);
let loopback: std::net::IpAddr = "127.0.0.1".parse().unwrap();
assert!(entries.iter().all(|(_, entry)| entry.ip == loopback));
assert_eq!(entries.len(), 2);
}
#[test]
fn a_manager_without_a_helper_never_reaches_one() {
init();
let manager = HostfileManager::without_helper();
assert!(
manager.helper().is_none(),
"the direct-fallback seam must not resolve a helper, whatever is installed on the \
machine running the test"
);
}
#[tokio::test]
async fn a_restart_forgets_a_persisted_id_that_never_reached_disk() {
let _db = kftray_commons::test_utils::test_db().await;
let mode = DatabaseMode::Memory;
persist_ssl_id_written("77-https", mode).await;
let manager = HostfileManager::without_helper();
let ids = manager.ssl_ids_written_including_persisted(mode).await;
assert!(
!ids.contains("77-https"),
"a persisted id with no matching line on disk must not be attributed"
);
assert!(
!persisted_ssl_ids_written(mode).await.contains("77-https"),
"a stale persisted id must be forgotten once it fails on-disk verification"
);
}
#[test]
fn persisted_ssl_ids_are_kept_only_when_a_matching_line_is_actually_on_disk() {
use kftray_commons::utils::hostsfile::SectionEntry;
let helper_section = vec![SectionEntry {
ip: "127.0.0.1".parse().unwrap(),
hostname: "app.local".to_owned(),
owner: Some("41-https".to_owned()),
}];
let direct_section = vec![SectionEntry {
ip: "127.0.0.1".parse().unwrap(),
hostname: "app.local.local".to_owned(),
owner: Some("41-https-local".to_owned()),
}];
let persisted = HashSet::from([
"41-https".to_owned(),
"41-https-local".to_owned(),
"99-https".to_owned(),
]);
let (verified, stale) = persisted_ids_verified_on_disk(
persisted,
&helper_section,
&direct_section,
&HashMap::new(),
);
assert_eq!(
verified,
HashSet::from(["41-https".to_owned(), "41-https-local".to_owned()])
);
assert_eq!(stale, HashSet::from(["99-https".to_owned()]));
}
#[test]
fn an_unmarked_line_matching_the_expected_mapping_verifies_a_persisted_id() {
use kftray_commons::utils::hostsfile::SectionEntry;
let helper_section = vec![SectionEntry {
ip: "127.0.0.1".parse().unwrap(),
hostname: "app.local".to_owned(),
owner: None,
}];
let direct_section = Vec::new();
let persisted = HashSet::from(["41-https".to_owned(), "99-https".to_owned()]);
let expected = HashMap::from([(
"41-https".to_owned(),
HostEntry {
ip: "127.0.0.1".parse().unwrap(),
hostname: "app.local".to_owned(),
},
)]);
let (verified, stale) =
persisted_ids_verified_on_disk(persisted, &helper_section, &direct_section, &expected);
assert_eq!(
verified,
HashSet::from(["41-https".to_owned()]),
"an unmarked line matching the expected ip and hostname must still verify the id"
);
assert_eq!(
stale,
HashSet::from(["99-https".to_owned()]),
"an id with no expected mapping and no marked line stays stale"
);
}
#[tokio::test]
async fn persisted_ssl_ids_survive_a_failed_second_write_whose_rollback_also_fails() {
let _db = kftray_commons::test_utils::test_db().await;
let mode = DatabaseMode::Memory;
persist_ssl_id_written("81-https", mode).await;
persist_ssl_id_written("81-https-local", mode).await;
settle_ssl_write_failure("81-https", "81-https-local", true, mode).await;
let ids = persisted_ssl_ids_written(mode).await;
assert!(
ids.contains("81-https") && ids.contains("81-https-local"),
"a leftover that could not be rolled back must stay attributable so a later stop \
can remove it"
);
forget_ssl_id_written("81-https", mode).await;
forget_ssl_id_written("81-https-local", mode).await;
}
#[tokio::test]
async fn persisted_ssl_ids_are_forgotten_once_a_failed_writes_rollback_is_verified() {
let _db = kftray_commons::test_utils::test_db().await;
let mode = DatabaseMode::Memory;
persist_ssl_id_written("82-https", mode).await;
persist_ssl_id_written("82-https-local", mode).await;
settle_ssl_write_failure("82-https", "82-https-local", false, mode).await;
let ids = persisted_ssl_ids_written(mode).await;
assert!(
!ids.contains("82-https") && !ids.contains("82-https-local"),
"a verified rollback must not leave a durable record behind"
);
}
#[test]
fn stranded_fallback_only_stays_sticky_on_permission_denied() {
assert_eq!(
stranded_fallback_outcome(&Ok(())),
StrandedFallbackOutcome::Resolved
);
assert_eq!(
stranded_fallback_outcome(&Err(std::io::Error::from(
std::io::ErrorKind::PermissionDenied
))),
StrandedFallbackOutcome::Sticky,
"a process that truly cannot write the hosts file must not be retried"
);
match stranded_fallback_outcome(&Err(std::io::Error::other("disk full"))) {
StrandedFallbackOutcome::Retryable(message) => {
assert!(message.contains("disk full"))
}
other => panic!("expected Retryable, got {other:?}"),
}
}
#[test]
fn this_run_remembers_what_it_wrote_under_an_id() {
let entry = HostEntry {
ip: "127.0.0.1".parse().unwrap(),
hostname: "app.local".to_owned(),
};
let manager = HostfileManager::without_helper();
let ids = [
"51".to_owned(),
"51-https".to_owned(),
"51-https-local".to_owned(),
];
assert!(!manager.wrote_any(&ids));
manager
.handed_to_helper
.lock()
.unwrap()
.insert(("51".to_owned(), entry));
assert!(manager.wrote_any(&ids));
assert!(!manager.wrote_any(&["52".to_owned()]));
let manager = HostfileManager::without_helper();
manager
.ssl_ids_written
.lock()
.unwrap()
.insert("51-https-local".to_owned());
assert!(manager.wrote_any(&ids));
}
#[tokio::test]
async fn an_alias_is_ruled_out_only_by_evidence_that_none_can_exist() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let id = 410_190;
let config = kftray_commons::models::config_model::Config {
id: Some(id),
service: Some("app".to_owned()),
alias: Some("app.local".to_owned()),
domain_enabled: Some(false),
protocol: "tcp".to_owned(),
..Default::default()
};
assert!(!alias_may_exist(id, Some(&config), mode).await);
assert!(
alias_may_exist(id, None, mode).await,
"without the configuration nothing rules an alias out"
);
let domain = kftray_commons::models::config_model::Config {
domain_enabled: Some(true),
..config.clone()
};
assert!(alias_may_exist(id, Some(&domain), mode).await);
persist_ssl_id_written(&format!("{id}-https"), mode).await;
assert!(
alias_may_exist(id, Some(&config), mode).await,
"a persisted HTTPS id is a write whose line may be on disk"
);
forget_ssl_id_written(&format!("{id}-https"), mode).await;
assert!(!alias_may_exist(id, Some(&config), mode).await);
}
#[tokio::test]
async fn an_unreadable_running_snapshot_keeps_the_hosts_verification() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let dir = tempfile::tempdir().unwrap();
let _env = kftray_commons::test_utils::EnvVarGuard::set(
"KFTRAY_CONFIG",
dir.path().to_str().unwrap(),
);
kftray_commons::utils::db::init().await.unwrap();
let _hosts =
kftray_commons::test_utils::HostsFileGuard::point_at(&dir.path().join("hosts"));
let mode = DatabaseMode::File;
let id = 410_192;
let config = kftray_commons::models::config_model::Config {
id: Some(id),
service: Some("app".to_owned()),
alias: Some("app.local".to_owned()),
domain_enabled: Some(false),
protocol: "tcp".to_owned(),
..Default::default()
};
rollback_config_host_entries(id, Some(&config), &[], mode)
.await
.expect("with no snapshot recorded, the missing hosts file is never opened");
let key = kftray_commons::utils::config_state::running_snapshot_key(id, mode);
kftray_commons::utils::settings::set_setting_with_mode(&key, "{not a config", mode)
.await
.unwrap();
let result = rollback_config_host_entries(id, Some(&config), &[], mode).await;
kftray_commons::utils::settings::delete_setting_with_mode(&key, mode)
.await
.unwrap();
result.expect_err(
"a snapshot that cannot be read may name an alias, so the hosts file is verified",
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_hosts_file_that_cannot_be_opened_still_fails_the_rollback_of_a_written_alias() {
use std::os::unix::fs::PermissionsExt;
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let dir = tempfile::tempdir().unwrap();
let hosts = dir.path().join("hosts");
std::fs::write(&hosts, "127.0.0.1 localhost\n").unwrap();
std::fs::set_permissions(&hosts, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::File::open(&hosts).is_ok() {
return;
}
let _hosts = kftray_commons::test_utils::HostsFileGuard::point_at(&hosts);
let config = kftray_commons::models::config_model::Config {
id: Some(410_191),
service: Some("app".to_owned()),
alias: Some("app.local".to_owned()),
domain_enabled: Some(true),
protocol: "tcp".to_owned(),
..Default::default()
};
let error = rollback_config_host_entries(410_191, Some(&config), &[], DatabaseMode::Memory)
.await
.expect_err("a refused permission must stay a failure");
assert_eq!(
error.kind(),
std::io::ErrorKind::PermissionDenied,
"{error}"
);
}
}