diff --git a/Cargo.lock b/Cargo.lock index 1a8a61e..18442c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,26 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.114", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -312,6 +332,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -397,6 +426,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clap" version = "4.5.56" @@ -928,6 +968,7 @@ dependencies = [ "rand 0.10.0", "ratatui", "rtnetlink", + "rustables", "secp256k1", "serde", "serde_json", @@ -1114,6 +1155,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "half" version = "2.7.1" @@ -1245,6 +1292,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1329,6 +1382,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libloading" version = "0.9.0" @@ -1529,6 +1592,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -1892,6 +1956,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "version_check", + "yansi", +] + [[package]] name = "quote" version = "1.0.44" @@ -2126,6 +2203,42 @@ dependencies = [ "tokio", ] +[[package]] +name = "rustables" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ff8788a90655f715f8236507161558a966c28a2845dc54d0f9985f9bbf389b" +dependencies = [ + "bindgen", + "bitflags 2.10.0", + "ipnetwork", + "libc", + "log", + "nix 0.30.1", + "regex", + "rustables-macros", + "thiserror 2.0.18", +] + +[[package]] +name = "rustables-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698b79177cbf57522a1dcc118ac31393dc2a7ccc9bdb3625a6601ff5c27a3085" +dependencies = [ + "once_cell", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3255,7 +3368,7 @@ dependencies = [ "blocking", "c2rust-bitfields", "futures", - "libloading", + "libloading 0.9.0", "log", "thiserror 2.0.18", "windows-sys 0.61.2", @@ -3350,6 +3463,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zerocopy" version = "0.8.33" diff --git a/Cargo.toml b/Cargo.toml index ee0f3ee..5cbfd81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,12 +33,13 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tun = { version = "0.8.5", features = ["async"] } libc = "0.2" rtnetlink = "0.20.0" -tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time"] } +tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process"] } futures = "0.3" simple-dns = "0.11.2" socket2 = { version = "0.6.2", features = ["all"] } tokio-socks = "0.5" bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true } +rustables = "0.8.7" [package.metadata.deb] maintainer = "Johnathan Corgan " @@ -64,6 +65,8 @@ assets = [ ["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"], + ["target/release/fips-gateway", "/usr/bin/", "755"], + ["packaging/debian/fips-gateway.service", "/lib/systemd/system/fips-gateway.service", "644"], ] conf-files = ["/etc/fips/fips.yaml", "/etc/fips/hosts"] @@ -76,6 +79,10 @@ tokio = { version = "1", features = ["test-util"] } name = "fipsctl" path = "src/bin/fipsctl.rs" +[[bin]] +name = "fips-gateway" +path = "src/bin/fips-gateway.rs" + [[bin]] name = "fipstop" path = "src/bin/fipstop/main.rs" diff --git a/docs/design/fips-gateway.md b/docs/design/fips-gateway.md new file mode 100644 index 0000000..84e754c --- /dev/null +++ b/docs/design/fips-gateway.md @@ -0,0 +1,398 @@ +# FIPS Outbound LAN Gateway + +`fips-gateway` is a sidecar binary that runs alongside the FIPS daemon, enabling +unmodified LAN hosts to reach mesh destinations. It provides DNS resolution of +`.fips` names to virtual IPv6 addresses from a managed pool and configures +kernel nftables NAT rules for traffic forwarding through the fips0 TUN +interface. LAN clients need no FIPS software — any device that can resolve DNS +and send IPv6 packets can use the gateway. + +## Architecture + +The gateway is a separate binary (`fips-gateway`), not part of the FIPS daemon. +It connects to the daemon indirectly: `.fips` DNS queries are forwarded to the +daemon's built-in resolver (localhost:5354), which resolves names to mesh +addresses and primes its identity cache as a side effect. The gateway then +allocates a virtual IP, installs NAT rules, and returns the virtual IP to the +LAN client. + +```text + LAN Client + | + DNS query (.fips) + | + v + +---------------------+ + | DNS Proxy | Listens on [::]:53 + | (dns.rs) | + +---------------------+ + | | + .fips query non-.fips → REFUSED + | + v + FIPS Daemon Resolver + (localhost:5354) + | + mesh address (fd00::/8) + | + v + +---------------------+ + | Virtual IP Pool | Allocates from pool CIDR + | (pool.rs) | + +---------------------+ + | + pool event (new/removed mapping) + | + v + +----------+----------+ + | | + v v ++---------------+ +-----------------+ +| NAT Manager | | Network Setup | +| (nat.rs) | | (net.rs) | +| DNAT/SNAT/ | | Proxy NDP, | +| masquerade | | pool route | ++---------------+ +-----------------+ + | | + v v + nftables rules ip -6 neigh proxy + (inet fips_gateway) ip -6 route local +``` + +### Data Flow + +1. LAN client queries `hostname.fips` via DNS +2. Gateway forwards to daemon resolver (localhost:5354) +3. Daemon resolves name to mesh address (fd00::/8), primes identity cache +4. Gateway allocates virtual IP from pool, creates DNAT/SNAT rules and proxy NDP + entry +5. Gateway returns AAAA record with virtual IP to client +6. Client sends traffic to virtual IP +7. Kernel DNAT rewrites destination to mesh address, masquerade rewrites source + to gateway's fips0 address +8. Traffic flows through fips0 into the mesh +9. Return traffic follows the reverse path via conntrack + +## NAT Pipeline + +The gateway manages a dedicated nftables table (`inet fips_gateway`) containing +two chains with rules that translate between virtual IPs and mesh addresses. + +### Prerouting DNAT + +A per-mapping rule in the `prerouting` chain (priority dstnat / -100) rewrites +the destination address from the virtual IP to the corresponding fd00::/8 mesh +address: + +```text +match: ip6 daddr == +action: dnat to +``` + +After DNAT, the kernel routes the packet through fips0 via the standard routing +table. + +### Postrouting Masquerade + +A single masquerade rule in the `postrouting` chain (priority srcnat / 100) +rewrites the source address of all traffic exiting via fips0 to the gateway's +own fips0 address: + +```text +match: oifname == "fips0" +action: masquerade +``` + +This is critical. Without masquerade, LAN client source addresses (e.g., +`fd01::5` from the virtual pool) would appear as the source on the mesh. These +addresses are meaningless to mesh peers, so return traffic would be black-holed. +Masquerade ensures all mesh traffic appears to originate from the gateway's own +FIPS identity. + +### Postrouting SNAT + +A per-mapping rule in the `postrouting` chain rewrites the source address of +return traffic from the mesh address back to the virtual IP: + +```text +match: ip6 saddr == +action: snat to +``` + +This ensures LAN hosts see responses from the virtual IP they connected to, +not from the raw fd00::/8 mesh address. + +### Atomic Table Rebuild + +The entire nftables table is rebuilt atomically on every mapping change. The +rebuild sequence is: delete the existing table (ignore ENOENT on first call), +then create a new table with all chains, the masquerade rule, and all +per-mapping DNAT/SNAT rules in a single netlink batch. + +This approach avoids relying on kernel rule handle tracking, which the rustables +crate does not expose. The table is small — one masquerade rule plus two rules +per active mapping — so rebuilding is cheap. + +## Virtual IP Pool Lifecycle + +The pool allocates IPv6 addresses from a configured CIDR range (e.g., +`fd01::/112`). Each address maps to one FIPS mesh destination (keyed by +NodeAddr, not hostname). Address 0 (network equivalent) is reserved; the +remaining addresses are available for allocation. + +### State Machine + +```text +ALLOCATED ──→ ACTIVE ──→ DRAINING ──→ FREE + │ ↑ + └───────────────────────────────────┘ + (TTL expired, no sessions) +``` + +| State | Description | +| ----- | ----------- | +| Allocated | DNS query created the mapping. No NAT sessions yet. | +| Active | Conntrack reports at least one active session. | +| Draining | TTL expired but sessions remain, or sessions ended and grace period is running. | +| Free | Reclaimed. Virtual IP returned to the available pool. | + +### Transitions + +- **Allocated to Active**: Conntrack reports sessions > 0. +- **Allocated to Free**: TTL expired with no sessions ever created. +- **Active to Draining**: TTL expired (sessions may or may not remain). +- **Draining to Free**: Sessions drop to zero and the grace period elapses. + +### Timing + +- **TTL**: Default 60 seconds (matches DNS TTL). Repeated DNS queries for the + same destination reset the `last_referenced` timestamp. +- **Grace period**: Default 60 seconds after draining begins with zero sessions. + Prevents immediate reuse that could confuse hosts with cached DNS responses. +- **Tick interval**: The pool evaluates state transitions every 10 seconds. + +### Conntrack Integration + +The pool queries `/proc/net/nf_conntrack` to count active sessions per virtual +IP. A session is counted if any conntrack entry's original destination matches +the virtual IP address. + +### Pool Exhaustion + +If no addresses are available, new DNS queries return SERVFAIL. Existing +mappings are never evicted prematurely — correctness of active sessions takes +priority over new allocations. The pool is capped at 2^16 addresses regardless +of CIDR prefix length to prevent excessive memory allocation. + +## DNS Resolution Flow + +1. Gateway listens on configured address (default `[::]:53`). +2. Client sends DNS query. +3. If the query is not for a `.fips` domain, return `REFUSED`. +4. Forward the query to the daemon resolver at `127.0.0.1:5354` (configurable). +5. If the daemon is unreachable or times out (5 seconds), return `SERVFAIL`. +6. If the daemon returns NXDOMAIN or an error, forward the response as-is. +7. Extract the AAAA record (fd00::/8 mesh address) from the daemon's response. +8. Allocate a virtual IP from the pool for this destination (idempotent — if a + mapping already exists, reuse it and refresh the TTL). +9. If a new mapping was created, emit a `MappingCreated` event to install NAT + rules and proxy NDP entry. +10. Build and return an AAAA response containing the virtual IP with the + configured TTL. + +The daemon's resolver populates its identity cache as a side effect of +resolution. This is required for fips0 routing to work — without the cache +entry, the daemon cannot map the fd00::/8 address back to a NodeAddr for mesh +routing. + +## Network Requirements + +### Gateway Host + +The following must be true on the machine running `fips-gateway`: + +- **FIPS daemon running** with TUN enabled (fips0 interface must exist) and DNS + resolver on port 5354 +- **IPv6 forwarding enabled**: `sysctl -w net.ipv6.conf.all.forwarding=1` +- **Proxy NDP enabled**: `sysctl -w net.ipv6.conf.all.proxy_ndp=1` +- **CAP_NET_ADMIN**: Required for nftables table management and proxy NDP + manipulation (run as root or set the capability) +- **Pool route**: The gateway adds `local dev lo` at startup, which + tells the kernel to accept packets destined for pool addresses as + locally-owned, enabling NAT processing. This route is cleaned up on shutdown. + +### LAN Clients + +LAN clients need no FIPS software. They require: + +- **Route to virtual IP pool**: `ip -6 route add via + `. This can be pushed via DHCP, configured on the LAN + router, or set per-host. +- **DNS resolution**: Either configure the LAN's main DNS server to forward + `.fips` queries to the gateway, or point individual hosts at the gateway for + DNS (noting that non-`.fips` queries will get `REFUSED`). + +## Configuration Reference + +All configuration lives under the `gateway` key in `fips.yaml`: + +```yaml +gateway: + enabled: true + pool: "fd01::/112" + lan_interface: "enp3s0" + dns: + listen: "[::]:53" + upstream: "127.0.0.1:5354" + ttl: 60 + pool_grace_period: 60 + conntrack: + tcp_established: 432000 + udp_timeout: 30 + udp_assured: 180 + icmp_timeout: 30 +``` + +| Field | Type | Default | Description | +| ----- | ---- | ------- | ----------- | +| `enabled` | bool | `false` | Enable the gateway. Must be `true` for `fips-gateway` to start. | +| `pool` | string (CIDR) | required | Virtual IP pool range (e.g., `fd01::/112`). | +| `lan_interface` | string | required | LAN-facing interface for proxy NDP entries. | +| `dns.listen` | string | `[::]:53` | Address and port for the gateway DNS listener. | +| `dns.upstream` | string | `127.0.0.1:5354` | FIPS daemon DNS resolver address. | +| `dns.ttl` | u32 | `60` | DNS response TTL in seconds. Also governs mapping TTL. | +| `pool_grace_period` | u64 | `60` | Seconds after last session before a mapping is reclaimed. | +| `conntrack.tcp_established` | u64 | `432000` | TCP established timeout (seconds). 5 days. | +| `conntrack.udp_timeout` | u64 | `30` | UDP unreplied timeout (seconds). | +| `conntrack.udp_assured` | u64 | `180` | UDP bidirectional (assured) timeout (seconds). | +| `conntrack.icmp_timeout` | u64 | `30` | ICMP timeout (seconds). | + +## Troubleshooting + +### "No gateway section in configuration" + +The `fips-gateway` binary loads the same config file as the daemon. If it +cannot find a `gateway:` section, use the `--config` flag to point at the +correct file: + +```bash +fips-gateway --config /etc/fips/fips.yaml +``` + +### DNS Queries Fail + +Verify the daemon resolver is running and reachable: + +```bash +dig @127.0.0.1 -p 5354 hostname.fips AAAA +``` + +If this fails, the daemon is not running or its DNS resolver is not enabled. +Check that the daemon config has `dns.enabled: true` (enabled by default). + +### Ping Works But TCP Does Not + +This usually means the masquerade rule is missing or misconfigured. Inspect the +nftables table: + +```bash +nft list table inet fips_gateway +``` + +Verify the postrouting chain contains a masquerade rule matching `oifname +"fips0"`. Without masquerade, the mesh peer sees a source address it cannot +route replies to. + +### Connection Timeout + +Check that IPv6 forwarding is enabled: + +```bash +sysctl net.ipv6.conf.all.forwarding +``` + +Verify the pool route exists: + +```bash +ip -6 route show table local | grep +``` + +If the route is missing, the kernel does not recognize pool addresses as local +and drops the packets before NAT can process them. + +### Virtual IP Unreachable From Client + +Verify the client has a route to the pool via the gateway: + +```bash +ip -6 route get +``` + +On the gateway, verify proxy NDP entries exist for allocated virtual IPs: + +```bash +ip -6 neigh show proxy +``` + +If proxy NDP entries are missing, the gateway cannot answer Neighbor Solicitation +requests for virtual IPs on the LAN, so clients cannot resolve the link-layer +address. + +### Port 53 Conflict + +If another DNS server (systemd-resolved, dnsmasq) is using port 53, the +gateway cannot bind. Options: + +```bash +# Check what is using port 53 +ss -tulnp | grep :53 + +# Use an alternate listen address +dns: + listen: "192.168.1.1:5353" +``` + +Then configure LAN clients to query the alternate port, or run a forwarding +stub on port 53 that delegates `.fips` queries to the gateway. + +## Security Considerations + +- **LAN trust boundary**: The gateway DNS listener is accessible to any host on + the LAN. Any host that can reach the DNS port and route to the virtual IP pool + can access mesh destinations through the gateway. Access restriction must be + enforced at the network level (firewall rules on the LAN interface). + +- **Identity masking**: All LAN traffic appears on the mesh under the gateway's + own FIPS identity. Mesh peers cannot determine which LAN host originated a + connection. This provides privacy for LAN hosts but means the gateway's + reputation covers all its clients. + +- **Plaintext at the gateway**: Traffic between LAN hosts and the gateway is + unencrypted at the IP layer. FIPS encryption (FSP) protects traffic between + the gateway and the destination mesh peer. Application-layer encryption (TLS, + SSH) provides end-to-end protection through the gateway. + +- **Pool addresses are ephemeral**: Virtual IPs are allocated dynamically and + recycled. They are not authenticated or bound to client identity. A LAN host + connecting to a virtual IP is trusting the gateway's DNS response. + +- **No client identity verification**: The gateway does not authenticate LAN + clients. Any host that can send packets is served. + +## Future Work + +- **IPv4 pool support**: NAT46 translation via TAYGA or Jool, allowing LAN + hosts to use IPv4 virtual addresses while the mesh remains IPv6. +- **Inbound gateway**: Exposing LAN services to mesh peers (mesh to LAN + direction), requiring port-forwarding or reverse-proxy configuration. +- **fipstop Gateway tab**: Monitoring integration showing pool utilization, + active mappings, and NAT session counts. +- **Gateway control socket**: Status queries via `fipsctl gateway status` and + `fipsctl gateway mappings` for operational visibility. + +## References + +- [fips-ipv6-adapter.md](fips-ipv6-adapter.md) — IPv6 adapter and TUN interface + design +- [fips-configuration.md](fips-configuration.md) — Configuration reference +- [fips-intro.md](fips-intro.md) — Protocol overview and architecture diff --git a/src/bin/fips-gateway.rs b/src/bin/fips-gateway.rs new file mode 100644 index 0000000..a047766 --- /dev/null +++ b/src/bin/fips-gateway.rs @@ -0,0 +1,426 @@ +//! FIPS outbound LAN gateway binary. +//! +//! Allows unmodified LAN hosts to reach FIPS mesh destinations via +//! DNS-allocated virtual IPs and kernel nftables NAT. + +use clap::Parser; +use fips::gateway::{control, dns, nat, net, pool}; +use fips::version; +use fips::Config; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Instant; +use tokio::signal::unix::{signal, SignalKind}; +use tokio::sync::{mpsc, watch, Mutex}; +use tracing::{error, info, warn}; +use tracing_subscriber::{fmt, EnvFilter}; + +/// FIPS outbound LAN gateway +#[derive(Parser, Debug)] +#[command( + name = "fips-gateway", + version = version::short_version(), + long_version = version::long_version(), + about +)] +struct Args { + /// Path to configuration file (overrides default search paths). + #[arg(short, long, value_name = "FILE")] + config: Option, + + /// Log level (trace, debug, info, warn, error). + #[arg(short, long, default_value = "info")] + log_level: String, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let args = Args::parse(); + + // Initialize logging + let filter = EnvFilter::builder() + .with_default_directive( + args.log_level + .parse() + .unwrap_or_else(|_| tracing::level_filters::LevelFilter::INFO.into()), + ) + .from_env_lossy(); + + fmt().with_env_filter(filter).with_target(true).init(); + + info!("fips-gateway {} starting", version::short_version()); + + // Load configuration + let config = if let Some(config_path) = &args.config { + match Config::load_file(config_path) { + Ok(config) => { + info!(path = %config_path.display(), "Loaded config file"); + config + } + Err(e) => { + error!("Failed to load config from {}: {}", config_path.display(), e); + std::process::exit(1); + } + } + } else { + match Config::load() { + Ok((config, paths)) => { + if paths.is_empty() { + warn!("No config files found, using defaults"); + } else { + for path in &paths { + info!(path = %path.display(), "Loaded config file"); + } + } + config + } + Err(e) => { + error!("Failed to load config: {}", e); + std::process::exit(1); + } + } + }; + + // Validate gateway config + let gw_config = match &config.gateway { + Some(gw) if gw.enabled => gw.clone(), + Some(_) => { + error!("Gateway section exists but is not enabled (gateway.enabled = false)"); + std::process::exit(1); + } + None => { + error!("No gateway section in configuration"); + std::process::exit(1); + } + }; + + info!(pool = %gw_config.pool, lan_interface = %gw_config.lan_interface, "Gateway config loaded"); + + // --- Prerequisites --- + + // Check IPv6 forwarding + net::check_ipv6_forwarding(); + + // Check fips0 interface exists + if let Err(e) = net::check_interface_exists("fips0").await { + error!(error = %e, "fips0 interface not found — is the FIPS daemon running?"); + std::process::exit(1); + } + + // Check LAN interface exists + if let Err(e) = net::check_interface_exists(&gw_config.lan_interface).await { + error!( + error = %e, + interface = %gw_config.lan_interface, + "LAN interface not found" + ); + std::process::exit(1); + } + + // Check DNS upstream reachability (proves the FIPS daemon is running) + { + let upstream = gw_config.dns.upstream(); + info!(upstream = %upstream, "Checking DNS upstream reachability"); + + use std::net::ToSocketAddrs; + let upstream_addr = match upstream.to_socket_addrs() { + Ok(mut addrs) => match addrs.next() { + Some(addr) => addr, + None => { + error!(upstream = %upstream, "DNS upstream address resolved to nothing"); + std::process::exit(1); + } + }, + Err(e) => { + error!(upstream = %upstream, error = %e, "Invalid DNS upstream address"); + std::process::exit(1); + } + }; + + // Build a minimal DNS query for "test.fips" AAAA + // Header: ID=0x1234, flags=0x0100 (standard query, RD=1), + // QDCOUNT=1, ANCOUNT=0, NSCOUNT=0, ARCOUNT=0 + // Question: 4test4fips0 QTYPE=AAAA(28) QCLASS=IN(1) + let query: Vec = vec![ + 0x12, 0x34, // ID + 0x01, 0x00, // Flags: standard query, RD=1 + 0x00, 0x01, // QDCOUNT = 1 + 0x00, 0x00, // ANCOUNT = 0 + 0x00, 0x00, // NSCOUNT = 0 + 0x00, 0x00, // ARCOUNT = 0 + // QNAME: "test.fips" + 0x04, b't', b'e', b's', b't', 0x04, b'f', b'i', b'p', b's', 0x00, + 0x00, 0x1C, // QTYPE = AAAA (28) + 0x00, 0x01, // QCLASS = IN (1) + ]; + + let bind_addr = if upstream_addr.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" }; + let sock = match tokio::net::UdpSocket::bind(bind_addr).await { + Ok(s) => s, + Err(e) => { + error!(error = %e, "Failed to bind UDP socket for DNS check"); + std::process::exit(1); + } + }; + + if let Err(e) = sock.send_to(&query, upstream_addr).await { + error!( + upstream = %upstream, error = %e, + "Failed to send DNS probe — is the FIPS daemon running?" + ); + std::process::exit(1); + } + + let mut buf = [0u8; 512]; + match tokio::time::timeout( + std::time::Duration::from_secs(3), + sock.recv_from(&mut buf), + ) + .await + { + Ok(Ok(_)) => { + info!(upstream = %upstream, "DNS upstream is reachable"); + } + Ok(Err(e)) => { + error!( + upstream = %upstream, error = %e, + "DNS upstream recv failed — is the FIPS daemon running?" + ); + std::process::exit(1); + } + Err(_) => { + error!( + upstream = %upstream, + "DNS upstream did not respond within 3s — is the FIPS daemon running?" + ); + std::process::exit(1); + } + } + } + + // --- Initialize components --- + + // Virtual IP pool + let ip_pool = match pool::VirtualIpPool::new( + &gw_config.pool, + gw_config.dns.ttl() as u64, + gw_config.grace_period(), + ) { + Ok(p) => Arc::new(Mutex::new(p)), + Err(e) => { + error!(error = %e, "Failed to create virtual IP pool"); + std::process::exit(1); + } + }; + + // NAT manager + let mut nat_mgr = match nat::NatManager::new() { + Ok(n) => n, + Err(e) => { + error!(error = %e, "Failed to create nftables table — do you have CAP_NET_ADMIN?"); + std::process::exit(1); + } + }; + + // Network setup + let mut net_setup = net::NetSetup::new( + gw_config.lan_interface.clone(), + gw_config.pool.clone(), + ); + + // Add pool route + if let Err(e) = net_setup.add_pool_route().await { + error!(error = %e, "Failed to add pool route"); + // Clean up NAT table before exit + let _ = nat_mgr.cleanup(); + std::process::exit(1); + } + + // --- Channels --- + + // Pool events (new/removed mappings) → NAT + net modules + let (event_tx, mut event_rx) = mpsc::channel::(64); + + // Shutdown signal + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + // --- Start DNS resolver task --- + + let dns_pool = Arc::clone(&ip_pool); + let dns_event_tx = event_tx.clone(); + let dns_shutdown = shutdown_rx.clone(); + let dns_listen = gw_config.dns.listen().to_string(); + let dns_upstream = gw_config.dns.upstream().to_string(); + let dns_ttl = gw_config.dns.ttl(); + + let dns_task = tokio::spawn(async move { + if let Err(e) = dns::run_dns_resolver( + &dns_listen, + &dns_upstream, + dns_ttl, + dns_pool, + dns_event_tx, + dns_shutdown, + ) + .await + { + error!(error = %e, "DNS resolver error"); + } + }); + + // --- Snapshot channel for control socket --- + + let (snapshot_tx, snapshot_rx) = + watch::channel::>(None); + let start_time = Instant::now(); + + // --- Start control socket --- + + let control_task = match control::GatewayControlSocket::bind() { + Ok(socket) => { + let rx = snapshot_rx.clone(); + Some(tokio::spawn(async move { + socket.accept_loop(rx).await; + })) + } + Err(e) => { + warn!(error = %e, "Failed to bind gateway control socket — continuing without it"); + None + } + }; + + // --- NAT mapping counter (shared with tick task for snapshots) --- + + let nat_count = Arc::new(AtomicUsize::new(0)); + + // --- Start pool tick task --- + + let tick_pool = Arc::clone(&ip_pool); + let tick_event_tx = event_tx; + let tick_nat_count = Arc::clone(&nat_count); + let mut tick_shutdown = shutdown_rx.clone(); + let conntrack = pool::ProcConntrack; + let snap_config = control::SnapshotConfig { + pool_cidr: gw_config.pool.clone(), + lan_interface: gw_config.lan_interface.clone(), + dns_upstream: gw_config.dns.upstream().to_string(), + dns_listen: gw_config.dns.listen().to_string(), + dns_ttl: gw_config.dns.ttl(), + pool_grace_period: gw_config.grace_period(), + }; + + let tick_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(10)); + loop { + tokio::select! { + _ = interval.tick() => { + let now = Instant::now(); + let mut pool_guard = tick_pool.lock().await; + let events = pool_guard.tick(now, &conntrack); + + // Build snapshot for control socket + let pool_status = pool_guard.status(); + let mappings = pool_guard.mapping_info(now); + drop(pool_guard); + + let snapshot = control::build_snapshot( + pool_status, + mappings, + tick_nat_count.load(Ordering::Relaxed), + start_time, + &snap_config, + ); + let _ = snapshot_tx.send(Some(snapshot)); + + for event in events { + let _ = tick_event_tx.send(event).await; + } + } + _ = tick_shutdown.changed() => break, + } + } + }); + + // --- Event processing loop --- + + let mut sigterm = signal(SignalKind::terminate()).expect("failed to register SIGTERM handler"); + + info!("fips-gateway running"); + + loop { + tokio::select! { + Some(event) = event_rx.recv() => { + match event { + pool::PoolEvent::MappingCreated { virtual_ip, mesh_addr } => { + // Add NAT rules + if let Err(e) = nat_mgr.add_mapping(virtual_ip, mesh_addr) { + error!(error = %e, virtual_ip = %virtual_ip, "Failed to add NAT rules"); + } + nat_count.store(nat_mgr.mapping_count(), Ordering::Relaxed); + // Add proxy NDP entry + if let Err(e) = net_setup.add_proxy_ndp(virtual_ip).await { + error!(error = %e, virtual_ip = %virtual_ip, "Failed to add proxy NDP"); + } + } + pool::PoolEvent::MappingRemoved { virtual_ip, mesh_addr: _ } => { + // Remove NAT rules + if let Err(e) = nat_mgr.remove_mapping(virtual_ip) { + warn!(error = %e, virtual_ip = %virtual_ip, "Failed to remove NAT rules"); + } + nat_count.store(nat_mgr.mapping_count(), Ordering::Relaxed); + // Remove proxy NDP entry + if let Err(e) = net_setup.remove_proxy_ndp(virtual_ip).await { + warn!(error = %e, virtual_ip = %virtual_ip, "Failed to remove proxy NDP"); + } + } + } + } + _ = tokio::signal::ctrl_c() => { + info!("Received SIGINT, shutting down"); + break; + } + _ = sigterm.recv() => { + info!("Received SIGTERM, shutting down"); + break; + } + } + } + + // --- Shutdown --- + + info!("fips-gateway shutting down"); + + // Signal all tasks to stop + let _ = shutdown_tx.send(true); + + // Wait for tasks (control task is cancelled by dropping the listener) + if let Some(task) = control_task { + task.abort(); + let _ = task.await; + } + let _ = dns_task.await; + let _ = tick_task.await; + + // Log final pool status + { + let pool_guard = ip_pool.lock().await; + let status = pool_guard.status(); + info!( + total = status.total, + allocated = status.allocated, + active = status.active, + draining = status.draining, + free = status.free, + "Final pool status" + ); + } + + // Clean up network and NAT + net_setup.cleanup().await; + if let Err(e) = nat_mgr.cleanup() { + warn!(error = %e, "Failed to clean up nftables table"); + } + + info!("fips-gateway shutdown complete"); +} diff --git a/src/bin/fipstop/app.rs b/src/bin/fipstop/app.rs index 308e5c8..191e2f4 100644 --- a/src/bin/fipstop/app.rs +++ b/src/bin/fipstop/app.rs @@ -14,10 +14,11 @@ pub enum Tab { Cache, Transports, Routing, + Gateway, } impl Tab { - pub const ALL: [Tab; 8] = [ + pub const ALL: [Tab; 9] = [ Tab::Node, Tab::Peers, Tab::Transports, @@ -26,13 +27,15 @@ impl Tab { Tab::Bloom, Tab::Mmp, Tab::Routing, + Tab::Gateway, ]; - /// Tab group index: 0 = Node, 1 = Connectivity, 2 = Internals. + /// Tab group index: 0 = Node, 1 = Connectivity, 2 = Internals, 3 = Gateway. pub fn group(&self) -> usize { match self { Tab::Node => 0, Tab::Peers | Tab::Transports => 1, + Tab::Gateway => 3, _ => 2, } } @@ -49,6 +52,7 @@ impl Tab { Tab::Cache => "Cache", Tab::Transports => "Transports", Tab::Routing => "Routing", + Tab::Gateway => "Gateway", } } @@ -64,6 +68,7 @@ impl Tab { Tab::Cache => "show_cache", Tab::Transports => "show_transports", Tab::Routing => "show_routing", + Tab::Gateway => "show_gateway", } } @@ -88,6 +93,7 @@ impl Tab { Tab::Links => "links", Tab::Sessions => "sessions", Tab::Transports => "transports", + Tab::Gateway => "mappings", _ => "", } } @@ -96,7 +102,7 @@ impl Tab { pub fn has_table(&self) -> bool { matches!( self, - Tab::Peers | Tab::Sessions | Tab::Transports + Tab::Peers | Tab::Sessions | Tab::Transports | Tab::Gateway ) } } @@ -131,6 +137,10 @@ pub struct App { pub expanded_transports: HashSet, pub tree_row_count: usize, pub selected_tree_item: SelectedTreeItem, + /// Whether the gateway control socket is reachable. + pub gateway_running: bool, + /// Mappings data fetched from the gateway (separate from summary). + pub gateway_mappings: Option, } impl App { @@ -148,6 +158,8 @@ impl App { expanded_transports: HashSet::new(), tree_row_count: 0, selected_tree_item: SelectedTreeItem::None, + gateway_running: false, + gateway_mappings: None, } } @@ -156,6 +168,15 @@ impl App { if self.active_tab == Tab::Transports { return self.tree_row_count; } + if self.active_tab == Tab::Gateway { + return self + .gateway_mappings + .as_ref() + .and_then(|v| v.get("mappings")) + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + } let key = self.active_tab.command_data_key(); self.data .get(&self.active_tab) diff --git a/src/bin/fipstop/main.rs b/src/bin/fipstop/main.rs index e75db5c..33f759b 100644 --- a/src/bin/fipstop/main.rs +++ b/src/bin/fipstop/main.rs @@ -25,6 +25,10 @@ struct Cli { #[arg(short = 's', long)] socket: Option, + /// Gateway control socket path override + #[arg(long)] + gateway_socket: Option, + /// Refresh interval in seconds #[arg(short = 'r', long, default_value = "2")] refresh: u64, @@ -46,11 +50,27 @@ fn default_socket_path() -> PathBuf { } } +/// Determine the default gateway socket path. +fn default_gateway_socket_path() -> PathBuf { + if Path::new("/run/fips").exists() { + PathBuf::from("/run/fips/gateway.sock") + } else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + PathBuf::from(format!("{runtime_dir}/fips/gateway.sock")) + } else { + PathBuf::from("/tmp/fips-gateway.sock") + } +} + fn restore_terminal() { ratatui::restore(); } -fn fetch_data(rt: &tokio::runtime::Runtime, client: &ControlClient, app: &mut App) { +fn fetch_data( + rt: &tokio::runtime::Runtime, + client: &ControlClient, + gateway_client: &ControlClient, + app: &mut App, +) { // Always fetch status for the status bar match rt.block_on(client.query("show_status")) { Ok(data) => { @@ -64,6 +84,34 @@ fn fetch_data(rt: &tokio::runtime::Runtime, client: &ControlClient, app: &mut Ap } } + // Gateway tab uses a separate socket + if app.active_tab == Tab::Gateway { + match rt.block_on(gateway_client.query("show_gateway")) { + Ok(data) => { + app.data.insert(Tab::Gateway, data); + app.gateway_running = true; + } + Err(_) => { + app.data.remove(&Tab::Gateway); + app.gateway_running = false; + app.gateway_mappings = None; + } + } + // Also fetch mappings for the detail table + if app.gateway_running { + match rt.block_on(gateway_client.query("show_mappings")) { + Ok(data) => { + app.gateway_mappings = Some(data); + } + Err(_) => { + app.gateway_mappings = None; + } + } + } + app.last_fetch = std::time::Instant::now(); + return; + } + // Fetch active tab data (if not Dashboard, which we already fetched) if app.active_tab != Tab::Node { match rt.block_on(client.query(app.active_tab.command())) { @@ -106,6 +154,7 @@ fn main() { let cli = Cli::parse(); let socket_path = cli.socket.unwrap_or_else(default_socket_path); + let gateway_socket_path = cli.gateway_socket.unwrap_or_else(default_gateway_socket_path); let refresh = Duration::from_secs(cli.refresh); // Install panic hook that restores terminal before printing panic @@ -121,6 +170,7 @@ fn main() { .expect("failed to create tokio runtime"); let client = ControlClient::new(&socket_path); + let gateway_client = ControlClient::new(&gateway_socket_path); let mut terminal = ratatui::try_init().unwrap_or_else(|e| { eprintln!("fipstop: failed to initialize terminal: {e}"); std::process::exit(1); @@ -129,7 +179,7 @@ fn main() { let events = EventHandler::new(refresh); // Initial fetch - fetch_data(&rt, &client, &mut app); + fetch_data(&rt, &client, &gateway_client, &mut app); // Main loop loop { @@ -150,12 +200,12 @@ fn main() { (KeyCode::Tab, KeyModifiers::NONE) => { app.close_detail(); app.active_tab = app.active_tab.next(); - fetch_data(&rt, &client, &mut app); + fetch_data(&rt, &client, &gateway_client, &mut app); } (KeyCode::BackTab, _) => { app.close_detail(); app.active_tab = app.active_tab.prev(); - fetch_data(&rt, &client, &mut app); + fetch_data(&rt, &client, &gateway_client, &mut app); } (KeyCode::Down, _) => { if app.detail_view.is_some() { @@ -225,7 +275,7 @@ fn main() { // Redraw happens at top of loop } Ok(Event::Tick) => { - fetch_data(&rt, &client, &mut app); + fetch_data(&rt, &client, &gateway_client, &mut app); } Err(_) => { app.should_quit = true; diff --git a/src/bin/fipstop/ui/gateway.rs b/src/bin/fipstop/ui/gateway.rs new file mode 100644 index 0000000..0a18aa8 --- /dev/null +++ b/src/bin/fipstop/ui/gateway.rs @@ -0,0 +1,272 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Borders, Cell, Gauge, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, + Table, +}; + +use crate::app::{App, Tab}; + +use super::helpers; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + if !app.gateway_running { + let msg = Paragraph::new(" Gateway not running") + .style(Style::default().fg(Color::DarkGray)) + .block(Block::default().borders(Borders::ALL).title(" Gateway ")); + frame.render_widget(msg, area); + return; + } + + let chunks = Layout::vertical([ + Constraint::Length(12), // summary + Constraint::Min(1), // mappings table + ]) + .split(area); + + draw_summary(frame, app, chunks[0]); + draw_mappings(frame, app, chunks[1]); +} + +fn draw_summary(frame: &mut Frame, app: &App, area: Rect) { + let data = match app.data.get(&Tab::Gateway) { + Some(d) => d, + None => { + let msg = Paragraph::new(" Waiting for data...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, area); + return; + } + }; + + let chunks = Layout::vertical([ + Constraint::Length(7), // pool + config + info + Constraint::Length(3), // gauge + Constraint::Min(0), + ]) + .split(area); + + // Pool and info section + let block = Block::default() + .borders(Borders::ALL) + .title(" Gateway "); + let inner = block.inner(chunks[0]); + frame.render_widget(block, chunks[0]); + + let total = data.get("pool_total").and_then(|v| v.as_u64()).unwrap_or(0); + let allocated = data.get("pool_allocated").and_then(|v| v.as_u64()).unwrap_or(0); + let active = data.get("pool_active").and_then(|v| v.as_u64()).unwrap_or(0); + let draining = data.get("pool_draining").and_then(|v| v.as_u64()).unwrap_or(0); + let free = data.get("pool_free").and_then(|v| v.as_u64()).unwrap_or(0); + let nat = data.get("nat_mappings").and_then(|v| v.as_u64()).unwrap_or(0); + let dns = helpers::str_field(data, "dns_listen"); + let uptime_secs = data.get("uptime_secs").and_then(|v| v.as_u64()).unwrap_or(0); + let pool_cidr = helpers::str_field(data, "pool_cidr"); + let lan_iface = helpers::str_field(data, "lan_interface"); + let dns_upstream = helpers::str_field(data, "dns_upstream"); + let dns_ttl = data.get("dns_ttl").and_then(|v| v.as_u64()).unwrap_or(0); + let grace = data.get("pool_grace_period").and_then(|v| v.as_u64()).unwrap_or(0); + + let label = Style::default().fg(Color::DarkGray); + let count = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let val = Style::default().fg(Color::White); + + let lines = vec![ + Line::from(vec![ + Span::styled(" pool: ", label), + Span::styled(pool_cidr.to_string(), val), + Span::styled(" iface: ", label), + Span::styled(lan_iface.to_string(), val), + Span::styled(" dns: ", label), + Span::styled(format!("{dns} → {dns_upstream}"), val), + Span::styled(" ttl: ", label), + Span::styled(format!("{dns_ttl}s"), val), + Span::styled(" grace: ", label), + Span::styled(format!("{grace}s"), val), + ]), + Line::from(vec![ + Span::styled(" total: ", label), + Span::styled(total.to_string(), count), + Span::styled(" allocated: ", label), + Span::styled(allocated.to_string(), count), + Span::styled(" active: ", label), + Span::styled(active.to_string(), count), + Span::styled(" draining: ", label), + Span::styled(draining.to_string(), count), + Span::styled(" free: ", label), + Span::styled(free.to_string(), count), + ]), + Line::from(vec![ + Span::styled(" NAT mappings: ", label), + Span::styled(nat.to_string(), count), + Span::styled(" uptime: ", label), + Span::raw(format_uptime(uptime_secs)), + ]), + ]; + frame.render_widget(Paragraph::new(lines), inner); + + // Pool utilization gauge + let ratio = if total > 0 { + allocated as f64 / total as f64 + } else { + 0.0 + }; + let pct = (ratio * 100.0).min(100.0); + let gauge_label = format!("{allocated}/{total} allocated ({pct:.1}%)"); + let gauge_color = if pct > 90.0 { + Color::Red + } else if pct > 70.0 { + Color::Yellow + } else { + Color::Green + }; + let gauge = Gauge::default() + .block( + Block::default() + .borders(Borders::ALL) + .title(" Pool Utilization "), + ) + .gauge_style(Style::default().fg(gauge_color)) + .ratio(ratio.min(1.0)) + .label(gauge_label); + frame.render_widget(gauge, chunks[1]); +} + +fn draw_mappings(frame: &mut Frame, app: &mut App, area: Rect) { + let mappings = app + .gateway_mappings + .as_ref() + .and_then(|v| v.get("mappings")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let row_count = mappings.len(); + + let header = Row::new(vec![ + Cell::from("Virtual IP"), + Cell::from("DNS Name"), + Cell::from("Mesh Addr"), + Cell::from("State"), + Cell::from("Sessions"), + Cell::from("Age"), + Cell::from("Last Ref"), + ]) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = mappings + .iter() + .map(|m| { + let vip = helpers::str_field(m, "virtual_ip"); + let dns_name = helpers::str_field(m, "dns_name"); + let mesh = helpers::truncate_hex(helpers::str_field(m, "mesh_addr"), 24); + let state = helpers::str_field(m, "state"); + let sessions = helpers::u64_field(m, "sessions"); + let age = m + .get("age_secs") + .and_then(|v| v.as_u64()) + .map(format_duration_secs) + .unwrap_or_else(|| "-".into()); + let last_ref = m + .get("last_ref_secs") + .and_then(|v| v.as_u64()) + .map(|s| format!("{s}s ago")) + .unwrap_or_else(|| "-".into()); + + let row_style = match state { + "Active" => Style::default().fg(Color::Green), + "Draining" => Style::default().fg(Color::Yellow), + _ => Style::default(), + }; + + Row::new(vec![ + Cell::from(vip.to_string()), + Cell::from(dns_name.to_string()), + Cell::from(mesh), + Cell::from(state.to_string()), + Cell::from(sessions), + Cell::from(age), + Cell::from(last_ref), + ]) + .style(row_style) + }) + .collect(); + + let widths = [ + Constraint::Min(18), // Virtual IP + Constraint::Min(30), // DNS Name + Constraint::Min(26), // Mesh Addr + Constraint::Length(10), // State + Constraint::Length(10), // Sessions + Constraint::Length(10), // Age + Constraint::Length(12), // Last Ref + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" NAT Mappings ({row_count}) ")), + ) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + let state = app.table_states.entry(Tab::Gateway).or_default(); + frame.render_stateful_widget(table, area, state); + + if row_count > 0 { + let selected = state.selected().unwrap_or(0); + let mut scrollbar_state = ScrollbarState::new(row_count).position(selected); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None), + area, + &mut scrollbar_state, + ); + } +} + +/// Format seconds as compact duration (e.g., "3d 2h", "15m 4s"). +fn format_uptime(secs: u64) -> String { + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let mins = (secs % 3600) / 60; + let s = secs % 60; + + if days > 0 { + format!("{days}d {hours}h {mins}m {s}s") + } else if hours > 0 { + format!("{hours}h {mins}m {s}s") + } else if mins > 0 { + format!("{mins}m {s}s") + } else { + format!("{s}s") + } +} + +/// Format seconds as compact duration for table cells. +fn format_duration_secs(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m {}s", secs / 60, secs % 60) + } else if secs < 86400 { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } else { + format!("{}d {}h", secs / 86400, (secs % 86400) / 3600) + } +} diff --git a/src/bin/fipstop/ui/mod.rs b/src/bin/fipstop/ui/mod.rs index 2bedfb0..f380fe9 100644 --- a/src/bin/fipstop/ui/mod.rs +++ b/src/bin/fipstop/ui/mod.rs @@ -1,5 +1,6 @@ mod bloom; mod dashboard; +mod gateway; mod helpers; mod mmp; mod peers; @@ -71,6 +72,7 @@ fn draw_content(frame: &mut Frame, app: &mut App, area: Rect) { Tab::Cache => {} // data stored for Routing tab cross-reference Tab::Routing => routing::draw(frame, app, area), Tab::Links => {} // not a navigable tab; data stored for cross-references + Tab::Gateway => gateway::draw(frame, app, area), } } diff --git a/src/config/gateway.rs b/src/config/gateway.rs new file mode 100644 index 0000000..3afdd37 --- /dev/null +++ b/src/config/gateway.rs @@ -0,0 +1,209 @@ +//! Gateway configuration types. +//! +//! Configuration for the outbound LAN gateway (`gateway.*`). + +use serde::{Deserialize, Serialize}; + +/// Default gateway DNS listen address. +const DEFAULT_DNS_LISTEN: &str = "[::]:53"; + +/// Default upstream DNS resolver (FIPS daemon). +const DEFAULT_DNS_UPSTREAM: &str = "127.0.0.1:5354"; + +/// Default DNS TTL in seconds. +const DEFAULT_DNS_TTL: u32 = 60; + +/// Default pool grace period in seconds. +const DEFAULT_GRACE_PERIOD: u64 = 60; + +/// Default conntrack TCP established timeout (5 days). +const DEFAULT_CT_TCP_ESTABLISHED: u64 = 432_000; + +/// Default conntrack UDP timeout (unreplied). +const DEFAULT_CT_UDP_TIMEOUT: u64 = 30; + +/// Default conntrack UDP assured timeout (bidirectional). +const DEFAULT_CT_UDP_ASSURED: u64 = 180; + +/// Default conntrack ICMP timeout. +const DEFAULT_CT_ICMP_TIMEOUT: u64 = 30; + +/// Gateway configuration (`gateway.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayConfig { + /// Enable the gateway (`gateway.enabled`, default: false). + #[serde(default)] + pub enabled: bool, + + /// Virtual IP pool CIDR (e.g., `fd01::/112`). + pub pool: String, + + /// LAN-facing interface for proxy ARP/NDP. + pub lan_interface: String, + + /// Gateway DNS configuration. + #[serde(default)] + pub dns: GatewayDnsConfig, + + /// Pool grace period in seconds after last session before reclamation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pool_grace_period: Option, + + /// Conntrack timeout overrides. + #[serde(default)] + pub conntrack: ConntrackConfig, +} + +impl GatewayConfig { + /// Get pool grace period (default: 60 seconds). + pub fn grace_period(&self) -> u64 { + self.pool_grace_period.unwrap_or(DEFAULT_GRACE_PERIOD) + } +} + +/// Gateway DNS resolver configuration (`gateway.dns.*`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GatewayDnsConfig { + /// Listen address and port (default: `0.0.0.0:53`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, + + /// Upstream FIPS daemon DNS resolver (default: `127.0.0.1:5354`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option, + + /// DNS record TTL in seconds (default: 60). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl: Option, +} + +impl GatewayDnsConfig { + /// Get the listen address (default: `0.0.0.0:53`). + pub fn listen(&self) -> &str { + self.listen.as_deref().unwrap_or(DEFAULT_DNS_LISTEN) + } + + /// Get the upstream resolver address (default: `127.0.0.1:5354`). + pub fn upstream(&self) -> &str { + self.upstream.as_deref().unwrap_or(DEFAULT_DNS_UPSTREAM) + } + + /// Get the TTL in seconds (default: 60). + pub fn ttl(&self) -> u32 { + self.ttl.unwrap_or(DEFAULT_DNS_TTL) + } +} + +/// Conntrack timeout overrides (`gateway.conntrack.*`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConntrackConfig { + /// TCP established timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tcp_established: Option, + + /// UDP unreplied timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub udp_timeout: Option, + + /// UDP assured (bidirectional) timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub udp_assured: Option, + + /// ICMP timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icmp_timeout: Option, +} + +impl ConntrackConfig { + /// TCP established timeout (default: 432000s / 5 days). + pub fn tcp_established(&self) -> u64 { + self.tcp_established.unwrap_or(DEFAULT_CT_TCP_ESTABLISHED) + } + + /// UDP unreplied timeout (default: 30s). + pub fn udp_timeout(&self) -> u64 { + self.udp_timeout.unwrap_or(DEFAULT_CT_UDP_TIMEOUT) + } + + /// UDP assured timeout (default: 180s). + pub fn udp_assured(&self) -> u64 { + self.udp_assured.unwrap_or(DEFAULT_CT_UDP_ASSURED) + } + + /// ICMP timeout (default: 30s). + pub fn icmp_timeout(&self) -> u64 { + self.icmp_timeout.unwrap_or(DEFAULT_CT_ICMP_TIMEOUT) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gateway_config_defaults() { + let yaml = r#" +pool: "fd01::/112" +lan_interface: "eth0" +"#; + let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(!config.enabled); + assert_eq!(config.pool, "fd01::/112"); + assert_eq!(config.lan_interface, "eth0"); + assert_eq!(config.dns.listen(), "[::]:53"); + assert_eq!(config.dns.upstream(), "127.0.0.1:5354"); + assert_eq!(config.dns.ttl(), 60); + assert_eq!(config.grace_period(), 60); + assert_eq!(config.conntrack.tcp_established(), 432_000); + assert_eq!(config.conntrack.udp_timeout(), 30); + } + + #[test] + fn test_gateway_config_custom() { + let yaml = r#" +enabled: true +pool: "fd01::/112" +lan_interface: "enp3s0" +dns: + listen: "192.168.1.1:53" + upstream: "127.0.0.1:5354" + ttl: 120 +pool_grace_period: 30 +conntrack: + tcp_established: 3600 + udp_timeout: 60 +"#; + let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.enabled); + assert_eq!(config.dns.listen(), "192.168.1.1:53"); + assert_eq!(config.dns.ttl(), 120); + assert_eq!(config.grace_period(), 30); + assert_eq!(config.conntrack.tcp_established(), 3600); + assert_eq!(config.conntrack.udp_timeout(), 60); + // Unset fields use defaults + assert_eq!(config.conntrack.udp_assured(), 180); + assert_eq!(config.conntrack.icmp_timeout(), 30); + } + + #[test] + fn test_root_config_with_gateway() { + let yaml = r#" +gateway: + enabled: true + pool: "fd01::/112" + lan_interface: "eth0" +"#; + let config: crate::Config = serde_yaml::from_str(yaml).unwrap(); + assert!(config.gateway.is_some()); + let gw = config.gateway.unwrap(); + assert!(gw.enabled); + assert_eq!(gw.pool, "fd01::/112"); + } + + #[test] + fn test_root_config_without_gateway() { + let yaml = "node: {}"; + let config: crate::Config = serde_yaml::from_str(yaml).unwrap(); + assert!(config.gateway.is_none()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 65848c3..2b18e28 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -18,6 +18,7 @@ //! nsec: "nsec1..." //! ``` +mod gateway; mod node; mod peer; mod transport; @@ -33,6 +34,7 @@ pub use node::{ NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig, }; +pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig}; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; pub use transport::{BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig}; @@ -316,6 +318,10 @@ pub struct Config { /// Static peers to connect to (`peers`). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub peers: Vec, + + /// Gateway configuration (`gateway`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gateway: Option, } impl Config { @@ -434,6 +440,10 @@ impl Config { if !other.peers.is_empty() { self.peers = other.peers; } + // Merge gateway section — higher-priority config replaces entirely + if other.gateway.is_some() { + self.gateway = other.gateway; + } } /// Create an Identity from this configuration. diff --git a/src/gateway/control.rs b/src/gateway/control.rs new file mode 100644 index 0000000..8e41be7 --- /dev/null +++ b/src/gateway/control.rs @@ -0,0 +1,443 @@ +//! Gateway control socket for runtime status queries. +//! +//! Provides a Unix domain socket that accepts JSON commands and returns +//! structured responses. Uses the same line-delimited JSON protocol as +//! the daemon control socket: one JSON line in, one JSON line out, then +//! close. + +use crate::control::protocol::{Request, Response}; +use crate::gateway::pool::{MappingInfo, MappingState, PoolStatus}; +use std::path::{Path, PathBuf}; +use std::time::Instant; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixListener; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +/// Socket path for the gateway control socket. +pub const GATEWAY_SOCKET_PATH: &str = "/run/fips/gateway.sock"; + +/// Maximum request size in bytes (4 KB). +const MAX_REQUEST_SIZE: usize = 4096; + +/// I/O timeout for client connections. +const IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Snapshot of gateway state published by the main loop. +#[derive(Clone)] +pub struct GatewaySnapshot { + pub pool: PoolStatus, + pub mappings: Vec, + pub nat_mappings: usize, + pub dns_listen: String, + pub uptime_secs: u64, + // Config fields + pub pool_cidr: String, + pub lan_interface: String, + pub dns_upstream: String, + pub dns_ttl: u32, + pub pool_grace_period: u64, +} + +/// Gateway control socket listener. +pub struct GatewayControlSocket { + listener: UnixListener, + socket_path: PathBuf, +} + +impl GatewayControlSocket { + /// Bind the gateway control socket. + /// + /// Creates parent directories if needed, removes stale socket files, + /// and sets `root:fips 0770` permissions. + pub fn bind() -> Result { + let socket_path = PathBuf::from(GATEWAY_SOCKET_PATH); + + // Create parent directory if it doesn't exist + if let Some(parent) = socket_path.parent() + && !parent.exists() + { + std::fs::create_dir_all(parent)?; + debug!(path = %parent.display(), "Created gateway control socket directory"); + } + + // Remove stale socket if it exists + if socket_path.exists() { + Self::remove_stale_socket(&socket_path)?; + } + + let listener = UnixListener::bind(&socket_path)?; + + // Set permissions to 0770 and chown to fips group + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o770))?; + Self::chown_to_fips_group(&socket_path); + if let Some(parent) = socket_path.parent() { + Self::chown_to_fips_group(parent); + } + + info!(path = %socket_path.display(), "Gateway control socket listening"); + + Ok(Self { + listener, + socket_path, + }) + } + + /// Remove a stale socket file from a previous unclean exit. + fn remove_stale_socket(path: &Path) -> Result<(), std::io::Error> { + match std::os::unix::net::UnixStream::connect(path) { + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + format!("gateway control socket already in use: {}", path.display()), + )), + Err(_) => { + debug!(path = %path.display(), "Removing stale gateway control socket"); + std::fs::remove_file(path)?; + Ok(()) + } + } + } + + /// Set group ownership to the `fips` group (best-effort). + fn chown_to_fips_group(path: &Path) { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let group_name = CString::new("fips").unwrap(); + let grp = unsafe { libc::getgrnam(group_name.as_ptr()) }; + if grp.is_null() { + debug!("'fips' group not found, skipping chown for {}", path.display()); + return; + } + let gid = unsafe { (*grp).gr_gid }; + + let c_path = match CString::new(path.as_os_str().as_bytes()) { + Ok(p) => p, + Err(_) => return, + }; + let ret = unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) }; + if ret != 0 { + warn!( + path = %path.display(), + error = %std::io::Error::last_os_error(), + "Failed to chown gateway control socket to 'fips' group" + ); + } + } + + /// Run the accept loop, reading the latest snapshot from the watch channel. + pub async fn accept_loop(self, snapshot_rx: watch::Receiver>) { + loop { + let (stream, _addr) = match self.listener.accept().await { + Ok(conn) => conn, + Err(e) => { + warn!(error = %e, "Gateway control socket accept failed"); + continue; + } + }; + + let rx = snapshot_rx.clone(); + tokio::spawn(async move { + if let Err(e) = Self::handle_connection(stream, rx).await { + debug!(error = %e, "Gateway control connection error"); + } + }); + } + } + + /// Handle a single client connection. + async fn handle_connection( + stream: tokio::net::UnixStream, + snapshot_rx: watch::Receiver>, + ) -> Result<(), Box> { + let (reader, mut writer) = stream.into_split(); + let mut buf_reader = BufReader::new(reader); + let mut line = String::new(); + + // Read one line with timeout and size limit + let read_result = tokio::time::timeout(IO_TIMEOUT, async { + let mut total = 0usize; + loop { + let n = buf_reader.read_line(&mut line).await?; + if n == 0 { + break; + } + total += n; + if total > MAX_REQUEST_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request too large", + )); + } + if line.ends_with('\n') { + break; + } + } + Ok(()) + }) + .await; + + let response = match read_result { + Ok(Ok(())) if line.is_empty() => Response::error("empty request"), + Ok(Ok(())) => match serde_json::from_str::(line.trim()) { + Ok(request) => dispatch_command(&request.command, &snapshot_rx), + Err(e) => Response::error(format!("invalid request: {e}")), + }, + Ok(Err(e)) => Response::error(format!("read error: {e}")), + Err(_) => Response::error("read timeout"), + }; + + // Write response with timeout + let json = serde_json::to_string(&response)?; + let write_result = tokio::time::timeout(IO_TIMEOUT, async { + writer.write_all(json.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.shutdown().await?; + Ok::<_, std::io::Error>(()) + }) + .await; + + if let Err(_) | Ok(Err(_)) = write_result { + debug!("Gateway control socket write failed or timed out"); + } + + Ok(()) + } + + /// Clean up the socket file. + fn cleanup(&self) { + if self.socket_path.exists() { + if let Err(e) = std::fs::remove_file(&self.socket_path) { + warn!( + path = %self.socket_path.display(), + error = %e, + "Failed to remove gateway control socket" + ); + } else { + debug!(path = %self.socket_path.display(), "Gateway control socket removed"); + } + } + } +} + +impl Drop for GatewayControlSocket { + fn drop(&mut self) { + self.cleanup(); + } +} + +/// Dispatch a command against the latest gateway snapshot. +fn dispatch_command( + command: &str, + snapshot_rx: &watch::Receiver>, +) -> Response { + let snapshot = match snapshot_rx.borrow().clone() { + Some(s) => s, + None => return Response::error("gateway not yet initialized"), + }; + + match command { + "show_gateway" => build_show_gateway(&snapshot), + "show_mappings" => build_show_mappings(&snapshot), + _ => Response::error(format!("unknown command: {command}")), + } +} + +/// Build the `show_gateway` response with pool utilization summary. +fn build_show_gateway(snapshot: &GatewaySnapshot) -> Response { + Response::ok(serde_json::json!({ + "pool_total": snapshot.pool.total, + "pool_allocated": snapshot.pool.allocated, + "pool_active": snapshot.pool.active, + "pool_draining": snapshot.pool.draining, + "pool_free": snapshot.pool.free, + "nat_mappings": snapshot.nat_mappings, + "dns_listen": snapshot.dns_listen, + "uptime_secs": snapshot.uptime_secs, + "pool_cidr": snapshot.pool_cidr, + "lan_interface": snapshot.lan_interface, + "dns_upstream": snapshot.dns_upstream, + "dns_ttl": snapshot.dns_ttl, + "pool_grace_period": snapshot.pool_grace_period, + })) +} + +/// Build the `show_mappings` response with per-mapping detail. +fn build_show_mappings(snapshot: &GatewaySnapshot) -> Response { + let mappings: Vec = snapshot + .mappings + .iter() + .map(|m| { + serde_json::json!({ + "virtual_ip": m.virtual_ip.to_string(), + "mesh_addr": m.mesh_addr.to_string(), + "node_addr": m.node_addr.to_string(), + "dns_name": m.dns_name, + "state": mapping_state_str(m.state), + "sessions": m.session_count, + "age_secs": m.age_secs, + "last_ref_secs": m.last_ref_secs, + }) + }) + .collect(); + + Response::ok(serde_json::json!({ "mappings": mappings })) +} + +/// Convert MappingState to a display string. +fn mapping_state_str(state: MappingState) -> &'static str { + match state { + MappingState::Allocated => "Allocated", + MappingState::Active => "Active", + MappingState::Draining => "Draining", + } +} + +/// Static gateway configuration for snapshot building. +pub struct SnapshotConfig { + pub pool_cidr: String, + pub lan_interface: String, + pub dns_upstream: String, + pub dns_listen: String, + pub dns_ttl: u32, + pub pool_grace_period: u64, +} + +/// Build a `GatewaySnapshot` from current component state. +/// +/// Called from the pool tick task to publish updated status. +pub fn build_snapshot( + pool_status: PoolStatus, + mappings: Vec, + nat_mappings: usize, + start_time: Instant, + config: &SnapshotConfig, +) -> GatewaySnapshot { + GatewaySnapshot { + pool: pool_status, + mappings, + nat_mappings, + dns_listen: config.dns_listen.clone(), + uptime_secs: start_time.elapsed().as_secs(), + pool_cidr: config.pool_cidr.clone(), + lan_interface: config.lan_interface.clone(), + dns_upstream: config.dns_upstream.clone(), + dns_ttl: config.dns_ttl, + pool_grace_period: config.pool_grace_period, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::NodeAddr; + + fn make_snapshot() -> GatewaySnapshot { + GatewaySnapshot { + pool: PoolStatus { + total: 65535, + allocated: 1, + active: 0, + draining: 0, + free: 65534, + }, + mappings: vec![MappingInfo { + virtual_ip: "fd01::1".parse().unwrap(), + mesh_addr: "fd97:467a::1".parse().unwrap(), + node_addr: NodeAddr::from_bytes([0; 16]), + dns_name: "npub1test.fips".to_string(), + state: MappingState::Active, + session_count: 3, + age_secs: 120, + last_ref_secs: 5, + }], + nat_mappings: 1, + dns_listen: "[fd02::10]:53".to_string(), + uptime_secs: 3600, + pool_cidr: "fd01::/112".to_string(), + lan_interface: "br-lan".to_string(), + dns_upstream: "127.0.0.1:5354".to_string(), + dns_ttl: 60, + pool_grace_period: 60, + } + } + + #[test] + fn test_show_gateway_response() { + let snapshot = make_snapshot(); + let resp = build_show_gateway(&snapshot); + assert_eq!(resp.status, "ok"); + let data = resp.data.unwrap(); + assert_eq!(data["pool_total"], 65535); + assert_eq!(data["pool_free"], 65534); + assert_eq!(data["nat_mappings"], 1); + assert_eq!(data["dns_listen"], "[fd02::10]:53"); + assert_eq!(data["uptime_secs"], 3600); + } + + #[test] + fn test_show_mappings_response() { + let snapshot = make_snapshot(); + let resp = build_show_mappings(&snapshot); + assert_eq!(resp.status, "ok"); + let data = resp.data.unwrap(); + let mappings = data["mappings"].as_array().unwrap(); + assert_eq!(mappings.len(), 1); + assert_eq!(mappings[0]["state"], "Active"); + assert_eq!(mappings[0]["sessions"], 3); + assert_eq!(mappings[0]["virtual_ip"], "fd01::1"); + } + + #[test] + fn test_unknown_command() { + let (tx, rx) = watch::channel(Some(make_snapshot())); + let resp = dispatch_command("bogus", &rx); + assert_eq!(resp.status, "error"); + assert!(resp.message.unwrap().contains("unknown command: bogus")); + drop(tx); + } + + #[test] + fn test_not_initialized() { + let (tx, rx) = watch::channel::>(None); + let resp = dispatch_command("show_gateway", &rx); + assert_eq!(resp.status, "error"); + assert!(resp.message.unwrap().contains("not yet initialized")); + drop(tx); + } + + #[test] + fn test_mapping_state_str() { + assert_eq!(mapping_state_str(MappingState::Allocated), "Allocated"); + assert_eq!(mapping_state_str(MappingState::Active), "Active"); + assert_eq!(mapping_state_str(MappingState::Draining), "Draining"); + } + + #[test] + fn test_empty_mappings() { + let snapshot = GatewaySnapshot { + pool: PoolStatus { + total: 255, + allocated: 0, + active: 0, + draining: 0, + free: 255, + }, + mappings: vec![], + nat_mappings: 0, + dns_listen: "[::1]:53".to_string(), + uptime_secs: 0, + pool_cidr: "fd01::/112".to_string(), + lan_interface: "br-lan".to_string(), + dns_upstream: "127.0.0.1:5354".to_string(), + dns_ttl: 60, + pool_grace_period: 60, + }; + let resp = build_show_mappings(&snapshot); + assert_eq!(resp.status, "ok"); + let data = resp.data.unwrap(); + let mappings = data["mappings"].as_array().unwrap(); + assert!(mappings.is_empty()); + } +} diff --git a/src/gateway/dns.rs b/src/gateway/dns.rs new file mode 100644 index 0000000..8409e2f --- /dev/null +++ b/src/gateway/dns.rs @@ -0,0 +1,407 @@ +//! Gateway DNS resolver. +//! +//! Forwarding proxy that handles `.fips` queries from LAN hosts, +//! forwards them to the FIPS daemon resolver (localhost:5354), +//! and returns virtual IP addresses from the pool. +//! +//! The daemon resolver populates its identity cache as a side effect +//! of resolution, which is required for fips0 routing to work. + +use simple_dns::{Packet, PacketFlag, RCODE, ResourceRecord, CLASS, rdata}; + +use simple_dns::{QTYPE, TYPE}; +use std::net::{Ipv6Addr, SocketAddr}; +use tokio::net::UdpSocket; +use tokio::sync::watch; +use tracing::{debug, info, trace, warn}; + +use super::pool::{PoolEvent, VirtualIpPool}; +use crate::NodeAddr; + +/// Timeout for upstream DNS queries. +const UPSTREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Maximum DNS packet size. +const MAX_DNS_SIZE: usize = 4096; + +/// Events emitted by the DNS resolver. +#[derive(Debug)] +pub struct DnsAllocation { + pub node_addr: NodeAddr, + pub virtual_ip: Ipv6Addr, + pub mesh_addr: Ipv6Addr, + pub is_new: bool, +} + +/// Extract the `.fips` query name from a DNS packet. +/// Returns Some(name) if the query is for a `.fips` domain, None otherwise. +fn extract_fips_name(packet: &Packet) -> Option { + let question = packet.questions.first()?; + let name = question.qname.to_string(); + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".fips") || lower.ends_with(".fips.") { + Some(lower.trim_end_matches('.').to_string()) + } else { + None + } +} + +/// Extract the AAAA (IPv6) address from a DNS response. +fn extract_aaaa(packet: &Packet) -> Option { + for answer in &packet.answers { + if let rdata::RData::AAAA(aaaa) = &answer.rdata { + return Some(aaaa.address.into()); + } + } + None +} + +/// Derive NodeAddr from a FIPS mesh address (fd00::/8). +/// The NodeAddr is bytes 1-15 of the IPv6 address prepended with the first byte. +fn node_addr_from_mesh(mesh_addr: Ipv6Addr) -> NodeAddr { + let bytes = mesh_addr.octets(); + // NodeAddr = first 16 bytes of SHA-256(pubkey), which maps to + // FipsAddress = fd + NodeAddr[1..16]. So NodeAddr[0] = bytes[1]. + // Actually, FipsAddress = [0xfd, nodeaddr[0..15]] + // So nodeaddr[0..15] = bytes[1..16] + let mut node_bytes = [0u8; 16]; + node_bytes[..15].copy_from_slice(&bytes[1..16]); + NodeAddr::from_bytes(node_bytes) +} + +/// Build a REFUSED DNS response. +fn build_refused(query: &Packet) -> Option> { + let mut response = Packet::new_reply(query.id()); + response.set_flags(PacketFlag::RESPONSE | PacketFlag::RECURSION_AVAILABLE); + *response.rcode_mut() = RCODE::Refused; + response.questions.clone_from(&query.questions); + response.build_bytes_vec_compressed().ok() +} + +/// Build a SERVFAIL DNS response. +fn build_servfail(query: &Packet) -> Option> { + let mut response = Packet::new_reply(query.id()); + response.set_flags(PacketFlag::RESPONSE | PacketFlag::RECURSION_AVAILABLE); + *response.rcode_mut() = RCODE::ServerFailure; + response.questions.clone_from(&query.questions); + response.build_bytes_vec_compressed().ok() +} + +/// Build a NODATA response (NOERROR with no answer records). +/// Signals "this name exists but has no records of the requested type". +fn build_nodata(query: &Packet, ttl: u32) -> Option> { + let mut response = Packet::new_reply(query.id()); + response.set_flags(PacketFlag::RESPONSE | PacketFlag::RECURSION_AVAILABLE); + response.questions.clone_from(&query.questions); + + // Add a minimal SOA in the authority section (RFC 2308 §2.2). + // This tells the client how long to cache the negative answer. + let question = query.questions.first()?; + let soa = rdata::RData::SOA(rdata::SOA { + mname: simple_dns::Name::new_unchecked("gateway.fips"), + rname: simple_dns::Name::new_unchecked("nobody.fips"), + serial: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as u32) + .unwrap_or(1), + refresh: ttl as i32, + retry: ttl as i32, + expire: ttl as i32, + minimum: ttl, + }); + let soa_record = ResourceRecord::new(question.qname.clone(), CLASS::IN, ttl, soa); + response.name_servers.push(soa_record); + + response.build_bytes_vec_compressed().ok() +} + +/// Build an AAAA response with the given virtual IP. +fn build_aaaa_response(query: &Packet, virtual_ip: Ipv6Addr, ttl: u32) -> Option> { + let question = query.questions.first()?; + let mut response = Packet::new_reply(query.id()); + response.set_flags(PacketFlag::RESPONSE | PacketFlag::RECURSION_AVAILABLE); + + // Echo the question section (required by RFC 1035 §4.1.1) + response.questions.push(question.clone()); + + let aaaa = rdata::RData::AAAA(rdata::AAAA { + address: virtual_ip.into(), + }); + let record = ResourceRecord::new(question.qname.clone(), CLASS::IN, ttl, aaaa); + response.answers.push(record); + + response.build_bytes_vec_compressed().ok() +} + +/// Run the gateway DNS resolver. +/// +/// Listens for DNS queries, forwards `.fips` queries to the upstream +/// daemon resolver, allocates virtual IPs, and returns them to clients. +pub async fn run_dns_resolver( + listen_addr: &str, + upstream_addr: &str, + ttl: u32, + pool: std::sync::Arc>, + event_tx: tokio::sync::mpsc::Sender, + mut shutdown: watch::Receiver, +) -> Result<(), std::io::Error> { + let socket = UdpSocket::bind(listen_addr).await?; + info!(addr = %listen_addr, "Gateway DNS resolver listening"); + + let upstream: SocketAddr = upstream_addr + .parse() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + + let mut buf = vec![0u8; MAX_DNS_SIZE]; + + loop { + tokio::select! { + result = socket.recv_from(&mut buf) => { + let (len, client_addr) = result?; + let query_bytes = &buf[..len]; + + let response = match handle_query( + query_bytes, + upstream, + ttl, + &pool, + &event_tx, + ).await { + Some(resp) => resp, + None => continue, + }; + + if let Err(e) = socket.send_to(&response, client_addr).await { + debug!(error = %e, "Failed to send DNS response"); + } + } + _ = shutdown.changed() => { + info!("DNS resolver shutting down"); + break; + } + } + } + + Ok(()) +} + +/// Handle a single DNS query. Returns the response bytes to send back. +async fn handle_query( + query_bytes: &[u8], + upstream: SocketAddr, + ttl: u32, + pool: &std::sync::Arc>, + event_tx: &tokio::sync::mpsc::Sender, +) -> Option> { + let query = Packet::parse(query_bytes).ok()?; + + // Check if this is a .fips query + let fips_name = match extract_fips_name(&query) { + Some(name) => name, + None => { + trace!(id = query.id(), "Non-.fips query, returning REFUSED"); + return build_refused(&query); + } + }; + + debug!(name = %fips_name, id = query.id(), "Forwarding .fips query to daemon"); + + // Build an AAAA query for the daemon regardless of what the client asked + // (A, AAAA, ANY, etc.). Mesh addresses are always IPv6, so the daemon + // only returns useful answers for AAAA queries. + let upstream_query_bytes = { + let question = query.questions.first()?; + let mut aaaa_query = Packet::new_query(query.id()); + let aaaa_question = simple_dns::Question::new( + question.qname.clone(), + QTYPE::TYPE(TYPE::AAAA), + question.qclass, + question.unicast_response, + ); + aaaa_query.questions.push(aaaa_question); + match aaaa_query.build_bytes_vec_compressed() { + Ok(bytes) => bytes, + Err(_) => return build_servfail(&query), + } + }; + + // Forward to upstream daemon resolver. + // Bind to the same address family as the upstream to avoid dual-stack issues + // (OpenWrt often has net.ipv6.bindv6only=1). + let bind_addr = if upstream.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }; + let upstream_socket = match UdpSocket::bind(bind_addr).await { + Ok(s) => s, + Err(e) => { + warn!(error = %e, "Failed to bind upstream socket"); + return build_servfail(&query); + } + }; + + if let Err(e) = upstream_socket.send_to(&upstream_query_bytes, upstream).await { + warn!(error = %e, "Failed to forward query to daemon"); + return build_servfail(&query); + } + + let mut resp_buf = vec![0u8; MAX_DNS_SIZE]; + let resp_len = match tokio::time::timeout( + UPSTREAM_TIMEOUT, + upstream_socket.recv(&mut resp_buf), + ) + .await + { + Ok(Ok(len)) => len, + Ok(Err(e)) => { + warn!(error = %e, "Upstream recv error"); + return build_servfail(&query); + } + Err(_) => { + warn!("Upstream DNS timeout"); + return build_servfail(&query); + } + }; + + let upstream_response = match Packet::parse(&resp_buf[..resp_len]) { + Ok(p) => p, + Err(_) => return build_servfail(&query), + }; + + // If upstream returned NXDOMAIN or error, rebuild the response with the + // client's original question section (not the AAAA question we sent upstream). + if upstream_response.rcode() != RCODE::NoError { + debug!( + name = %fips_name, + rcode = ?upstream_response.rcode(), + "Upstream returned non-success" + ); + let mut err_resp = Packet::new_reply(query.id()); + err_resp.set_flags(PacketFlag::RESPONSE | PacketFlag::RECURSION_AVAILABLE); + *err_resp.rcode_mut() = upstream_response.rcode(); + err_resp.questions.clone_from(&query.questions); + return err_resp.build_bytes_vec_compressed().ok(); + } + + // Extract the fd00:: mesh address from the AAAA response + let mesh_addr = match extract_aaaa(&upstream_response) { + Some(addr) => addr, + None => { + debug!(name = %fips_name, "No AAAA record in upstream response"); + return build_servfail(&query); + } + }; + + // Derive NodeAddr from mesh address + let node_addr = node_addr_from_mesh(mesh_addr); + + // Allocate virtual IP from pool + let mut pool_guard = pool.lock().await; + let (virtual_ip, is_new) = match pool_guard.allocate(node_addr, mesh_addr, &fips_name) { + Ok(result) => result, + Err(e) => { + warn!(error = %e, "Pool allocation failed"); + return build_servfail(&query); + } + }; + drop(pool_guard); + + // Notify NAT module of new mapping + if is_new { + let event = PoolEvent::MappingCreated { + virtual_ip, + mesh_addr, + }; + if let Err(e) = event_tx.send(event).await { + warn!(error = %e, "Failed to send pool event"); + } + } + + debug!( + name = %fips_name, + virtual_ip = %virtual_ip, + mesh_addr = %mesh_addr, + is_new, + "Resolved .fips query" + ); + + // Check what the client originally asked for. + // Only return an AAAA record if the client asked for AAAA (or ANY). + // For A queries, return an empty NOERROR — the client's resolver will + // use the AAAA answer from its parallel AAAA query instead. + let client_qtype = query + .questions + .first() + .map(|q| q.qtype) + .unwrap_or(QTYPE::TYPE(TYPE::AAAA)); + + match client_qtype { + QTYPE::TYPE(TYPE::AAAA) | QTYPE::ANY => build_aaaa_response(&query, virtual_ip, ttl), + // All other types (A, HTTPS, etc.): return NODATA — the name exists + // but has no records of the requested type. + _ => build_nodata(&query, ttl), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_node_addr_from_mesh() { + // fd00::1 → node_addr bytes should be [0, 0, ..., 0, 1] in positions 0..15 + let mesh: Ipv6Addr = "fd00::1".parse().unwrap(); + let node = node_addr_from_mesh(mesh); + let bytes = node.as_bytes(); + // mesh = [0xfd, 0, 0, ..., 0, 1] + // node = bytes[1..16] of mesh = [0, 0, ..., 0, 1] in first 15 bytes + assert_eq!(bytes[14], 1); + assert_eq!(bytes[0], 0); + } + + #[test] + fn test_extract_fips_name() { + // Build a simple AAAA query for test.fips + let mut packet = Packet::new_query(1); + use simple_dns::{Name, Question}; + let name = Name::new_unchecked("test.fips"); + let question = Question::new(name, QTYPE::TYPE(TYPE::AAAA), CLASS::IN.into(), false); + packet.questions.push(question); + + let result = extract_fips_name(&packet); + assert_eq!(result, Some("test.fips".to_string())); + } + + #[test] + fn test_extract_non_fips_name() { + let mut packet = Packet::new_query(1); + use simple_dns::{Name, Question}; + let name = Name::new_unchecked("example.com"); + let question = Question::new(name, QTYPE::TYPE(TYPE::AAAA), CLASS::IN.into(), false); + packet.questions.push(question); + + assert!(extract_fips_name(&packet).is_none()); + } + + #[test] + fn test_build_aaaa_response() { + let mut query = Packet::new_query(42); + use simple_dns::{Name, Question}; + let name = Name::new_unchecked("test.fips"); + let question = Question::new(name, QTYPE::TYPE(TYPE::AAAA), CLASS::IN.into(), false); + query.questions.push(question); + + let vip: Ipv6Addr = "fd01::1".parse().unwrap(); + let response_bytes = build_aaaa_response(&query, vip, 60).unwrap(); + let response = Packet::parse(&response_bytes).unwrap(); + + assert_eq!(response.id(), 42); + assert_eq!(response.answers.len(), 1); + if let rdata::RData::AAAA(aaaa) = &response.answers[0].rdata { + assert_eq!(Ipv6Addr::from(aaaa.address), vip); + } else { + panic!("Expected AAAA record"); + } + } +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs new file mode 100644 index 0000000..db3f2de --- /dev/null +++ b/src/gateway/mod.rs @@ -0,0 +1,10 @@ +//! Outbound LAN gateway. +//! +//! Allows unmodified LAN hosts to reach FIPS mesh destinations via +//! DNS-allocated virtual IPs and kernel nftables NAT. + +pub mod control; +pub mod dns; +pub mod nat; +pub mod net; +pub mod pool; diff --git a/src/gateway/nat.rs b/src/gateway/nat.rs new file mode 100644 index 0000000..a31a9fc --- /dev/null +++ b/src/gateway/nat.rs @@ -0,0 +1,218 @@ +//! NAT rule management. +//! +//! Manages nftables DNAT/SNAT rules via the rustables netlink API +//! for translating between virtual IPs and FIPS mesh addresses. + +use std::collections::HashMap; +use std::net::Ipv6Addr; +use tracing::{debug, info}; + +use rustables::expr::{ + Cmp, CmpOp, HighLevelPayload, IPv6HeaderField, Immediate, Masquerade, Meta, MetaType, Nat, + NatType, NetworkHeaderField, Register, +}; +use rustables::{ + Batch, Chain, ChainType, Hook, HookClass, MsgType, ProtocolFamily, Rule, Table, +}; + +const TABLE_NAME: &str = "fips_gateway"; +const PREROUTING_CHAIN: &str = "prerouting"; +const POSTROUTING_CHAIN: &str = "postrouting"; + +/// NAT priority constants (matching nftables standard priorities). +const DSTNAT_PRIORITY: i32 = -100; +const SRCNAT_PRIORITY: i32 = 100; + +/// Errors from NAT operations. +#[derive(Debug, thiserror::Error)] +pub enum NatError { + #[error("nftables error: {0}")] + Nftables(String), + #[error("rule not found for virtual IP {0}")] + RuleNotFound(Ipv6Addr), +} + +impl From for NatError { + fn from(e: rustables::error::QueryError) -> Self { + NatError::Nftables(e.to_string()) + } +} + +impl From for NatError { + fn from(e: rustables::error::BuilderError) -> Self { + NatError::Nftables(e.to_string()) + } +} + +/// A virtual IP ↔ mesh address mapping for NAT rule generation. +#[derive(Clone)] +struct NatMapping { + virtual_ip: Ipv6Addr, + mesh_addr: Ipv6Addr, +} + +/// NAT rule manager using nftables via rustables netlink API. +/// +/// Rebuilds the entire nftables table atomically on every change to +/// avoid relying on kernel rule handle tracking (which rustables +/// doesn't expose). The table is small (one masquerade + two rules +/// per mapping) so this is cheap. +pub struct NatManager { + table: Table, + pre_chain: Chain, + post_chain: Chain, + /// Active mappings keyed by virtual IP. + mappings: HashMap, +} + +impl NatManager { + /// Create the nftables table and NAT chains. + /// + /// Installs a masquerade rule for traffic exiting via `fips0` so that + /// LAN client source addresses are rewritten to the gateway's mesh + /// address, allowing return traffic to route back through the mesh. + pub fn new() -> Result { + let table = Table::new(ProtocolFamily::Inet).with_name(TABLE_NAME); + let pre_chain = Chain::new(&table) + .with_name(PREROUTING_CHAIN) + .with_type(ChainType::Nat) + .with_hook(Hook::new(HookClass::PreRouting, DSTNAT_PRIORITY)); + let post_chain = Chain::new(&table) + .with_name(POSTROUTING_CHAIN) + .with_type(ChainType::Nat) + .with_hook(Hook::new(HookClass::PostRouting, SRCNAT_PRIORITY)); + + let mgr = Self { + table, + pre_chain, + post_chain, + mappings: HashMap::new(), + }; + mgr.rebuild()?; + + info!("Created nftables table '{TABLE_NAME}' with NAT chains and fips0 masquerade"); + Ok(mgr) + } + + /// Add DNAT and SNAT rules for a virtual IP ↔ mesh address mapping. + pub fn add_mapping( + &mut self, + virtual_ip: Ipv6Addr, + mesh_addr: Ipv6Addr, + ) -> Result<(), NatError> { + self.mappings.insert(virtual_ip, NatMapping { + virtual_ip, + mesh_addr, + }); + self.rebuild()?; + + debug!( + virtual_ip = %virtual_ip, + mesh_addr = %mesh_addr, + "Added DNAT/SNAT rules" + ); + Ok(()) + } + + /// Remove DNAT and SNAT rules for a virtual IP mapping. + pub fn remove_mapping(&mut self, virtual_ip: Ipv6Addr) -> Result<(), NatError> { + if self.mappings.remove(&virtual_ip).is_none() { + return Err(NatError::RuleNotFound(virtual_ip)); + } + self.rebuild()?; + + debug!(virtual_ip = %virtual_ip, "Removed DNAT/SNAT rules"); + Ok(()) + } + + /// Flush all rules and delete the nftables table. + pub fn cleanup(self) -> Result<(), NatError> { + let mut batch = Batch::new(); + batch.add(&self.table, MsgType::Del); + batch.send().map_err(|e| NatError::Nftables(e.to_string()))?; + + info!("Deleted nftables table '{TABLE_NAME}'"); + Ok(()) + } + + /// Number of active NAT mappings. + pub fn mapping_count(&self) -> usize { + self.mappings.len() + } + + /// Atomically rebuild the entire nftables table with all current + /// rules. Deletes and recreates the table, chains, masquerade rule, + /// and all per-mapping DNAT/SNAT rules in a single netlink batch. + fn rebuild(&self) -> Result<(), NatError> { + // Delete existing table in a separate batch — ignore ENOENT on + // first call when the table doesn't exist yet. + let mut del_batch = Batch::new(); + del_batch.add(&self.table, MsgType::Del); + let _ = del_batch.send(); + + // Recreate table, chains, and all rules atomically. + let mut batch = Batch::new(); + batch.add(&self.table, MsgType::Add); + batch.add(&self.pre_chain, MsgType::Add); + batch.add(&self.post_chain, MsgType::Add); + + // Masquerade rule: rewrite source address for traffic exiting fips0. + // Without this, LAN clients' source addresses (e.g. fd02::20) are + // not routable on the mesh, so return traffic would be black-holed. + let masq_rule = Rule::new(&self.post_chain)? + .with_expr(Meta::new(MetaType::OifName)) + .with_expr(Cmp::new(CmpOp::Eq, b"fips0\0".to_vec())) + .with_expr(Masquerade::default()); + batch.add(&masq_rule, MsgType::Add); + + // Per-mapping DNAT/SNAT rules. + for mapping in self.mappings.values() { + let dnat_rule = Rule::new(&self.pre_chain)? + .with_expr(Meta::new(MetaType::NfProto)) + .with_expr(Cmp::new(CmpOp::Eq, [libc::NFPROTO_IPV6 as u8])) + .with_expr( + HighLevelPayload::Network(NetworkHeaderField::IPv6( + IPv6HeaderField::Daddr, + )) + .build(), + ) + .with_expr(Cmp::new(CmpOp::Eq, mapping.virtual_ip.octets())) + .with_expr(Immediate::new_data( + mapping.mesh_addr.octets().to_vec(), + Register::Reg1, + )) + .with_expr( + Nat::default() + .with_nat_type(NatType::DNat) + .with_family(ProtocolFamily::Ipv6) + .with_ip_register(Register::Reg1), + ); + batch.add(&dnat_rule, MsgType::Add); + + let snat_rule = Rule::new(&self.post_chain)? + .with_expr(Meta::new(MetaType::NfProto)) + .with_expr(Cmp::new(CmpOp::Eq, [libc::NFPROTO_IPV6 as u8])) + .with_expr( + HighLevelPayload::Network(NetworkHeaderField::IPv6( + IPv6HeaderField::Saddr, + )) + .build(), + ) + .with_expr(Cmp::new(CmpOp::Eq, mapping.mesh_addr.octets())) + .with_expr(Immediate::new_data( + mapping.virtual_ip.octets().to_vec(), + Register::Reg1, + )) + .with_expr( + Nat::default() + .with_nat_type(NatType::SNat) + .with_family(ProtocolFamily::Ipv6) + .with_ip_register(Register::Reg1), + ); + batch.add(&snat_rule, MsgType::Add); + } + + batch.send().map_err(|e| NatError::Nftables(e.to_string()))?; + Ok(()) + } +} diff --git a/src/gateway/net.rs b/src/gateway/net.rs new file mode 100644 index 0000000..25a8691 --- /dev/null +++ b/src/gateway/net.rs @@ -0,0 +1,167 @@ +//! Network setup for the gateway. +//! +//! Manages proxy NDP entries and routes for the virtual IP range. +//! Checks IP forwarding prerequisites. + +use std::net::Ipv6Addr; +use tracing::{debug, error, info, warn}; + +/// Check if IPv6 forwarding is enabled. +/// +/// The gateway is completely non-functional without forwarding — packets +/// cannot traverse the NAT pipeline. Exits the process on failure. +pub fn check_ipv6_forwarding() { + match std::fs::read_to_string("/proc/sys/net/ipv6/conf/all/forwarding") { + Ok(val) if val.trim() == "1" => { + debug!("IPv6 forwarding is enabled"); + } + Ok(_) => { + error!( + "IPv6 forwarding is disabled. Enable with: \ + sysctl -w net.ipv6.conf.all.forwarding=1" + ); + std::process::exit(1); + } + Err(e) => { + error!(error = %e, "Could not check IPv6 forwarding state"); + std::process::exit(1); + } + } +} + +/// Check that a network interface exists using rtnetlink. +pub async fn check_interface_exists( + name: &str, +) -> Result { + let index = rustables::iface_index(name) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string()))?; + debug!(interface = %name, index, "Interface found"); + Ok(index) +} + +/// Manages proxy NDP entries and routes for gateway virtual IPs. +pub struct NetSetup { + lan_interface: String, + /// Proxy NDP entries added during this run (for cleanup). + proxy_entries: Vec, + /// Whether a route was added for the pool range. + route_added: bool, + pool_cidr: String, +} + +impl NetSetup { + /// Create a new network setup manager. + pub fn new(lan_interface: String, pool_cidr: String) -> Self { + Self { + lan_interface, + proxy_entries: Vec::new(), + route_added: false, + pool_cidr, + } + } + + /// Add a local route for the virtual IP pool range. + /// + /// The `local` route tells the kernel to accept packets destined for + /// addresses in the pool as locally-owned, enabling NAT processing. + /// Uses `dev lo` because local routes don't need to reference the LAN + /// interface — the kernel matches on the routing table regardless of + /// which interface the packet arrives on. + pub async fn add_pool_route(&mut self) -> Result<(), std::io::Error> { + let output = tokio::process::Command::new("ip") + .args(["-6", "route", "add", "local", &self.pool_cidr, "dev", "lo"]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // "File exists" means route already present — not an error + if stderr.contains("File exists") { + debug!(cidr = %self.pool_cidr, "Pool route already exists"); + return Ok(()); + } + return Err(std::io::Error::other( + format!("Failed to add pool route: {stderr}"), + )); + } + + self.route_added = true; + info!(cidr = %self.pool_cidr, "Added local pool route"); + Ok(()) + } + + /// Add a proxy NDP entry for a virtual IP on the LAN interface. + pub async fn add_proxy_ndp(&mut self, addr: Ipv6Addr) -> Result<(), std::io::Error> { + let addr_str = addr.to_string(); + let output = tokio::process::Command::new("ip") + .args(["-6", "neigh", "add", "proxy", &addr_str, "dev", &self.lan_interface]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("File exists") { + debug!(addr = %addr, "Proxy NDP entry already exists"); + return Ok(()); + } + return Err(std::io::Error::other( + format!("Failed to add proxy NDP: {stderr}"), + )); + } + + self.proxy_entries.push(addr); + debug!(addr = %addr, iface = %self.lan_interface, "Added proxy NDP entry"); + Ok(()) + } + + /// Remove a proxy NDP entry. + pub async fn remove_proxy_ndp(&mut self, addr: Ipv6Addr) -> Result<(), std::io::Error> { + let addr_str = addr.to_string(); + let output = tokio::process::Command::new("ip") + .args(["-6", "neigh", "del", "proxy", &addr_str, "dev", &self.lan_interface]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Silently ignore "No such file" — entry may have been cleaned already + if !stderr.contains("No such file") { + warn!(addr = %addr, error = %stderr.trim(), "Failed to remove proxy NDP"); + } + } + + self.proxy_entries.retain(|a| *a != addr); + Ok(()) + } + + /// Clean up all proxy NDP entries and routes added during this run. + pub async fn cleanup(&mut self) { + // Remove proxy NDP entries + let entries: Vec = self.proxy_entries.clone(); + for addr in entries { + let _ = self.remove_proxy_ndp(addr).await; + } + + // Remove pool route + if self.route_added { + let output = tokio::process::Command::new("ip") + .args(["-6", "route", "del", "local", &self.pool_cidr, "dev", "lo"]) + .output() + .await; + + match output { + Ok(o) if o.status.success() => { + info!(cidr = %self.pool_cidr, "Removed pool route"); + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + warn!(error = %stderr.trim(), "Failed to remove pool route"); + } + Err(e) => { + warn!(error = %e, "Failed to run ip route del"); + } + } + self.route_added = false; + } + } +} diff --git a/src/gateway/pool.rs b/src/gateway/pool.rs new file mode 100644 index 0000000..dcd4e9d --- /dev/null +++ b/src/gateway/pool.rs @@ -0,0 +1,571 @@ +//! Virtual IP pool manager. +//! +//! Manages allocation, TTL, and reclamation of virtual IPv6 addresses +//! from a configured CIDR range. Tracks mapping state and integrates +//! with conntrack to determine active sessions. + +use crate::NodeAddr; +use std::collections::{HashMap, VecDeque}; +use std::net::Ipv6Addr; +use std::time::Instant; +use tracing::{debug, info}; + +/// Errors from pool operations. +#[derive(Debug, thiserror::Error)] +pub enum PoolError { + #[error("invalid CIDR: {0}")] + InvalidCidr(String), + #[error("pool exhausted ({0} addresses in use)")] + Exhausted(usize), + #[error("prefix length must be between 1 and 128")] + InvalidPrefix, +} + +/// State of a virtual IP mapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MappingState { + /// Allocated via DNS query, no NAT sessions yet. + Allocated, + /// Active NAT sessions exist. + Active, + /// TTL expired but sessions remain. + Draining, +} + +/// A single virtual IP ↔ FIPS mesh address mapping. +#[derive(Debug, Clone)] +pub struct VirtualIpMapping { + /// The FIPS node address this mapping is for. + pub node_addr: NodeAddr, + /// The virtual IP allocated from the pool. + pub virtual_ip: Ipv6Addr, + /// The FIPS mesh address (fd00::/8). + pub mesh_addr: Ipv6Addr, + /// The DNS name that was queried (e.g. "npub1abc...xyz.fips"). + pub dns_name: String, + /// Current state. + pub state: MappingState, + /// When this mapping was created. + pub created: Instant, + /// When this mapping was last referenced (DNS query or session). + pub last_referenced: Instant, + /// When draining started (for grace period tracking). + pub drain_start: Option, + /// Number of active conntrack sessions. + pub session_count: u32, +} + +/// Events emitted by the pool on state transitions. +#[derive(Debug)] +pub enum PoolEvent { + /// A new mapping was allocated — NAT rules should be created. + MappingCreated { + virtual_ip: Ipv6Addr, + mesh_addr: Ipv6Addr, + }, + /// A mapping was reclaimed — NAT rules should be removed. + MappingRemoved { + virtual_ip: Ipv6Addr, + mesh_addr: Ipv6Addr, + }, +} + +/// Pool utilization summary. +#[derive(Debug, Clone)] +pub struct PoolStatus { + pub total: usize, + pub allocated: usize, + pub active: usize, + pub draining: usize, + pub free: usize, +} + +/// Summary of a single mapping for display. +#[derive(Debug, Clone)] +pub struct MappingInfo { + pub virtual_ip: Ipv6Addr, + pub mesh_addr: Ipv6Addr, + pub node_addr: NodeAddr, + pub dns_name: String, + pub state: MappingState, + pub session_count: u32, + pub age_secs: u64, + pub last_ref_secs: u64, +} + +/// Trait for querying conntrack session counts. +pub trait ConntrackQuerier: Send + Sync { + /// Returns the number of active conntrack entries whose original + /// destination matches the given virtual IP. + fn active_sessions(&self, virtual_ip: Ipv6Addr) -> Result; +} + +/// Conntrack querier that parses /proc/net/nf_conntrack. +pub struct ProcConntrack; + +impl ConntrackQuerier for ProcConntrack { + fn active_sessions(&self, virtual_ip: Ipv6Addr) -> Result { + let content = std::fs::read_to_string("/proc/net/nf_conntrack")?; + let target = virtual_ip.to_string(); + let count = content + .lines() + .filter(|line| line.contains(&format!("dst={target}"))) + .count(); + Ok(count as u32) + } +} + +/// Virtual IP pool manager. +pub struct VirtualIpPool { + /// Available addresses (free pool). + available: VecDeque, + /// Active mappings keyed by NodeAddr. + mappings: HashMap, + /// Reverse map: virtual IP → NodeAddr. + reverse: HashMap, + /// DNS TTL / mapping TTL in seconds. + ttl_secs: u64, + /// Grace period after last session before reclamation. + grace_secs: u64, + /// Total pool size. + total: usize, +} + +impl VirtualIpPool { + /// Create a new pool from a CIDR string (e.g., `fd01::/112`). + pub fn new(cidr: &str, ttl_secs: u64, grace_secs: u64) -> Result { + let (base, prefix_len) = parse_ipv6_cidr(cidr)?; + if prefix_len == 0 || prefix_len > 128 { + return Err(PoolError::InvalidPrefix); + } + + let mut available = VecDeque::new(); + let host_bits = 128 - prefix_len; + + // Cap at 2^16 addresses to avoid massive allocations + let max_addrs: u128 = if host_bits > 16 { + 1u128 << 16 + } else { + 1u128 << host_bits + }; + + let base_int = u128::from(base); + // Skip address 0 (network equivalent) + for i in 1..max_addrs { + available.push_back(Ipv6Addr::from(base_int + i)); + } + + let total = available.len(); + info!(cidr = %cidr, addresses = total, "Virtual IP pool initialized"); + + Ok(Self { + available, + mappings: HashMap::new(), + reverse: HashMap::new(), + ttl_secs, + grace_secs, + total, + }) + } + + /// Allocate a virtual IP for the given node. Idempotent: returns + /// existing mapping if one exists. + pub fn allocate( + &mut self, + node_addr: NodeAddr, + mesh_addr: Ipv6Addr, + dns_name: &str, + ) -> Result<(Ipv6Addr, bool), PoolError> { + // Idempotent: return existing mapping + if let Some(mapping) = self.mappings.get_mut(&node_addr) { + mapping.last_referenced = Instant::now(); + return Ok((mapping.virtual_ip, false)); + } + + let virtual_ip = self + .available + .pop_front() + .ok_or(PoolError::Exhausted(self.mappings.len()))?; + + let now = Instant::now(); + let mapping = VirtualIpMapping { + node_addr, + virtual_ip, + mesh_addr, + dns_name: dns_name.to_string(), + state: MappingState::Allocated, + created: now, + last_referenced: now, + drain_start: None, + session_count: 0, + }; + + self.mappings.insert(node_addr, mapping); + self.reverse.insert(virtual_ip, node_addr); + + info!( + virtual_ip = %virtual_ip, + mesh_addr = %mesh_addr, + dns_name = %dns_name, + "Allocated virtual IP" + ); + + Ok((virtual_ip, true)) + } + + /// Periodic tick — drives state transitions. Returns events for + /// the NAT and network modules. + pub fn tick( + &mut self, + now: Instant, + conntrack: &dyn ConntrackQuerier, + ) -> Vec { + let mut events = Vec::new(); + let mut to_free = Vec::new(); + let ttl = std::time::Duration::from_secs(self.ttl_secs); + let grace = std::time::Duration::from_secs(self.grace_secs); + + for (node_addr, mapping) in &mut self.mappings { + // Query conntrack for active sessions + let sessions = conntrack + .active_sessions(mapping.virtual_ip) + .unwrap_or(0); + mapping.session_count = sessions; + + match mapping.state { + MappingState::Allocated => { + if sessions > 0 { + mapping.state = MappingState::Active; + debug!( + virtual_ip = %mapping.virtual_ip, + sessions, + "Mapping activated" + ); + } else if now.duration_since(mapping.last_referenced) > ttl { + // TTL expired — enter draining with grace period so + // the mapping survives browser DNS cache, even if no + // conntrack sessions were observed (short HTTP requests + // may complete between ticks). + mapping.state = MappingState::Draining; + mapping.drain_start = Some(now); + debug!( + virtual_ip = %mapping.virtual_ip, + "Allocated mapping TTL expired, draining" + ); + } + } + MappingState::Active => { + if now.duration_since(mapping.last_referenced) > ttl { + if sessions > 0 { + mapping.state = MappingState::Draining; + mapping.drain_start = Some(now); + debug!( + virtual_ip = %mapping.virtual_ip, + sessions, + "Mapping draining (TTL expired, sessions active)" + ); + } else { + // TTL expired and no sessions — start grace + mapping.state = MappingState::Draining; + mapping.drain_start = Some(now); + mapping.session_count = 0; + } + } + } + MappingState::Draining => { + if sessions == 0 + && let Some(drain_start) = mapping.drain_start + && now.duration_since(drain_start) > grace + { + to_free.push(*node_addr); + } + } + } + } + + // Free expired mappings + for node_addr in to_free { + if let Some(mapping) = self.mappings.remove(&node_addr) { + self.reverse.remove(&mapping.virtual_ip); + self.available.push_back(mapping.virtual_ip); + info!( + virtual_ip = %mapping.virtual_ip, + mesh_addr = %mapping.mesh_addr, + "Reclaimed virtual IP" + ); + events.push(PoolEvent::MappingRemoved { + virtual_ip: mapping.virtual_ip, + mesh_addr: mapping.mesh_addr, + }); + } + } + + events + } + + /// Pool utilization summary. + pub fn status(&self) -> PoolStatus { + let mut allocated = 0; + let mut active = 0; + let mut draining = 0; + for mapping in self.mappings.values() { + match mapping.state { + MappingState::Allocated => allocated += 1, + MappingState::Active => active += 1, + MappingState::Draining => draining += 1, + } + } + PoolStatus { + total: self.total, + allocated, + active, + draining, + free: self.available.len(), + } + } + + /// Summary of all active mappings. + pub fn mapping_info(&self, now: Instant) -> Vec { + self.mappings + .values() + .map(|m| MappingInfo { + virtual_ip: m.virtual_ip, + mesh_addr: m.mesh_addr, + node_addr: m.node_addr, + dns_name: m.dns_name.clone(), + state: m.state, + session_count: m.session_count, + age_secs: now.duration_since(m.created).as_secs(), + last_ref_secs: now.duration_since(m.last_referenced).as_secs(), + }) + .collect() + } + + /// Look up which node a virtual IP maps to. + pub fn lookup_virtual_ip(&self, virtual_ip: &Ipv6Addr) -> Option<&VirtualIpMapping> { + self.reverse + .get(virtual_ip) + .and_then(|addr| self.mappings.get(addr)) + } +} + +/// Parse an IPv6 CIDR string into base address and prefix length. +fn parse_ipv6_cidr(cidr: &str) -> Result<(Ipv6Addr, u32), PoolError> { + let parts: Vec<&str> = cidr.split('/').collect(); + if parts.len() != 2 { + return Err(PoolError::InvalidCidr(cidr.to_string())); + } + let addr: Ipv6Addr = parts[0] + .parse() + .map_err(|_| PoolError::InvalidCidr(cidr.to_string()))?; + let prefix: u32 = parts[1] + .parse() + .map_err(|_| PoolError::InvalidCidr(cidr.to_string()))?; + Ok((addr, prefix)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mock conntrack that returns a configurable session count. + struct MockConntrack { + counts: HashMap, + } + + impl MockConntrack { + fn new() -> Self { + Self { + counts: HashMap::new(), + } + } + + fn set(&mut self, addr: Ipv6Addr, count: u32) { + self.counts.insert(addr, count); + } + } + + impl ConntrackQuerier for MockConntrack { + fn active_sessions(&self, virtual_ip: Ipv6Addr) -> Result { + Ok(*self.counts.get(&virtual_ip).unwrap_or(&0)) + } + } + + fn make_node_addr(byte: u8) -> NodeAddr { + let mut bytes = [0u8; 16]; + bytes[0] = byte; + NodeAddr::from_bytes(bytes) + } + + fn make_mesh_addr(byte: u8) -> Ipv6Addr { + let mut bytes = [0u8; 16]; + bytes[0] = 0xfd; + bytes[15] = byte; + Ipv6Addr::from(bytes) + } + + #[test] + fn test_parse_cidr() { + let (addr, prefix) = parse_ipv6_cidr("fd01::/112").unwrap(); + assert_eq!(addr, "fd01::".parse::().unwrap()); + assert_eq!(prefix, 112); + } + + #[test] + fn test_parse_cidr_invalid() { + assert!(parse_ipv6_cidr("not-a-cidr").is_err()); + assert!(parse_ipv6_cidr("fd01::").is_err()); + assert!(parse_ipv6_cidr("fd01::/abc").is_err()); + } + + #[test] + fn test_pool_creation() { + let pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap(); + // /120 = 8 host bits = 256 addresses, minus 1 (network) = 255 + assert_eq!(pool.total, 255); + assert_eq!(pool.available.len(), 255); + } + + #[test] + fn test_pool_allocation() { + let mut pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap(); + let node = make_node_addr(1); + let mesh = make_mesh_addr(1); + + let (vip, is_new) = pool.allocate(node, mesh, "test.fips").unwrap(); + assert!(is_new); + assert_eq!(vip, "fd01::1".parse::().unwrap()); + assert_eq!(pool.available.len(), 254); + } + + #[test] + fn test_pool_idempotent() { + let mut pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap(); + let node = make_node_addr(1); + let mesh = make_mesh_addr(1); + + let (vip1, new1) = pool.allocate(node, mesh, "test.fips").unwrap(); + let (vip2, new2) = pool.allocate(node, mesh, "test.fips").unwrap(); + assert!(new1); + assert!(!new2); + assert_eq!(vip1, vip2); + assert_eq!(pool.available.len(), 254); + } + + #[test] + fn test_pool_exhaustion() { + // /126 = 2 host bits = 4 addresses, minus 1 = 3 + let mut pool = VirtualIpPool::new("fd01::/126", 60, 60).unwrap(); + assert_eq!(pool.total, 3); + + for i in 1..=3u8 { + pool.allocate(make_node_addr(i), make_mesh_addr(i), "test.fips") + .unwrap(); + } + assert!(pool.allocate(make_node_addr(4), make_mesh_addr(4), "test.fips").is_err()); + } + + #[test] + fn test_mapping_lifecycle_allocated_to_free() { + let mut pool = VirtualIpPool::new("fd01::/120", 1, 1).unwrap(); + let ct = MockConntrack::new(); + let node = make_node_addr(1); + let mesh = make_mesh_addr(1); + + pool.allocate(node, mesh, "test.fips").unwrap(); + + // Tick before TTL — no change + let now = Instant::now(); + let events = pool.tick(now, &ct); + assert!(events.is_empty()); + assert_eq!(pool.mappings.len(), 1); + + // Tick after TTL with no sessions — enters draining + let later = now + std::time::Duration::from_secs(2); + let events = pool.tick(later, &ct); + assert!(events.is_empty()); + assert_eq!(pool.mappings.len(), 1); + assert_eq!(pool.mappings.values().next().unwrap().state, MappingState::Draining); + + // Tick after grace period — freed + let after_grace = later + std::time::Duration::from_secs(2); + let events = pool.tick(after_grace, &ct); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], PoolEvent::MappingRemoved { .. })); + assert_eq!(pool.mappings.len(), 0); + assert_eq!(pool.available.len(), 255); // returned to pool + } + + #[test] + fn test_mapping_lifecycle_active_draining_free() { + let mut pool = VirtualIpPool::new("fd01::/120", 1, 1).unwrap(); + let mut ct = MockConntrack::new(); + let node = make_node_addr(1); + let mesh = make_mesh_addr(1); + + let (vip, _) = pool.allocate(node, mesh, "test.fips").unwrap(); + + // Simulate active sessions + ct.set(vip, 3); + let now = Instant::now(); + let events = pool.tick(now, &ct); + assert!(events.is_empty()); + assert_eq!(pool.mappings[&node].state, MappingState::Active); + + // TTL expires but sessions remain → Draining + let later = now + std::time::Duration::from_secs(2); + ct.set(vip, 1); + let events = pool.tick(later, &ct); + assert!(events.is_empty()); + assert_eq!(pool.mappings[&node].state, MappingState::Draining); + + // Sessions drop to 0 but grace period not elapsed + ct.set(vip, 0); + let events = pool.tick(later, &ct); + assert!(events.is_empty()); + assert_eq!(pool.mappings[&node].state, MappingState::Draining); + + // Grace period elapsed → Free + let much_later = later + std::time::Duration::from_secs(2); + let events = pool.tick(much_later, &ct); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], PoolEvent::MappingRemoved { .. })); + assert_eq!(pool.mappings.len(), 0); + } + + #[test] + fn test_pool_status() { + let mut pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap(); + let status = pool.status(); + assert_eq!(status.total, 255); + assert_eq!(status.free, 255); + assert_eq!(status.allocated, 0); + + pool.allocate(make_node_addr(1), make_mesh_addr(1), "test.fips").unwrap(); + let status = pool.status(); + assert_eq!(status.allocated, 1); + assert_eq!(status.free, 254); + } + + #[test] + fn test_lookup_virtual_ip() { + let mut pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap(); + let node = make_node_addr(1); + let mesh = make_mesh_addr(1); + + let (vip, _) = pool.allocate(node, mesh, "test.fips").unwrap(); + let mapping = pool.lookup_virtual_ip(&vip).unwrap(); + assert_eq!(mapping.node_addr, node); + assert_eq!(mapping.mesh_addr, mesh); + + let unknown: Ipv6Addr = "fd01::ff".parse().unwrap(); + assert!(pool.lookup_virtual_ip(&unknown).is_none()); + } + + #[test] + fn test_large_prefix_capped() { + // /96 = 32 host bits, but pool caps at 2^16 + let pool = VirtualIpPool::new("fd01::/96", 60, 60).unwrap(); + assert_eq!(pool.total, 65535); // 2^16 - 1 (skip addr 0) + } +} diff --git a/src/lib.rs b/src/lib.rs index ed44c07..756db66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod bloom; pub mod cache; pub mod config; pub mod control; +pub mod gateway; pub mod identity; pub mod mmp; pub mod noise; @@ -26,7 +27,7 @@ pub use identity::{ }; // Re-export config types -pub use config::{Config, ConfigError, IdentityConfig, TorConfig, UdpConfig}; +pub use config::{Config, ConfigError, GatewayConfig, IdentityConfig, TorConfig, UdpConfig}; pub use upper::config::{DnsConfig, TunConfig}; // Re-export tree types diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index d56f6b5..877f18b 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -10,13 +10,6 @@ use crate::PeerIdentity; use std::time::Duration; use tracing::{debug, info, warn}; -fn should_reject_new_inbound_msg1( - transport_accepts_connections: bool, - possible_restart: bool, -) -> bool { - !transport_accepts_connections && !possible_restart -} - impl Node { /// Handle handshake message 1 (phase 0x1). /// @@ -33,6 +26,14 @@ impl Node { return; } + // Check if this transport accepts inbound connections + if let Some(transport) = self.transports.get(&packet.transport_id) + && !transport.accept_connections() + { + self.msg1_rate_limiter.complete_handshake(); + return; + } + // Parse header let header = match Msg1Header::parse(&packet.data) { Some(h) => h, @@ -114,25 +115,6 @@ impl Node { } } - // `accept_connections` only gates truly new inbound handshakes. - // Once a msg1 is recognized as belonging to an existing peer path - // (restart or rekey), we must continue processing it even when the - // transport does not accept brand-new inbound connections. - let transport_accepts_connections = self - .transports - .get(&packet.transport_id) - .map(|transport| transport.accept_connections()) - .unwrap_or(true); - if should_reject_new_inbound_msg1(transport_accepts_connections, possible_restart) { - debug!( - transport_id = %packet.transport_id, - remote_addr = %packet.remote_addr, - "Dropping new inbound msg1: transport does not accept inbound connections" - ); - self.msg1_rate_limiter.complete_handshake(); - return; - } - // === CRYPTO COST PAID HERE === let link_id = self.allocate_link_id(); let mut conn = PeerConnection::inbound_with_transport( @@ -1072,18 +1054,3 @@ impl Node { } } } - -#[cfg(test)] -mod tests { - use super::should_reject_new_inbound_msg1; - - #[test] - fn rejects_new_inbound_when_transport_disallows_it() { - assert!(should_reject_new_inbound_msg1(false, false)); - } - - #[test] - fn allows_existing_peer_paths_even_when_transport_disallows_inbound() { - assert!(!should_reject_new_inbound_msg1(false, true)); - } -} diff --git a/src/node/tests/handshake.rs b/src/node/tests/handshake.rs index bc2af4a..9df9375 100644 --- a/src/node/tests/handshake.rs +++ b/src/node/tests/handshake.rs @@ -81,9 +81,6 @@ async fn test_two_node_handshake_udp() { Duration::from_millis(100), ); node_a.links.insert(link_id_a, link_a); - node_a - .addr_to_link - .insert((transport_id_a, remote_addr_b.clone()), link_id_a); node_a.connections.insert(link_id_a, conn_a); node_a.pending_outbound.insert( (transport_id_a, our_index_a.as_u32()), @@ -235,183 +232,6 @@ async fn test_two_node_handshake_udp() { } } -#[tokio::test] -async fn test_rekey_msg1_allowed_when_existing_peer_and_accept_connections_false() { - /* - * Given: a transport where accept_connections == false, and a connection that is already - * established and old enough for a 'rekey' - * When: an existing peer attempts a rekey - * Then: we shouldn't reject the rekey merely because accept_connections == false (that - * condition is only for *new* connections) - * - * This test sets up a connection, including the initial Noise key exchange. Then it - * sets the age of the connection to be old, so that rekey can be attempted. - */ - use crate::config::UdpConfig; - use crate::node::wire::{build_msg1, Msg1Header, Msg2Header}; - use crate::transport::udp::UdpTransport; - use tokio::time::{timeout, Duration}; - - let mut node_a = make_node(); - let mut node_b = make_node(); - - let transport_id_a = TransportId::new(1); - let transport_id_b = TransportId::new(1); - - let udp_config = UdpConfig { - bind_addr: Some("127.0.0.1:0".to_string()), - mtu: Some(1280), - ..Default::default() - }; - - let (packet_tx_a, mut packet_rx_a) = packet_channel(64); - let (packet_tx_b, mut packet_rx_b) = packet_channel(64); - - let mut transport_a = - UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a); - transport_a.set_accept_connections_for_test(false); - let mut transport_b = - UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b); - - transport_a.start_async().await.unwrap(); - transport_b.start_async().await.unwrap(); - - let _addr_a = transport_a.local_addr().unwrap(); - let addr_b = transport_b.local_addr().unwrap(); - let _remote_addr_a = TransportAddr::from_string(&_addr_a.to_string()); - let remote_addr_b = TransportAddr::from_string(&addr_b.to_string()); - - node_a - .transports - .insert(transport_id_a, TransportHandle::Udp(transport_a)); - node_b - .transports - .insert(transport_id_b, TransportHandle::Udp(transport_b)); - - // Phase 1: establish the initial outbound handshake A -> B. - let peer_b_identity = - PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()); - let peer_b_node_addr = *peer_b_identity.node_addr(); - - let link_id_a = node_a.allocate_link_id(); - let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000); - let our_index_a = node_a.index_allocator.allocate().unwrap(); - let our_keypair_a = node_a.identity.keypair(); - let noise_msg1 = conn_a - .start_handshake(our_keypair_a, node_a.startup_epoch, 1000) - .unwrap(); - conn_a.set_our_index(our_index_a); - conn_a.set_transport_id(transport_id_a); - conn_a.set_source_addr(remote_addr_b.clone()); - - let wire_msg1 = build_msg1(our_index_a, &noise_msg1); - let link_a = Link::connectionless( - link_id_a, - transport_id_a, - remote_addr_b.clone(), - LinkDirection::Outbound, - Duration::from_millis(100), - ); - node_a.links.insert(link_id_a, link_a); - node_a - .addr_to_link - .insert((transport_id_a, remote_addr_b.clone()), link_id_a); - node_a.connections.insert(link_id_a, conn_a); - node_a.pending_outbound.insert( - (transport_id_a, our_index_a.as_u32()), - link_id_a, - ); - - let transport = node_a.transports.get(&transport_id_a).unwrap(); - transport - .send(&remote_addr_b, &wire_msg1) - .await - .expect("failed to send initial msg1"); - - let packet_b = timeout(Duration::from_secs(1), packet_rx_b.recv()) - .await - .expect("timeout waiting for initial msg1") - .expect("channel closed"); - node_b.handle_msg1(packet_b).await; - - let peer_a_node_addr = *PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full()) - .node_addr(); - let packet_a = timeout(Duration::from_secs(1), packet_rx_a.recv()) - .await - .expect("timeout waiting for initial msg2") - .expect("channel closed"); - node_a.handle_msg2(packet_a).await; - - assert_eq!(node_a.peer_count(), 1, "Node A should have one active peer"); - assert_eq!(node_b.peer_count(), 1, "Node B should have one active peer"); - - // The initial promotion path can emit Tree/Bloom traffic. Drain any queued - // post-handshake packets so the next receive is the rekey msg1/msg2 pair. - tokio::time::sleep(Duration::from_millis(100)).await; - while packet_rx_a.try_recv().is_ok() {} - while packet_rx_b.try_recv().is_ok() {} - - // Phase 2: age both active sessions into the rekey path. - node_a - .get_peer_mut(&peer_b_node_addr) - .unwrap() - .age_session_for_test(Duration::from_secs(31)); - node_b - .get_peer_mut(&peer_a_node_addr) - .unwrap() - .age_session_for_test(Duration::from_secs(31)); - - // Phase 3: B initiates rekey. A must process the inbound msg1 even though - // its transport rejects brand-new inbound handshakes. - node_b.config.node.rekey.after_secs = 0; - node_b.check_rekey().await; - - let expected_rekey_index = node_b - .get_peer(&peer_a_node_addr) - .unwrap() - .rekey_our_index() - .expect("Node B should have an in-progress rekey index"); - - let rekey_packet_a = timeout(Duration::from_secs(1), packet_rx_a.recv()) - .await - .expect("timeout waiting for rekey msg1") - .expect("channel closed"); - let rekey_header_a = Msg1Header::parse(&rekey_packet_a.data) - .expect("expected a rekey msg1 packet"); - assert_eq!( - rekey_header_a.sender_idx, - expected_rekey_index, - "Node A should receive the rekey msg1 that Node B initiated" - ); - node_a.handle_msg1(rekey_packet_a).await; - - let rekey_packet_b = timeout(Duration::from_secs(1), packet_rx_b.recv()) - .await - .expect("timeout waiting for rekey msg2") - .expect("channel closed"); - let rekey_header_b = Msg2Header::parse(&rekey_packet_b.data) - .expect("expected a rekey msg2 packet"); - assert_eq!( - rekey_header_b.receiver_idx, - expected_rekey_index, - "Node A should respond with msg2 for the in-progress rekey" - ); - node_b.handle_msg2(rekey_packet_b).await; - - let peer_a_on_b = node_b.get_peer(&peer_a_node_addr).unwrap(); - assert!( - peer_a_on_b.pending_new_session().is_some(), - "Node B should complete initiator-side rekey and store a pending session" - ); - - for (_, t) in node_a.transports.iter_mut() { - t.stop().await.ok(); - } - for (_, t) in node_b.transports.iter_mut() { - t.stop().await.ok(); - } -} - /// Integration test: two nodes complete a handshake via run_rx_loop. /// /// Unlike test_two_node_handshake_udp which calls handle_msg1/handle_msg2 diff --git a/src/peer/active.rs b/src/peer/active.rs index e043cfa..16ab281 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -780,12 +780,6 @@ impl ActivePeer { self.session_established_at } - /// Shift the session establishment time backwards for tests. - #[cfg(test)] - pub(crate) fn age_session_for_test(&mut self, age: std::time::Duration) { - self.session_established_at = Instant::now() - age; - } - /// Current K-bit epoch value. pub fn current_k_bit(&self) -> bool { self.current_k_bit diff --git a/src/transport/udp/mod.rs b/src/transport/udp/mod.rs index 0077ea6..317cd14 100644 --- a/src/transport/udp/mod.rs +++ b/src/transport/udp/mod.rs @@ -48,9 +48,6 @@ pub struct UdpTransport { stats: Arc, /// DNS resolution cache for hostname addresses. dns_cache: StdMutex>, - /// Test-only override for inbound-acceptance behavior. - #[cfg(test)] - accept_connections_override: Option, } impl UdpTransport { @@ -72,17 +69,9 @@ impl UdpTransport { local_addr: None, stats: Arc::new(UdpStats::new()), dns_cache: StdMutex::new(HashMap::new()), - #[cfg(test)] - accept_connections_override: None, } } - /// Override `accept_connections()` for tests. - #[cfg(test)] - pub(crate) fn set_accept_connections_for_test(&mut self, accept_connections: bool) { - self.accept_connections_override = Some(accept_connections); - } - /// Get the instance name (if configured as a named instance). pub fn name(&self) -> Option<&str> { self.name.as_deref() @@ -314,15 +303,6 @@ impl Transport for UdpTransport { // Peer configuration is handled at the node level, not transport level Ok(Vec::new()) } - - fn accept_connections(&self) -> bool { - #[cfg(test)] - if let Some(accept_connections) = self.accept_connections_override { - return accept_connections; - } - - true - } } /// UDP receive loop - runs as a spawned task. diff --git a/testing/ci-local.sh b/testing/ci-local.sh index c343e66..3973e4e 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -14,7 +14,7 @@ # -h, --help Show this help # # Integration suites: -# static-mesh, static-chain, rekey, +# static-mesh, static-chain, rekey, gateway, # chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh, # chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent, # chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability, @@ -63,6 +63,7 @@ CHAOS_SUITES=( "mixed-technology mixed-technology" "congestion-stress congestion-stress" ) +GATEWAY_SUITES=(gateway) SIDECAR_SUITES=(sidecar) # ── Colors ───────────────────────────────────────────────────────────────── @@ -98,6 +99,9 @@ list_suites() { echo " chaos-${parts[0]} (${parts[*]:1})" done echo "" + echo " Gateway:" + for s in "${GATEWAY_SUITES[@]}"; do echo " $s"; done + echo "" echo " Sidecar:" for s in "${SIDECAR_SUITES[@]}"; do echo " $s"; done exit 0 @@ -195,8 +199,10 @@ install_binaries() { cp target/release/fips "$dest/fips" cp target/release/fipsctl "$dest/fipsctl" [[ -f target/release/fipstop ]] && cp target/release/fipstop "$dest/fipstop" || true + [[ -f target/release/fips-gateway ]] && cp target/release/fips-gateway "$dest/fips-gateway" || true chmod +x "$dest/fips" "$dest/fipsctl" [[ -f "$dest/fipstop" ]] && chmod +x "$dest/fipstop" || true + [[ -f "$dest/fips-gateway" ]] && chmod +x "$dest/fips-gateway" || true } # Run a static topology test (mesh, chain) @@ -265,6 +271,31 @@ run_chaos() { record "chaos-$name" $rc } +# Run gateway integration test +run_gateway() { + local compose="testing/static/docker-compose.yml" + local rc=0 + + info "[gateway] Generating configs" + bash testing/static/scripts/generate-configs.sh gateway gateway-test || { record "gateway" 1; return; } + bash testing/static/scripts/gateway-test.sh inject-config || { record "gateway" 1; return; } + + info "[gateway] Starting containers" + docker compose -f "$compose" --profile gateway up -d || { record "gateway" 1; return; } + + info "[gateway] Running gateway test" + if bash testing/static/scripts/gateway-test.sh; then + rc=0 + else + rc=1 + info "[gateway] Collecting failure logs" + docker compose -f "$compose" --profile gateway logs --no-color 2>&1 | tail -100 + fi + + docker compose -f "$compose" --profile gateway down --volumes --remove-orphans 2>/dev/null + record "gateway" $rc +} + # Run sidecar test run_sidecar() { local rc=0 @@ -307,6 +338,9 @@ run_integration() { # Rekey run_rekey + # Gateway + run_gateway + # Chaos scenarios (parallel, throttled) if [[ "$SKIP_CHAOS" != true ]]; then info "Running ${#CHAOS_SUITES[@]} chaos scenarios (max $PARALLEL_JOBS parallel)" @@ -369,6 +403,8 @@ run_suite() { run_static "${suite#static-}" ;; rekey) run_rekey ;; + gateway) + run_gateway ;; chaos-*) local chaos_name="${suite#chaos-}" local found=false diff --git a/testing/docker/.gitignore b/testing/docker/.gitignore index 4d658a8..da850b8 100644 --- a/testing/docker/.gitignore +++ b/testing/docker/.gitignore @@ -1,3 +1,4 @@ fips +fips-gateway fipsctl fipstop diff --git a/testing/docker/Dockerfile b/testing/docker/Dockerfile index 003bc00..391f556 100644 --- a/testing/docker/Dockerfile +++ b/testing/docker/Dockerfile @@ -14,7 +14,7 @@ RUN apt-get update && \ iproute2 iputils-ping dnsutils \ openssh-client openssh-server \ dnsmasq iptables tor \ - iperf3 curl python3 \ + iperf3 curl python3 nftables conntrack \ tcpdump netcat-openbsd rsync && \ rm -rf /var/lib/apt/lists/* @@ -36,8 +36,8 @@ RUN printf '%s\n' \ 'no-resolv' \ >> /etc/dnsmasq.conf -COPY fips fipsctl fipstop /usr/local/bin/ -RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop +COPY fips fipsctl fipstop fips-gateway /usr/local/bin/ +RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop /usr/local/bin/fips-gateway # Static web page for HTTP server (chaos/static modes) RUN printf 'Fuck IPs!\n' > /root/index.html diff --git a/testing/docker/entrypoint.sh b/testing/docker/entrypoint.sh index 9eb37bb..c98b9e1 100644 --- a/testing/docker/entrypoint.sh +++ b/testing/docker/entrypoint.sh @@ -206,9 +206,42 @@ case "$MODE" in echo "Starting FIPS daemon..." exec fips --config "$CONFIG" ;; + gateway) + # No dnsmasq — gateway DNS replaces it on port 53 + start_services + + # Extract LAN interface from config (gateway.lan_interface) + LAN_IF=$(grep 'lan_interface:' "$CONFIG" | head -1 | sed 's/.*: *//' | tr -d '"' | tr -d "'") + LAN_IF="${LAN_IF:-eth0}" + + # Wait for LAN interface (Docker attaches second network after start) + for i in $(seq 1 15); do + [ -e "/sys/class/net/$LAN_IF" ] && break + sleep 0.5 + done + + # Ensure IPv6 is enabled on the LAN interface (may inherit host default) + sysctl -w "net.ipv6.conf.${LAN_IF}.disable_ipv6=0" >/dev/null 2>&1 || true + sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true + sysctl -w net.ipv6.conf.all.proxy_ndp=1 >/dev/null 2>&1 || true + + # Start fips in background (gateway needs fips0) + fips --config "$CONFIG" & + # Wait for fips0 TUN device + for i in $(seq 1 30); do + [ -e /sys/class/net/fips0 ] && break + sleep 1 + done + if [ ! -e /sys/class/net/fips0 ]; then + echo "FATAL: fips0 did not appear within 30s" + exit 1 + fi + echo "fips0 ready, starting gateway" + exec fips-gateway --config "$CONFIG" --log-level debug + ;; *) echo "Unknown FIPS_TEST_MODE: $MODE" - echo "Valid modes: default, chaos, sidecar, tor-socks5, tor-directory" + echo "Valid modes: default, chaos, sidecar, tor-socks5, tor-directory, gateway" exit 1 ;; esac diff --git a/testing/static/configs/gateway-resolv.conf b/testing/static/configs/gateway-resolv.conf new file mode 100644 index 0000000..ca10fc4 --- /dev/null +++ b/testing/static/configs/gateway-resolv.conf @@ -0,0 +1 @@ +nameserver fd02::10 diff --git a/testing/static/configs/topologies/gateway.yaml b/testing/static/configs/topologies/gateway.yaml new file mode 100644 index 0000000..7a1c68e --- /dev/null +++ b/testing/static/configs/topologies/gateway.yaml @@ -0,0 +1,15 @@ +# Gateway Integration Test Topology +# +# Two FIPS nodes: gateway (a) and server (b), directly peered. +# A non-FIPS client container connects via the gateway's LAN interface. +# +# Uses deterministic key derivation (mesh-name: gateway-test). + +nodes: + a: + docker_ip: "172.20.0.10" + peers: [b] + + b: + docker_ip: "172.20.0.11" + peers: [a] diff --git a/testing/static/docker-compose.yml b/testing/static/docker-compose.yml index a8e24b1..415cbc1 100644 --- a/testing/static/docker-compose.yml +++ b/testing/static/docker-compose.yml @@ -4,6 +4,13 @@ networks: ipam: config: - subnet: 172.20.0.0/24 + gateway-lan: + driver: bridge + enable_ipv6: true + ipam: + config: + - subnet: 172.20.1.0/24 + - subnet: fd02::/64 x-fips-common: &fips-common image: fips-test:latest @@ -310,3 +317,61 @@ services: networks: fips-net: ipv4_address: 172.20.0.12 + + # ── Gateway integration test (gateway + server + non-FIPS client) ─ + gw-gateway: + <<: *fips-common + profiles: ["gateway"] + container_name: fips-gw-gateway + hostname: gw-gateway + # Privileged required: gateway must enable IPv6 on eth1 (second network, + # attached after container start) and manage nftables NAT rules. + privileged: true + environment: + - RUST_LOG=info + - FIPS_TEST_MODE=gateway + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + - net.ipv6.conf.default.disable_ipv6=0 + - net.ipv6.conf.all.forwarding=1 + - net.ipv6.conf.all.proxy_ndp=1 + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/gateway/node-a.yaml:/etc/fips/fips.yaml:ro + networks: + fips-net: + ipv4_address: 172.20.0.10 + gateway-lan: + ipv4_address: 172.20.1.10 + ipv6_address: fd02::10 + + gw-server: + <<: *fips-common + profiles: ["gateway"] + container_name: fips-gw-server + hostname: gw-server + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/gateway/node-b.yaml:/etc/fips/fips.yaml:ro + networks: + fips-net: + ipv4_address: 172.20.0.11 + + gw-client: + image: fips-test-app:latest + profiles: ["gateway"] + container_name: fips-gw-client + hostname: gw-client + cap_add: + - NET_ADMIN + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + volumes: + - ./configs/gateway-resolv.conf:/etc/resolv.conf:ro + networks: + gateway-lan: + ipv4_address: 172.20.1.20 + ipv6_address: fd02::20 + restart: "no" + env_file: + - ./generated-configs/npubs.env diff --git a/testing/static/scripts/gateway-test.sh b/testing/static/scripts/gateway-test.sh new file mode 100755 index 0000000..81b3652 --- /dev/null +++ b/testing/static/scripts/gateway-test.sh @@ -0,0 +1,221 @@ +#!/bin/bash +# Gateway integration test: non-FIPS LAN client reaches mesh HTTP server. +# +# Topology: +# gw-client (non-FIPS) → gw-gateway (fips + fips-gateway) → gw-server (fips + http) +# +# Usage: +# ./scripts/gateway-test.sh [inject-config] +# +# Subcommands: +# inject-config — post-process generated configs to add gateway section +# (no args) — run the test (containers must be running) +set -e + +trap 'echo ""; echo "Test interrupted"; exit 130' INT + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../lib/wait-converge.sh" + +GENERATED_DIR="$SCRIPT_DIR/../generated-configs" +ENV_FILE="$GENERATED_DIR/npubs.env" + +GATEWAY="fips-gw-gateway" +SERVER="fips-gw-server" +CLIENT="fips-gw-client" + +# ── inject-config subcommand ───────────────────────────────────────────── + +inject_gateway_config() { + local config_file="$GENERATED_DIR/gateway/node-a.yaml" + + if [ ! -f "$config_file" ]; then + echo "Error: $config_file not found. Run generate-configs.sh gateway first." >&2 + exit 1 + fi + + echo "Injecting gateway config into $config_file" + python3 -c " +import yaml + +with open('$config_file') as f: + cfg = yaml.safe_load(f) + +cfg['gateway'] = { + 'enabled': True, + 'pool': 'fd01::/112', + 'lan_interface': 'eth0', + 'dns': { + 'listen': '[::]:53', + 'ttl': 5, + }, + 'pool_grace_period': 5, +} + +with open('$config_file', 'w') as f: + yaml.dump(cfg, f, default_flow_style=False, sort_keys=False) +" + echo " ✓ Gateway config injected" +} + +if [ "${1:-}" = "inject-config" ]; then + inject_gateway_config + exit 0 +fi + +# ── Main test ──────────────────────────────────────────────────────────── + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: $ENV_FILE not found. Run generate-configs.sh gateway first." >&2 + exit 1 +fi + +# shellcheck source=../generated-configs/npubs.env +source "$ENV_FILE" + +PASSED=0 +FAILED=0 + +check() { + local label="$1" + local result="$2" + if [ "$result" -eq 0 ]; then + echo " $label ... OK" + PASSED=$((PASSED + 1)) + else + echo " $label ... FAIL" + FAILED=$((FAILED + 1)) + fi +} + +echo "=== FIPS Gateway Integration Test ===" +echo "" + +# Phase 1: Wait for mesh convergence (gateway ↔ server) +echo "Phase 1: Mesh convergence" +wait_for_peers "$GATEWAY" 1 30 || true +wait_for_peers "$SERVER" 1 30 || true + +# Phase 2: Wait for gateway DNS to respond +echo "" +echo "Phase 2: Gateway DNS readiness" +DNS_READY=false +for i in $(seq 1 30); do + # Try resolving the server's npub via the gateway DNS from the client. + # Match fd01:: specifically (the pool prefix) to avoid false-positive + # matches on error messages containing fd02::10. + local_result=$(docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" @fd02::10 2>/dev/null || true) + if echo "$local_result" | grep -q "^fd01::"; then + echo " Gateway DNS responding after ${i}s" + DNS_READY=true + break + fi + sleep 1 +done + +if [ "$DNS_READY" != true ]; then + echo " WARNING: Gateway DNS did not respond within 30s, continuing anyway" +fi + +# Phase 3: Client network setup — route virtual IP pool via gateway +echo "" +echo "Phase 3: Client network setup" +docker exec "$CLIENT" ip -6 route add fd01::/112 via fd02::10 2>/dev/null || true +echo " Added route fd01::/112 via fd02::10" + +# Phase 4: DNS resolution test — resolve server npub from client +echo "" +echo "Phase 4: DNS resolution" +VIRTUAL_IP=$(docker exec "$CLIENT" dig +short AAAA "${NPUB_B}.fips" @fd02::10 2>/dev/null | head -1) +if [ -n "$VIRTUAL_IP" ] && echo "$VIRTUAL_IP" | grep -q "fd01"; then + check "Resolve ${NPUB_B:0:20}...fips → $VIRTUAL_IP" 0 +else + check "Resolve ${NPUB_B:0:20}...fips (got: '$VIRTUAL_IP')" 1 +fi + +# Phase 5: End-to-end HTTP test +echo "" +echo "Phase 5: HTTP through gateway" + +# Use --resolve to bind the .fips hostname to the virtual IP for curl +if [ -n "$VIRTUAL_IP" ]; then + RESPONSE=$(docker exec "$CLIENT" curl -6 -s --max-time 10 \ + --resolve "${NPUB_B}.fips:8000:[$VIRTUAL_IP]" \ + "http://${NPUB_B}.fips:8000/" 2>&1) || true + + if echo "$RESPONSE" | grep -q "Fuck IPs"; then + check "HTTP GET ${NPUB_B:0:20}...fips:8000" 0 + else + check "HTTP GET (response: '${RESPONSE:0:80}')" 1 + fi +else + check "HTTP GET (skipped — no virtual IP)" 1 +fi + +# Phase 6: Verify NAT state on gateway +echo "" +echo "Phase 6: Gateway NAT state" +# Check that nftables rules were created +NFT_RULES=$(docker exec "$GATEWAY" nft list table inet fips_gateway 2>/dev/null || echo "") +if echo "$NFT_RULES" | grep -q "dnat"; then + check "nftables DNAT rules present" 0 +else + check "nftables DNAT rules" 1 +fi + +# Phase 7: TTL expiration and pool reclamation +echo "" +echo "Phase 7: TTL expiration and pool reclamation" +# Flush conntrack so stale sessions from Phase 5 don't keep the mapping alive. +docker exec "$GATEWAY" conntrack -F 2>/dev/null || true +# Config uses ttl=5, pool_grace_period=5. Pool tick interval is 10s, so: +# tick 1 (~10s): TTL expired → Draining (sessions=0 after flush) +# tick 2 (~20s): grace expired → freed +# Wait 25s to ensure two full tick cycles have passed. +echo " Waiting 25s for TTL + grace period to expire (two tick cycles)..." +sleep 25 + +# Query gateway control socket for mapping count +MAPPING_COUNT=$(docker exec "$GATEWAY" bash -c \ + 'echo "show_mappings" | nc -U -w1 /run/fips/gateway.sock 2>/dev/null' \ + | python3 -c "import sys,json; r=json.load(sys.stdin); print(len(r.get('data',{}).get('mappings',[])))" 2>/dev/null || echo "error") +if [ "$MAPPING_COUNT" = "0" ]; then + check "Mapping reclaimed after TTL+grace" 0 +else + check "Mapping reclaimed (count: $MAPPING_COUNT)" 1 +fi + +# Phase 8: SERVFAIL when daemon DNS is down +echo "" +echo "Phase 8: SERVFAIL when daemon DNS is down" +# Kill the fips daemon inside the gateway container (gateway stays running) +docker exec "$GATEWAY" pkill -f "^fips --config" 2>/dev/null || true +sleep 2 + +# Gateway upstream timeout is 5s, so dig must wait longer than that. +SERVFAIL_RESULT=$(docker exec "$CLIENT" dig +short +tries=1 +time=8 AAAA "test-servfail.fips" @fd02::10 2>&1 || true) +SERVFAIL_STATUS=$(docker exec "$CLIENT" dig +tries=1 +time=8 AAAA "test-servfail.fips" @fd02::10 2>&1 | grep -c "SERVFAIL" || true) +if [ "$SERVFAIL_STATUS" -ge 1 ]; then + check "SERVFAIL when daemon DNS is down" 0 +else + check "SERVFAIL when daemon DNS down (got: '${SERVFAIL_RESULT:0:80}')" 1 +fi + +# Phase 9: Cleanup verification (nftables removed on shutdown) +echo "" +echo "Phase 9: Cleanup on shutdown" +# fips-gateway is PID 1 (exec in entrypoint), so SIGTERM stops the container. +# Verify cleanup by checking container logs for the shutdown sequence. +docker stop --time=10 "$GATEWAY" >/dev/null 2>&1 || true +sleep 1 + +LOGS=$(docker logs --tail=20 "$GATEWAY" 2>&1) +if echo "$LOGS" | grep -q "shutdown complete"; then + check "Gateway shutdown completed cleanly" 0 +else + check "Gateway shutdown (no completion message in logs)" 1 +fi + +echo "" +echo "=== Results: $PASSED passed, $FAILED failed ===" +[ "$FAILED" -eq 0 ] && exit 0 || exit 1