use crate::config::PeerConfig;
use crate::{NodeAddr, PeerIdentity};
use std::collections::HashMap;
use std::path::Path;
use std::time::SystemTime;
use tracing::{debug, info, warn};
#[cfg(unix)]
pub const DEFAULT_HOSTS_PATH: &str = "/etc/fips/hosts";
#[cfg(windows)]
pub const DEFAULT_HOSTS_PATH: &str = r"C:\ProgramData\fips\hosts";
#[derive(Debug, Clone, Default)]
pub struct HostMap {
by_name: HashMap<String, String>,
by_addr: HashMap<NodeAddr, String>,
}
#[derive(Debug, thiserror::Error)]
pub enum HostMapError {
#[error("invalid hostname '{hostname}': {reason}")]
InvalidHostname { hostname: String, reason: String },
#[error("invalid npub '{npub}': {source}")]
InvalidNpub {
npub: String,
source: crate::IdentityError,
},
#[error("I/O error reading {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("{path}:{line}: {reason}")]
Parse {
path: String,
line: usize,
reason: String,
},
}
impl HostMap {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, hostname: &str, npub: &str) -> Result<(), HostMapError> {
validate_hostname(hostname)?;
let peer = PeerIdentity::from_npub(npub).map_err(|e| HostMapError::InvalidNpub {
npub: npub.to_string(),
source: e,
})?;
let key = hostname.to_ascii_lowercase();
self.by_name.insert(key.clone(), npub.to_string());
self.by_addr.insert(*peer.node_addr(), key);
Ok(())
}
pub fn lookup_npub(&self, hostname: &str) -> Option<&str> {
self.by_name
.get(&hostname.to_ascii_lowercase())
.map(|s| s.as_str())
}
pub fn lookup_hostname(&self, node_addr: &NodeAddr) -> Option<&str> {
self.by_addr.get(node_addr).map(|s| s.as_str())
}
pub fn len(&self) -> usize {
self.by_name.len()
}
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
pub fn from_peer_configs(peers: &[PeerConfig]) -> Self {
let mut map = Self::new();
for peer in peers {
if let Some(alias) = &peer.alias
&& let Err(e) = map.insert(alias, &peer.npub)
{
warn!(alias = %alias, npub = %peer.npub, error = %e, "Skipping invalid peer alias for host map");
}
}
if !map.is_empty() {
debug!(count = map.len(), "Host map entries from peer config");
}
map
}
pub fn load_hosts_file(path: &Path) -> Self {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!(path = %path.display(), "No hosts file found, skipping");
return Self::new();
}
Err(e) => {
warn!(path = %path.display(), error = %e, "Failed to read hosts file");
return Self::new();
}
};
let mut map = Self::new();
for (line_num, line) in contents.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let fields: Vec<&str> = trimmed.split_whitespace().collect();
if fields.len() != 2 {
warn!(
path = %path.display(),
line = line_num + 1,
content = %trimmed,
"Expected 'hostname npub', skipping"
);
continue;
}
let hostname = fields[0];
let npub = fields[1];
if let Err(e) = map.insert(hostname, npub) {
warn!(
path = %path.display(),
line = line_num + 1,
error = %e,
"Skipping invalid hosts file entry"
);
}
}
if !map.is_empty() {
info!(path = %path.display(), count = map.len(), "Loaded hosts file");
}
map
}
pub fn merge(&mut self, other: HostMap) {
for (name, npub) in other.by_name {
self.by_name.insert(name, npub);
}
for (addr, name) in other.by_addr {
self.by_addr.insert(addr, name);
}
}
}
pub fn file_mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
}
pub struct HostMapReloader {
base: HostMap,
effective: HostMap,
file_backed: bool,
path: std::path::PathBuf,
last_mtime: Option<SystemTime>,
}
impl HostMapReloader {
pub fn new(base: HostMap, path: std::path::PathBuf) -> Self {
let last_mtime = file_mtime(&path);
let hosts_file = HostMap::load_hosts_file(&path);
let mut effective = base.clone();
effective.merge(hosts_file);
Self {
base,
effective,
file_backed: true,
path,
last_mtime,
}
}
pub fn memory_only(base: HostMap) -> Self {
Self {
effective: base.clone(),
base,
file_backed: false,
path: std::path::PathBuf::new(),
last_mtime: None,
}
}
pub fn hosts(&self) -> &HostMap {
&self.effective
}
pub fn check_reload(&mut self) -> bool {
if !self.file_backed {
return false;
}
let current_mtime = file_mtime(&self.path);
if current_mtime == self.last_mtime {
return false;
}
self.last_mtime = current_mtime;
let hosts_file = HostMap::load_hosts_file(&self.path);
let mut new_effective = self.base.clone();
new_effective.merge(hosts_file);
let count = new_effective.len();
self.effective = new_effective;
info!(
path = %self.path.display(),
entries = count,
"Reloaded hosts file"
);
true
}
}
pub fn validate_hostname(hostname: &str) -> Result<(), HostMapError> {
let err = |reason: &str| HostMapError::InvalidHostname {
hostname: hostname.to_string(),
reason: reason.to_string(),
};
if hostname.is_empty() {
return Err(err("empty hostname"));
}
if hostname.len() > 63 {
return Err(err("exceeds 63 characters"));
}
if hostname.to_ascii_lowercase().starts_with("npub1") {
return Err(err(
"must not start with 'npub1' (ambiguous with npub resolution)",
));
}
if hostname.starts_with('-') {
return Err(err("must not start with a hyphen"));
}
if hostname.ends_with('-') {
return Err(err("must not end with a hyphen"));
}
for ch in hostname.chars() {
if !ch.is_ascii_alphanumeric() && ch != '-' {
return Err(err(&format!("invalid character '{ch}'")));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Identity;
#[test]
fn test_valid_hostnames() {
let valid = [
"gateway",
"core-vm",
"a",
"node1",
"my-peer-2",
"A",
"GATEWAY",
"a1b2c3",
&"x".repeat(63),
];
for h in valid {
assert!(validate_hostname(h).is_ok(), "should be valid: {h}");
}
}
#[test]
fn test_invalid_hostnames() {
let cases = [
("", "empty"),
("-starts", "starts with hyphen"),
("ends-", "ends with hyphen"),
("has space", "space"),
("has.dot", "dot"),
("has_underscore", "underscore"),
(&"x".repeat(64), "too long"),
("npub1foo", "npub1 prefix"),
("NPUB1bar", "npub1 prefix case"),
];
for (h, desc) in cases {
assert!(
validate_hostname(h).is_err(),
"should be invalid ({desc}): {h}"
);
}
}
#[test]
fn test_insert_and_lookup() {
let id = Identity::generate();
let npub = id.npub();
let mut map = HostMap::new();
map.insert("gateway", &npub).unwrap();
assert_eq!(map.lookup_npub("gateway"), Some(npub.as_str()));
assert_eq!(map.lookup_npub("GATEWAY"), Some(npub.as_str()));
assert_eq!(map.lookup_npub("Gateway"), Some(npub.as_str()));
assert_eq!(map.lookup_hostname(id.node_addr()), Some("gateway"));
assert_eq!(map.len(), 1);
}
#[test]
fn test_insert_invalid_hostname() {
let id = Identity::generate();
let mut map = HostMap::new();
assert!(map.insert("", &id.npub()).is_err());
assert!(map.is_empty());
}
#[test]
fn test_insert_invalid_npub() {
let mut map = HostMap::new();
assert!(map.insert("gateway", "not-an-npub").is_err());
assert!(map.is_empty());
}
#[test]
fn test_insert_duplicate_overwrites() {
let id1 = Identity::generate();
let id2 = Identity::generate();
let mut map = HostMap::new();
map.insert("gateway", &id1.npub()).unwrap();
map.insert("gateway", &id2.npub()).unwrap();
assert_eq!(map.lookup_npub("gateway"), Some(id2.npub().as_str()));
assert_eq!(map.len(), 1);
}
#[test]
fn test_lookup_missing() {
let map = HostMap::new();
assert!(map.lookup_npub("nonexistent").is_none());
}
#[test]
fn test_from_peer_configs_with_alias() {
let id = Identity::generate();
let peers = vec![PeerConfig {
npub: id.npub(),
alias: Some("core".to_string()),
..Default::default()
}];
let map = HostMap::from_peer_configs(&peers);
assert_eq!(map.lookup_npub("core"), Some(id.npub().as_str()));
}
#[test]
fn test_from_peer_configs_without_alias() {
let id = Identity::generate();
let peers = vec![PeerConfig {
npub: id.npub(),
alias: None,
..Default::default()
}];
let map = HostMap::from_peer_configs(&peers);
assert!(map.is_empty());
}
#[test]
fn test_from_peer_configs_invalid_alias_skipped() {
let id = Identity::generate();
let peers = vec![PeerConfig {
npub: id.npub(),
alias: Some("has space".to_string()),
..Default::default()
}];
let map = HostMap::from_peer_configs(&peers);
assert!(map.is_empty());
}
#[test]
fn test_load_hosts_file_not_found() {
let map = HostMap::load_hosts_file(Path::new("/nonexistent/path/hosts"));
assert!(map.is_empty());
}
#[test]
fn test_load_hosts_file_valid() {
let id1 = Identity::generate();
let id2 = Identity::generate();
let content = format!(
"# A comment\n\
gateway {}\n\
\n\
# Another comment\n\
core-vm {}\n",
id1.npub(),
id2.npub()
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hosts");
std::fs::write(&path, content).unwrap();
let map = HostMap::load_hosts_file(&path);
assert_eq!(map.len(), 2);
assert_eq!(map.lookup_npub("gateway"), Some(id1.npub().as_str()));
assert_eq!(map.lookup_npub("core-vm"), Some(id2.npub().as_str()));
}
#[test]
fn test_load_hosts_file_skips_bad_lines() {
let id = Identity::generate();
let content = format!(
"gateway {}\n\
bad_host {}\n\
too many fields here\n\
good-host {}\n",
id.npub(),
id.npub(),
id.npub()
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hosts");
std::fs::write(&path, content).unwrap();
let map = HostMap::load_hosts_file(&path);
assert_eq!(map.len(), 2);
assert!(map.lookup_npub("gateway").is_some());
assert!(map.lookup_npub("good-host").is_some());
}
#[test]
fn test_load_hosts_file_whitespace_handling() {
let id = Identity::generate();
let content = format!(
" # indented comment \n\
\t gateway \t {} \t \n\
\n\
\t \n",
id.npub()
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hosts");
std::fs::write(&path, content).unwrap();
let map = HostMap::load_hosts_file(&path);
assert_eq!(map.len(), 1);
assert!(map.lookup_npub("gateway").is_some());
}
#[test]
fn test_merge_non_overlapping() {
let id1 = Identity::generate();
let id2 = Identity::generate();
let mut map1 = HostMap::new();
map1.insert("alpha", &id1.npub()).unwrap();
let mut map2 = HostMap::new();
map2.insert("beta", &id2.npub()).unwrap();
map1.merge(map2);
assert_eq!(map1.len(), 2);
assert!(map1.lookup_npub("alpha").is_some());
assert!(map1.lookup_npub("beta").is_some());
}
#[test]
fn test_merge_overlapping_other_wins() {
let id1 = Identity::generate();
let id2 = Identity::generate();
let mut map1 = HostMap::new();
map1.insert("gateway", &id1.npub()).unwrap();
let mut map2 = HostMap::new();
map2.insert("gateway", &id2.npub()).unwrap();
map1.merge(map2);
assert_eq!(map1.len(), 1);
assert_eq!(map1.lookup_npub("gateway"), Some(id2.npub().as_str()));
}
#[test]
fn test_reloader_initial_load() {
let id1 = Identity::generate();
let id2 = Identity::generate();
let mut base = HostMap::new();
base.insert("core", &id1.npub()).unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hosts");
std::fs::write(&path, format!("gateway {}\n", id2.npub())).unwrap();
let reloader = HostMapReloader::new(base, path);
assert_eq!(reloader.hosts().len(), 2);
assert!(reloader.hosts().lookup_npub("core").is_some());
assert!(reloader.hosts().lookup_npub("gateway").is_some());
}
#[test]
fn test_reloader_no_hosts_file() {
let id = Identity::generate();
let mut base = HostMap::new();
base.insert("core", &id.npub()).unwrap();
let reloader = HostMapReloader::new(base, std::path::PathBuf::from("/nonexistent/hosts"));
assert_eq!(reloader.hosts().len(), 1);
assert!(reloader.hosts().lookup_npub("core").is_some());
}
#[test]
fn test_reloader_detects_file_change() {
let id1 = Identity::generate();
let id2 = Identity::generate();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hosts");
std::fs::write(&path, format!("gateway {}\n", id1.npub())).unwrap();
let mut reloader = HostMapReloader::new(HostMap::new(), path.clone());
assert_eq!(reloader.hosts().len(), 1);
assert_eq!(
reloader.hosts().lookup_npub("gateway"),
Some(id1.npub().as_str())
);
assert!(!reloader.check_reload());
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(
&path,
format!("gateway {}\nnew-host {}\n", id1.npub(), id2.npub()),
)
.unwrap();
assert!(reloader.check_reload());
assert_eq!(reloader.hosts().len(), 2);
assert!(reloader.hosts().lookup_npub("new-host").is_some());
}
#[test]
fn test_reloader_detects_file_deletion() {
let id = Identity::generate();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hosts");
std::fs::write(&path, format!("gateway {}\n", id.npub())).unwrap();
let mut reloader = HostMapReloader::new(HostMap::new(), path.clone());
assert_eq!(reloader.hosts().len(), 1);
std::fs::remove_file(&path).unwrap();
assert!(reloader.check_reload());
assert!(reloader.hosts().is_empty());
}
#[test]
fn test_reloader_detects_file_creation() {
let id = Identity::generate();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hosts");
let mut reloader = HostMapReloader::new(HostMap::new(), path.clone());
assert!(reloader.hosts().is_empty());
std::fs::write(&path, format!("gateway {}\n", id.npub())).unwrap();
assert!(reloader.check_reload());
assert_eq!(reloader.hosts().len(), 1);
assert!(reloader.hosts().lookup_npub("gateway").is_some());
}
#[test]
fn test_reloader_preserves_base_on_reload() {
let id_base = Identity::generate();
let id_file = Identity::generate();
let mut base = HostMap::new();
base.insert("core", &id_base.npub()).unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hosts");
std::fs::write(&path, format!("gateway {}\n", id_file.npub())).unwrap();
let mut reloader = HostMapReloader::new(base, path.clone());
assert_eq!(reloader.hosts().len(), 2);
std::fs::remove_file(&path).unwrap();
assert!(reloader.check_reload());
assert_eq!(reloader.hosts().len(), 1);
assert!(reloader.hosts().lookup_npub("core").is_some());
assert!(reloader.hosts().lookup_npub("gateway").is_none());
}
}