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:
Johnathan Corgan
2026-03-08 18:34:54 +00:00
parent ead91c75da
commit 0bb6e70fb5
10 changed files with 1033 additions and 28 deletions
+3
View File
@@ -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
View File
@@ -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"
+22
View File
@@ -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.
+1
View File
@@ -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"
+9
View File
@@ -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
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+674
View File
@@ -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
/// - 163 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());
}
}
+1
View File
@@ -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;