diff --git a/CHANGELOG.md b/CHANGELOG.md index 685c813..8dd021f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ECN congestion signaling and transport congestion detection - Persistent identity with key file management (`fipsctl keygen`) - Periodic Noise rekey with fresh DH for forward secrecy (FMP + FSP) +- Host-to-npub static mapping: resolve `hostname.fips` via host map + populated from peer config aliases and `/etc/fips/hosts` file +- DNS responder auto-reloads hosts file on modification (no restart needed) - Systemd service packaging with tarball installer - Build version metadata: git commit hash, dirty flag, and target triple embedded in all binaries via `--version` diff --git a/Cargo.toml b/Cargo.toml index 219b1a6..dd88800 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,11 +55,12 @@ assets = [ ["target/release/fipsctl", "/usr/bin/", "755"], ["target/release/fipstop", "/usr/bin/", "755"], ["packaging/common/fips.yaml", "/etc/fips/fips.yaml", "600"], + ["packaging/common/hosts", "/etc/fips/hosts", "644"], ["packaging/debian/fips.service", "/lib/systemd/system/fips.service", "644"], ["packaging/debian/fips-dns.service", "/lib/systemd/system/fips-dns.service", "644"], ["packaging/debian/fips.tmpfiles", "/usr/lib/tmpfiles.d/fips.conf", "644"], ] -conf-files = ["/etc/fips/fips.yaml"] +conf-files = ["/etc/fips/fips.yaml", "/etc/fips/hosts"] [dev-dependencies] tempfile = "3.15" diff --git a/packaging/common/hosts b/packaging/common/hosts new file mode 100644 index 0000000..2c1c17f --- /dev/null +++ b/packaging/common/hosts @@ -0,0 +1,22 @@ +# FIPS Hosts File +# +# Static hostname-to-npub mappings for .fips DNS resolution. +# Each entry allows resolving hostname.fips to the peer's IPv6 address. +# +# Format: hostname npub1... +# +# Rules: +# - Lines starting with # are comments +# - Empty lines are ignored +# - Hostnames: a-z, 0-9, hyphens only; max 63 characters +# - One hostname and one npub per line, separated by whitespace +# - Duplicate hostnames: last entry wins +# +# Peer aliases from fips.yaml are also resolved automatically. +# Entries here take precedence over peer aliases on conflict. +# +# Examples: +# vps-tx npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u +# vps-chi npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98 +# +# Changes are picked up automatically on the next DNS query. diff --git a/packaging/systemd/build-tarball.sh b/packaging/systemd/build-tarball.sh index 1c487da..129621a 100755 --- a/packaging/systemd/build-tarball.sh +++ b/packaging/systemd/build-tarball.sh @@ -40,6 +40,7 @@ cp "${SCRIPT_DIR}/uninstall.sh" "${STAGING_DIR}/" cp "${SCRIPT_DIR}/fips.service" "${STAGING_DIR}/" cp "${SCRIPT_DIR}/fips-dns.service" "${STAGING_DIR}/" cp "${PACKAGING_DIR}/common/fips.yaml" "${STAGING_DIR}/" +cp "${PACKAGING_DIR}/common/hosts" "${STAGING_DIR}/" cp "${SCRIPT_DIR}/README.install.md" "${STAGING_DIR}/" chmod +x "${STAGING_DIR}/install.sh" "${STAGING_DIR}/uninstall.sh" diff --git a/packaging/systemd/install.sh b/packaging/systemd/install.sh index 4afc866..e77891d 100755 --- a/packaging/systemd/install.sh +++ b/packaging/systemd/install.sh @@ -10,6 +10,7 @@ # /usr/local/bin/fipsctl CLI query tool # /usr/local/bin/fipstop TUI monitor # /etc/fips/fips.yaml Configuration (preserved if exists) +# /etc/fips/hosts Host-to-npub mappings (preserved if exists) # /etc/systemd/system/fips.service systemd unit # /etc/systemd/system/fips-dns.service DNS routing for .fips domain @@ -73,6 +74,14 @@ else echo "Configuration installed to ${CONFIG_FILE}" fi +HOSTS_FILE="${CONFIG_DIR}/hosts" +if [ -f "${HOSTS_FILE}" ]; then + echo "Hosts file exists at ${HOSTS_FILE}, not overwriting." +else + install -m 0644 "${SCRIPT_DIR}/hosts" "${HOSTS_FILE}" + echo "Hosts file installed to ${HOSTS_FILE}" +fi + # --- Install systemd unit --- was_active=false diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 7636222..bc5a6fb 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -448,10 +448,13 @@ impl Node { let dns_channel_size = self.config.node.buffers.dns_channel; let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(dns_channel_size); let dns_ttl = self.config.dns.ttl(); - let handle = tokio::spawn(crate::upper::dns::run_dns_responder(socket, identity_tx, dns_ttl)); + let base_hosts = crate::upper::hosts::HostMap::from_peer_configs(self.config.peers()); + let hosts_path = std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH); + let reloader = crate::upper::hosts::HostMapReloader::new(base_hosts, hosts_path); + info!(bind = %bind, hosts = reloader.hosts().len(), "DNS responder started for .fips domain (auto-reload enabled)"); + let handle = tokio::spawn(crate::upper::dns::run_dns_responder(socket, identity_tx, dns_ttl, reloader)); self.dns_identity_rx = Some(identity_rx); self.dns_task = Some(handle); - info!(bind = %bind, "DNS responder started for .fips domain"); } Err(e) => { warn!(bind = %bind, error = %e, "Failed to start DNS responder"); diff --git a/src/node/mod.rs b/src/node/mod.rs index f0221c5..11d812a 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -33,6 +33,7 @@ use crate::transport::tcp::TcpTransport; #[cfg(target_os = "linux")] use crate::transport::ethernet::EthernetTransport; use crate::tree::TreeState; +use crate::upper::hosts::HostMap; use crate::upper::icmp_rate_limit::IcmpRateLimiter; use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx}; use self::wire::{build_encrypted, build_established_header, prepend_inner_header, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP}; @@ -40,6 +41,7 @@ use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr, PeerIdentity use rand::Rng; use std::collections::{HashMap, VecDeque}; use std::fmt; +use std::sync::Arc; use std::thread::JoinHandle; use thiserror::Error; @@ -380,6 +382,11 @@ pub struct Node { /// Human-readable names for configured peers (alias or short npub). /// Populated at startup from peer config. peer_aliases: HashMap, + + // === Host Map === + /// Static hostname → npub mapping for DNS resolution. + /// Built at construction from peer aliases and /etc/fips/hosts. + host_map: Arc, } impl Node { @@ -433,6 +440,13 @@ impl Node { let max_links = config.node.limits.max_links; let coords_response_interval_ms = config.node.session.coords_response_interval_ms; + let mut host_map = HostMap::from_peer_configs(config.peers()); + let hosts_file = HostMap::load_hosts_file(std::path::Path::new( + crate::upper::hosts::DEFAULT_HOSTS_PATH, + )); + host_map.merge(hosts_file); + let host_map = Arc::new(host_map); + Ok(Self { identity, startup_epoch, @@ -483,6 +497,7 @@ impl Node { last_parent_reeval: None, last_congestion_log: None, peer_aliases: HashMap::new(), + host_map, }) } @@ -530,6 +545,8 @@ impl Node { let max_links = config.node.limits.max_links; let coords_response_interval_ms = config.node.session.coords_response_interval_ms; + let host_map = Arc::new(HostMap::new()); + Self { identity, startup_epoch, @@ -580,6 +597,7 @@ impl Node { last_parent_reeval: None, last_congestion_log: None, peer_aliases: HashMap::new(), + host_map, } } @@ -730,11 +748,15 @@ impl Node { /// Return a human-readable display name for a NodeAddr. /// /// Lookup order: - /// 1. Configured peer alias or short npub (from startup map) - /// 2. Active peer's short npub (e.g., inbound peer not in config) - /// 3. Session endpoint's short npub (end-to-end, may not be direct peer) - /// 4. Truncated NodeAddr hex (unknown address) + /// 1. Host map hostname (from peer aliases + /etc/fips/hosts) + /// 2. Configured peer alias or short npub (from startup map) + /// 3. Active peer's short npub (e.g., inbound peer not in config) + /// 4. Session endpoint's short npub (end-to-end, may not be direct peer) + /// 5. Truncated NodeAddr hex (unknown address) pub(crate) fn peer_display_name(&self, addr: &NodeAddr) -> String { + if let Some(hostname) = self.host_map.lookup_hostname(addr) { + return hostname.to_string(); + } if let Some(name) = self.peer_aliases.get(addr) { return name.clone(); } diff --git a/src/upper/dns.rs b/src/upper/dns.rs index 95aefeb..98e6514 100644 --- a/src/upper/dns.rs +++ b/src/upper/dns.rs @@ -1,10 +1,16 @@ //! FIPS DNS Responder //! -//! Resolves `.fips` queries to FipsAddress IPv6 addresses. -//! The resolution is pure computation: npub → PublicKey → NodeAddr → FipsAddress. +//! Resolves `.fips` queries to FipsAddress IPv6 addresses. Two resolution +//! paths are supported: +//! +//! 1. **Hostname**: `.fips` — looked up in the [`HostMap`] to get +//! an npub, then resolved to IPv6. +//! 2. **Direct npub**: `.fips` — pure computation from public key. +//! //! As a side effect, resolved identities are sent to the Node for identity //! cache population, enabling subsequent TUN packet routing. +use crate::upper::hosts::{HostMap, HostMapReloader}; use crate::{NodeAddr, PeerIdentity}; use simple_dns::rdata::{RData, AAAA}; use simple_dns::{Packet, Name, ResourceRecord, CLASS, RCODE, PacketFlag, QTYPE, TYPE}; @@ -23,23 +29,58 @@ pub type DnsIdentityTx = tokio::sync::mpsc::Sender; /// Channel receiver consumed by the Node RX event loop. pub type DnsIdentityRx = tokio::sync::mpsc::Receiver; -/// Resolve a `.fips` domain name to an IPv6 address and identity. +/// Extract the label before `.fips` from a DNS query name. /// -/// The name should be `.fips` (with optional trailing dot). -/// Returns the FipsAddress IPv6, NodeAddr, and full PublicKey on success. -pub fn resolve_fips_query(name: &str) -> Option<(Ipv6Addr, NodeAddr, secp256k1::PublicKey)> { +/// Handles trailing dots and case-insensitive `.fips` suffix matching. +fn extract_fips_label(name: &str) -> Option<&str> { let name = name.strip_suffix('.').unwrap_or(name); - let npub = name.strip_suffix(".fips") + name.strip_suffix(".fips") .or_else(|| name.strip_suffix(".FIPS")) .or_else(|| { - // Case-insensitive check for .fips suffix let lower = name.to_ascii_lowercase(); if lower.ends_with(".fips") { Some(&name[..name.len() - 5]) } else { None } - })?; + }) +} + +/// Resolve a `.fips` domain name to an IPv6 address and identity. +/// +/// The name should be `.fips` (with optional trailing dot). +/// Returns the FipsAddress IPv6, NodeAddr, and full PublicKey on success. +pub fn resolve_fips_query(name: &str) -> Option<(Ipv6Addr, NodeAddr, secp256k1::PublicKey)> { + let npub = extract_fips_label(name)?; + let peer = PeerIdentity::from_npub(npub).ok()?; + let ipv6 = peer.address().to_ipv6(); + let node_addr = *peer.node_addr(); + let pubkey = peer.pubkey_full(); + + Some((ipv6, node_addr, pubkey)) +} + +/// Resolve a `.fips` domain name with host map lookup. +/// +/// Resolution order: +/// 1. Extract the label before `.fips` +/// 2. If the label matches a hostname in the host map, use the mapped npub +/// 3. Otherwise, treat the label as a direct npub +/// 4. Resolve the npub to IPv6 via `PeerIdentity` +pub fn resolve_fips_query_with_hosts( + name: &str, + hosts: &HostMap, +) -> Option<(Ipv6Addr, NodeAddr, secp256k1::PublicKey)> { + let label = extract_fips_label(name)?; + + // Try host map first, then direct npub + let npub_owned; + let npub = if let Some(mapped) = hosts.lookup_npub(label) { + npub_owned = mapped.to_string(); + &npub_owned + } else { + label + }; let peer = PeerIdentity::from_npub(npub).ok()?; let ipv6 = peer.address().to_ipv6(); @@ -52,8 +93,13 @@ pub fn resolve_fips_query(name: &str) -> Option<(Ipv6Addr, NodeAddr, secp256k1:: /// Handle a raw DNS query packet and produce a response. /// /// Returns the response bytes and an optional resolved identity (for AAAA queries -/// that successfully resolved a `.fips` name). -pub fn handle_dns_packet(query_bytes: &[u8], ttl: u32) -> Option<(Vec, Option)> { +/// that successfully resolved a `.fips` name). The host map is consulted first +/// for hostname resolution before falling back to direct npub resolution. +pub fn handle_dns_packet( + query_bytes: &[u8], + ttl: u32, + hosts: &HostMap, +) -> Option<(Vec, Option)> { let query = Packet::parse(query_bytes).ok()?; let question = query.questions.first()?; @@ -64,7 +110,7 @@ pub fn handle_dns_packet(query_bytes: &[u8], ttl: u32) -> Option<(Vec, Optio response.set_flags(PacketFlag::AUTHORITATIVE_ANSWER); if is_aaaa - && let Some((ipv6, node_addr, pubkey)) = resolve_fips_query(&qname) + && let Some((ipv6, node_addr, pubkey)) = resolve_fips_query_with_hosts(&qname, hosts) { let name = Name::new_unchecked(&qname).into_owned(); let record = ResourceRecord::new( @@ -89,11 +135,14 @@ pub fn handle_dns_packet(query_bytes: &[u8], ttl: u32) -> Option<(Vec, Optio /// Run the DNS responder UDP server loop. /// /// Listens for DNS queries, resolves `.fips` names, and sends resolved -/// identities to the Node via the identity channel. +/// identities to the Node via the identity channel. The host map reloader +/// checks the hosts file modification time on each request and reloads +/// automatically when changes are detected. pub async fn run_dns_responder( socket: tokio::net::UdpSocket, identity_tx: DnsIdentityTx, ttl: u32, + mut reloader: HostMapReloader, ) { let mut buf = [0u8; 512]; // Standard DNS UDP max @@ -108,7 +157,10 @@ pub async fn run_dns_responder( let query_bytes = &buf[..len]; - match handle_dns_packet(query_bytes, ttl) { + // Check for hosts file changes on each request (cheap stat call) + reloader.check_reload(); + + match handle_dns_packet(query_bytes, ttl, reloader.hosts()) { Some((response_bytes, identity)) => { if let Some(id) = identity { debug!( @@ -199,27 +251,84 @@ mod tests { assert!(resolve_fips_query("fips").is_none()); } + // --- resolve_fips_query_with_hosts tests --- + + #[test] + fn test_resolve_hostname_via_hosts() { + let identity = Identity::generate(); + let expected_ipv6 = identity.address().to_ipv6(); + + let mut hosts = HostMap::new(); + hosts.insert("gateway", &identity.npub()).unwrap(); + + let result = resolve_fips_query_with_hosts("gateway.fips", &hosts); + assert!(result.is_some(), "should resolve hostname via host map"); + let (ipv6, node_addr, _) = result.unwrap(); + assert_eq!(ipv6, expected_ipv6); + assert_eq!(node_addr, *identity.node_addr()); + } + + #[test] + fn test_resolve_hostname_case_insensitive() { + let identity = Identity::generate(); + + let mut hosts = HostMap::new(); + hosts.insert("gateway", &identity.npub()).unwrap(); + + assert!(resolve_fips_query_with_hosts("Gateway.FIPS", &hosts).is_some()); + assert!(resolve_fips_query_with_hosts("GATEWAY.fips", &hosts).is_some()); + } + + #[test] + fn test_resolve_hostname_trailing_dot() { + let identity = Identity::generate(); + + let mut hosts = HostMap::new(); + hosts.insert("gateway", &identity.npub()).unwrap(); + + assert!(resolve_fips_query_with_hosts("gateway.fips.", &hosts).is_some()); + } + + #[test] + fn test_resolve_npub_with_empty_hosts() { + let identity = Identity::generate(); + let expected_ipv6 = identity.address().to_ipv6(); + let hosts = HostMap::new(); + + let query = format!("{}.fips", identity.npub()); + let result = resolve_fips_query_with_hosts(&query, &hosts); + assert!(result.is_some(), "should fall through to npub resolution"); + let (ipv6, _, _) = result.unwrap(); + assert_eq!(ipv6, expected_ipv6); + } + + #[test] + fn test_resolve_unknown_hostname_returns_none() { + let hosts = HostMap::new(); + assert!(resolve_fips_query_with_hosts("unknown.fips", &hosts).is_none()); + } + + // --- handle_dns_packet tests --- + #[test] fn test_handle_aaaa_query() { let identity = Identity::generate(); let npub = identity.npub(); let expected_ipv6 = identity.address().to_ipv6(); + let hosts = HostMap::new(); - // Build a DNS AAAA query packet let query_name = format!("{}.fips", npub); let query_packet = build_test_query(&query_name, TYPE::AAAA); - let result = handle_dns_packet(&query_packet, 300); + let result = handle_dns_packet(&query_packet, 300, &hosts); assert!(result.is_some(), "should handle AAAA query"); let (response_bytes, identity_opt) = result.unwrap(); assert!(identity_opt.is_some(), "should produce identity"); - // Parse the response let response = Packet::parse(&response_bytes).unwrap(); assert_eq!(response.answers.len(), 1); - // Verify the AAAA record if let RData::AAAA(aaaa) = &response.answers[0].rdata { let addr = Ipv6Addr::from(aaaa.address); assert_eq!(addr, expected_ipv6); @@ -228,11 +337,38 @@ mod tests { } } + #[test] + fn test_handle_aaaa_query_hostname() { + let identity = Identity::generate(); + let expected_ipv6 = identity.address().to_ipv6(); + + let mut hosts = HostMap::new(); + hosts.insert("gateway", &identity.npub()).unwrap(); + + let query_packet = build_test_query("gateway.fips", TYPE::AAAA); + + let result = handle_dns_packet(&query_packet, 300, &hosts); + assert!(result.is_some(), "should handle hostname AAAA query"); + + let (response_bytes, identity_opt) = result.unwrap(); + assert!(identity_opt.is_some(), "should produce identity for hostname"); + + let response = Packet::parse(&response_bytes).unwrap(); + assert_eq!(response.answers.len(), 1); + + if let RData::AAAA(aaaa) = &response.answers[0].rdata { + assert_eq!(Ipv6Addr::from(aaaa.address), expected_ipv6); + } else { + panic!("expected AAAA record"); + } + } + #[test] fn test_handle_nxdomain_for_unknown() { + let hosts = HostMap::new(); let query_packet = build_test_query("unknown.fips", TYPE::AAAA); - let result = handle_dns_packet(&query_packet, 300); + let result = handle_dns_packet(&query_packet, 300, &hosts); assert!(result.is_some()); let (response_bytes, identity_opt) = result.unwrap(); @@ -246,10 +382,11 @@ mod tests { #[test] fn test_handle_non_aaaa_query() { let identity = Identity::generate(); + let hosts = HostMap::new(); let query_name = format!("{}.fips", identity.npub()); let query_packet = build_test_query(&query_name, TYPE::A); - let result = handle_dns_packet(&query_packet, 300); + let result = handle_dns_packet(&query_packet, 300, &hosts); assert!(result.is_some()); let (response_bytes, identity_opt) = result.unwrap(); @@ -265,6 +402,12 @@ mod tests { let npub = identity.npub(); let expected_ipv6 = identity.address().to_ipv6(); + // Use a nonexistent path — reloader handles missing file gracefully + let reloader = HostMapReloader::new( + HostMap::new(), + std::path::PathBuf::from("/nonexistent/hosts"), + ); + // Bind responder on ephemeral port let server_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let server_addr = server_socket.local_addr().unwrap(); @@ -272,7 +415,12 @@ mod tests { let (identity_tx, mut identity_rx) = tokio::sync::mpsc::channel(16); // Spawn the responder - let responder_handle = tokio::spawn(run_dns_responder(server_socket, identity_tx, 300)); + let responder_handle = tokio::spawn(run_dns_responder( + server_socket, + identity_tx, + 300, + reloader, + )); // Send a query let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); @@ -310,6 +458,127 @@ mod tests { responder_handle.abort(); } + #[tokio::test] + async fn test_dns_responder_with_hosts() { + let identity = Identity::generate(); + let expected_ipv6 = identity.address().to_ipv6(); + + // Write a hosts file with our test entry + let dir = tempfile::tempdir().unwrap(); + let hosts_path = dir.path().join("hosts"); + std::fs::write(&hosts_path, format!("gateway {}\n", identity.npub())).unwrap(); + + let reloader = HostMapReloader::new(HostMap::new(), hosts_path); + + let server_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let server_addr = server_socket.local_addr().unwrap(); + + let (identity_tx, mut identity_rx) = tokio::sync::mpsc::channel(16); + + let responder_handle = tokio::spawn(run_dns_responder( + server_socket, + identity_tx, + 300, + reloader, + )); + + // Query by hostname instead of npub + let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let query = build_test_query("gateway.fips", TYPE::AAAA); + client_socket.send_to(&query, server_addr).await.unwrap(); + + let mut buf = [0u8; 512]; + let (len, _) = tokio::time::timeout( + std::time::Duration::from_secs(2), + client_socket.recv_from(&mut buf), + ) + .await + .unwrap() + .unwrap(); + + let response = Packet::parse(&buf[..len]).unwrap(); + assert_eq!(response.answers.len(), 1); + if let RData::AAAA(aaaa) = &response.answers[0].rdata { + assert_eq!(Ipv6Addr::from(aaaa.address), expected_ipv6); + } else { + panic!("expected AAAA record"); + } + + // Verify identity registration + let resolved = tokio::time::timeout( + std::time::Duration::from_secs(1), + identity_rx.recv(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(resolved.node_addr, *identity.node_addr()); + + responder_handle.abort(); + } + + #[tokio::test] + async fn test_dns_responder_auto_reload() { + let id1 = Identity::generate(); + let id2 = Identity::generate(); + let expected_ipv6_2 = id2.address().to_ipv6(); + + // Start with hosts file containing only id1 + let dir = tempfile::tempdir().unwrap(); + let hosts_path = dir.path().join("hosts"); + std::fs::write(&hosts_path, format!("gateway {}\n", id1.npub())).unwrap(); + + let reloader = HostMapReloader::new(HostMap::new(), hosts_path.clone()); + + let server_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let server_addr = server_socket.local_addr().unwrap(); + let (identity_tx, _identity_rx) = tokio::sync::mpsc::channel(16); + + let responder_handle = tokio::spawn(run_dns_responder( + server_socket, + identity_tx, + 300, + reloader, + )); + + let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + + // "server2" should not resolve yet + let query = build_test_query("server2.fips", TYPE::AAAA); + client_socket.send_to(&query, server_addr).await.unwrap(); + let mut buf = [0u8; 512]; + let (len, _) = tokio::time::timeout( + std::time::Duration::from_secs(2), + client_socket.recv_from(&mut buf), + ).await.unwrap().unwrap(); + let response = Packet::parse(&buf[..len]).unwrap(); + assert!(response.answers.is_empty(), "server2 should not resolve before reload"); + + // Update the hosts file to add server2 + std::thread::sleep(std::time::Duration::from_millis(50)); + std::fs::write( + &hosts_path, + format!("gateway {}\nserver2 {}\n", id1.npub(), id2.npub()), + ).unwrap(); + + // Next query should trigger reload — query server2 again + let query = build_test_query("server2.fips", TYPE::AAAA); + client_socket.send_to(&query, server_addr).await.unwrap(); + let (len, _) = tokio::time::timeout( + std::time::Duration::from_secs(2), + client_socket.recv_from(&mut buf), + ).await.unwrap().unwrap(); + let response = Packet::parse(&buf[..len]).unwrap(); + assert_eq!(response.answers.len(), 1, "server2 should resolve after reload"); + if let RData::AAAA(aaaa) = &response.answers[0].rdata { + assert_eq!(Ipv6Addr::from(aaaa.address), expected_ipv6_2); + } else { + panic!("expected AAAA record"); + } + + responder_handle.abort(); + } + /// Build a test DNS query packet for a given name and record type. fn build_test_query(name: &str, rtype: TYPE) -> Vec { use simple_dns::Question; diff --git a/src/upper/hosts.rs b/src/upper/hosts.rs new file mode 100644 index 0000000..1bbf960 --- /dev/null +++ b/src/upper/hosts.rs @@ -0,0 +1,674 @@ +//! Host-to-npub static mapping. +//! +//! Provides a `HostMap` that resolves human-readable hostnames to Nostr +//! public keys (npubs). Populated from two sources: +//! +//! 1. Peer `alias` fields in the YAML configuration +//! 2. An operator-maintained hosts file (`/etc/fips/hosts`) +//! +//! The DNS resolver checks the host map before falling back to direct +//! npub resolution, enabling `gateway.fips` instead of `npub1...xyz.fips`. + +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}; + +/// Default path for the FIPS hosts file. +pub const DEFAULT_HOSTS_PATH: &str = "/etc/fips/hosts"; + +/// Bidirectional hostname ↔ npub mapping table. +#[derive(Debug, Clone, Default)] +pub struct HostMap { + /// hostname (lowercase) → npub string + by_name: HashMap, + /// NodeAddr → hostname (for reverse display lookups) + by_addr: HashMap, +} + +/// Errors from host map operations. +#[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 { + /// Create an empty host map. + pub fn new() -> Self { + Self::default() + } + + /// Insert a hostname → npub mapping. + /// + /// Validates the hostname and npub before inserting. The hostname is + /// stored in lowercase for case-insensitive matching. + 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(()) + } + + /// Look up the npub for a hostname (case-insensitive). + pub fn lookup_npub(&self, hostname: &str) -> Option<&str> { + self.by_name.get(&hostname.to_ascii_lowercase()).map(|s| s.as_str()) + } + + /// Look up the hostname for a NodeAddr (reverse lookup for display). + pub fn lookup_hostname(&self, node_addr: &NodeAddr) -> Option<&str> { + self.by_addr.get(node_addr).map(|s| s.as_str()) + } + + /// Number of entries in the map. + pub fn len(&self) -> usize { + self.by_name.len() + } + + /// Whether the map is empty. + pub fn is_empty(&self) -> bool { + self.by_name.is_empty() + } + + /// Build a host map from configured peer aliases. + /// + /// Peers with a valid `alias` field are inserted. Invalid hostnames + /// or npubs are logged as warnings and skipped. + 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 + } + + /// Load a host map from a hosts file. + /// + /// If the file does not exist, returns an empty map (not an error). + /// Parse errors on individual lines are logged as warnings and skipped. + 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(); + + // Skip empty lines and comments + 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 + } + + /// Merge another host map into this one. The other map wins on conflicts. + 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); + } + } +} + +/// Return the modification time of a file, or `None` if it doesn't exist or +/// the metadata can't be read. +pub fn file_mtime(path: &Path) -> Option { + std::fs::metadata(path).ok().and_then(|m| m.modified().ok()) +} + +/// Tracks a hosts file and reloads it when the modification time changes. +/// +/// Holds the base host map (from peer config aliases) and the current +/// effective map (base + hosts file). On each `check_reload()`, stats the +/// hosts file and rebuilds the effective map if the mtime has changed. +pub struct HostMapReloader { + /// Base map from peer config aliases (never changes). + base: HostMap, + /// Current effective map (base merged with hosts file). + effective: HostMap, + /// Path to the hosts file. + path: std::path::PathBuf, + /// Last observed modification time (None if file didn't exist). + last_mtime: Option, +} + +impl HostMapReloader { + /// Create a new reloader. + /// + /// Performs the initial load of the hosts file and merges with the base map. + 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, + path, + last_mtime, + } + } + + /// Get a reference to the current effective host map. + pub fn hosts(&self) -> &HostMap { + &self.effective + } + + /// Check if the hosts file has been modified and reload if so. + /// + /// Returns `true` if the map was reloaded. + pub fn check_reload(&mut self) -> bool { + let current_mtime = file_mtime(&self.path); + + if current_mtime == self.last_mtime { + return false; + } + + // File appeared, disappeared, or was modified + 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 + } +} + +/// Validate a hostname for use as a FIPS DNS alias. +/// +/// Rules: +/// - ASCII alphanumeric and hyphens only `[a-zA-Z0-9-]` +/// - Must not start or end with a hyphen +/// - 1–63 characters +/// - Must not start with `npub1` (prevents ambiguity with npub resolution) +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; + + // --- validate_hostname tests --- + + #[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}"); + } + } + + // --- HostMap insert / lookup tests --- + + #[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()); + } + + // --- from_peer_configs tests --- + + #[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()); + } + + // --- load_hosts_file tests --- + + #[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); + // "gateway" is valid, "bad_host" has underscore, middle line has 3 fields + // "good-host" is valid + 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()); + } + + // --- merge tests --- + + #[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())); + } + + // --- HostMapReloader tests --- + + #[test] + fn test_reloader_initial_load() { + let id1 = Identity::generate(); + let id2 = Identity::generate(); + + // Base map from peer config + let mut base = HostMap::new(); + base.insert("core", &id1.npub()).unwrap(); + + // Hosts file with another entry + 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"), + ); + // Only base entries present + 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()) + ); + + // No change yet + assert!(!reloader.check_reload()); + + // Modify the file — bump mtime by writing new content + // Sleep briefly to ensure mtime changes (filesystem granularity) + 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); + + // Delete the file + 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"); + + // Start with no file + let mut reloader = HostMapReloader::new(HostMap::new(), path.clone()); + assert!(reloader.hosts().is_empty()); + + // Create the file + 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); + + // Delete hosts file — base entries should remain + 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()); + } +} diff --git a/src/upper/mod.rs b/src/upper/mod.rs index 9a04e2f..ba8814b 100644 --- a/src/upper/mod.rs +++ b/src/upper/mod.rs @@ -7,6 +7,7 @@ pub mod config; pub mod dns; +pub mod hosts; pub mod icmp; pub mod icmp_rate_limit; pub mod tcp_mss;