1use crate::config::PeerConfig;
13use crate::{NodeAddr, PeerIdentity};
14use std::collections::HashMap;
15use std::path::Path;
16use std::time::SystemTime;
17use tracing::{debug, info, warn};
18
19#[cfg(unix)]
21pub const DEFAULT_HOSTS_PATH: &str = "/etc/fips/hosts";
22#[cfg(windows)]
23pub const DEFAULT_HOSTS_PATH: &str = r"C:\ProgramData\fips\hosts";
24
25#[derive(Debug, Clone, Default)]
27pub struct HostMap {
28 by_name: HashMap<String, String>,
30 by_addr: HashMap<NodeAddr, String>,
32}
33
34#[derive(Debug, thiserror::Error)]
36pub enum HostMapError {
37 #[error("invalid hostname '{hostname}': {reason}")]
38 InvalidHostname { hostname: String, reason: String },
39
40 #[error("invalid npub '{npub}': {source}")]
41 InvalidNpub {
42 npub: String,
43 source: crate::IdentityError,
44 },
45
46 #[error("I/O error reading {path}: {source}")]
47 Io {
48 path: String,
49 source: std::io::Error,
50 },
51
52 #[error("{path}:{line}: {reason}")]
53 Parse {
54 path: String,
55 line: usize,
56 reason: String,
57 },
58}
59
60impl HostMap {
61 pub fn new() -> Self {
63 Self::default()
64 }
65
66 pub fn insert(&mut self, hostname: &str, npub: &str) -> Result<(), HostMapError> {
71 validate_hostname(hostname)?;
72
73 let peer = PeerIdentity::from_npub(npub).map_err(|e| HostMapError::InvalidNpub {
74 npub: npub.to_string(),
75 source: e,
76 })?;
77
78 let key = hostname.to_ascii_lowercase();
79 self.by_name.insert(key.clone(), npub.to_string());
80 self.by_addr.insert(*peer.node_addr(), key);
81 Ok(())
82 }
83
84 pub fn lookup_npub(&self, hostname: &str) -> Option<&str> {
86 self.by_name
87 .get(&hostname.to_ascii_lowercase())
88 .map(|s| s.as_str())
89 }
90
91 pub fn lookup_hostname(&self, node_addr: &NodeAddr) -> Option<&str> {
93 self.by_addr.get(node_addr).map(|s| s.as_str())
94 }
95
96 pub fn len(&self) -> usize {
98 self.by_name.len()
99 }
100
101 pub fn is_empty(&self) -> bool {
103 self.by_name.is_empty()
104 }
105
106 pub fn from_peer_configs(peers: &[PeerConfig]) -> Self {
111 let mut map = Self::new();
112 for peer in peers {
113 if let Some(alias) = &peer.alias
114 && let Err(e) = map.insert(alias, &peer.npub)
115 {
116 warn!(alias = %alias, npub = %peer.npub, error = %e, "Skipping invalid peer alias for host map");
117 }
118 }
119 if !map.is_empty() {
120 debug!(count = map.len(), "Host map entries from peer config");
121 }
122 map
123 }
124
125 pub fn load_hosts_file(path: &Path) -> Self {
130 let contents = match std::fs::read_to_string(path) {
131 Ok(c) => c,
132 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
133 debug!(path = %path.display(), "No hosts file found, skipping");
134 return Self::new();
135 }
136 Err(e) => {
137 warn!(path = %path.display(), error = %e, "Failed to read hosts file");
138 return Self::new();
139 }
140 };
141
142 let mut map = Self::new();
143 for (line_num, line) in contents.lines().enumerate() {
144 let trimmed = line.trim();
145
146 if trimmed.is_empty() || trimmed.starts_with('#') {
148 continue;
149 }
150
151 let fields: Vec<&str> = trimmed.split_whitespace().collect();
152 if fields.len() != 2 {
153 warn!(
154 path = %path.display(),
155 line = line_num + 1,
156 content = %trimmed,
157 "Expected 'hostname npub', skipping"
158 );
159 continue;
160 }
161
162 let hostname = fields[0];
163 let npub = fields[1];
164
165 if let Err(e) = map.insert(hostname, npub) {
166 warn!(
167 path = %path.display(),
168 line = line_num + 1,
169 error = %e,
170 "Skipping invalid hosts file entry"
171 );
172 }
173 }
174
175 if !map.is_empty() {
176 info!(path = %path.display(), count = map.len(), "Loaded hosts file");
177 }
178 map
179 }
180
181 pub fn merge(&mut self, other: HostMap) {
183 for (name, npub) in other.by_name {
184 self.by_name.insert(name, npub);
185 }
186 for (addr, name) in other.by_addr {
187 self.by_addr.insert(addr, name);
188 }
189 }
190}
191
192pub fn file_mtime(path: &Path) -> Option<SystemTime> {
195 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
196}
197
198pub struct HostMapReloader {
204 base: HostMap,
206 effective: HostMap,
208 file_backed: bool,
210 path: std::path::PathBuf,
212 last_mtime: Option<SystemTime>,
214}
215
216impl HostMapReloader {
217 pub fn new(base: HostMap, path: std::path::PathBuf) -> Self {
221 let last_mtime = file_mtime(&path);
222 let hosts_file = HostMap::load_hosts_file(&path);
223 let mut effective = base.clone();
224 effective.merge(hosts_file);
225
226 Self {
227 base,
228 effective,
229 file_backed: true,
230 path,
231 last_mtime,
232 }
233 }
234
235 pub fn memory_only(base: HostMap) -> Self {
240 Self {
241 effective: base.clone(),
242 base,
243 file_backed: false,
244 path: std::path::PathBuf::new(),
245 last_mtime: None,
246 }
247 }
248
249 pub fn hosts(&self) -> &HostMap {
251 &self.effective
252 }
253
254 pub fn check_reload(&mut self) -> bool {
258 if !self.file_backed {
259 return false;
260 }
261
262 let current_mtime = file_mtime(&self.path);
263
264 if current_mtime == self.last_mtime {
265 return false;
266 }
267
268 self.last_mtime = current_mtime;
270 let hosts_file = HostMap::load_hosts_file(&self.path);
271 let mut new_effective = self.base.clone();
272 new_effective.merge(hosts_file);
273
274 let count = new_effective.len();
275 self.effective = new_effective;
276
277 info!(
278 path = %self.path.display(),
279 entries = count,
280 "Reloaded hosts file"
281 );
282 true
283 }
284}
285
286pub fn validate_hostname(hostname: &str) -> Result<(), HostMapError> {
294 let err = |reason: &str| HostMapError::InvalidHostname {
295 hostname: hostname.to_string(),
296 reason: reason.to_string(),
297 };
298
299 if hostname.is_empty() {
300 return Err(err("empty hostname"));
301 }
302
303 if hostname.len() > 63 {
304 return Err(err("exceeds 63 characters"));
305 }
306
307 if hostname.to_ascii_lowercase().starts_with("npub1") {
308 return Err(err(
309 "must not start with 'npub1' (ambiguous with npub resolution)",
310 ));
311 }
312
313 if hostname.starts_with('-') {
314 return Err(err("must not start with a hyphen"));
315 }
316
317 if hostname.ends_with('-') {
318 return Err(err("must not end with a hyphen"));
319 }
320
321 for ch in hostname.chars() {
322 if !ch.is_ascii_alphanumeric() && ch != '-' {
323 return Err(err(&format!("invalid character '{ch}'")));
324 }
325 }
326
327 Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::Identity;
334
335 #[test]
338 fn test_valid_hostnames() {
339 let valid = [
340 "gateway",
341 "core-vm",
342 "a",
343 "node1",
344 "my-peer-2",
345 "A",
346 "GATEWAY",
347 "a1b2c3",
348 &"x".repeat(63),
349 ];
350 for h in valid {
351 assert!(validate_hostname(h).is_ok(), "should be valid: {h}");
352 }
353 }
354
355 #[test]
356 fn test_invalid_hostnames() {
357 let cases = [
358 ("", "empty"),
359 ("-starts", "starts with hyphen"),
360 ("ends-", "ends with hyphen"),
361 ("has space", "space"),
362 ("has.dot", "dot"),
363 ("has_underscore", "underscore"),
364 (&"x".repeat(64), "too long"),
365 ("npub1foo", "npub1 prefix"),
366 ("NPUB1bar", "npub1 prefix case"),
367 ];
368 for (h, desc) in cases {
369 assert!(
370 validate_hostname(h).is_err(),
371 "should be invalid ({desc}): {h}"
372 );
373 }
374 }
375
376 #[test]
379 fn test_insert_and_lookup() {
380 let id = Identity::generate();
381 let npub = id.npub();
382
383 let mut map = HostMap::new();
384 map.insert("gateway", &npub).unwrap();
385
386 assert_eq!(map.lookup_npub("gateway"), Some(npub.as_str()));
387 assert_eq!(map.lookup_npub("GATEWAY"), Some(npub.as_str()));
388 assert_eq!(map.lookup_npub("Gateway"), Some(npub.as_str()));
389 assert_eq!(map.lookup_hostname(id.node_addr()), Some("gateway"));
390 assert_eq!(map.len(), 1);
391 }
392
393 #[test]
394 fn test_insert_invalid_hostname() {
395 let id = Identity::generate();
396 let mut map = HostMap::new();
397 assert!(map.insert("", &id.npub()).is_err());
398 assert!(map.is_empty());
399 }
400
401 #[test]
402 fn test_insert_invalid_npub() {
403 let mut map = HostMap::new();
404 assert!(map.insert("gateway", "not-an-npub").is_err());
405 assert!(map.is_empty());
406 }
407
408 #[test]
409 fn test_insert_duplicate_overwrites() {
410 let id1 = Identity::generate();
411 let id2 = Identity::generate();
412
413 let mut map = HostMap::new();
414 map.insert("gateway", &id1.npub()).unwrap();
415 map.insert("gateway", &id2.npub()).unwrap();
416
417 assert_eq!(map.lookup_npub("gateway"), Some(id2.npub().as_str()));
418 assert_eq!(map.len(), 1);
419 }
420
421 #[test]
422 fn test_lookup_missing() {
423 let map = HostMap::new();
424 assert!(map.lookup_npub("nonexistent").is_none());
425 }
426
427 #[test]
430 fn test_from_peer_configs_with_alias() {
431 let id = Identity::generate();
432 let peers = vec![PeerConfig {
433 npub: id.npub(),
434 alias: Some("core".to_string()),
435 ..Default::default()
436 }];
437
438 let map = HostMap::from_peer_configs(&peers);
439 assert_eq!(map.lookup_npub("core"), Some(id.npub().as_str()));
440 }
441
442 #[test]
443 fn test_from_peer_configs_without_alias() {
444 let id = Identity::generate();
445 let peers = vec![PeerConfig {
446 npub: id.npub(),
447 alias: None,
448 ..Default::default()
449 }];
450
451 let map = HostMap::from_peer_configs(&peers);
452 assert!(map.is_empty());
453 }
454
455 #[test]
456 fn test_from_peer_configs_invalid_alias_skipped() {
457 let id = Identity::generate();
458 let peers = vec![PeerConfig {
459 npub: id.npub(),
460 alias: Some("has space".to_string()),
461 ..Default::default()
462 }];
463
464 let map = HostMap::from_peer_configs(&peers);
465 assert!(map.is_empty());
466 }
467
468 #[test]
471 fn test_load_hosts_file_not_found() {
472 let map = HostMap::load_hosts_file(Path::new("/nonexistent/path/hosts"));
473 assert!(map.is_empty());
474 }
475
476 #[test]
477 fn test_load_hosts_file_valid() {
478 let id1 = Identity::generate();
479 let id2 = Identity::generate();
480 let content = format!(
481 "# A comment\n\
482 gateway {}\n\
483 \n\
484 # Another comment\n\
485 core-vm {}\n",
486 id1.npub(),
487 id2.npub()
488 );
489
490 let dir = tempfile::tempdir().unwrap();
491 let path = dir.path().join("hosts");
492 std::fs::write(&path, content).unwrap();
493
494 let map = HostMap::load_hosts_file(&path);
495 assert_eq!(map.len(), 2);
496 assert_eq!(map.lookup_npub("gateway"), Some(id1.npub().as_str()));
497 assert_eq!(map.lookup_npub("core-vm"), Some(id2.npub().as_str()));
498 }
499
500 #[test]
501 fn test_load_hosts_file_skips_bad_lines() {
502 let id = Identity::generate();
503 let content = format!(
504 "gateway {}\n\
505 bad_host {}\n\
506 too many fields here\n\
507 good-host {}\n",
508 id.npub(),
509 id.npub(),
510 id.npub()
511 );
512
513 let dir = tempfile::tempdir().unwrap();
514 let path = dir.path().join("hosts");
515 std::fs::write(&path, content).unwrap();
516
517 let map = HostMap::load_hosts_file(&path);
518 assert_eq!(map.len(), 2);
521 assert!(map.lookup_npub("gateway").is_some());
522 assert!(map.lookup_npub("good-host").is_some());
523 }
524
525 #[test]
526 fn test_load_hosts_file_whitespace_handling() {
527 let id = Identity::generate();
528 let content = format!(
529 " # indented comment \n\
530 \t gateway \t {} \t \n\
531 \n\
532 \t \n",
533 id.npub()
534 );
535
536 let dir = tempfile::tempdir().unwrap();
537 let path = dir.path().join("hosts");
538 std::fs::write(&path, content).unwrap();
539
540 let map = HostMap::load_hosts_file(&path);
541 assert_eq!(map.len(), 1);
542 assert!(map.lookup_npub("gateway").is_some());
543 }
544
545 #[test]
548 fn test_merge_non_overlapping() {
549 let id1 = Identity::generate();
550 let id2 = Identity::generate();
551
552 let mut map1 = HostMap::new();
553 map1.insert("alpha", &id1.npub()).unwrap();
554
555 let mut map2 = HostMap::new();
556 map2.insert("beta", &id2.npub()).unwrap();
557
558 map1.merge(map2);
559 assert_eq!(map1.len(), 2);
560 assert!(map1.lookup_npub("alpha").is_some());
561 assert!(map1.lookup_npub("beta").is_some());
562 }
563
564 #[test]
565 fn test_merge_overlapping_other_wins() {
566 let id1 = Identity::generate();
567 let id2 = Identity::generate();
568
569 let mut map1 = HostMap::new();
570 map1.insert("gateway", &id1.npub()).unwrap();
571
572 let mut map2 = HostMap::new();
573 map2.insert("gateway", &id2.npub()).unwrap();
574
575 map1.merge(map2);
576 assert_eq!(map1.len(), 1);
577 assert_eq!(map1.lookup_npub("gateway"), Some(id2.npub().as_str()));
578 }
579
580 #[test]
583 fn test_reloader_initial_load() {
584 let id1 = Identity::generate();
585 let id2 = Identity::generate();
586
587 let mut base = HostMap::new();
589 base.insert("core", &id1.npub()).unwrap();
590
591 let dir = tempfile::tempdir().unwrap();
593 let path = dir.path().join("hosts");
594 std::fs::write(&path, format!("gateway {}\n", id2.npub())).unwrap();
595
596 let reloader = HostMapReloader::new(base, path);
597 assert_eq!(reloader.hosts().len(), 2);
598 assert!(reloader.hosts().lookup_npub("core").is_some());
599 assert!(reloader.hosts().lookup_npub("gateway").is_some());
600 }
601
602 #[test]
603 fn test_reloader_no_hosts_file() {
604 let id = Identity::generate();
605 let mut base = HostMap::new();
606 base.insert("core", &id.npub()).unwrap();
607
608 let reloader = HostMapReloader::new(base, std::path::PathBuf::from("/nonexistent/hosts"));
609 assert_eq!(reloader.hosts().len(), 1);
611 assert!(reloader.hosts().lookup_npub("core").is_some());
612 }
613
614 #[test]
615 fn test_reloader_detects_file_change() {
616 let id1 = Identity::generate();
617 let id2 = Identity::generate();
618
619 let dir = tempfile::tempdir().unwrap();
620 let path = dir.path().join("hosts");
621 std::fs::write(&path, format!("gateway {}\n", id1.npub())).unwrap();
622
623 let mut reloader = HostMapReloader::new(HostMap::new(), path.clone());
624 assert_eq!(reloader.hosts().len(), 1);
625 assert_eq!(
626 reloader.hosts().lookup_npub("gateway"),
627 Some(id1.npub().as_str())
628 );
629
630 assert!(!reloader.check_reload());
632
633 std::thread::sleep(std::time::Duration::from_millis(50));
636 std::fs::write(
637 &path,
638 format!("gateway {}\nnew-host {}\n", id1.npub(), id2.npub()),
639 )
640 .unwrap();
641
642 assert!(reloader.check_reload());
643 assert_eq!(reloader.hosts().len(), 2);
644 assert!(reloader.hosts().lookup_npub("new-host").is_some());
645 }
646
647 #[test]
648 fn test_reloader_detects_file_deletion() {
649 let id = Identity::generate();
650
651 let dir = tempfile::tempdir().unwrap();
652 let path = dir.path().join("hosts");
653 std::fs::write(&path, format!("gateway {}\n", id.npub())).unwrap();
654
655 let mut reloader = HostMapReloader::new(HostMap::new(), path.clone());
656 assert_eq!(reloader.hosts().len(), 1);
657
658 std::fs::remove_file(&path).unwrap();
660
661 assert!(reloader.check_reload());
662 assert!(reloader.hosts().is_empty());
663 }
664
665 #[test]
666 fn test_reloader_detects_file_creation() {
667 let id = Identity::generate();
668
669 let dir = tempfile::tempdir().unwrap();
670 let path = dir.path().join("hosts");
671
672 let mut reloader = HostMapReloader::new(HostMap::new(), path.clone());
674 assert!(reloader.hosts().is_empty());
675
676 std::fs::write(&path, format!("gateway {}\n", id.npub())).unwrap();
678
679 assert!(reloader.check_reload());
680 assert_eq!(reloader.hosts().len(), 1);
681 assert!(reloader.hosts().lookup_npub("gateway").is_some());
682 }
683
684 #[test]
685 fn test_reloader_preserves_base_on_reload() {
686 let id_base = Identity::generate();
687 let id_file = Identity::generate();
688
689 let mut base = HostMap::new();
690 base.insert("core", &id_base.npub()).unwrap();
691
692 let dir = tempfile::tempdir().unwrap();
693 let path = dir.path().join("hosts");
694 std::fs::write(&path, format!("gateway {}\n", id_file.npub())).unwrap();
695
696 let mut reloader = HostMapReloader::new(base, path.clone());
697 assert_eq!(reloader.hosts().len(), 2);
698
699 std::fs::remove_file(&path).unwrap();
701 assert!(reloader.check_reload());
702 assert_eq!(reloader.hosts().len(), 1);
703 assert!(reloader.hosts().lookup_npub("core").is_some());
704 assert!(reloader.hosts().lookup_npub("gateway").is_none());
705 }
706}