mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Add host-to-npub static mapping with DNS hostname resolution
Add a HostMap that resolves human-readable hostnames to npubs, enabling `gateway.fips` instead of the full `npub1...xyz.fips`. Two sources populate the map: peer `alias` fields from the YAML config and an operator-maintained hosts file at /etc/fips/hosts. The DNS responder auto-reloads the hosts file on each request by checking the file modification time, so operators can update mappings without restarting the daemon. - New src/upper/hosts.rs: HostMap, HostMapReloader, hostname validation, hosts file parser with auto-reload on mtime change - DNS resolver checks host map before falling back to direct npub - Node uses host map for peer display names - Default hosts file added to both .deb and tarball packaging - 26 new tests (789 total)
This commit is contained in:
@@ -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`
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
+26
-4
@@ -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<NodeAddr, String>,
|
||||
|
||||
// === Host Map ===
|
||||
/// Static hostname → npub mapping for DNS resolution.
|
||||
/// Built at construction from peer aliases and /etc/fips/hosts.
|
||||
host_map: Arc<HostMap>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
+290
-21
@@ -1,10 +1,16 @@
|
||||
//! FIPS DNS Responder
|
||||
//!
|
||||
//! Resolves `<npub>.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**: `<hostname>.fips` — looked up in the [`HostMap`] to get
|
||||
//! an npub, then resolved to IPv6.
|
||||
//! 2. **Direct npub**: `<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<DnsResolvedIdentity>;
|
||||
/// Channel receiver consumed by the Node RX event loop.
|
||||
pub type DnsIdentityRx = tokio::sync::mpsc::Receiver<DnsResolvedIdentity>;
|
||||
|
||||
/// 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 `<npub>.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 `<npub>.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<u8>, Option<DnsResolvedIdentity>)> {
|
||||
/// 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<u8>, Option<DnsResolvedIdentity>)> {
|
||||
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<u8>, 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<u8>, 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<u8> {
|
||||
use simple_dns::Question;
|
||||
|
||||
@@ -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<String, String>,
|
||||
/// NodeAddr → hostname (for reverse display lookups)
|
||||
by_addr: HashMap<NodeAddr, String>,
|
||||
}
|
||||
|
||||
/// 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<SystemTime> {
|
||||
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<SystemTime>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user