Implement outbound LAN gateway

Add fips-gateway binary: a separate daemon that allows unmodified LAN
hosts to reach FIPS mesh destinations via DNS-allocated virtual IPs
and kernel nftables NAT.

Gateway DNS resolver: forwarding proxy on [::]:53 that intercepts
.fips queries, forwards to daemon resolver (localhost:5354), allocates
virtual IPs from pool, returns AAAA records. Always sends AAAA upstream
regardless of client query type, returns proper NODATA for non-AAAA.

Virtual IP pool: fd01::/112 pool with state machine lifecycle
(Allocated → Active → Draining → Free), TTL-based reclamation,
conntrack integration for session tracking.

NAT manager: nftables DNAT/SNAT rules via rustables netlink API,
per-mapping rule lifecycle, fips0 masquerade for LAN client source
address rewriting.

Network setup: local pool route, proxy NDP for virtual IPs on LAN
interface, IPv6 forwarding validation.

Control socket at /run/fips/gateway.sock with show_gateway and
show_mappings queries. fipstop Gateway tab with pool summary gauge
and mappings table.

Gateway config section in fips.yaml with pool CIDR, LAN interface,
DNS upstream, TTL, and grace period settings.

Design doc at docs/design/fips-gateway.md.

Integration test (testing/static/scripts/gateway-test.sh): three
containers verifying DNS resolution, end-to-end HTTP, NAT state,
TTL expiration, SERVFAIL fallback, and clean shutdown.
This commit is contained in:
Johnathan Corgan
2026-04-09 16:53:32 +00:00
parent 51119347c3
commit 60e5fefb1f
29 changed files with 3727 additions and 263 deletions
Generated
+120 -1
View File
@@ -155,6 +155,26 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" 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]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.5.3" version = "0.5.3"
@@ -312,6 +332,15 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -397,6 +426,17 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "clap" name = "clap"
version = "4.5.56" version = "4.5.56"
@@ -928,6 +968,7 @@ dependencies = [
"rand 0.10.0", "rand 0.10.0",
"ratatui", "ratatui",
"rtnetlink", "rtnetlink",
"rustables",
"secp256k1", "secp256k1",
"serde", "serde",
"serde_json", "serde_json",
@@ -1114,6 +1155,12 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]] [[package]]
name = "half" name = "half"
version = "2.7.1" version = "2.7.1"
@@ -1245,6 +1292,12 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "ipnetwork"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -1329,6 +1382,16 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "libloading" name = "libloading"
version = "0.9.0" version = "0.9.0"
@@ -1529,6 +1592,7 @@ dependencies = [
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"memoffset",
] ]
[[package]] [[package]]
@@ -1892,6 +1956,19 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.44"
@@ -2126,6 +2203,42 @@ dependencies = [
"tokio", "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]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -3255,7 +3368,7 @@ dependencies = [
"blocking", "blocking",
"c2rust-bitfields", "c2rust-bitfields",
"futures", "futures",
"libloading", "libloading 0.9.0",
"log", "log",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -3350,6 +3463,12 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.33" version = "0.8.33"
+8 -1
View File
@@ -33,12 +33,13 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tun = { version = "0.8.5", features = ["async"] } tun = { version = "0.8.5", features = ["async"] }
libc = "0.2" libc = "0.2"
rtnetlink = "0.20.0" 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" futures = "0.3"
simple-dns = "0.11.2" simple-dns = "0.11.2"
socket2 = { version = "0.6.2", features = ["all"] } socket2 = { version = "0.6.2", features = ["all"] }
tokio-socks = "0.5" tokio-socks = "0.5"
bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true } bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true }
rustables = "0.8.7"
[package.metadata.deb] [package.metadata.deb]
maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>" maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>"
@@ -64,6 +65,8 @@ assets = [
["packaging/debian/fips.service", "/lib/systemd/system/fips.service", "644"], ["packaging/debian/fips.service", "/lib/systemd/system/fips.service", "644"],
["packaging/debian/fips-dns.service", "/lib/systemd/system/fips-dns.service", "644"], ["packaging/debian/fips-dns.service", "/lib/systemd/system/fips-dns.service", "644"],
["packaging/debian/fips.tmpfiles", "/usr/lib/tmpfiles.d/fips.conf", "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"] conf-files = ["/etc/fips/fips.yaml", "/etc/fips/hosts"]
@@ -76,6 +79,10 @@ tokio = { version = "1", features = ["test-util"] }
name = "fipsctl" name = "fipsctl"
path = "src/bin/fipsctl.rs" path = "src/bin/fipsctl.rs"
[[bin]]
name = "fips-gateway"
path = "src/bin/fips-gateway.rs"
[[bin]] [[bin]]
name = "fipstop" name = "fipstop"
path = "src/bin/fipstop/main.rs" path = "src/bin/fipstop/main.rs"
+398
View File
@@ -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 == <virtual_ip>
action: dnat to <mesh_addr>
```
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 == <mesh_addr>
action: snat to <virtual_ip>
```
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 <pool-cidr> 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 <pool-cidr> via
<gateway-lan-addr>`. 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 <pool-cidr>
```
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 <virtual-ip>
```
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
+426
View File
@@ -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<PathBuf>,
/// 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<u8> = 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::<pool::PoolEvent>(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::<Option<control::GatewaySnapshot>>(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");
}
+24 -3
View File
@@ -14,10 +14,11 @@ pub enum Tab {
Cache, Cache,
Transports, Transports,
Routing, Routing,
Gateway,
} }
impl Tab { impl Tab {
pub const ALL: [Tab; 8] = [ pub const ALL: [Tab; 9] = [
Tab::Node, Tab::Node,
Tab::Peers, Tab::Peers,
Tab::Transports, Tab::Transports,
@@ -26,13 +27,15 @@ impl Tab {
Tab::Bloom, Tab::Bloom,
Tab::Mmp, Tab::Mmp,
Tab::Routing, 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 { pub fn group(&self) -> usize {
match self { match self {
Tab::Node => 0, Tab::Node => 0,
Tab::Peers | Tab::Transports => 1, Tab::Peers | Tab::Transports => 1,
Tab::Gateway => 3,
_ => 2, _ => 2,
} }
} }
@@ -49,6 +52,7 @@ impl Tab {
Tab::Cache => "Cache", Tab::Cache => "Cache",
Tab::Transports => "Transports", Tab::Transports => "Transports",
Tab::Routing => "Routing", Tab::Routing => "Routing",
Tab::Gateway => "Gateway",
} }
} }
@@ -64,6 +68,7 @@ impl Tab {
Tab::Cache => "show_cache", Tab::Cache => "show_cache",
Tab::Transports => "show_transports", Tab::Transports => "show_transports",
Tab::Routing => "show_routing", Tab::Routing => "show_routing",
Tab::Gateway => "show_gateway",
} }
} }
@@ -88,6 +93,7 @@ impl Tab {
Tab::Links => "links", Tab::Links => "links",
Tab::Sessions => "sessions", Tab::Sessions => "sessions",
Tab::Transports => "transports", Tab::Transports => "transports",
Tab::Gateway => "mappings",
_ => "", _ => "",
} }
} }
@@ -96,7 +102,7 @@ impl Tab {
pub fn has_table(&self) -> bool { pub fn has_table(&self) -> bool {
matches!( matches!(
self, 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<u64>, pub expanded_transports: HashSet<u64>,
pub tree_row_count: usize, pub tree_row_count: usize,
pub selected_tree_item: SelectedTreeItem, 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<serde_json::Value>,
} }
impl App { impl App {
@@ -148,6 +158,8 @@ impl App {
expanded_transports: HashSet::new(), expanded_transports: HashSet::new(),
tree_row_count: 0, tree_row_count: 0,
selected_tree_item: SelectedTreeItem::None, selected_tree_item: SelectedTreeItem::None,
gateway_running: false,
gateway_mappings: None,
} }
} }
@@ -156,6 +168,15 @@ impl App {
if self.active_tab == Tab::Transports { if self.active_tab == Tab::Transports {
return self.tree_row_count; 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(); let key = self.active_tab.command_data_key();
self.data self.data
.get(&self.active_tab) .get(&self.active_tab)
+55 -5
View File
@@ -25,6 +25,10 @@ struct Cli {
#[arg(short = 's', long)] #[arg(short = 's', long)]
socket: Option<PathBuf>, socket: Option<PathBuf>,
/// Gateway control socket path override
#[arg(long)]
gateway_socket: Option<PathBuf>,
/// Refresh interval in seconds /// Refresh interval in seconds
#[arg(short = 'r', long, default_value = "2")] #[arg(short = 'r', long, default_value = "2")]
refresh: u64, 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() { fn restore_terminal() {
ratatui::restore(); 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 // Always fetch status for the status bar
match rt.block_on(client.query("show_status")) { match rt.block_on(client.query("show_status")) {
Ok(data) => { 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) // Fetch active tab data (if not Dashboard, which we already fetched)
if app.active_tab != Tab::Node { if app.active_tab != Tab::Node {
match rt.block_on(client.query(app.active_tab.command())) { match rt.block_on(client.query(app.active_tab.command())) {
@@ -106,6 +154,7 @@ fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let socket_path = cli.socket.unwrap_or_else(default_socket_path); 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); let refresh = Duration::from_secs(cli.refresh);
// Install panic hook that restores terminal before printing panic // Install panic hook that restores terminal before printing panic
@@ -121,6 +170,7 @@ fn main() {
.expect("failed to create tokio runtime"); .expect("failed to create tokio runtime");
let client = ControlClient::new(&socket_path); let client = ControlClient::new(&socket_path);
let gateway_client = ControlClient::new(&gateway_socket_path);
let mut terminal = ratatui::try_init().unwrap_or_else(|e| { let mut terminal = ratatui::try_init().unwrap_or_else(|e| {
eprintln!("fipstop: failed to initialize terminal: {e}"); eprintln!("fipstop: failed to initialize terminal: {e}");
std::process::exit(1); std::process::exit(1);
@@ -129,7 +179,7 @@ fn main() {
let events = EventHandler::new(refresh); let events = EventHandler::new(refresh);
// Initial fetch // Initial fetch
fetch_data(&rt, &client, &mut app); fetch_data(&rt, &client, &gateway_client, &mut app);
// Main loop // Main loop
loop { loop {
@@ -150,12 +200,12 @@ fn main() {
(KeyCode::Tab, KeyModifiers::NONE) => { (KeyCode::Tab, KeyModifiers::NONE) => {
app.close_detail(); app.close_detail();
app.active_tab = app.active_tab.next(); app.active_tab = app.active_tab.next();
fetch_data(&rt, &client, &mut app); fetch_data(&rt, &client, &gateway_client, &mut app);
} }
(KeyCode::BackTab, _) => { (KeyCode::BackTab, _) => {
app.close_detail(); app.close_detail();
app.active_tab = app.active_tab.prev(); app.active_tab = app.active_tab.prev();
fetch_data(&rt, &client, &mut app); fetch_data(&rt, &client, &gateway_client, &mut app);
} }
(KeyCode::Down, _) => { (KeyCode::Down, _) => {
if app.detail_view.is_some() { if app.detail_view.is_some() {
@@ -225,7 +275,7 @@ fn main() {
// Redraw happens at top of loop // Redraw happens at top of loop
} }
Ok(Event::Tick) => { Ok(Event::Tick) => {
fetch_data(&rt, &client, &mut app); fetch_data(&rt, &client, &gateway_client, &mut app);
} }
Err(_) => { Err(_) => {
app.should_quit = true; app.should_quit = true;
+272
View File
@@ -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<Row> = 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)
}
}
+2
View File
@@ -1,5 +1,6 @@
mod bloom; mod bloom;
mod dashboard; mod dashboard;
mod gateway;
mod helpers; mod helpers;
mod mmp; mod mmp;
mod peers; 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::Cache => {} // data stored for Routing tab cross-reference
Tab::Routing => routing::draw(frame, app, area), Tab::Routing => routing::draw(frame, app, area),
Tab::Links => {} // not a navigable tab; data stored for cross-references Tab::Links => {} // not a navigable tab; data stored for cross-references
Tab::Gateway => gateway::draw(frame, app, area),
} }
} }
+209
View File
@@ -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<u64>,
/// 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<String>,
/// Upstream FIPS daemon DNS resolver (default: `127.0.0.1:5354`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream: Option<String>,
/// DNS record TTL in seconds (default: 60).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl: Option<u32>,
}
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<u64>,
/// UDP unreplied timeout in seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub udp_timeout: Option<u64>,
/// UDP assured (bidirectional) timeout in seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub udp_assured: Option<u64>,
/// ICMP timeout in seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icmp_timeout: Option<u64>,
}
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());
}
}
+10
View File
@@ -18,6 +18,7 @@
//! nsec: "nsec1..." //! nsec: "nsec1..."
//! ``` //! ```
mod gateway;
mod node; mod node;
mod peer; mod peer;
mod transport; mod transport;
@@ -33,6 +34,7 @@ pub use node::{
NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig, NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig,
TreeConfig, TreeConfig,
}; };
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig}; pub use transport::{BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig};
@@ -316,6 +318,10 @@ pub struct Config {
/// Static peers to connect to (`peers`). /// Static peers to connect to (`peers`).
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peers: Vec<PeerConfig>, pub peers: Vec<PeerConfig>,
/// Gateway configuration (`gateway`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<GatewayConfig>,
} }
impl Config { impl Config {
@@ -434,6 +440,10 @@ impl Config {
if !other.peers.is_empty() { if !other.peers.is_empty() {
self.peers = other.peers; 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. /// Create an Identity from this configuration.
+443
View File
@@ -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<MappingInfo>,
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<Self, std::io::Error> {
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<Option<GatewaySnapshot>>) {
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<Option<GatewaySnapshot>>,
) -> Result<(), Box<dyn std::error::Error>> {
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::<Request>(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<Option<GatewaySnapshot>>,
) -> 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<serde_json::Value> = 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<MappingInfo>,
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::<Option<GatewaySnapshot>>(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());
}
}
+407
View File
@@ -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<String> {
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<Ipv6Addr> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<tokio::sync::Mutex<VirtualIpPool>>,
event_tx: tokio::sync::mpsc::Sender<PoolEvent>,
mut shutdown: watch::Receiver<bool>,
) -> 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<tokio::sync::Mutex<VirtualIpPool>>,
event_tx: &tokio::sync::mpsc::Sender<PoolEvent>,
) -> Option<Vec<u8>> {
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");
}
}
}
+10
View File
@@ -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;
+218
View File
@@ -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<rustables::error::QueryError> for NatError {
fn from(e: rustables::error::QueryError) -> Self {
NatError::Nftables(e.to_string())
}
}
impl From<rustables::error::BuilderError> 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<Ipv6Addr, NatMapping>,
}
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<Self, NatError> {
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(())
}
}
+167
View File
@@ -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<u32, std::io::Error> {
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<Ipv6Addr>,
/// 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<Ipv6Addr> = 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;
}
}
}
+571
View File
@@ -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<Instant>,
/// 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<u32, std::io::Error>;
}
/// Conntrack querier that parses /proc/net/nf_conntrack.
pub struct ProcConntrack;
impl ConntrackQuerier for ProcConntrack {
fn active_sessions(&self, virtual_ip: Ipv6Addr) -> Result<u32, std::io::Error> {
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<Ipv6Addr>,
/// Active mappings keyed by NodeAddr.
mappings: HashMap<NodeAddr, VirtualIpMapping>,
/// Reverse map: virtual IP → NodeAddr.
reverse: HashMap<Ipv6Addr, NodeAddr>,
/// 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<Self, PoolError> {
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<PoolEvent> {
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<MappingInfo> {
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<Ipv6Addr, u32>,
}
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<u32, std::io::Error> {
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::<Ipv6Addr>().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::<Ipv6Addr>().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)
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ pub mod bloom;
pub mod cache; pub mod cache;
pub mod config; pub mod config;
pub mod control; pub mod control;
pub mod gateway;
pub mod identity; pub mod identity;
pub mod mmp; pub mod mmp;
pub mod noise; pub mod noise;
@@ -26,7 +27,7 @@ pub use identity::{
}; };
// Re-export config types // 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}; pub use upper::config::{DnsConfig, TunConfig};
// Re-export tree types // Re-export tree types
+8 -41
View File
@@ -10,13 +10,6 @@ use crate::PeerIdentity;
use std::time::Duration; use std::time::Duration;
use tracing::{debug, info, warn}; 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 { impl Node {
/// Handle handshake message 1 (phase 0x1). /// Handle handshake message 1 (phase 0x1).
/// ///
@@ -33,6 +26,14 @@ impl Node {
return; 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 // Parse header
let header = match Msg1Header::parse(&packet.data) { let header = match Msg1Header::parse(&packet.data) {
Some(h) => h, 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 === // === CRYPTO COST PAID HERE ===
let link_id = self.allocate_link_id(); let link_id = self.allocate_link_id();
let mut conn = PeerConnection::inbound_with_transport( 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));
}
}
-180
View File
@@ -81,9 +81,6 @@ async fn test_two_node_handshake_udp() {
Duration::from_millis(100), Duration::from_millis(100),
); );
node_a.links.insert(link_id_a, link_a); 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.connections.insert(link_id_a, conn_a);
node_a.pending_outbound.insert( node_a.pending_outbound.insert(
(transport_id_a, our_index_a.as_u32()), (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. /// Integration test: two nodes complete a handshake via run_rx_loop.
/// ///
/// Unlike test_two_node_handshake_udp which calls handle_msg1/handle_msg2 /// Unlike test_two_node_handshake_udp which calls handle_msg1/handle_msg2
-6
View File
@@ -780,12 +780,6 @@ impl ActivePeer {
self.session_established_at 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. /// Current K-bit epoch value.
pub fn current_k_bit(&self) -> bool { pub fn current_k_bit(&self) -> bool {
self.current_k_bit self.current_k_bit
-20
View File
@@ -48,9 +48,6 @@ pub struct UdpTransport {
stats: Arc<UdpStats>, stats: Arc<UdpStats>,
/// DNS resolution cache for hostname addresses. /// DNS resolution cache for hostname addresses.
dns_cache: StdMutex<HashMap<TransportAddr, (SocketAddr, Instant)>>, dns_cache: StdMutex<HashMap<TransportAddr, (SocketAddr, Instant)>>,
/// Test-only override for inbound-acceptance behavior.
#[cfg(test)]
accept_connections_override: Option<bool>,
} }
impl UdpTransport { impl UdpTransport {
@@ -72,17 +69,9 @@ impl UdpTransport {
local_addr: None, local_addr: None,
stats: Arc::new(UdpStats::new()), stats: Arc::new(UdpStats::new()),
dns_cache: StdMutex::new(HashMap::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). /// Get the instance name (if configured as a named instance).
pub fn name(&self) -> Option<&str> { pub fn name(&self) -> Option<&str> {
self.name.as_deref() self.name.as_deref()
@@ -314,15 +303,6 @@ impl Transport for UdpTransport {
// Peer configuration is handled at the node level, not transport level // Peer configuration is handled at the node level, not transport level
Ok(Vec::new()) 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. /// UDP receive loop - runs as a spawned task.
+37 -1
View File
@@ -14,7 +14,7 @@
# -h, --help Show this help # -h, --help Show this help
# #
# Integration suites: # Integration suites:
# static-mesh, static-chain, rekey, # static-mesh, static-chain, rekey, gateway,
# chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh, # chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh,
# chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent, # chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent,
# chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability, # chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability,
@@ -63,6 +63,7 @@ CHAOS_SUITES=(
"mixed-technology mixed-technology" "mixed-technology mixed-technology"
"congestion-stress congestion-stress" "congestion-stress congestion-stress"
) )
GATEWAY_SUITES=(gateway)
SIDECAR_SUITES=(sidecar) SIDECAR_SUITES=(sidecar)
# ── Colors ───────────────────────────────────────────────────────────────── # ── Colors ─────────────────────────────────────────────────────────────────
@@ -98,6 +99,9 @@ list_suites() {
echo " chaos-${parts[0]} (${parts[*]:1})" echo " chaos-${parts[0]} (${parts[*]:1})"
done done
echo "" echo ""
echo " Gateway:"
for s in "${GATEWAY_SUITES[@]}"; do echo " $s"; done
echo ""
echo " Sidecar:" echo " Sidecar:"
for s in "${SIDECAR_SUITES[@]}"; do echo " $s"; done for s in "${SIDECAR_SUITES[@]}"; do echo " $s"; done
exit 0 exit 0
@@ -195,8 +199,10 @@ install_binaries() {
cp target/release/fips "$dest/fips" cp target/release/fips "$dest/fips"
cp target/release/fipsctl "$dest/fipsctl" cp target/release/fipsctl "$dest/fipsctl"
[[ -f target/release/fipstop ]] && cp target/release/fipstop "$dest/fipstop" || true [[ -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" chmod +x "$dest/fips" "$dest/fipsctl"
[[ -f "$dest/fipstop" ]] && chmod +x "$dest/fipstop" || true [[ -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) # Run a static topology test (mesh, chain)
@@ -265,6 +271,31 @@ run_chaos() {
record "chaos-$name" $rc 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 test
run_sidecar() { run_sidecar() {
local rc=0 local rc=0
@@ -307,6 +338,9 @@ run_integration() {
# Rekey # Rekey
run_rekey run_rekey
# Gateway
run_gateway
# Chaos scenarios (parallel, throttled) # Chaos scenarios (parallel, throttled)
if [[ "$SKIP_CHAOS" != true ]]; then if [[ "$SKIP_CHAOS" != true ]]; then
info "Running ${#CHAOS_SUITES[@]} chaos scenarios (max $PARALLEL_JOBS parallel)" info "Running ${#CHAOS_SUITES[@]} chaos scenarios (max $PARALLEL_JOBS parallel)"
@@ -369,6 +403,8 @@ run_suite() {
run_static "${suite#static-}" ;; run_static "${suite#static-}" ;;
rekey) rekey)
run_rekey ;; run_rekey ;;
gateway)
run_gateway ;;
chaos-*) chaos-*)
local chaos_name="${suite#chaos-}" local chaos_name="${suite#chaos-}"
local found=false local found=false
+1
View File
@@ -1,3 +1,4 @@
fips fips
fips-gateway
fipsctl fipsctl
fipstop fipstop
+3 -3
View File
@@ -14,7 +14,7 @@ RUN apt-get update && \
iproute2 iputils-ping dnsutils \ iproute2 iputils-ping dnsutils \
openssh-client openssh-server \ openssh-client openssh-server \
dnsmasq iptables tor \ dnsmasq iptables tor \
iperf3 curl python3 \ iperf3 curl python3 nftables conntrack \
tcpdump netcat-openbsd rsync && \ tcpdump netcat-openbsd rsync && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
@@ -36,8 +36,8 @@ RUN printf '%s\n' \
'no-resolv' \ 'no-resolv' \
>> /etc/dnsmasq.conf >> /etc/dnsmasq.conf
COPY fips fipsctl fipstop /usr/local/bin/ COPY fips fipsctl fipstop fips-gateway /usr/local/bin/
RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop 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) # Static web page for HTTP server (chaos/static modes)
RUN printf 'Fuck IPs!\n' > /root/index.html RUN printf 'Fuck IPs!\n' > /root/index.html
+34 -1
View File
@@ -206,9 +206,42 @@ case "$MODE" in
echo "Starting FIPS daemon..." echo "Starting FIPS daemon..."
exec fips --config "$CONFIG" 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 "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 exit 1
;; ;;
esac esac
@@ -0,0 +1 @@
nameserver fd02::10
@@ -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]
+65
View File
@@ -4,6 +4,13 @@ networks:
ipam: ipam:
config: config:
- subnet: 172.20.0.0/24 - 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 x-fips-common: &fips-common
image: fips-test:latest image: fips-test:latest
@@ -310,3 +317,61 @@ services:
networks: networks:
fips-net: fips-net:
ipv4_address: 172.20.0.12 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
+221
View File
@@ -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