Skip to main content

fips_core/upper/
hosts.rs

1//! Host-to-npub static mapping.
2//!
3//! Provides a `HostMap` that resolves human-readable hostnames to Nostr
4//! public keys (npubs). Populated from two sources:
5//!
6//! 1. Peer `alias` fields in the YAML configuration
7//! 2. An operator-maintained hosts file (`/etc/fips/hosts`)
8//!
9//! The DNS resolver checks the host map before falling back to direct
10//! npub resolution, enabling `gateway.fips` instead of `npub1...xyz.fips`.
11
12use 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/// Default path for the FIPS hosts file.
20#[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/// Bidirectional hostname ↔ npub mapping table.
26#[derive(Debug, Clone, Default)]
27pub struct HostMap {
28    /// hostname (lowercase) → npub string
29    by_name: HashMap<String, String>,
30    /// NodeAddr → hostname (for reverse display lookups)
31    by_addr: HashMap<NodeAddr, String>,
32}
33
34/// Errors from host map operations.
35#[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    /// Create an empty host map.
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Insert a hostname → npub mapping.
67    ///
68    /// Validates the hostname and npub before inserting. The hostname is
69    /// stored in lowercase for case-insensitive matching.
70    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    /// Look up the npub for a hostname (case-insensitive).
85    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    /// Look up the hostname for a NodeAddr (reverse lookup for display).
92    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    /// Number of entries in the map.
97    pub fn len(&self) -> usize {
98        self.by_name.len()
99    }
100
101    /// Whether the map is empty.
102    pub fn is_empty(&self) -> bool {
103        self.by_name.is_empty()
104    }
105
106    /// Build a host map from configured peer aliases.
107    ///
108    /// Peers with a valid `alias` field are inserted. Invalid hostnames
109    /// or npubs are logged as warnings and skipped.
110    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    /// Load a host map from a hosts file.
126    ///
127    /// If the file does not exist, returns an empty map (not an error).
128    /// Parse errors on individual lines are logged as warnings and skipped.
129    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            // Skip empty lines and comments
147            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    /// Merge another host map into this one. The other map wins on conflicts.
182    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
192/// Return the modification time of a file, or `None` if it doesn't exist or
193/// the metadata can't be read.
194pub fn file_mtime(path: &Path) -> Option<SystemTime> {
195    std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
196}
197
198/// Tracks a hosts file and reloads it when the modification time changes.
199///
200/// Holds the base host map (from peer config aliases) and the current
201/// effective map (base + hosts file). On each `check_reload()`, stats the
202/// hosts file and rebuilds the effective map if the mtime has changed.
203pub struct HostMapReloader {
204    /// Base map from peer config aliases (never changes).
205    base: HostMap,
206    /// Current effective map (base merged with hosts file).
207    effective: HostMap,
208    /// Whether to watch and reload an operator-managed hosts file.
209    file_backed: bool,
210    /// Path to the hosts file.
211    path: std::path::PathBuf,
212    /// Last observed modification time (None if file didn't exist).
213    last_mtime: Option<SystemTime>,
214}
215
216impl HostMapReloader {
217    /// Create a new reloader.
218    ///
219    /// Performs the initial load of the hosts file and merges with the base map.
220    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    /// Create a memory-only reloader from the base host map.
236    ///
237    /// Embedded applications use this to avoid probing daemon-oriented system
238    /// paths such as `/etc/fips/hosts` inside mobile sandboxes.
239    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    /// Get a reference to the current effective host map.
250    pub fn hosts(&self) -> &HostMap {
251        &self.effective
252    }
253
254    /// Check if the hosts file has been modified and reload if so.
255    ///
256    /// Returns `true` if the map was reloaded.
257    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        // File appeared, disappeared, or was modified
269        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
286/// Validate a hostname for use as a FIPS DNS alias.
287///
288/// Rules:
289/// - ASCII alphanumeric and hyphens only `[a-zA-Z0-9-]`
290/// - Must not start or end with a hyphen
291/// - 1–63 characters
292/// - Must not start with `npub1` (prevents ambiguity with npub resolution)
293pub 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    // --- validate_hostname tests ---
336
337    #[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    // --- HostMap insert / lookup tests ---
377
378    #[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    // --- from_peer_configs tests ---
428
429    #[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    // --- load_hosts_file tests ---
469
470    #[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        // "gateway" is valid, "bad_host" has underscore, middle line has 3 fields
519        // "good-host" is valid
520        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    // --- merge tests ---
546
547    #[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    // --- HostMapReloader tests ---
581
582    #[test]
583    fn test_reloader_initial_load() {
584        let id1 = Identity::generate();
585        let id2 = Identity::generate();
586
587        // Base map from peer config
588        let mut base = HostMap::new();
589        base.insert("core", &id1.npub()).unwrap();
590
591        // Hosts file with another entry
592        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        // Only base entries present
610        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        // No change yet
631        assert!(!reloader.check_reload());
632
633        // Modify the file — bump mtime by writing new content
634        // Sleep briefly to ensure mtime changes (filesystem granularity)
635        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        // Delete the file
659        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        // Start with no file
673        let mut reloader = HostMapReloader::new(HostMap::new(), path.clone());
674        assert!(reloader.hosts().is_empty());
675
676        // Create the file
677        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        // Delete hosts file — base entries should remain
700        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}