mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
DNS responder for .fips domain, two-node UDP example
Add DNS responder that resolves <npub>.fips queries to FipsAddress IPv6 addresses. Resolution is pure computation (npub → NodeAddr → IPv6) with identity cache priming as a side effect, enabling subsequent TUN packet routing to non-peer destinations. - New dns.rs module: resolve_fips_query(), handle_dns_packet() using simple-dns crate, run_dns_responder() async UDP server loop - DnsConfig in config.rs: enabled, bind_addr (127.0.0.1), port (5354) - Fourth select! arm in RX event loop for DNS identity channel - DNS task spawn/abort in node lifecycle - 10 new tests (420 total) Add examples/two-node-udp/ with standalone walkthrough for testing two FIPS nodes in Linux network namespaces over a veth pair. Includes network diagram, config files, DNS routing setup, and troubleshooting.
This commit is contained in:
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: <npub>.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 <node_addr>`
|
||||
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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<String>,
|
||||
|
||||
/// Listen port (`dns.port`). Defaults to 5354.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
//! FIPS DNS Responder
|
||||
//!
|
||||
//! Resolves `<npub>.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<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.
|
||||
///
|
||||
/// 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 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<u8>, Option<DnsResolvedIdentity>)> {
|
||||
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<u8> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -286,6 +286,12 @@ pub struct Node {
|
||||
/// TUN writer thread handle.
|
||||
tun_writer_handle: Option<JoinHandle<()>>,
|
||||
|
||||
// === DNS Responder ===
|
||||
/// Receiver for resolved identities from the DNS responder.
|
||||
dns_identity_rx: Option<crate::dns::DnsIdentityRx>,
|
||||
/// DNS responder task handle.
|
||||
dns_task: Option<tokio::task::JoinHandle<()>>,
|
||||
|
||||
// === 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(),
|
||||
|
||||
Reference in New Issue
Block a user