diff --git a/Cargo.lock b/Cargo.lock index db628cd..421c8c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,7 @@ dependencies = [ "serde", "serde_yaml", "sha2", + "simple-dns", "tempfile", "thiserror 2.0.18", "tokio", @@ -1111,6 +1112,15 @@ dependencies = [ "libc", ] +[[package]] +name = "simple-dns" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a" +dependencies = [ + "bitflags", +] + [[package]] name = "slab" version = "0.4.11" diff --git a/Cargo.toml b/Cargo.toml index 2b83b1e..a16a7fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ libc = "0.2" rtnetlink = "0.14" tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time"] } futures = "0.3" +simple-dns = "0.9" [dev-dependencies] tempfile = "3.15" diff --git a/examples/two-node-udp/README.md b/examples/two-node-udp/README.md new file mode 100644 index 0000000..0f0b84d --- /dev/null +++ b/examples/two-node-udp/README.md @@ -0,0 +1,324 @@ +# Two-Node UDP Test + +This example demonstrates two FIPS nodes communicating over UDP using Linux +network namespaces. Both nodes establish an encrypted peer link, build a +spanning tree, and create end-to-end sessions. With TUN and DNS enabled, you +can `ping6` between nodes using `.fips` domain names or raw IPv6 addresses. + +## Network Diagram + +```text + Namespace: fips-a Namespace: fips-b + ┌─────────────────────┐ ┌─────────────────────┐ + │ │ │ │ + │ ┌───────────────┐ │ │ ┌───────────────┐ │ + │ │ FIPS Node A │ │ │ │ FIPS Node B │ │ + │ │ │ │ │ │ │ │ + │ │ fd69:e08d:.. │ │ │ │ fd8e:302c:.. │ │ + │ └──┬─────────┬──┘ │ │ └──┬─────────┬──┘ │ + │ │ │ │ │ │ │ │ + │ ┌──┴──┐ ┌───┴──┐ │ │ ┌──┴──┐ ┌───┴──┐ │ + │ │fips0│ │ DNS │ │ │ │fips0│ │ DNS │ │ + │ │ TUN │ │:5354 │ │ │ │ TUN │ │:5354 │ │ + │ └─────┘ └──────┘ │ │ └─────┘ └──────┘ │ + │ │ │ │ + │ ┌────────────────┐ │ │ ┌────────────────┐ │ + │ │ veth-a │ │ UDP :4000 │ │ veth-b │ │ + │ │ 10.0.0.1/24 ├─┼──────────────┼─┤ 10.0.0.2/24 │ │ + │ └────────────────┘ │ │ └────────────────┘ │ + └─────────────────────┘ └─────────────────────┘ + + Transport layer: UDP over IPv4 veth pair (10.0.0.0/24) + Data plane: IPv6 over FIPS mesh (fd::/8 via fips0 TUN) + DNS: .fips → FIPS IPv6 address (127.0.0.1:5354) +``` + +## Prerequisites + +- Linux with network namespace support (requires root for namespace setup) +- IPv6 enabled (`sysctl net.ipv6.conf.all.disable_ipv6` should be `0`) +- `iproute2` tools (`ip`, `resolvectl`) +- Rust toolchain (to build the FIPS binary) + +## Node Identities + +| Node | npub | FIPS Address | +|------|------|-------------| +| A | `npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m` | `fd69:e08d:65cc:3a6b:9c2c:2ac4:bd40:5e4b` | +| B | `npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le` | `fd8e:302c:287e:b48d:6268:122f:da76:b77` | + +## Step 1: Build FIPS + +From the FIPS source root: + +```bash +cargo build +``` + +The binary will be at `target/debug/fips`. Note the absolute path — you'll +need it when running inside namespaces. + +## Step 2: Create Network Namespaces + +This creates two namespaces (`fips-a` and `fips-b`) connected by a virtual +ethernet pair. Each namespace has its own isolated network stack, including +its own routing table, TUN devices, and DNS resolver. + +```bash +# Create namespaces +sudo ip netns add fips-a +sudo ip netns add fips-b + +# Create a veth pair connecting them +sudo ip link add veth-a type veth peer name veth-b + +# Move each end into its namespace +sudo ip link set veth-a netns fips-a +sudo ip link set veth-b netns fips-b + +# Configure IPv4 addresses (used by UDP transport) +sudo ip netns exec fips-a ip addr add 10.0.0.1/24 dev veth-a +sudo ip netns exec fips-b ip addr add 10.0.0.2/24 dev veth-b + +# Bring interfaces up +sudo ip netns exec fips-a ip link set veth-a up +sudo ip netns exec fips-b ip link set veth-b up + +# Enable loopback in both (needed for DNS responder on 127.0.0.1) +sudo ip netns exec fips-a ip link set lo up +sudo ip netns exec fips-b ip link set lo up + +# Enable IPv6 in both namespaces +sudo ip netns exec fips-a sysctl -w net.ipv6.conf.all.disable_ipv6=0 +sudo ip netns exec fips-b sysctl -w net.ipv6.conf.all.disable_ipv6=0 +``` + +Verify connectivity between namespaces: + +```bash +sudo ip netns exec fips-a ping -c 1 10.0.0.2 +``` + +## Step 3: Start Node A + +Open **Terminal 1**. This runs the FIPS daemon for Node A inside its +namespace. The daemon creates a `fips0` TUN device, starts the DNS responder, +connects to Node B over UDP, and begins the Noise IK handshake. + +```bash +sudo ip netns exec fips-a \ + env RUST_LOG=info \ + /path/to/target/debug/fips --config /path/to/examples/two-node-udp/fips-a.yaml +``` + +Replace `/path/to/` with the actual absolute paths to your build and this +example directory. + +You should see output like: + +```text +INFO fips: FIPS starting +INFO fips: Node created: +INFO fips: npub: npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m +INFO fips: address: fd69:e08d:65cc:3a6b:9c2c:2ac4:bd40:5e4b +INFO fips: TUN device active: +INFO fips: name: fips0 +INFO fips: address: fd69:e08d:65cc:3a6b:9c2c:2ac4:bd40:5e4b +INFO fips: DNS responder started for .fips domain +INFO fips: Peer connection initiated (node-b) +``` + +## Step 4: Start Node B + +Open **Terminal 2**. Run Node B in its namespace: + +```bash +sudo ip netns exec fips-b \ + env RUST_LOG=info \ + /path/to/target/debug/fips --config /path/to/examples/two-node-udp/fips-b.yaml +``` + +Once both nodes are running, you should see handshake completion messages in +both terminals: + +```text +INFO fips::node::handlers::handshake: Peer promoted to active +``` + +The spanning tree will converge within a few seconds (TreeAnnounce exchange), +followed by bloom filter exchange (FilterAnnounce). + +## Step 5: Configure DNS Routing + +Each namespace needs its own DNS routing configuration so that `.fips` queries +are sent to the local DNS responder. Open **Terminal 3** for these commands. + +For Node A's namespace: + +```bash +# Tell systemd-resolved (inside namespace) to route .fips queries to the +# DNS responder listening on 127.0.0.1:5354 via the fips0 interface. +sudo ip netns exec fips-a resolvectl dns fips0 127.0.0.1:5354 +sudo ip netns exec fips-a resolvectl domain fips0 '~fips' +``` + +For Node B's namespace: + +```bash +sudo ip netns exec fips-b resolvectl dns fips0 127.0.0.1:5354 +sudo ip netns exec fips-b resolvectl domain fips0 '~fips' +``` + +Verify the configuration: + +```bash +sudo ip netns exec fips-a resolvectl status fips0 +sudo ip netns exec fips-b resolvectl status fips0 +``` + +> **Note:** `resolvectl` inside a network namespace requires that +> `systemd-resolved` is accessible from within the namespace. If your system +> does not support this, you can test DNS resolution directly with `dig` +> instead of relying on the system resolver: +> +> ```bash +> sudo ip netns exec fips-a dig @127.0.0.1 -p 5354 AAAA \ +> npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le.fips +> ``` + +## Step 6: Test DNS Resolution + +Verify that the DNS responder correctly resolves npub names to FIPS addresses. +From Terminal 3: + +```bash +# From Node A, resolve Node B's name +sudo ip netns exec fips-a dig @127.0.0.1 -p 5354 AAAA \ + npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le.fips + +# Expected answer: fd8e:302c:287e:b48d:6268:122f:da76:b77 +``` + +Watch Terminal 1 — you should see a log line: + +```text +DEBUG fips::dns: DNS resolved .fips name, registering identity +``` + +This confirms the identity cache was populated. The subsequent ping will be +able to route through the mesh. + +## Step 7: Ping Between Nodes + +Now test end-to-end connectivity. The first ping triggers session +establishment (Noise IK handshake through the mesh), so it may take a moment +longer than subsequent pings. + +### Ping Node B from Node A + +From Terminal 3: + +```bash +sudo ip netns exec fips-a ping6 -c 4 fd8e:302c:287e:b48d:6268:122f:da76:b77 +``` + +If DNS routing is configured (Step 5), you can use the `.fips` name instead: + +```bash +sudo ip netns exec fips-a ping6 -c 4 \ + npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le.fips +``` + +### Ping Node A from Node B + +```bash +sudo ip netns exec fips-b ping6 -c 4 fd69:e08d:65cc:3a6b:9c2c:2ac4:bd40:5e4b +``` + +Or with DNS: + +```bash +sudo ip netns exec fips-b ping6 -c 4 \ + npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m.fips +``` + +> **Note:** FIPS does not yet implement ICMPv6 Echo Reply (type 129). The +> first ping will trigger session establishment (visible in daemon logs as +> SessionSetup/SessionAck messages), but ping replies require Echo Reply +> support which is not yet implemented. You can verify that the session was +> established and data was delivered by watching the daemon log output for +> `DataPacket` messages. + +## Step 8: Watch the Logs + +While pinging, watch the daemon terminals for the protocol flow: + +1. **DNS resolution** — `DNS resolved .fips name, registering identity` +2. **TUN packet** — `TUN packet received` with src/dst addresses +3. **Session initiation** — `Initiating session to ` +4. **SessionSetup sent** — Noise IK msg1 sent through mesh +5. **SessionSetup received** — Responder processes msg1 +6. **SessionAck** — Responder sends msg2 back +7. **Session established** — Both sides transition to Established +8. **DataPacket** — Encrypted IPv6 payload delivered + +Set `RUST_LOG=debug` for the full protocol trace, or `RUST_LOG=info` for +high-level events only. + +## Cleanup + +Stop both FIPS daemons with Ctrl+C in Terminals 1 and 2. Then tear down +the namespaces: + +```bash +sudo ip netns delete fips-a +sudo ip netns delete fips-b +``` + +This also removes the veth pair and TUN devices automatically. + +## Troubleshooting + +### "Permission denied" creating TUN device + +The FIPS binary needs `CAP_NET_ADMIN` to create TUN devices. Running via +`sudo ip netns exec` already provides root privileges. If running outside +a namespace, use: + +```bash +sudo setcap cap_net_admin+ep /path/to/target/debug/fips +``` + +### "Address already in use" on DNS port + +Another process is using port 5354. Change the `dns.port` in the YAML config +to a different port (e.g., 5355). + +### No handshake completion + +Check that the veth pair is up and the namespaces can reach each other: + +```bash +sudo ip netns exec fips-a ping -c 1 10.0.0.2 +``` + +If this fails, the namespace setup is incomplete. + +### IPv6 disabled + +```bash +sudo ip netns exec fips-a sysctl net.ipv6.conf.all.disable_ipv6 +# Should be 0 +``` + +### DNS query returns no answer + +Verify the DNS responder is running by querying it directly: + +```bash +sudo ip netns exec fips-a dig @127.0.0.1 -p 5354 AAAA \ + npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le.fips +``` + +If this works but `resolvectl query` doesn't, the systemd-resolved routing +may not be configured correctly in the namespace. diff --git a/examples/two-node-udp/fips-a.yaml b/examples/two-node-udp/fips-a.yaml new file mode 100644 index 0000000..3b89fc7 --- /dev/null +++ b/examples/two-node-udp/fips-a.yaml @@ -0,0 +1,32 @@ +# FIPS Node A configuration +# +# Identity: npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m +# FIPS address: fd69:e08d:65cc:3a6b:9c2c:2ac4:bd40:5e4b + +node: + identity: + nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + port: 5354 + +transports: + udp: + bind_addr: "10.0.0.1:4000" + mtu: 1280 + +peers: + - npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le" + alias: "node-b" + addresses: + - transport: udp + addr: "10.0.0.2:4000" + priority: 1 + connect_policy: auto_connect diff --git a/examples/two-node-udp/fips-b.yaml b/examples/two-node-udp/fips-b.yaml new file mode 100644 index 0000000..13ef66d --- /dev/null +++ b/examples/two-node-udp/fips-b.yaml @@ -0,0 +1,32 @@ +# FIPS Node B configuration +# +# Identity: npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le +# FIPS address: fd8e:302c:287e:b48d:6268:122f:da76:b77 + +node: + identity: + nsec: "b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0" + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + port: 5354 + +transports: + udp: + bind_addr: "10.0.0.2:4000" + mtu: 1280 + +peers: + - npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" + alias: "node-a" + addresses: + - transport: udp + addr: "10.0.0.1:4000" + priority: 1 + connect_policy: auto_connect diff --git a/src/config.rs b/src/config.rs index c171c39..65e1b46 100644 --- a/src/config.rs +++ b/src/config.rs @@ -107,6 +107,40 @@ const DEFAULT_TUN_NAME: &str = "fips0"; /// Default TUN MTU (IPv6 minimum). const DEFAULT_TUN_MTU: u16 = 1280; +/// Default DNS responder bind address. +const DEFAULT_DNS_BIND_ADDR: &str = "127.0.0.1"; + +/// Default DNS responder port. +const DEFAULT_DNS_PORT: u16 = 5354; + +/// DNS responder configuration (`dns.*`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DnsConfig { + /// Enable DNS responder (`dns.enabled`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub enabled: bool, + + /// Bind address (`dns.bind_addr`). Defaults to "127.0.0.1". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bind_addr: Option, + + /// Listen port (`dns.port`). Defaults to 5354. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +impl DnsConfig { + /// Get the bind address, using default if not configured. + pub fn bind_addr(&self) -> &str { + self.bind_addr.as_deref().unwrap_or(DEFAULT_DNS_BIND_ADDR) + } + + /// Get the port, using default if not configured. + pub fn port(&self) -> u16 { + self.port.unwrap_or(DEFAULT_DNS_PORT) + } +} + /// Default UDP bind address. const DEFAULT_UDP_BIND_ADDR: &str = "0.0.0.0:4000"; @@ -425,6 +459,10 @@ pub struct Config { #[serde(default)] pub tun: TunConfig, + /// DNS responder configuration (`dns.*`). + #[serde(default)] + pub dns: DnsConfig, + /// Transport instances (`transports.*`). #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")] pub transports: TransportsConfig, @@ -530,6 +568,16 @@ impl Config { if other.tun.mtu.is_some() { self.tun.mtu = other.tun.mtu; } + // Merge dns section + if other.dns.enabled { + self.dns.enabled = true; + } + if other.dns.bind_addr.is_some() { + self.dns.bind_addr = other.dns.bind_addr; + } + if other.dns.port.is_some() { + self.dns.port = other.dns.port; + } // Merge transports section self.transports.merge(other.transports); // Merge peers (replace if non-empty) diff --git a/src/dns.rs b/src/dns.rs new file mode 100644 index 0000000..84f6bda --- /dev/null +++ b/src/dns.rs @@ -0,0 +1,326 @@ +//! FIPS DNS Responder +//! +//! Resolves `.fips` queries to FipsAddress IPv6 addresses. +//! The resolution is pure computation: npub → PublicKey → NodeAddr → FipsAddress. +//! As a side effect, resolved identities are sent to the Node for identity +//! cache population, enabling subsequent TUN packet routing. + +use crate::{NodeAddr, PeerIdentity}; +use simple_dns::rdata::{RData, AAAA}; +use simple_dns::{Packet, Name, ResourceRecord, CLASS, RCODE, PacketFlag, QTYPE, TYPE}; +use std::net::Ipv6Addr; +use tracing::{debug, warn}; + +/// Identity resolved by the DNS responder, sent to Node for cache population. +pub struct DnsResolvedIdentity { + pub node_addr: NodeAddr, + pub pubkey: secp256k1::PublicKey, +} + +/// Channel sender for DNS → Node identity registration. +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. +/// +/// 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 name = name.strip_suffix('.').unwrap_or(name); + let npub = 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 + } + })?; + + 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)) +} + +/// 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]) -> Option<(Vec, Option)> { + let query = Packet::parse(query_bytes).ok()?; + let question = query.questions.first()?; + + let qname = question.qname.to_string(); + let is_aaaa = matches!(question.qtype, QTYPE::TYPE(TYPE::AAAA)); + + let mut response = query.into_reply(); + response.set_flags(PacketFlag::AUTHORITATIVE_ANSWER); + + if is_aaaa + && let Some((ipv6, node_addr, pubkey)) = resolve_fips_query(&qname) + { + let name = Name::new_unchecked(&qname).into_owned(); + let record = ResourceRecord::new( + name, + CLASS::IN, + 300, // 5 minute TTL + RData::AAAA(AAAA::from(ipv6)), + ); + response.answers.push(record); + + let identity = DnsResolvedIdentity { node_addr, pubkey }; + let bytes = response.build_bytes_vec_compressed().ok()?; + return Some((bytes, Some(identity))); + } + + // Query we can't answer: NXDOMAIN + *response.rcode_mut() = RCODE::NameError; + let bytes = response.build_bytes_vec_compressed().ok()?; + Some((bytes, None)) +} + +/// 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. +pub async fn run_dns_responder( + socket: tokio::net::UdpSocket, + identity_tx: DnsIdentityTx, +) { + let mut buf = [0u8; 512]; // Standard DNS UDP max + + loop { + let (len, src) = match socket.recv_from(&mut buf).await { + Ok(result) => result, + Err(e) => { + warn!(error = %e, "DNS socket recv error"); + continue; + } + }; + + let query_bytes = &buf[..len]; + + match handle_dns_packet(query_bytes) { + Some((response_bytes, identity)) => { + if let Some(id) = identity { + debug!( + node_addr = %id.node_addr, + "DNS resolved .fips name, registering identity" + ); + let _ = identity_tx.send(id).await; + } + + if let Err(e) = socket.send_to(&response_bytes, src).await { + debug!(error = %e, "DNS send error"); + } + } + None => { + debug!(len, "Failed to parse DNS query, dropping"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Identity; + + #[test] + fn test_resolve_valid_npub() { + let identity = Identity::generate(); + let npub = identity.npub(); + let expected_ipv6 = identity.address().to_ipv6(); + + let query = format!("{}.fips", npub); + let result = resolve_fips_query(&query); + + assert!(result.is_some(), "should resolve valid npub.fips"); + let (ipv6, node_addr, _pubkey) = result.unwrap(); + assert_eq!(ipv6, expected_ipv6); + assert_eq!(node_addr, *identity.node_addr()); + } + + #[test] + fn test_resolve_trailing_dot() { + let identity = Identity::generate(); + let npub = identity.npub(); + let expected_ipv6 = identity.address().to_ipv6(); + + let query = format!("{}.fips.", npub); + let result = resolve_fips_query(&query); + + assert!(result.is_some(), "should handle trailing dot"); + let (ipv6, _, _) = result.unwrap(); + assert_eq!(ipv6, expected_ipv6); + } + + #[test] + fn test_resolve_case_insensitive() { + let identity = Identity::generate(); + let npub = identity.npub(); + + // .FIPS + let result = resolve_fips_query(&format!("{}.FIPS", npub)); + assert!(result.is_some(), "should handle .FIPS"); + + // .Fips + let result = resolve_fips_query(&format!("{}.Fips", npub)); + assert!(result.is_some(), "should handle .Fips"); + } + + #[test] + fn test_resolve_invalid_npub() { + let result = resolve_fips_query("not-a-valid-npub.fips"); + assert!(result.is_none()); + } + + #[test] + fn test_resolve_wrong_suffix() { + let identity = Identity::generate(); + let npub = identity.npub(); + + let result = resolve_fips_query(&format!("{}.com", npub)); + assert!(result.is_none()); + } + + #[test] + fn test_resolve_empty_name() { + assert!(resolve_fips_query("").is_none()); + assert!(resolve_fips_query(".fips").is_none()); + assert!(resolve_fips_query("fips").is_none()); + } + + #[test] + fn test_handle_aaaa_query() { + let identity = Identity::generate(); + let npub = identity.npub(); + let expected_ipv6 = identity.address().to_ipv6(); + + // 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); + 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); + } else { + panic!("expected AAAA record"); + } + } + + #[test] + fn test_handle_nxdomain_for_unknown() { + let query_packet = build_test_query("unknown.fips", TYPE::AAAA); + + let result = handle_dns_packet(&query_packet); + assert!(result.is_some()); + + let (response_bytes, identity_opt) = result.unwrap(); + assert!(identity_opt.is_none(), "should not produce identity for unknown"); + + let response = Packet::parse(&response_bytes).unwrap(); + assert_eq!(response.rcode(), RCODE::NameError); + assert!(response.answers.is_empty()); + } + + #[test] + fn test_handle_non_aaaa_query() { + let identity = Identity::generate(); + let query_name = format!("{}.fips", identity.npub()); + let query_packet = build_test_query(&query_name, TYPE::A); + + let result = handle_dns_packet(&query_packet); + assert!(result.is_some()); + + let (response_bytes, identity_opt) = result.unwrap(); + assert!(identity_opt.is_none(), "A query should not resolve .fips"); + + let response = Packet::parse(&response_bytes).unwrap(); + assert_eq!(response.rcode(), RCODE::NameError); + } + + #[tokio::test] + async fn test_dns_responder_udp() { + let identity = Identity::generate(); + let npub = identity.npub(); + let expected_ipv6 = identity.address().to_ipv6(); + + // 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(); + + 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)); + + // Send a query + let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let query = build_test_query(&format!("{}.fips", npub), TYPE::AAAA); + client_socket.send_to(&query, server_addr).await.unwrap(); + + // Receive response + 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 was sent through channel + 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(); + } + + /// 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; + + let mut packet = Packet::new_query(0x1234); + let question = Question::new( + Name::new_unchecked(name).into_owned(), + QTYPE::TYPE(rtype), + simple_dns::QCLASS::CLASS(CLASS::IN), + false, + ); + packet.questions.push(question); + packet.build_bytes_vec().unwrap() + } +} diff --git a/src/lib.rs b/src/lib.rs index fadf70f..143182f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod bloom; pub mod cache; pub mod config; +pub mod dns; pub mod icmp; pub mod identity; pub mod index; @@ -26,7 +27,10 @@ pub use identity::{ }; // Re-export config types -pub use config::{Config, ConfigError, IdentityConfig, TunConfig, UdpConfig}; +pub use config::{Config, ConfigError, DnsConfig, IdentityConfig, TunConfig, UdpConfig}; + +// Re-export DNS types +pub use dns::{DnsIdentityRx, DnsIdentityTx, DnsResolvedIdentity}; // Re-export tree types pub use tree::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError, TreeState}; diff --git a/src/node/handlers/rx_loop.rs b/src/node/handlers/rx_loop.rs index 6b0da69..0f46e33 100644 --- a/src/node/handlers/rx_loop.rs +++ b/src/node/handlers/rx_loop.rs @@ -18,6 +18,8 @@ impl Node { /// Also processes outbound IPv6 packets from the TUN reader for session /// encapsulation and routing through the mesh. /// + /// Also processes DNS-resolved identities for identity cache population. + /// /// Also runs a periodic tick (1s) to clean up stale handshake connections /// that never received a response. This prevents resource leaks when peers /// are unreachable. @@ -39,6 +41,16 @@ impl Node { } }; + // Take the DNS identity receiver, or create a dummy channel (when DNS + // is disabled). Same pattern as TUN outbound. + let (mut dns_identity_rx, _dns_guard) = match self.dns_identity_rx.take() { + Some(rx) => (rx, None), + None => { + let (tx, rx) = tokio::sync::mpsc::channel(1); + (rx, Some(tx)) + } + }; + let mut tick = tokio::time::interval(Duration::from_secs(1)); info!("RX event loop started"); @@ -54,6 +66,13 @@ impl Node { Some(ipv6_packet) = tun_outbound_rx.recv() => { self.handle_tun_outbound(ipv6_packet).await; } + Some(identity) = dns_identity_rx.recv() => { + debug!( + node_addr = %identity.node_addr, + "Registering identity from DNS resolution" + ); + self.register_identity(identity.node_addr, identity.pubkey); + } _ = tick.tick() => { self.check_timeouts(); let now_ms = std::time::SystemTime::now() diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 468ea1e..f08866c 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -310,6 +310,23 @@ impl Node { } } + // Initialize DNS responder (independent of TUN) + if self.config.dns.enabled { + let bind = format!("{}:{}", self.config.dns.bind_addr(), self.config.dns.port()); + match tokio::net::UdpSocket::bind(&bind).await { + Ok(socket) => { + let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(64); + let handle = tokio::spawn(crate::dns::run_dns_responder(socket, identity_tx)); + 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"); + } + } + } + self.state = NodeState::Running; info!("Node started:"); info!(" state: {}", self.state); @@ -329,6 +346,12 @@ impl Node { self.state = NodeState::Stopping; info!(state = %self.state, "Node stopping"); + // Stop DNS responder + if let Some(handle) = self.dns_task.take() { + handle.abort(); + debug!("DNS responder stopped"); + } + // Send disconnect notifications to all active peers before closing transports self.send_disconnect_to_all_peers(DisconnectReason::Shutdown).await; diff --git a/src/node/mod.rs b/src/node/mod.rs index fbfc6ca..62e0a6b 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -286,6 +286,12 @@ pub struct Node { /// TUN writer thread handle. tun_writer_handle: Option>, + // === DNS Responder === + /// Receiver for resolved identities from the DNS responder. + dns_identity_rx: Option, + /// DNS responder task handle. + dns_task: Option>, + // === Index-Based Session Dispatch === /// Allocator for session indices. index_allocator: IndexAllocator, @@ -368,6 +374,8 @@ impl Node { tun_outbound_rx: None, tun_reader_handle: None, tun_writer_handle: None, + dns_identity_rx: None, + dns_task: None, index_allocator: IndexAllocator::new(), peers_by_index: HashMap::new(), pending_outbound: HashMap::new(), @@ -423,6 +431,8 @@ impl Node { tun_outbound_rx: None, tun_reader_handle: None, tun_writer_handle: None, + dns_identity_rx: None, + dns_task: None, index_allocator: IndexAllocator::new(), peers_by_index: HashMap::new(), pending_outbound: HashMap::new(),