mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 00:04:54 +00:00
Make Nostr-mediated overlay discovery unconditional, mirroring the philosophy of PR #79's collapse of the tui/ble/gateway features in favor of platform cfg gates. nostr / nostr-sdk are pure Rust over WebSockets/TCP, so they build cleanly on every FIPS-supported platform — there is no need for the parallel feature gate. The flag was already in `default = [...]`, so no behavior change for anyone using `cargo build` without `--no-default-features`. Operators who explicitly disabled the feature will now find Nostr code present in the binary; the runtime check `node.discovery.nostr.enabled` still controls whether the runtime starts. Cargo.toml: - Remove the `[features]` table entirely. - Drop `optional = true` from `nostr` and `nostr-sdk`. Source: 27 cfg sites collapsed across 5 files — `src/discovery.rs`, `src/discovery/nostr/mod.rs`, `src/node/handlers/rx_loop.rs`, `src/node/lifecycle.rs`, `src/node/mod.rs`. Two `#[cfg(not(feature = "nostr-discovery"))]` fallback blocks (the udp:nat-without-runtime debug-log path and the "feature not compiled in" warning at startup) were removed as dead code; the always-on path already handles the missing-runtime case via `nostr_discovery: Option<NostrDiscovery>`. Packaging and tooling: - `packaging/openwrt-ipk/Makefile`: drop a stale `--features gateway` flag (the `gateway` feature was already removed in PR #79; this was a leftover that the build path tolerated only because cargo ignored unknown feature names). - `testing/scripts/build.sh`: drop `DEFAULT_CARGO_BUILD_ARGS=(--features nostr-discovery)`; defaults are empty. - `packaging/common/fips.yaml`: drop the "requires the nostr-discovery feature" comment from the discovery section. Bundled cleanup: - Apply `cargo clippy --fix` against three pre-existing warnings in `src/discovery/nostr/runtime.rs` and `src/discovery/nostr/stun.rs` (collapsed `if let Some` chain; two redundant `as i32` casts). These were always present but masked when the feature gate was off; they surface now that the code is unconditionally compiled. - `cargo fmt` settled two minor formatting drift sites in `src/bin/fips-gateway.rs` and `src/config/mod.rs`. Tests: 1083 passed, 0 failed, 4 ignored. clippy clean. fmt clean.
497 lines
16 KiB
Rust
497 lines
16 KiB
Rust
//! FIPS outbound LAN gateway binary.
|
|
//!
|
|
//! Allows unmodified LAN hosts to reach FIPS mesh destinations via
|
|
//! DNS-allocated virtual IPs and kernel nftables NAT.
|
|
|
|
#[cfg(target_os = "linux")]
|
|
use clap::Parser;
|
|
#[cfg(target_os = "linux")]
|
|
use fips::Config;
|
|
#[cfg(target_os = "linux")]
|
|
use fips::gateway::{control, dns, nat, net, pool};
|
|
#[cfg(target_os = "linux")]
|
|
use fips::version;
|
|
#[cfg(target_os = "linux")]
|
|
use std::path::PathBuf;
|
|
#[cfg(target_os = "linux")]
|
|
use std::sync::Arc;
|
|
#[cfg(target_os = "linux")]
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
#[cfg(target_os = "linux")]
|
|
use std::time::Instant;
|
|
#[cfg(target_os = "linux")]
|
|
use tokio::signal::unix::{SignalKind, signal};
|
|
#[cfg(target_os = "linux")]
|
|
use tokio::sync::{Mutex, mpsc, watch};
|
|
#[cfg(target_os = "linux")]
|
|
use tracing::{error, info, warn};
|
|
#[cfg(target_os = "linux")]
|
|
use tracing_subscriber::{EnvFilter, fmt};
|
|
|
|
/// FIPS outbound LAN gateway
|
|
#[cfg(target_os = "linux")]
|
|
#[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,
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn main() {
|
|
eprintln!("fips-gateway requires Linux (nftables unavailable on this platform)");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[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);
|
|
}
|
|
};
|
|
|
|
if let Err(e) = gw_config.validate_port_forwards() {
|
|
error!("Invalid gateway.port_forwards: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
info!(
|
|
pool = %gw_config.pool,
|
|
lan_interface = %gw_config.lan_interface,
|
|
port_forwards = gw_config.port_forwards.len(),
|
|
"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);
|
|
}
|
|
};
|
|
|
|
// Retry the upstream probe up to MAX_PROBE_ATTEMPTS times with a
|
|
// 1-second per-attempt timeout and a 1-second sleep between
|
|
// attempts. Total worst-case wait: ~10 seconds.
|
|
//
|
|
// Bounded retry covers the cold-boot race where this gateway and
|
|
// the fips daemon start at approximately the same time: the
|
|
// daemon's TUN may be up (the systemd ExecStartPre wait already
|
|
// gates on that) while its DNS responder is still binding
|
|
// [::1]:5354. Without this retry, the gateway hard-failed after
|
|
// a single 3-second probe and relied on Restart=on-failure for
|
|
// recovery.
|
|
const MAX_PROBE_ATTEMPTS: u32 = 5;
|
|
const PROBE_TIMEOUT_SECS: u64 = 1;
|
|
const PROBE_RETRY_DELAY_SECS: u64 = 1;
|
|
|
|
let mut buf = [0u8; 512];
|
|
let mut last_failure: Option<String> = None;
|
|
let mut succeeded = false;
|
|
for attempt in 1..=MAX_PROBE_ATTEMPTS {
|
|
if let Err(e) = sock.send_to(&query, upstream_addr).await {
|
|
last_failure = Some(format!("send_to failed: {}", e));
|
|
} else {
|
|
match tokio::time::timeout(
|
|
std::time::Duration::from_secs(PROBE_TIMEOUT_SECS),
|
|
sock.recv_from(&mut buf),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(_)) => {
|
|
info!(
|
|
upstream = %upstream, attempt = attempt,
|
|
"DNS upstream is reachable"
|
|
);
|
|
succeeded = true;
|
|
break;
|
|
}
|
|
Ok(Err(e)) => {
|
|
last_failure = Some(format!("recv_from failed: {}", e));
|
|
}
|
|
Err(_) => {
|
|
last_failure = Some(format!("no response within {}s", PROBE_TIMEOUT_SECS));
|
|
}
|
|
}
|
|
}
|
|
if attempt < MAX_PROBE_ATTEMPTS {
|
|
info!(
|
|
upstream = %upstream, attempt = attempt,
|
|
last = ?last_failure,
|
|
"DNS upstream probe attempt failed; retrying"
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_secs(PROBE_RETRY_DELAY_SECS)).await;
|
|
}
|
|
}
|
|
if !succeeded {
|
|
error!(
|
|
upstream = %upstream,
|
|
attempts = MAX_PROBE_ATTEMPTS,
|
|
last = ?last_failure,
|
|
"DNS upstream did not become reachable after exhausting retries — 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(gw_config.lan_interface.clone()) {
|
|
Ok(n) => n,
|
|
Err(e) => {
|
|
error!(error = %e, "Failed to create nftables table — do you have CAP_NET_ADMIN?");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Install inbound port-forward rules (TASK-2026-0061).
|
|
if let Err(e) = nat_mgr.set_port_forwards(&gw_config.port_forwards) {
|
|
error!(error = %e, "Failed to install port-forward rules");
|
|
let _ = nat_mgr.cleanup();
|
|
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");
|
|
}
|