mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
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:
Generated
+120
-1
@@ -155,6 +155,26 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
@@ -312,6 +332,15 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -397,6 +426,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.56"
|
||||
@@ -928,6 +968,7 @@ dependencies = [
|
||||
"rand 0.10.0",
|
||||
"ratatui",
|
||||
"rtnetlink",
|
||||
"rustables",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1114,6 +1155,12 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -1245,6 +1292,12 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "ipnetwork"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -1329,6 +1382,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
@@ -1529,6 +1592,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1892,6 +1956,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2-diagnostics"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"version_check",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
@@ -2126,6 +2203,42 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustables"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19ff8788a90655f715f8236507161558a966c28a2845dc54d0f9985f9bbf389b"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.10.0",
|
||||
"ipnetwork",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.30.1",
|
||||
"regex",
|
||||
"rustables-macros",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustables-macros"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "698b79177cbf57522a1dcc118ac31393dc2a7ccc9bdb3625a6601ff5c27a3085"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"proc-macro2-diagnostics",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3255,7 +3368,7 @@ dependencies = [
|
||||
"blocking",
|
||||
"c2rust-bitfields",
|
||||
"futures",
|
||||
"libloading",
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -3350,6 +3463,12 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.33"
|
||||
|
||||
+8
-1
@@ -33,12 +33,13 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tun = { version = "0.8.5", features = ["async"] }
|
||||
libc = "0.2"
|
||||
rtnetlink = "0.20.0"
|
||||
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time"] }
|
||||
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process"] }
|
||||
futures = "0.3"
|
||||
simple-dns = "0.11.2"
|
||||
socket2 = { version = "0.6.2", features = ["all"] }
|
||||
tokio-socks = "0.5"
|
||||
bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true }
|
||||
rustables = "0.8.7"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>"
|
||||
@@ -64,6 +65,8 @@ assets = [
|
||||
["packaging/debian/fips.service", "/lib/systemd/system/fips.service", "644"],
|
||||
["packaging/debian/fips-dns.service", "/lib/systemd/system/fips-dns.service", "644"],
|
||||
["packaging/debian/fips.tmpfiles", "/usr/lib/tmpfiles.d/fips.conf", "644"],
|
||||
["target/release/fips-gateway", "/usr/bin/", "755"],
|
||||
["packaging/debian/fips-gateway.service", "/lib/systemd/system/fips-gateway.service", "644"],
|
||||
]
|
||||
conf-files = ["/etc/fips/fips.yaml", "/etc/fips/hosts"]
|
||||
|
||||
@@ -76,6 +79,10 @@ tokio = { version = "1", features = ["test-util"] }
|
||||
name = "fipsctl"
|
||||
path = "src/bin/fipsctl.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "fips-gateway"
|
||||
path = "src/bin/fips-gateway.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "fipstop"
|
||||
path = "src/bin/fipstop/main.rs"
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -14,10 +14,11 @@ pub enum Tab {
|
||||
Cache,
|
||||
Transports,
|
||||
Routing,
|
||||
Gateway,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub const ALL: [Tab; 8] = [
|
||||
pub const ALL: [Tab; 9] = [
|
||||
Tab::Node,
|
||||
Tab::Peers,
|
||||
Tab::Transports,
|
||||
@@ -26,13 +27,15 @@ impl Tab {
|
||||
Tab::Bloom,
|
||||
Tab::Mmp,
|
||||
Tab::Routing,
|
||||
Tab::Gateway,
|
||||
];
|
||||
|
||||
/// Tab group index: 0 = Node, 1 = Connectivity, 2 = Internals.
|
||||
/// Tab group index: 0 = Node, 1 = Connectivity, 2 = Internals, 3 = Gateway.
|
||||
pub fn group(&self) -> usize {
|
||||
match self {
|
||||
Tab::Node => 0,
|
||||
Tab::Peers | Tab::Transports => 1,
|
||||
Tab::Gateway => 3,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
@@ -49,6 +52,7 @@ impl Tab {
|
||||
Tab::Cache => "Cache",
|
||||
Tab::Transports => "Transports",
|
||||
Tab::Routing => "Routing",
|
||||
Tab::Gateway => "Gateway",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +68,7 @@ impl Tab {
|
||||
Tab::Cache => "show_cache",
|
||||
Tab::Transports => "show_transports",
|
||||
Tab::Routing => "show_routing",
|
||||
Tab::Gateway => "show_gateway",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +93,7 @@ impl Tab {
|
||||
Tab::Links => "links",
|
||||
Tab::Sessions => "sessions",
|
||||
Tab::Transports => "transports",
|
||||
Tab::Gateway => "mappings",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
@@ -96,7 +102,7 @@ impl Tab {
|
||||
pub fn has_table(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Tab::Peers | Tab::Sessions | Tab::Transports
|
||||
Tab::Peers | Tab::Sessions | Tab::Transports | Tab::Gateway
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +137,10 @@ pub struct App {
|
||||
pub expanded_transports: HashSet<u64>,
|
||||
pub tree_row_count: usize,
|
||||
pub selected_tree_item: SelectedTreeItem,
|
||||
/// Whether the gateway control socket is reachable.
|
||||
pub gateway_running: bool,
|
||||
/// Mappings data fetched from the gateway (separate from summary).
|
||||
pub gateway_mappings: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -148,6 +158,8 @@ impl App {
|
||||
expanded_transports: HashSet::new(),
|
||||
tree_row_count: 0,
|
||||
selected_tree_item: SelectedTreeItem::None,
|
||||
gateway_running: false,
|
||||
gateway_mappings: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +168,15 @@ impl App {
|
||||
if self.active_tab == Tab::Transports {
|
||||
return self.tree_row_count;
|
||||
}
|
||||
if self.active_tab == Tab::Gateway {
|
||||
return self
|
||||
.gateway_mappings
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("mappings"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
let key = self.active_tab.command_data_key();
|
||||
self.data
|
||||
.get(&self.active_tab)
|
||||
|
||||
+55
-5
@@ -25,6 +25,10 @@ struct Cli {
|
||||
#[arg(short = 's', long)]
|
||||
socket: Option<PathBuf>,
|
||||
|
||||
/// Gateway control socket path override
|
||||
#[arg(long)]
|
||||
gateway_socket: Option<PathBuf>,
|
||||
|
||||
/// Refresh interval in seconds
|
||||
#[arg(short = 'r', long, default_value = "2")]
|
||||
refresh: u64,
|
||||
@@ -46,11 +50,27 @@ fn default_socket_path() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the default gateway socket path.
|
||||
fn default_gateway_socket_path() -> PathBuf {
|
||||
if Path::new("/run/fips").exists() {
|
||||
PathBuf::from("/run/fips/gateway.sock")
|
||||
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
PathBuf::from(format!("{runtime_dir}/fips/gateway.sock"))
|
||||
} else {
|
||||
PathBuf::from("/tmp/fips-gateway.sock")
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_terminal() {
|
||||
ratatui::restore();
|
||||
}
|
||||
|
||||
fn fetch_data(rt: &tokio::runtime::Runtime, client: &ControlClient, app: &mut App) {
|
||||
fn fetch_data(
|
||||
rt: &tokio::runtime::Runtime,
|
||||
client: &ControlClient,
|
||||
gateway_client: &ControlClient,
|
||||
app: &mut App,
|
||||
) {
|
||||
// Always fetch status for the status bar
|
||||
match rt.block_on(client.query("show_status")) {
|
||||
Ok(data) => {
|
||||
@@ -64,6 +84,34 @@ fn fetch_data(rt: &tokio::runtime::Runtime, client: &ControlClient, app: &mut Ap
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway tab uses a separate socket
|
||||
if app.active_tab == Tab::Gateway {
|
||||
match rt.block_on(gateway_client.query("show_gateway")) {
|
||||
Ok(data) => {
|
||||
app.data.insert(Tab::Gateway, data);
|
||||
app.gateway_running = true;
|
||||
}
|
||||
Err(_) => {
|
||||
app.data.remove(&Tab::Gateway);
|
||||
app.gateway_running = false;
|
||||
app.gateway_mappings = None;
|
||||
}
|
||||
}
|
||||
// Also fetch mappings for the detail table
|
||||
if app.gateway_running {
|
||||
match rt.block_on(gateway_client.query("show_mappings")) {
|
||||
Ok(data) => {
|
||||
app.gateway_mappings = Some(data);
|
||||
}
|
||||
Err(_) => {
|
||||
app.gateway_mappings = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
app.last_fetch = std::time::Instant::now();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch active tab data (if not Dashboard, which we already fetched)
|
||||
if app.active_tab != Tab::Node {
|
||||
match rt.block_on(client.query(app.active_tab.command())) {
|
||||
@@ -106,6 +154,7 @@ fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let socket_path = cli.socket.unwrap_or_else(default_socket_path);
|
||||
let gateway_socket_path = cli.gateway_socket.unwrap_or_else(default_gateway_socket_path);
|
||||
let refresh = Duration::from_secs(cli.refresh);
|
||||
|
||||
// Install panic hook that restores terminal before printing panic
|
||||
@@ -121,6 +170,7 @@ fn main() {
|
||||
.expect("failed to create tokio runtime");
|
||||
|
||||
let client = ControlClient::new(&socket_path);
|
||||
let gateway_client = ControlClient::new(&gateway_socket_path);
|
||||
let mut terminal = ratatui::try_init().unwrap_or_else(|e| {
|
||||
eprintln!("fipstop: failed to initialize terminal: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -129,7 +179,7 @@ fn main() {
|
||||
let events = EventHandler::new(refresh);
|
||||
|
||||
// Initial fetch
|
||||
fetch_data(&rt, &client, &mut app);
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
|
||||
// Main loop
|
||||
loop {
|
||||
@@ -150,12 +200,12 @@ fn main() {
|
||||
(KeyCode::Tab, KeyModifiers::NONE) => {
|
||||
app.close_detail();
|
||||
app.active_tab = app.active_tab.next();
|
||||
fetch_data(&rt, &client, &mut app);
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
}
|
||||
(KeyCode::BackTab, _) => {
|
||||
app.close_detail();
|
||||
app.active_tab = app.active_tab.prev();
|
||||
fetch_data(&rt, &client, &mut app);
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
}
|
||||
(KeyCode::Down, _) => {
|
||||
if app.detail_view.is_some() {
|
||||
@@ -225,7 +275,7 @@ fn main() {
|
||||
// Redraw happens at top of loop
|
||||
}
|
||||
Ok(Event::Tick) => {
|
||||
fetch_data(&rt, &client, &mut app);
|
||||
fetch_data(&rt, &client, &gateway_client, &mut app);
|
||||
}
|
||||
Err(_) => {
|
||||
app.should_quit = true;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod bloom;
|
||||
mod dashboard;
|
||||
mod gateway;
|
||||
mod helpers;
|
||||
mod mmp;
|
||||
mod peers;
|
||||
@@ -71,6 +72,7 @@ fn draw_content(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
Tab::Cache => {} // data stored for Routing tab cross-reference
|
||||
Tab::Routing => routing::draw(frame, app, area),
|
||||
Tab::Links => {} // not a navigable tab; data stored for cross-references
|
||||
Tab::Gateway => gateway::draw(frame, app, area),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
//! nsec: "nsec1..."
|
||||
//! ```
|
||||
|
||||
mod gateway;
|
||||
mod node;
|
||||
mod peer;
|
||||
mod transport;
|
||||
@@ -33,6 +34,7 @@ pub use node::{
|
||||
NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig,
|
||||
TreeConfig,
|
||||
};
|
||||
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig};
|
||||
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
|
||||
pub use transport::{BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig};
|
||||
|
||||
@@ -316,6 +318,10 @@ pub struct Config {
|
||||
/// Static peers to connect to (`peers`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub peers: Vec<PeerConfig>,
|
||||
|
||||
/// Gateway configuration (`gateway`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gateway: Option<GatewayConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -434,6 +440,10 @@ impl Config {
|
||||
if !other.peers.is_empty() {
|
||||
self.peers = other.peers;
|
||||
}
|
||||
// Merge gateway section — higher-priority config replaces entirely
|
||||
if other.gateway.is_some() {
|
||||
self.gateway = other.gateway;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an Identity from this configuration.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -8,6 +8,7 @@ pub mod bloom;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
pub mod gateway;
|
||||
pub mod identity;
|
||||
pub mod mmp;
|
||||
pub mod noise;
|
||||
@@ -26,7 +27,7 @@ pub use identity::{
|
||||
};
|
||||
|
||||
// Re-export config types
|
||||
pub use config::{Config, ConfigError, IdentityConfig, TorConfig, UdpConfig};
|
||||
pub use config::{Config, ConfigError, GatewayConfig, IdentityConfig, TorConfig, UdpConfig};
|
||||
pub use upper::config::{DnsConfig, TunConfig};
|
||||
|
||||
// Re-export tree types
|
||||
|
||||
@@ -10,13 +10,6 @@ use crate::PeerIdentity;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
fn should_reject_new_inbound_msg1(
|
||||
transport_accepts_connections: bool,
|
||||
possible_restart: bool,
|
||||
) -> bool {
|
||||
!transport_accepts_connections && !possible_restart
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Handle handshake message 1 (phase 0x1).
|
||||
///
|
||||
@@ -33,6 +26,14 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this transport accepts inbound connections
|
||||
if let Some(transport) = self.transports.get(&packet.transport_id)
|
||||
&& !transport.accept_connections()
|
||||
{
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse header
|
||||
let header = match Msg1Header::parse(&packet.data) {
|
||||
Some(h) => h,
|
||||
@@ -114,25 +115,6 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
// `accept_connections` only gates truly new inbound handshakes.
|
||||
// Once a msg1 is recognized as belonging to an existing peer path
|
||||
// (restart or rekey), we must continue processing it even when the
|
||||
// transport does not accept brand-new inbound connections.
|
||||
let transport_accepts_connections = self
|
||||
.transports
|
||||
.get(&packet.transport_id)
|
||||
.map(|transport| transport.accept_connections())
|
||||
.unwrap_or(true);
|
||||
if should_reject_new_inbound_msg1(transport_accepts_connections, possible_restart) {
|
||||
debug!(
|
||||
transport_id = %packet.transport_id,
|
||||
remote_addr = %packet.remote_addr,
|
||||
"Dropping new inbound msg1: transport does not accept inbound connections"
|
||||
);
|
||||
self.msg1_rate_limiter.complete_handshake();
|
||||
return;
|
||||
}
|
||||
|
||||
// === CRYPTO COST PAID HERE ===
|
||||
let link_id = self.allocate_link_id();
|
||||
let mut conn = PeerConnection::inbound_with_transport(
|
||||
@@ -1072,18 +1054,3 @@ impl Node {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_reject_new_inbound_msg1;
|
||||
|
||||
#[test]
|
||||
fn rejects_new_inbound_when_transport_disallows_it() {
|
||||
assert!(should_reject_new_inbound_msg1(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_existing_peer_paths_even_when_transport_disallows_inbound() {
|
||||
assert!(!should_reject_new_inbound_msg1(false, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +81,6 @@ async fn test_two_node_handshake_udp() {
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
node_a.links.insert(link_id_a, link_a);
|
||||
node_a
|
||||
.addr_to_link
|
||||
.insert((transport_id_a, remote_addr_b.clone()), link_id_a);
|
||||
node_a.connections.insert(link_id_a, conn_a);
|
||||
node_a.pending_outbound.insert(
|
||||
(transport_id_a, our_index_a.as_u32()),
|
||||
@@ -235,183 +232,6 @@ async fn test_two_node_handshake_udp() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rekey_msg1_allowed_when_existing_peer_and_accept_connections_false() {
|
||||
/*
|
||||
* Given: a transport where accept_connections == false, and a connection that is already
|
||||
* established and old enough for a 'rekey'
|
||||
* When: an existing peer attempts a rekey
|
||||
* Then: we shouldn't reject the rekey merely because accept_connections == false (that
|
||||
* condition is only for *new* connections)
|
||||
*
|
||||
* This test sets up a connection, including the initial Noise key exchange. Then it
|
||||
* sets the age of the connection to be old, so that rekey can be attempted.
|
||||
*/
|
||||
use crate::config::UdpConfig;
|
||||
use crate::node::wire::{build_msg1, Msg1Header, Msg2Header};
|
||||
use crate::transport::udp::UdpTransport;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
let mut node_a = make_node();
|
||||
let mut node_b = make_node();
|
||||
|
||||
let transport_id_a = TransportId::new(1);
|
||||
let transport_id_b = TransportId::new(1);
|
||||
|
||||
let udp_config = UdpConfig {
|
||||
bind_addr: Some("127.0.0.1:0".to_string()),
|
||||
mtu: Some(1280),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (packet_tx_a, mut packet_rx_a) = packet_channel(64);
|
||||
let (packet_tx_b, mut packet_rx_b) = packet_channel(64);
|
||||
|
||||
let mut transport_a =
|
||||
UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
|
||||
transport_a.set_accept_connections_for_test(false);
|
||||
let mut transport_b =
|
||||
UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
|
||||
|
||||
transport_a.start_async().await.unwrap();
|
||||
transport_b.start_async().await.unwrap();
|
||||
|
||||
let _addr_a = transport_a.local_addr().unwrap();
|
||||
let addr_b = transport_b.local_addr().unwrap();
|
||||
let _remote_addr_a = TransportAddr::from_string(&_addr_a.to_string());
|
||||
let remote_addr_b = TransportAddr::from_string(&addr_b.to_string());
|
||||
|
||||
node_a
|
||||
.transports
|
||||
.insert(transport_id_a, TransportHandle::Udp(transport_a));
|
||||
node_b
|
||||
.transports
|
||||
.insert(transport_id_b, TransportHandle::Udp(transport_b));
|
||||
|
||||
// Phase 1: establish the initial outbound handshake A -> B.
|
||||
let peer_b_identity =
|
||||
PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full());
|
||||
let peer_b_node_addr = *peer_b_identity.node_addr();
|
||||
|
||||
let link_id_a = node_a.allocate_link_id();
|
||||
let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000);
|
||||
let our_index_a = node_a.index_allocator.allocate().unwrap();
|
||||
let our_keypair_a = node_a.identity.keypair();
|
||||
let noise_msg1 = conn_a
|
||||
.start_handshake(our_keypair_a, node_a.startup_epoch, 1000)
|
||||
.unwrap();
|
||||
conn_a.set_our_index(our_index_a);
|
||||
conn_a.set_transport_id(transport_id_a);
|
||||
conn_a.set_source_addr(remote_addr_b.clone());
|
||||
|
||||
let wire_msg1 = build_msg1(our_index_a, &noise_msg1);
|
||||
let link_a = Link::connectionless(
|
||||
link_id_a,
|
||||
transport_id_a,
|
||||
remote_addr_b.clone(),
|
||||
LinkDirection::Outbound,
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
node_a.links.insert(link_id_a, link_a);
|
||||
node_a
|
||||
.addr_to_link
|
||||
.insert((transport_id_a, remote_addr_b.clone()), link_id_a);
|
||||
node_a.connections.insert(link_id_a, conn_a);
|
||||
node_a.pending_outbound.insert(
|
||||
(transport_id_a, our_index_a.as_u32()),
|
||||
link_id_a,
|
||||
);
|
||||
|
||||
let transport = node_a.transports.get(&transport_id_a).unwrap();
|
||||
transport
|
||||
.send(&remote_addr_b, &wire_msg1)
|
||||
.await
|
||||
.expect("failed to send initial msg1");
|
||||
|
||||
let packet_b = timeout(Duration::from_secs(1), packet_rx_b.recv())
|
||||
.await
|
||||
.expect("timeout waiting for initial msg1")
|
||||
.expect("channel closed");
|
||||
node_b.handle_msg1(packet_b).await;
|
||||
|
||||
let peer_a_node_addr = *PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full())
|
||||
.node_addr();
|
||||
let packet_a = timeout(Duration::from_secs(1), packet_rx_a.recv())
|
||||
.await
|
||||
.expect("timeout waiting for initial msg2")
|
||||
.expect("channel closed");
|
||||
node_a.handle_msg2(packet_a).await;
|
||||
|
||||
assert_eq!(node_a.peer_count(), 1, "Node A should have one active peer");
|
||||
assert_eq!(node_b.peer_count(), 1, "Node B should have one active peer");
|
||||
|
||||
// The initial promotion path can emit Tree/Bloom traffic. Drain any queued
|
||||
// post-handshake packets so the next receive is the rekey msg1/msg2 pair.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
while packet_rx_a.try_recv().is_ok() {}
|
||||
while packet_rx_b.try_recv().is_ok() {}
|
||||
|
||||
// Phase 2: age both active sessions into the rekey path.
|
||||
node_a
|
||||
.get_peer_mut(&peer_b_node_addr)
|
||||
.unwrap()
|
||||
.age_session_for_test(Duration::from_secs(31));
|
||||
node_b
|
||||
.get_peer_mut(&peer_a_node_addr)
|
||||
.unwrap()
|
||||
.age_session_for_test(Duration::from_secs(31));
|
||||
|
||||
// Phase 3: B initiates rekey. A must process the inbound msg1 even though
|
||||
// its transport rejects brand-new inbound handshakes.
|
||||
node_b.config.node.rekey.after_secs = 0;
|
||||
node_b.check_rekey().await;
|
||||
|
||||
let expected_rekey_index = node_b
|
||||
.get_peer(&peer_a_node_addr)
|
||||
.unwrap()
|
||||
.rekey_our_index()
|
||||
.expect("Node B should have an in-progress rekey index");
|
||||
|
||||
let rekey_packet_a = timeout(Duration::from_secs(1), packet_rx_a.recv())
|
||||
.await
|
||||
.expect("timeout waiting for rekey msg1")
|
||||
.expect("channel closed");
|
||||
let rekey_header_a = Msg1Header::parse(&rekey_packet_a.data)
|
||||
.expect("expected a rekey msg1 packet");
|
||||
assert_eq!(
|
||||
rekey_header_a.sender_idx,
|
||||
expected_rekey_index,
|
||||
"Node A should receive the rekey msg1 that Node B initiated"
|
||||
);
|
||||
node_a.handle_msg1(rekey_packet_a).await;
|
||||
|
||||
let rekey_packet_b = timeout(Duration::from_secs(1), packet_rx_b.recv())
|
||||
.await
|
||||
.expect("timeout waiting for rekey msg2")
|
||||
.expect("channel closed");
|
||||
let rekey_header_b = Msg2Header::parse(&rekey_packet_b.data)
|
||||
.expect("expected a rekey msg2 packet");
|
||||
assert_eq!(
|
||||
rekey_header_b.receiver_idx,
|
||||
expected_rekey_index,
|
||||
"Node A should respond with msg2 for the in-progress rekey"
|
||||
);
|
||||
node_b.handle_msg2(rekey_packet_b).await;
|
||||
|
||||
let peer_a_on_b = node_b.get_peer(&peer_a_node_addr).unwrap();
|
||||
assert!(
|
||||
peer_a_on_b.pending_new_session().is_some(),
|
||||
"Node B should complete initiator-side rekey and store a pending session"
|
||||
);
|
||||
|
||||
for (_, t) in node_a.transports.iter_mut() {
|
||||
t.stop().await.ok();
|
||||
}
|
||||
for (_, t) in node_b.transports.iter_mut() {
|
||||
t.stop().await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Integration test: two nodes complete a handshake via run_rx_loop.
|
||||
///
|
||||
/// Unlike test_two_node_handshake_udp which calls handle_msg1/handle_msg2
|
||||
|
||||
@@ -780,12 +780,6 @@ impl ActivePeer {
|
||||
self.session_established_at
|
||||
}
|
||||
|
||||
/// Shift the session establishment time backwards for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn age_session_for_test(&mut self, age: std::time::Duration) {
|
||||
self.session_established_at = Instant::now() - age;
|
||||
}
|
||||
|
||||
/// Current K-bit epoch value.
|
||||
pub fn current_k_bit(&self) -> bool {
|
||||
self.current_k_bit
|
||||
|
||||
@@ -48,9 +48,6 @@ pub struct UdpTransport {
|
||||
stats: Arc<UdpStats>,
|
||||
/// DNS resolution cache for hostname addresses.
|
||||
dns_cache: StdMutex<HashMap<TransportAddr, (SocketAddr, Instant)>>,
|
||||
/// Test-only override for inbound-acceptance behavior.
|
||||
#[cfg(test)]
|
||||
accept_connections_override: Option<bool>,
|
||||
}
|
||||
|
||||
impl UdpTransport {
|
||||
@@ -72,17 +69,9 @@ impl UdpTransport {
|
||||
local_addr: None,
|
||||
stats: Arc::new(UdpStats::new()),
|
||||
dns_cache: StdMutex::new(HashMap::new()),
|
||||
#[cfg(test)]
|
||||
accept_connections_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override `accept_connections()` for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_accept_connections_for_test(&mut self, accept_connections: bool) {
|
||||
self.accept_connections_override = Some(accept_connections);
|
||||
}
|
||||
|
||||
/// Get the instance name (if configured as a named instance).
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
@@ -314,15 +303,6 @@ impl Transport for UdpTransport {
|
||||
// Peer configuration is handled at the node level, not transport level
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn accept_connections(&self) -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(accept_connections) = self.accept_connections_override {
|
||||
return accept_connections;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// UDP receive loop - runs as a spawned task.
|
||||
|
||||
+37
-1
@@ -14,7 +14,7 @@
|
||||
# -h, --help Show this help
|
||||
#
|
||||
# Integration suites:
|
||||
# static-mesh, static-chain, rekey,
|
||||
# static-mesh, static-chain, rekey, gateway,
|
||||
# chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh,
|
||||
# chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent,
|
||||
# chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability,
|
||||
@@ -63,6 +63,7 @@ CHAOS_SUITES=(
|
||||
"mixed-technology mixed-technology"
|
||||
"congestion-stress congestion-stress"
|
||||
)
|
||||
GATEWAY_SUITES=(gateway)
|
||||
SIDECAR_SUITES=(sidecar)
|
||||
|
||||
# ── Colors ─────────────────────────────────────────────────────────────────
|
||||
@@ -98,6 +99,9 @@ list_suites() {
|
||||
echo " chaos-${parts[0]} (${parts[*]:1})"
|
||||
done
|
||||
echo ""
|
||||
echo " Gateway:"
|
||||
for s in "${GATEWAY_SUITES[@]}"; do echo " $s"; done
|
||||
echo ""
|
||||
echo " Sidecar:"
|
||||
for s in "${SIDECAR_SUITES[@]}"; do echo " $s"; done
|
||||
exit 0
|
||||
@@ -195,8 +199,10 @@ install_binaries() {
|
||||
cp target/release/fips "$dest/fips"
|
||||
cp target/release/fipsctl "$dest/fipsctl"
|
||||
[[ -f target/release/fipstop ]] && cp target/release/fipstop "$dest/fipstop" || true
|
||||
[[ -f target/release/fips-gateway ]] && cp target/release/fips-gateway "$dest/fips-gateway" || true
|
||||
chmod +x "$dest/fips" "$dest/fipsctl"
|
||||
[[ -f "$dest/fipstop" ]] && chmod +x "$dest/fipstop" || true
|
||||
[[ -f "$dest/fips-gateway" ]] && chmod +x "$dest/fips-gateway" || true
|
||||
}
|
||||
|
||||
# Run a static topology test (mesh, chain)
|
||||
@@ -265,6 +271,31 @@ run_chaos() {
|
||||
record "chaos-$name" $rc
|
||||
}
|
||||
|
||||
# Run gateway integration test
|
||||
run_gateway() {
|
||||
local compose="testing/static/docker-compose.yml"
|
||||
local rc=0
|
||||
|
||||
info "[gateway] Generating configs"
|
||||
bash testing/static/scripts/generate-configs.sh gateway gateway-test || { record "gateway" 1; return; }
|
||||
bash testing/static/scripts/gateway-test.sh inject-config || { record "gateway" 1; return; }
|
||||
|
||||
info "[gateway] Starting containers"
|
||||
docker compose -f "$compose" --profile gateway up -d || { record "gateway" 1; return; }
|
||||
|
||||
info "[gateway] Running gateway test"
|
||||
if bash testing/static/scripts/gateway-test.sh; then
|
||||
rc=0
|
||||
else
|
||||
rc=1
|
||||
info "[gateway] Collecting failure logs"
|
||||
docker compose -f "$compose" --profile gateway logs --no-color 2>&1 | tail -100
|
||||
fi
|
||||
|
||||
docker compose -f "$compose" --profile gateway down --volumes --remove-orphans 2>/dev/null
|
||||
record "gateway" $rc
|
||||
}
|
||||
|
||||
# Run sidecar test
|
||||
run_sidecar() {
|
||||
local rc=0
|
||||
@@ -307,6 +338,9 @@ run_integration() {
|
||||
# Rekey
|
||||
run_rekey
|
||||
|
||||
# Gateway
|
||||
run_gateway
|
||||
|
||||
# Chaos scenarios (parallel, throttled)
|
||||
if [[ "$SKIP_CHAOS" != true ]]; then
|
||||
info "Running ${#CHAOS_SUITES[@]} chaos scenarios (max $PARALLEL_JOBS parallel)"
|
||||
@@ -369,6 +403,8 @@ run_suite() {
|
||||
run_static "${suite#static-}" ;;
|
||||
rekey)
|
||||
run_rekey ;;
|
||||
gateway)
|
||||
run_gateway ;;
|
||||
chaos-*)
|
||||
local chaos_name="${suite#chaos-}"
|
||||
local found=false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fips
|
||||
fips-gateway
|
||||
fipsctl
|
||||
fipstop
|
||||
|
||||
@@ -14,7 +14,7 @@ RUN apt-get update && \
|
||||
iproute2 iputils-ping dnsutils \
|
||||
openssh-client openssh-server \
|
||||
dnsmasq iptables tor \
|
||||
iperf3 curl python3 \
|
||||
iperf3 curl python3 nftables conntrack \
|
||||
tcpdump netcat-openbsd rsync && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -36,8 +36,8 @@ RUN printf '%s\n' \
|
||||
'no-resolv' \
|
||||
>> /etc/dnsmasq.conf
|
||||
|
||||
COPY fips fipsctl fipstop /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop
|
||||
COPY fips fipsctl fipstop fips-gateway /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop /usr/local/bin/fips-gateway
|
||||
|
||||
# Static web page for HTTP server (chaos/static modes)
|
||||
RUN printf 'Fuck IPs!\n' > /root/index.html
|
||||
|
||||
@@ -206,9 +206,42 @@ case "$MODE" in
|
||||
echo "Starting FIPS daemon..."
|
||||
exec fips --config "$CONFIG"
|
||||
;;
|
||||
gateway)
|
||||
# No dnsmasq — gateway DNS replaces it on port 53
|
||||
start_services
|
||||
|
||||
# Extract LAN interface from config (gateway.lan_interface)
|
||||
LAN_IF=$(grep 'lan_interface:' "$CONFIG" | head -1 | sed 's/.*: *//' | tr -d '"' | tr -d "'")
|
||||
LAN_IF="${LAN_IF:-eth0}"
|
||||
|
||||
# Wait for LAN interface (Docker attaches second network after start)
|
||||
for i in $(seq 1 15); do
|
||||
[ -e "/sys/class/net/$LAN_IF" ] && break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# Ensure IPv6 is enabled on the LAN interface (may inherit host default)
|
||||
sysctl -w "net.ipv6.conf.${LAN_IF}.disable_ipv6=0" >/dev/null 2>&1 || true
|
||||
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
|
||||
sysctl -w net.ipv6.conf.all.proxy_ndp=1 >/dev/null 2>&1 || true
|
||||
|
||||
# Start fips in background (gateway needs fips0)
|
||||
fips --config "$CONFIG" &
|
||||
# Wait for fips0 TUN device
|
||||
for i in $(seq 1 30); do
|
||||
[ -e /sys/class/net/fips0 ] && break
|
||||
sleep 1
|
||||
done
|
||||
if [ ! -e /sys/class/net/fips0 ]; then
|
||||
echo "FATAL: fips0 did not appear within 30s"
|
||||
exit 1
|
||||
fi
|
||||
echo "fips0 ready, starting gateway"
|
||||
exec fips-gateway --config "$CONFIG" --log-level debug
|
||||
;;
|
||||
*)
|
||||
echo "Unknown FIPS_TEST_MODE: $MODE"
|
||||
echo "Valid modes: default, chaos, sidecar, tor-socks5, tor-directory"
|
||||
echo "Valid modes: default, chaos, sidecar, tor-socks5, tor-directory, gateway"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -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]
|
||||
@@ -4,6 +4,13 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/24
|
||||
gateway-lan:
|
||||
driver: bridge
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.1.0/24
|
||||
- subnet: fd02::/64
|
||||
|
||||
x-fips-common: &fips-common
|
||||
image: fips-test:latest
|
||||
@@ -310,3 +317,61 @@ services:
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.12
|
||||
|
||||
# ── Gateway integration test (gateway + server + non-FIPS client) ─
|
||||
gw-gateway:
|
||||
<<: *fips-common
|
||||
profiles: ["gateway"]
|
||||
container_name: fips-gw-gateway
|
||||
hostname: gw-gateway
|
||||
# Privileged required: gateway must enable IPv6 on eth1 (second network,
|
||||
# attached after container start) and manage nftables NAT rules.
|
||||
privileged: true
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
- FIPS_TEST_MODE=gateway
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
- net.ipv6.conf.default.disable_ipv6=0
|
||||
- net.ipv6.conf.all.forwarding=1
|
||||
- net.ipv6.conf.all.proxy_ndp=1
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/gateway/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.10
|
||||
gateway-lan:
|
||||
ipv4_address: 172.20.1.10
|
||||
ipv6_address: fd02::10
|
||||
|
||||
gw-server:
|
||||
<<: *fips-common
|
||||
profiles: ["gateway"]
|
||||
container_name: fips-gw-server
|
||||
hostname: gw-server
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/gateway/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.11
|
||||
|
||||
gw-client:
|
||||
image: fips-test-app:latest
|
||||
profiles: ["gateway"]
|
||||
container_name: fips-gw-client
|
||||
hostname: gw-client
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
volumes:
|
||||
- ./configs/gateway-resolv.conf:/etc/resolv.conf:ro
|
||||
networks:
|
||||
gateway-lan:
|
||||
ipv4_address: 172.20.1.20
|
||||
ipv6_address: fd02::20
|
||||
restart: "no"
|
||||
env_file:
|
||||
- ./generated-configs/npubs.env
|
||||
|
||||
Executable
+221
@@ -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
|
||||
Reference in New Issue
Block a user