Merge branch 'master' into next

This commit is contained in:
Johnathan Corgan
2026-04-13 06:37:23 +00:00
5 changed files with 310 additions and 23 deletions
+110
View File
@@ -85,6 +85,46 @@ with v0.2.x peers.
### Added
#### Platform Support
- Windows platform support: wintun TUN device, TCP control socket on
`localhost:21210` (in place of the Unix domain socket), Windows
Service lifecycle (`--install-service`, `--uninstall-service`,
`--service`), ZIP packaging with PowerShell install/uninstall scripts,
and CI build/test matrix entry
([#45](https://github.com/jmcorgan/fips/pull/45))
- macOS platform support: native `utun` TUN interface management, raw
Ethernet transport via BPF, `.pkg` packaging with launchd plist and
uninstall script, x86_64 cross-compile from arm64, and CI build/unit
test jobs
- `gateway` Cargo feature flag gates the optional Linux-only
`rustables` dependency so macOS and Windows builds never pull in
nftables bindings
#### Outbound LAN Gateway
- New `fips-gateway` binary that lets unmodified LAN hosts reach FIPS
mesh destinations via DNS-allocated virtual IPs and kernel nftables
NAT. Virtual-IP pool (`fd01::/112` by default) with state-machine
lifecycle and TTL-based reclamation; conntrack-backed session
tracking; proxy NDP on the LAN interface; control socket at
`/run/fips/gateway.sock` with `show_gateway` and `show_mappings`;
fipstop Gateway tab with pool gauge and mappings table; design doc
at `docs/design/fips-gateway.md`; integration test harness
- Gateway packaging: systemd service unit with `After=fips.service`,
Debian and AUR package entries, OpenWrt procd init with dnsmasq
forwarding, proxy NDP, RA route advertisements, and IPv6 forwarding
sysctls. Gateway enabled by default on OpenWrt
#### Examples
- macOS WireGuard sidecar: run FIPS in a local Docker container and
route `.fips` traffic from the macOS host through a WireGuard tunnel
to the container's `fips0` interface. Only traffic destined for
`fd00::/8` transits the sidecar; regular internet traffic continues
to use the host network
([#51](https://github.com/jmcorgan/fips/pull/51))
#### Bluetooth Transport
- Bluetooth Low Energy (BLE) L2CAP Connection-Oriented Channel transport
@@ -96,6 +136,31 @@ with v0.2.x peers.
- Cross-probe tie-breaker using deterministic NodeAddr comparison
- Connection pool with configurable capacity and eviction
#### DNS
- Multi-backend `.fips` DNS configuration: a detection script
configures whichever resolver is available, in priority order:
systemd dns-delegate (systemd >= 258), systemd-resolved via
`resolvectl`, standalone dnsmasq, NetworkManager with the dnsmasq
plugin. Teardown reads the recorded backend from
`/run/fips/dns-backend` and reverses only what was applied
([#58](https://github.com/jmcorgan/fips/pull/58),
fixes [#52](https://github.com/jmcorgan/fips/issues/52))
#### Operator Configuration
- `node.log_level` config field (case-insensitive, default `info`)
replaces the hardcoded `RUST_LOG=info` previously baked into
systemd units and the OpenWrt procd init script. The daemon now
loads config before initializing tracing so the configured level
takes effect; `RUST_LOG` still overrides when set
#### Documentation
- Pre-implementation proposal for NAT traversal using Nostr relays
as the signaling channel and STUN for reflexive address discovery
(`docs/proposals/`)
#### Packaging and Deployment
- Linux release artifact workflow: builds x86_64 and aarch64 tarballs
@@ -106,6 +171,20 @@ with v0.2.x peers.
([#21](https://github.com/jmcorgan/fips/pull/21),
[@dskvr](https://github.com/dskvr))
### Changed
- MMP link-layer report intervals retuned for constrained transports:
steady-state floor raised from 100ms to 1000ms, ceiling from 2000ms
to 5000ms. Cold-start uses a 200ms floor for the first 5 SRTT samples
before switching to steady-state. Reduces BLE overhead ~10× while
keeping reports well above the EWMA convergence threshold.
Session-layer intervals unchanged
- 35 info-level log messages demoted to debug (handshake
cross-connection mechanics, periodic MMP telemetry, TUN/transport
shutdown, retry scheduling). Info output now focuses on
operator-relevant state changes: lifecycle events, peer promotions,
session establishment, parent switches, transport start/stop
### Fixed
- Control socket path detection in fipsctl and fipstop now checks for
@@ -118,6 +197,37 @@ with v0.2.x peers.
unavailable on OpenWrt targets
- IPv6 routing policy rule added at TUN setup to protect `fd00::/8`
from interception by Tailscale's table 52 default route
- Bloom filter routing no longer swallows traffic when no bloom
candidate is strictly closer than the current node. `find_next_hop`
now falls through to greedy tree routing in that case instead of
returning `NoRoute`, which previously caused dropped packets in
topologies where the tree parent was closer but not a bloom
candidate
- Auto-connect peers now reconnect after a graceful `Disconnect`
notification from the remote side. `handle_disconnect` previously
removed the peer without scheduling a reconnect, orphaning the
entry on a clean upstream shutdown; the other removal paths
(link-dead, decrypt failure, peer restart) already scheduled
reconnect ([#60](https://github.com/jmcorgan/fips/issues/60),
reported by [@SwapMarket](https://github.com/SwapMarket))
- `fipsctl connect` now rejects FIPS mesh (`fd00::/8`) addresses for
`udp`, `tcp`, and `ethernet` transports with a clear error message
instead of echoing success while the daemon silently failed the
bind with `EAFNOSUPPORT`
([#61](https://github.com/jmcorgan/fips/issues/61),
reported by [@SwapMarket](https://github.com/SwapMarket))
- Rekey msg1 on non-accepting transports (e.g. UDP holepunch) was
rejected at the top of `handle_msg1()`, which broke rekey handshakes
on established links and produced repeated "dual rekey initiation"
log floods. The gate now only blocks truly new inbound handshakes
from unknown addresses; rekey and restart msg1s for established
peers are processed normally
([#47](https://github.com/jmcorgan/fips/issues/47),
[#49](https://github.com/jmcorgan/fips/pull/49))
- `fipstop` now uses `ratatui::try_init()` instead of `ratatui::init()`,
so terminal initialization failures (e.g. Docker on macOS Sequoia,
or environments without a usable tty) produce a clean error message
instead of a hard crash
## [0.2.0] - 2026-03-22
+43 -21
View File
@@ -45,8 +45,12 @@ endpoints.
forward secrecy and protocol negotiation in the handshake
- **Nostr-native identity** — secp256k1 keypairs as node addresses, no
registration or central authority
- **IPv6 adaptation** — TUN interface maps npubs to fd00::/8 addresses for
unmodified IP applications; static hostname mapping (`/etc/fips/hosts`)
- **IPv6 adaptation** — TUN interface maps npubs to fd00::/8 addresses
for unmodified IP applications; built-in `.fips` DNS resolver with
optional static hostname mapping (`/etc/fips/hosts`)
- **Outbound LAN gateway** — optional `fips-gateway` daemon lets
unmodified LAN hosts reach `.fips` destinations via a
DNS-allocated virtual IP pool and kernel nftables NAT
- **Metrics Measurement Protocol** — per-link RTT, loss, jitter, and goodput
measurement with mesh size estimation
- **ECN congestion signaling** — hop-by-hop CE flag relay with RFC 3168 IPv6
@@ -69,18 +73,21 @@ supported (see transport matrix below).
### Transport support by platform
| Transport | Linux | macOS | Windows |
|-----------|:-----:|:-----:|:-------:|
| UDP | ✅ | ✅ | |
| TCP | ✅ | ✅ | |
| Ethernet | ✅ | ✅ | ❌ |
| Tor | ✅ | ✅ | |
| BLE | ✅ | ❌ | ❌ |
| Transport | Linux | macOS | Windows | OpenWrt |
|-----------|:-----:|:-----:|:-------:|:-------:|
| UDP | ✅ | ✅ | ✅ | ✅ |
| TCP | ✅ | ✅ | ✅ | ✅ |
| Ethernet | ✅ | ✅ | ❌ | ✅ |
| Tor | ✅ | ✅ | ✅ | ✅ |
| BLE | ✅ | ❌ | ❌ | ❌ |
On **Linux**, the BLE transport requires BlueZ and libdbus. On
Debian/Ubuntu: `sudo apt install bluez libdbus-1-dev`. Then build with
BLE enabled: `cargo build --release --features ble`.
On **OpenWrt**, BLE is disabled because libdbus is not available on
the target. All other transports work and ship in the default ipk.
## Installation
After building, choose one of the following methods to install.
@@ -262,7 +269,11 @@ for the full reference.
FIPS includes a DNS resolver (enabled by default, port 5354) that maps
`.fips` names to fd00::/8 IPv6 addresses.
**Linux** (systemd-resolved):
**Linux**: The `.deb` package auto-detects and configures whichever
resolver is present (systemd dns-delegate, systemd-resolved, dnsmasq,
or NetworkManager with dnsmasq); no manual setup is needed. For
manual or tarball installs, point your resolver at `127.0.0.1:5354`
for the `fips` domain — e.g., with systemd-resolved:
```bash
sudo resolvectl dns fips0 127.0.0.1:5354
@@ -323,13 +334,20 @@ including static topology tests and stochastic chaos simulation.
## Examples
- [examples/wireguard-sidecar-macos/](examples/wireguard-sidecar-macos/) -
Run a local WireGuard sidecar on macOS so `.fips` traffic can reach the mesh
through Docker.
The macOS WireGuard sidecar only forwards FIPS IPv6 traffic destined for
`fd00::/8` from `wg0` to `fips0`. Regular internet traffic does not transit the
sidecar and continues to use the host network normally.
- [examples/sidecar-nostr-relay/](examples/sidecar-nostr-relay/)
Run a [strfry](https://github.com/hoytech/strfry) Nostr relay
reachable exclusively over the FIPS mesh. The relay container shares
the FIPS sidecar's network namespace and is isolated from the host
network.
- [examples/k8s-sidecar/](examples/k8s-sidecar/) — Run FIPS as a
Kubernetes Pod sidecar. The sidecar creates `fips0` in the Pod's
shared network namespace so every other container in the Pod gets
mesh access without modification.
- [examples/wireguard-sidecar-macos/](examples/wireguard-sidecar-macos/) —
Reach the FIPS mesh from a macOS host through a local Docker
container over a WireGuard tunnel. Only traffic destined for
`fd00::/8` transits the sidecar; regular internet traffic continues
to use the host network.
## Documentation
@@ -346,8 +364,9 @@ If you want to contribute, start with:
## Project Structure
```text
src/ Rust source (library + fips/fipsctl/fipstop binaries)
packaging/ Debian, systemd tarball, and shared packaging files
src/ Rust source (library + fips/fipsctl/fipstop/fips-gateway binaries)
packaging/ Debian, macOS .pkg, Windows ZIP, OpenWrt ipk, AUR, systemd tarball
examples/ Deployment examples (Nostr relay, K8s sidecar, macOS WireGuard)
docs/design/ Protocol design specifications
testing/ Docker-based integration test harnesses
```
@@ -364,14 +383,17 @@ Ethernet, Tor, and Bluetooth (BLE) with a small live mesh of deployed nodes.
- Noise XX encryption at both layers with protocol negotiation
- Periodic Noise rekey with hitless cutover for forward secrecy (FMP + FSP)
- Persistent node identity with key file management
- IPv6 TUN adapter with DNS resolution of `.fips` names
- IPv6 TUN adapter with built-in `.fips` DNS resolver and multi-backend
auto-configuration (systemd dns-delegate, systemd-resolved, dnsmasq,
NetworkManager)
- Static hostname mapping (`/etc/fips/hosts`) with auto-reload
- Per-link metrics (RTT, loss, jitter, goodput) and mesh size estimation
- ECN congestion signaling (hop-by-hop CE relay, IPv6 CE marking, kernel drop detection)
- UDP, TCP, Ethernet, Tor, and BLE transports (BLE via L2CAP CoC with per-link MTU negotiation)
- Outbound LAN gateway for unmodified hosts via DNS-allocated virtual IPs and nftables NAT
- Runtime inspection and peer management via `fipsctl` and `fipstop`
- Reproducible builds with toolchain pinning and SOURCE_DATE_EPOCH
- Linux (Debian, systemd tarball, OpenWrt, AUR) and macOS packaging
- Linux (Debian, systemd tarball, OpenWrt, AUR), macOS (`.pkg`), and Windows (ZIP, service) packaging
- Docker-based integration and chaos testing
### Near-term priorities
+108
View File
@@ -12,6 +12,7 @@ use fips::upper::hosts::HostMap;
use fips::version;
use fips::{Identity, encode_nsec};
use std::io::{BufRead, BufReader, Write};
use std::net::{Ipv6Addr, SocketAddrV6};
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -249,6 +250,50 @@ fn default_key_dir() -> PathBuf {
}
}
/// Check if `address` is an IPv6 literal in `fd00::/8` (FIPS mesh ULA range).
///
/// Handles three common syntaxes:
/// - bare IPv6: `fd9d:...`
/// - bracketed + port: `[fd9d:...]:2121`
/// - bare IPv6 + port: `fd9d:...:2121` (ambiguous; accepted if tail is numeric)
fn is_fips_mesh_address(address: &str) -> bool {
let is_ula = |a: &Ipv6Addr| a.octets()[0] == 0xfd;
if let Ok(a) = address.parse::<Ipv6Addr>() {
return is_ula(&a);
}
if let Ok(sa) = address.parse::<SocketAddrV6>() {
return is_ula(sa.ip());
}
if let Some((host, port)) = address.rsplit_once(':')
&& port.chars().all(|c| c.is_ascii_digit())
&& !port.is_empty()
{
let host = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(a) = host.parse::<Ipv6Addr>() {
return is_ula(&a);
}
}
false
}
/// Reject `fd00::/8` addresses for transports that expect a reachable network endpoint.
///
/// FIPS mesh ULAs are derived from npubs and only make sense as destinations
/// inside an already-established mesh — they are not valid udp/tcp/ethernet
/// transport endpoints. Without this check the CLI echoes success while the
/// daemon rejects the bind with EAFNOSUPPORT (issue #61).
fn validate_connect_address(address: &str, transport: &str) -> Result<(), String> {
let checked = matches!(transport, "udp" | "tcp" | "ethernet");
if checked && is_fips_mesh_address(address) {
return Err(format!(
"'{address}' is a FIPS mesh address (fd00::/8), not a reachable {transport} endpoint.\n\
Provide the peer's routable IP/hostname and port (e.g., '192.0.2.1:2121' or 'peer.example.com:2121')."
));
}
Ok(())
}
/// Resolve a peer identifier to an npub.
///
/// If the identifier starts with "npub1", it's returned as-is.
@@ -328,6 +373,10 @@ fn main() {
address,
transport,
} => {
if let Err(e) = validate_connect_address(address, transport) {
eprintln!("error: {e}");
std::process::exit(1);
}
let npub = resolve_peer(peer);
build_command(
"connect",
@@ -353,3 +402,62 @@ fn main() {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_bare_ula_literal() {
assert!(is_fips_mesh_address("fd9d:abcd::1"));
assert!(is_fips_mesh_address("fd00::"));
assert!(is_fips_mesh_address(
"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
));
}
#[test]
fn detects_bracketed_ula_with_port() {
assert!(is_fips_mesh_address("[fd9d:abcd::1]:2121"));
assert!(is_fips_mesh_address("[fd00::1]:8443"));
}
#[test]
fn detects_bare_ula_with_port() {
assert!(is_fips_mesh_address("fd9d:abcd::1:2121"));
}
#[test]
fn rejects_non_ula_ipv6() {
// fc00::/7 other half (fcXX:) is also ULA but not fd00::/8 — we only
// block the fd-prefixed half that FIPS actually uses.
assert!(!is_fips_mesh_address("fc00::1"));
assert!(!is_fips_mesh_address("::1"));
assert!(!is_fips_mesh_address("2001:db8::1"));
assert!(!is_fips_mesh_address("[2001:db8::1]:2121"));
}
#[test]
fn ignores_ipv4_and_hostnames() {
assert!(!is_fips_mesh_address("192.0.2.1:2121"));
assert!(!is_fips_mesh_address("peer.example.com:2121"));
assert!(!is_fips_mesh_address("coinos.pro:2121"));
}
#[test]
fn validates_only_target_transports() {
assert!(validate_connect_address("fd9d::1:2121", "udp").is_err());
assert!(validate_connect_address("fd9d::1:2121", "tcp").is_err());
assert!(validate_connect_address("fd9d::1:2121", "ethernet").is_err());
// Other transports are not inspected — they may legitimately accept
// non-IP endpoints (tor onion, etc.).
assert!(validate_connect_address("fd9d::1:2121", "tor").is_ok());
}
#[test]
fn allows_valid_endpoints() {
assert!(validate_connect_address("192.0.2.1:2121", "udp").is_ok());
assert!(validate_connect_address("peer.example.com:2121", "tcp").is_ok());
assert!(validate_connect_address("[2001:db8::1]:2121", "udp").is_ok());
}
}
+12 -2
View File
@@ -71,8 +71,12 @@ impl Node {
/// Handle a Disconnect notification from a peer.
///
/// The peer is signaling an orderly departure. We immediately remove
/// them from all state rather than waiting for timeout detection.
fn handle_disconnect(&mut self, from: &NodeAddr, payload: &[u8]) {
/// them from all state rather than waiting for timeout detection, and
/// schedule a reconnect if the peer is configured as auto-connect.
/// Without this, a graceful upstream shutdown orphans auto-connect
/// entries — other removal paths (link-dead, decrypt failure, peer
/// restart) all schedule reconnect.
pub(in crate::node) fn handle_disconnect(&mut self, from: &NodeAddr, payload: &[u8]) {
let disconnect = match crate::protocol::Disconnect::decode(payload) {
Ok(msg) => msg,
Err(e) => {
@@ -87,7 +91,13 @@ impl Node {
"Peer sent disconnect notification"
);
let addr = *from;
self.remove_active_peer(from);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.schedule_reconnect(addr, now_ms);
}
/// Remove an active peer and clean up all associated state.
+37
View File
@@ -804,6 +804,43 @@ fn test_schedule_reconnect_fresh_state() {
assert_eq!(state.retry_after_ms, 1_000 + expected_delay);
}
/// Test that a graceful Disconnect from an auto-connect peer schedules reconnect.
///
/// Regression test for issue #60: `handle_disconnect` previously called
/// `remove_active_peer` without `schedule_reconnect`, orphaning auto-connect
/// entries on a clean upstream shutdown. Other peer-removal paths (link-dead,
/// decrypt failure, peer restart) all schedule reconnect.
#[test]
fn test_disconnect_schedules_reconnect() {
use crate::protocol::{Disconnect, DisconnectReason};
let peer_identity = Identity::generate();
let peer_npub = peer_identity.npub();
let peer_node_addr = *PeerIdentity::from_npub(&peer_npub).unwrap().node_addr();
let mut config = Config::new();
config.peers.push(crate::config::PeerConfig::new(
peer_npub,
"udp",
"10.0.0.2:2121",
));
let mut node = Node::new(config).unwrap();
let payload = Disconnect::new(DisconnectReason::Shutdown).encode();
node.handle_disconnect(&peer_node_addr, &payload);
let state = node
.retry_pending
.get(&peer_node_addr)
.expect("handle_disconnect should schedule reconnect for auto-connect peer");
assert!(state.reconnect, "Entry should be marked as reconnect");
assert_eq!(
state.retry_count, 0,
"Fresh reconnect after disconnect should start at count=0"
);
}
/// Test that promote_connection clears retry_pending.
#[test]
fn test_promote_clears_retry_pending() {