diff --git a/server/Cargo.lock b/server/Cargo.lock index 5d62337..80da4e8 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -228,6 +228,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -329,6 +335,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -543,6 +555,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -924,6 +946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -959,6 +982,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -997,6 +1029,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1048,9 +1090,15 @@ name = "sn-node" version = "0.1.0" dependencies = [ "clap", + "serde", "serde_json", + "sn-exit", + "tempfile", "thiserror", "tokio", + "toml", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1191,6 +1239,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1217,6 +1266,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1570,6 +1660,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/server/Cargo.toml b/server/Cargo.toml index 81a4e1f..1603397 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -18,8 +18,10 @@ panic = "deny" unreachable = "deny" [workspace.dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "time", "process", "signal"] } +serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } sha2 = "0.10" hex = "0.4" diff --git a/server/crates/sn-exit/src/proxy.rs b/server/crates/sn-exit/src/proxy.rs index 10e4e7f..cee69dc 100644 --- a/server/crates/sn-exit/src/proxy.rs +++ b/server/crates/sn-exit/src/proxy.rs @@ -6,6 +6,7 @@ //! the exit only sees which host is contacted and how many bytes flow. use std::collections::HashMap; +use std::future::Future; use std::net::IpAddr; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -128,17 +129,38 @@ fn roll_window( /// Accept loop. Runs until the listener errors fatally. pub async fn serve(config: ExitConfig, listener: TcpListener) -> Result<(), ExitError> { + serve_until(config, listener, std::future::pending()).await +} + +/// Like [`serve`], but stops accepting when `shutdown` completes (used +/// by the node daemon for kill-switch semantics). Connections already +/// accepted keep running in the background; the caller or process exit +/// owns them. +pub async fn serve_until( + config: ExitConfig, + listener: TcpListener, + shutdown: impl Future, +) -> Result<(), ExitError> { let config = Arc::new(config); let ledger = Arc::new(QuotaLedger::new()); + tokio::pin!(shutdown); loop { - let (stream, peer) = listener.accept().await?; - let config = Arc::clone(&config); - let ledger = Arc::clone(&ledger); - tokio::spawn(async move { - if let Err(error) = handle_connection(stream, peer.ip(), config, ledger).await { - tracing::debug!(%peer, "connection ended: {error}"); + tokio::select! { + () = &mut shutdown => { + tracing::info!("exit: accept loop stopped (shutdown)"); + return Ok(()); } - }); + accepted = listener.accept() => { + let (stream, peer) = accepted?; + let config = Arc::clone(&config); + let ledger = Arc::clone(&ledger); + tokio::spawn(async move { + if let Err(error) = handle_connection(stream, peer.ip(), config, ledger).await { + tracing::debug!(%peer, "connection ended: {error}"); + } + }); + } + } } } diff --git a/server/crates/sn-node/Cargo.toml b/server/crates/sn-node/Cargo.toml index 4c3657e..92c22f2 100644 --- a/server/crates/sn-node/Cargo.toml +++ b/server/crates/sn-node/Cargo.toml @@ -7,9 +7,17 @@ license.workspace = true [dependencies] tokio = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } clap = { workspace = true } +sn-exit = { path = "../sn-exit" } + +[dev-dependencies] +tempfile = { workspace = true } [lints] workspace = true diff --git a/server/crates/sn-node/src/config.rs b/server/crates/sn-node/src/config.rs index b3456b3..7f7534e 100644 --- a/server/crates/sn-node/src/config.rs +++ b/server/crates/sn-node/src/config.rs @@ -239,6 +239,17 @@ pub fn write_config(path: &Path, json: &str) -> Result<(), NodeError> { Ok(()) } +/// Parse a TUN interface setting: `auto`, `none` (headless router-only) +/// or an explicit interface name. +pub fn parse_ifname(input: &str) -> Result { + match input.trim() { + "auto" => Ok(IfName::Auto), + "none" => Ok(IfName::Headless), + name if !name.is_empty() => Ok(IfName::Named(name.to_string())), + _ => Err(NodeError::InvalidIfName(input.to_string())), + } +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] diff --git a/server/crates/sn-node/src/daemon.rs b/server/crates/sn-node/src/daemon.rs new file mode 100644 index 0000000..d1a4766 --- /dev/null +++ b/server/crates/sn-node/src/daemon.rs @@ -0,0 +1,337 @@ +//! The `SpiderNet` node daemon: supervises the Yggdrasil overlay sidecar +//! and runs the optional exit service in-process. +//! +//! Responsibilities (kill-switch semantics): +//! +//! - manage the yggdrasil sidecar when `[yggdrasil] manage = true`: +//! persistent node key at `/node.key`, generated config at +//! `/yggdrasil.conf`, child process with graceful stop; +//! - wait for the overlay address via the admin socket and log it; +//! - run the exit service (`sn-exit`) in-process when +//! `[exit] enabled = true`; otherwise leech mode (overlay only, +//! receive without offering an exit); +//! - on shutdown (SIGINT/SIGTERM wired in by the CLI): stop the accept +//! loop, then stop the sidecar — SIGTERM first, `start_kill` after a +//! bounded grace period; +//! - if the sidecar dies unexpectedly: shut down and exit with an error +//! (supervisor duty). +//! +//! [`run_daemon`] takes the shutdown as a plain future so tests can drive +//! it without signals. + +use std::future::Future; +use std::path::Path; +use std::pin::{pin, Pin}; +use std::time::Duration; + +use tokio::net::TcpListener; +use tokio::process::Child; + +use crate::config::{self, PublicKeyHex}; +use crate::daemon_config::{ValidatedDaemonConfig, YGGDRASIL_CONF_FILE}; +use crate::error::NodeError; +use crate::yggdrasil::{AdminClient, YggAddr}; + +/// Printed first by the daemon CLI; the software carries no warranty and +/// accepts no liability (AGPL-3.0, sections 15/16). +pub const NO_WARRANTY_BANNER: &str = + "NO WARRANTY — SpiderNet sn-node daemon (AGPL-3.0, no liability)"; + +/// How long the daemon waits for the overlay (admin socket) to come up. +const OVERLAY_WAIT: Duration = Duration::from_secs(60); +/// Interval between admin socket attempts while the overlay is down. +const OVERLAY_POLL: Duration = Duration::from_secs(1); +/// Grace period for SIGTERM-terminated yggdrasil before we kill it. +const TERMINATE_GRACE: Duration = Duration::from_secs(5); + +/// Run the node daemon until `shutdown` completes or a fatal error +/// occurs. Returns `Ok(())` on a clean shutdown. +pub async fn run_daemon( + config: ValidatedDaemonConfig, + shutdown: impl Future + Send, +) -> Result<(), NodeError> { + let mut shutdown = pin!(shutdown); + + log_config_summary(&config); + + let mut child = if config.manage() { + Some(spawn_sidecar(&config).await?) + } else { + None + }; + + let overlay = match wait_for_overlay(&config, &mut child, shutdown.as_mut()).await { + OverlayOutcome::Up(address) => address, + OverlayOutcome::Shutdown => { + terminate_sidecar(&mut child).await; + return Ok(()); + } + OverlayOutcome::Failed(error) => { + kill_child(&mut child).await; + return Err(error); + } + }; + + if let Some(exit) = config.exit() { + let bind = exit.bind().resolve(overlay); + let listener = match TcpListener::bind(bind).await { + Ok(listener) => listener, + Err(source) => { + kill_child(&mut child).await; + return Err(NodeError::ExitListen { addr: bind, source }); + } + }; + tracing::info!("exit listening on {bind}"); + if exit.allowlist().is_loopback_only() { + tracing::warn!( + "exit in DEV MODE: no neighbor allowlist — only loopback clients are accepted" + ); + } + + let exit_config = + sn_exit::config::ExitConfig::new(bind, exit.allowlist().clone(), exit.quota()); + tokio::select! { + result = sn_exit::proxy::serve_until(exit_config, listener, shutdown.as_mut()) => { + if let Err(error) = result { + kill_child(&mut child).await; + return Err(NodeError::Exit(error)); + } + } + status = child_exit(&mut child) => { + kill_child(&mut child).await; + return Err(NodeError::SidecarExited( + status.unwrap_or_else(|| "unknown".to_string()), + )); + } + } + } else { + // Leech mode: overlay only, until signal or sidecar death. + tokio::select! { + () = shutdown.as_mut() => {} + status = child_exit(&mut child) => { + return Err(NodeError::SidecarExited( + status.unwrap_or_else(|| "unknown".to_string()), + )); + } + } + } + + terminate_sidecar(&mut child).await; + tracing::info!("SpiderNet node daemon stopped"); + Ok(()) +} + +fn log_config_summary(config: &ValidatedDaemonConfig) { + let overlay = if config.manage() { + "managed sidecar" + } else { + "external (yggdrasil.manage = false)" + }; + let exit = match config.exit() { + Some(_) => "enabled (sharing the uplink)", + None => "disabled (leech mode)", + }; + tracing::info!( + admin = %config.admin_endpoint().config_value(), + state_dir = %config.state_dir().display(), + overlay, + exit, + "SpiderNet node daemon starting" + ); +} + +/// Prepare the state directory (node key + generated Yggdrasil config) +/// and spawn the sidecar process. +async fn spawn_sidecar(config: &ValidatedDaemonConfig) -> Result { + let state_dir = config.state_dir().to_path_buf(); + tokio::fs::create_dir_all(&state_dir).await?; + let key_path = state_dir.join(crate::daemon_config::NODE_KEY_FILE); + + let settings = config.node_settings(); + // One `yggdrasil -genconf -json` run provides both the base config + // and — on first boot — the fresh node key. + let json = config::generate_config(config.yggdrasil_binary(), &settings)?; + let base: serde_json::Value = serde_json::from_str(&json)?; + + if key_path.exists() { + // The node identity must survive restarts: reuse the persisted + // key, never regenerate it. + // + // The base JSON still embeds a fresh `PrivateKey`, but per the + // official configuration reference PrivateKeyPath takes + // precedence when both are set + // (https://yggdrasil-network.github.io/configurationref.html), + // so yggdrasil runs with the persisted key. + let existing = tokio::fs::read_to_string(&key_path).await?; + let key = existing.trim(); + PublicKeyHex::parse(key).map_err(|err| { + NodeError::DaemonConfig(format!( + "existing node key {} is invalid ({err}); the node identity \ + must stay stable, so it is not regenerated", + key_path.display() + )) + })?; + tracing::info!("node key loaded from {}", key_path.display()); + } else { + let generated = base + .get("PrivateKey") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NodeError::DaemonConfig(format!( + "`{} -genconf -json` output has no `PrivateKey` field", + config.yggdrasil_binary() + )) + })?; + let key = PublicKeyHex::parse(generated) + .map_err(|err| NodeError::DaemonConfig(format!("generated node key: {err}")))?; + write_private_key(&key_path, key.as_str())?; + tracing::info!( + "node key generated and stored at {} (mode 600)", + key_path.display() + ); + } + + let conf_path = state_dir.join(YGGDRASIL_CONF_FILE); + config::write_config(&conf_path, &json)?; + tracing::info!("yggdrasil config written to {}", conf_path.display()); + + let mut command = tokio::process::Command::new(config.yggdrasil_binary()); + command.arg("-useconffile").arg(&conf_path); + let child = command.spawn()?; + tracing::info!("yggdrasil sidecar spawned (pid {:?})", child.id()); + Ok(child) +} + +/// Write the node's private key with owner-only permissions (mode 600). +fn write_private_key(path: &Path, key: &str) -> Result<(), NodeError> { + use std::io::Write as _; + + let mut file = std::fs::File::create(path)?; + file.write_all(key.as_bytes())?; + file.flush()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +/// Result of waiting for the overlay. +enum OverlayOutcome { + Up(YggAddr), + /// Shutdown was requested while waiting. + Shutdown, + Failed(NodeError), +} + +/// Poll the admin socket until the overlay reports the node's own +/// address. Aborts on shutdown and on unexpected sidecar death; the +/// sidecar child (if any) is reaped by the `child.wait()` arm. +async fn wait_for_overlay( + config: &ValidatedDaemonConfig, + child: &mut Option, + mut shutdown: Pin<&mut (impl Future + Send)>, +) -> OverlayOutcome { + let admin = config.admin_endpoint(); + let deadline = tokio::time::Instant::now() + OVERLAY_WAIT; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return OverlayOutcome::Failed(NodeError::OverlayNotUp { + endpoint: admin.config_value(), + timeout_secs: OVERLAY_WAIT.as_secs(), + }); + } + + let attempt_timeout = remaining.min(OVERLAY_POLL); + tokio::select! { + biased; + () = shutdown.as_mut() => return OverlayOutcome::Shutdown, + status = child_exit(&mut *child) => { + return OverlayOutcome::Failed(NodeError::SidecarExited( + status.unwrap_or_else(|| "unknown".to_string()), + )); + } + result = AdminClient::get_self(admin, attempt_timeout) => match result { + Ok(info) => { + tracing::info!("overlay is up: address {}", info.address); + if let Ok(peers) = AdminClient::get_peers(admin, attempt_timeout).await { + tracing::info!("overlay peers: {}", peers.len()); + } else { + tracing::info!("overlay peers: not reported yet"); + } + return OverlayOutcome::Up(info.address); + } + Err(_) => tokio::time::sleep(OVERLAY_POLL).await, + } + } + } +} + +/// Resolve when the sidecar process exits; never resolves without one. +/// Returns a human-readable exit status description. +async fn child_exit(child: &mut Option) -> Option { + if let Some(child) = child { + return Some(match child.wait().await { + Ok(status) => status.to_string(), + Err(error) => format!("wait failed: {error}"), + }); + } + std::future::pending::<()>().await; + None +} + +/// Stop the sidecar gracefully: SIGTERM via `kill`, bounded grace, then +/// a hard kill. +async fn terminate_sidecar(child: &mut Option) { + let Some(child) = child.as_mut() else { + return; + }; + let Some(pid) = child.id() else { + tracing::info!("yggdrasil sidecar already gone"); + return; + }; + + tracing::info!("stopping yggdrasil sidecar (pid {pid}): sending SIGTERM"); + match std::process::Command::new("kill") + .arg("-TERM") + .arg(pid.to_string()) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => tracing::warn!("`kill -TERM {pid}` failed: {status}"), + Err(error) => tracing::warn!("could not run `kill -TERM {pid}`: {error}"), + } + + match tokio::time::timeout(TERMINATE_GRACE, child.wait()).await { + Ok(Ok(status)) => tracing::info!("yggdrasil sidecar stopped ({status})"), + Ok(Err(error)) => tracing::warn!("waiting for yggdrasil failed: {error}"), + Err(_) => { + tracing::warn!( + "yggdrasil did not exit within {}s; killing", + TERMINATE_GRACE.as_secs() + ); + if let Err(error) = child.start_kill() { + tracing::warn!("start_kill failed: {error}"); + } + child.wait().await.ok(); + tracing::info!("yggdrasil sidecar killed"); + } + } +} + +/// Kill the sidecar immediately (failure path); a no-op when it is +/// already gone. +async fn kill_child(child: &mut Option) { + let Some(child) = child.as_mut() else { + return; + }; + if child.id().is_some() { + tracing::warn!("killing yggdrasil sidecar after failure"); + if let Err(error) = child.start_kill() { + tracing::warn!("start_kill failed: {error}"); + } + } + child.wait().await.ok(); +} diff --git a/server/crates/sn-node/src/daemon_config.rs b/server/crates/sn-node/src/daemon_config.rs new file mode 100644 index 0000000..2b80710 --- /dev/null +++ b/server/crates/sn-node/src/daemon_config.rs @@ -0,0 +1,555 @@ +//! Typed, validated configuration for the node daemon (`sn-node daemon`). +//! +//! The TOML file is parsed into raw optional sections and then validated +//! into types that carry their invariants ("parsed data is validated"): +//! URIs, keys, interface names and addresses reuse the existing parsers of +//! `sn-node` and `sn-exit`, so an invalid config fails with a clear error +//! at load time instead of a mid-run surprise. +//! +//! File schema (TOML): +//! +//! ```toml +//! [node] +//! admin_endpoint = "tcp://localhost:9001" # optional; also used as Yggdrasil AdminListen +//! state_dir = "spidernet-state" # optional; holds node.key + generated yggdrasil.conf +//! +//! [yggdrasil] +//! manage = true # optional, default true; false = an already-running yggdrasil daemon is used +//! binary = "yggdrasil" # optional, default "yggdrasil"; only relevant when manage = true +//! peers = [] # optional; list of PeerUri strings (tcp://, tls://, quic://, socks://) +//! listen = [] # optional; list of ListenUri strings (tcp://, tls://, quic://) +//! allow_keys = [] # optional; 64-hex neighbor public keys (AllowedPublicKeys ACL) +//! no_multicast = false # optional bool +//! ifname = "auto" # optional: "auto" | "none" | interface name +//! +//! [exit] +//! enabled = false # optional, default false (false = leech mode) +//! bind = "" # optional explicit bind address; default "[]:port" +//! port = 8080 # optional u16, default 8080 (used when bind is unset) +//! allow = [] # optional neighbor overlay addresses (sn-exit --allow semantics) +//! quota_mib = 1024 # optional u64, default 1024; 0 = no quota +//! window_hours = 24 # optional u64, default 24 +//! ``` + +use std::net::{IpAddr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Deserialize; +use sn_exit::config::{parse_bind, parse_source, QuotaPolicy, SourceAllowlist}; + +use crate::config::{parse_ifname, IfName, ListenUri, NodeSettings, PeerUri, PublicKeyHex}; +use crate::error::NodeError; +use crate::yggdrasil::{AdminEndpoint, YggAddr}; + +/// Default state directory (relative to the daemon's working directory). +pub const DEFAULT_STATE_DIR: &str = "spidernet-state"; +/// Default yggdrasil binary (looked up in `PATH`). +pub const DEFAULT_YGGDRASIL_BINARY: &str = "yggdrasil"; +/// Name of the persisted node private key inside the state directory. +pub const NODE_KEY_FILE: &str = "node.key"; +/// Name of the generated Yggdrasil config inside the state directory. +pub const YGGDRASIL_CONF_FILE: &str = "yggdrasil.conf"; +/// Default admin endpoint when `[node] admin_endpoint` is unset. +const DEFAULT_ADMIN_ENDPOINT: &str = "tcp://localhost:9001"; +/// Default exit port when `[exit] bind` is unset. +const DEFAULT_EXIT_PORT: u16 = 8080; +/// Default per-neighbor quota in MiB. +const DEFAULT_QUOTA_MIB: u64 = 1024; +/// Default quota window in hours. +const DEFAULT_WINDOW_HOURS: u64 = 24; +const MIB: u64 = 1024 * 1024; + +/// Where the exit service binds. Resolved at runtime because the overlay +/// address is only known once the overlay is up. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitBind { + /// An explicit address from the config. + Explicit(SocketAddr), + /// `[]:port` on the node's own overlay address. + Overlay { port: u16 }, +} + +impl ExitBind { + /// Resolve this bind against the node's overlay address. + pub const fn resolve(self, overlay: YggAddr) -> SocketAddr { + match self { + Self::Explicit(addr) => addr, + Self::Overlay { port } => SocketAddr::new(IpAddr::V6(overlay.inner()), port), + } + } +} + +/// Validated settings for the exit service; present only when +/// `[exit] enabled = true`. Without it the daemon runs in leech mode +/// (overlay only, receive without offering an exit). +#[derive(Debug, Clone)] +pub struct ExitServiceSettings { + bind: ExitBind, + allowlist: SourceAllowlist, + quota: Option, +} + +impl ExitServiceSettings { + /// Where the exit should bind. + pub const fn bind(&self) -> ExitBind { + self.bind + } + + /// Which neighbor source addresses may use the exit. + pub const fn allowlist(&self) -> &SourceAllowlist { + &self.allowlist + } + + /// Per-neighbor quota, or none when disabled (`quota_mib = 0`). + pub const fn quota(&self) -> Option { + self.quota + } +} + +/// A fully validated daemon config. Construction goes through +/// [`parse`]/[`load`]; every field carries its invariant afterwards. +#[derive(Debug, Clone)] +pub struct ValidatedDaemonConfig { + admin_endpoint: AdminEndpoint, + state_dir: PathBuf, + manage: bool, + yggdrasil_binary: String, + peers: Vec, + listen: Vec, + allowed_keys: Vec, + no_multicast: bool, + if_name: IfName, + exit: Option, +} + +impl ValidatedDaemonConfig { + /// Admin socket endpoint; also used as Yggdrasil `AdminListen` when + /// the daemon manages the sidecar. + pub const fn admin_endpoint(&self) -> &AdminEndpoint { + &self.admin_endpoint + } + + /// State directory: holds `node.key` and the generated yggdrasil + /// config. + pub fn state_dir(&self) -> &Path { + &self.state_dir + } + + /// Whether the daemon manages the yggdrasil sidecar process. + pub const fn manage(&self) -> bool { + self.manage + } + + /// Path or name of the yggdrasil binary. + pub fn yggdrasil_binary(&self) -> &str { + &self.yggdrasil_binary + } + + /// The Yggdrasil settings derived from this config, with the node key + /// at `/node.key`. + pub fn node_settings(&self) -> NodeSettings { + NodeSettings { + peers: self.peers.clone(), + listen: self.listen.clone(), + multicast: Some(if self.no_multicast { + crate::config::MulticastMode::Disabled + } else { + crate::config::MulticastMode::PlatformDefault + }), + admin_listen: Some(self.admin_endpoint.clone()), + if_name: Some(self.if_name.clone()), + allowed_public_keys: self.allowed_keys.clone(), + private_key_path: Some(self.state_dir.join(NODE_KEY_FILE)), + } + } + + /// Exit service settings, or `None` in leech mode. + pub const fn exit(&self) -> Option<&ExitServiceSettings> { + self.exit.as_ref() + } +} + +/// Parse a daemon config from TOML text. +pub fn parse(input: &str) -> Result { + let raw: RawDaemonConfig = toml::from_str(input) + .map_err(|err| NodeError::DaemonConfig(format!("cannot parse config: {err}")))?; + validate(raw) +} + +/// Parse a daemon config from the file at `path`. +pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|err| { + NodeError::DaemonConfig(format!("cannot read config file {}: {err}", path.display())) + })?; + parse(&text) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawDaemonConfig { + #[serde(default)] + node: RawNodeSection, + #[serde(default)] + yggdrasil: RawYggdrasilSection, + #[serde(default)] + exit: RawExitSection, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawNodeSection { + admin_endpoint: Option, + state_dir: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawYggdrasilSection { + manage: Option, + binary: Option, + #[serde(default)] + peers: Option>, + #[serde(default)] + listen: Option>, + #[serde(default)] + allow_keys: Option>, + no_multicast: Option, + ifname: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawExitSection { + enabled: Option, + bind: Option, + port: Option, + #[serde(default)] + allow: Option>, + quota_mib: Option, + window_hours: Option, +} + +fn validate(raw: RawDaemonConfig) -> Result { + let RawDaemonConfig { + node, + yggdrasil, + exit, + } = raw; + + let admin_endpoint = match node.admin_endpoint.as_deref() { + None => AdminEndpoint::parse(DEFAULT_ADMIN_ENDPOINT) + .map_err(|err| NodeError::DaemonConfig(err.to_string()))?, + Some(value) => AdminEndpoint::parse(value) + .map_err(|err| NodeError::DaemonConfig(format!("node.admin_endpoint: {err}")))?, + }; + let state_dir = node + .state_dir + .map_or_else(|| PathBuf::from(DEFAULT_STATE_DIR), PathBuf::from); + + let manage = yggdrasil.manage.unwrap_or(true); + let yggdrasil_binary = yggdrasil + .binary + .unwrap_or_else(|| DEFAULT_YGGDRASIL_BINARY.to_string()); + + let peers = parse_each( + yggdrasil.peers.as_deref(), + "yggdrasil.peers", + PeerUri::parse, + )?; + let listen = parse_each( + yggdrasil.listen.as_deref(), + "yggdrasil.listen", + ListenUri::parse, + )?; + let allowed_keys = parse_each( + yggdrasil.allow_keys.as_deref(), + "yggdrasil.allow_keys", + PublicKeyHex::parse, + )?; + let no_multicast = yggdrasil.no_multicast.unwrap_or(false); + let if_name = yggdrasil.ifname.as_deref().unwrap_or("auto"); + let if_name = parse_ifname(if_name) + .map_err(|err| NodeError::DaemonConfig(format!("yggdrasil.ifname: {err}")))?; + + let exit = validate_exit(&exit)?; + + if !manage && exit.is_none() { + return Err(NodeError::DaemonConfig( + "nothing to do: set `[yggdrasil] manage = true` or `[exit] enabled = true`".to_string(), + )); + } + + Ok(ValidatedDaemonConfig { + admin_endpoint, + state_dir, + manage, + yggdrasil_binary, + peers, + listen, + allowed_keys, + no_multicast, + if_name, + exit, + }) +} + +fn validate_exit(raw: &RawExitSection) -> Result, NodeError> { + let enabled = raw.enabled.unwrap_or(false); + + let explicit_bind = match raw.bind.as_deref() { + None | Some("") => None, + Some(value) => Some( + parse_bind(value) + .map_err(|err| NodeError::DaemonConfig(format!("exit.bind: {err}")))?, + ), + }; + let allow_sources: Vec = parse_each(raw.allow.as_deref(), "exit.allow", parse_source)?; + + // Without a neighbor allowlist only loopback clients would be + // accepted — that is acceptable only for development, where an + // explicit loopback bind makes the intent unmistakable. + if enabled && allow_sources.is_empty() { + let dev_mode = explicit_bind.is_some_and(|addr| addr.ip().is_loopback()); + if !dev_mode { + return Err(NodeError::DaemonConfig( + "exit enabled but no neighbor allowlist: add `exit.allow` entries, \ + or (dev only) set `exit.bind` to a loopback address" + .to_string(), + )); + } + } + + let allowlist = if allow_sources.is_empty() { + SourceAllowlist::loopback_only() + } else { + SourceAllowlist::new(allow_sources) + }; + + let quota = match raw.quota_mib.unwrap_or(DEFAULT_QUOTA_MIB) { + 0 => None, + mib => Some( + QuotaPolicy::try_new( + mib.saturating_mul(MIB), + Duration::from_secs( + raw.window_hours + .unwrap_or(DEFAULT_WINDOW_HOURS) + .saturating_mul(3600), + ), + ) + .map_err(|err| NodeError::DaemonConfig(format!("exit.quota: {err}")))?, + ), + }; + + let bind = match explicit_bind { + Some(addr) => ExitBind::Explicit(addr), + None => ExitBind::Overlay { + port: raw.port.unwrap_or(DEFAULT_EXIT_PORT), + }, + }; + + if enabled { + Ok(Some(ExitServiceSettings { + bind, + allowlist, + quota, + })) + } else { + Ok(None) + } +} + +/// Parse every entry with `parse`, wrapping failures in a single +/// config error that names the offending section. +fn parse_each( + inputs: Option<&[String]>, + what: &str, + parse: fn(&str) -> Result, +) -> Result, NodeError> { + let inputs = inputs.unwrap_or(&[]); + inputs + .iter() + .map(|input| parse(input).map_err(|err| NodeError::DaemonConfig(format!("{what}: {err}")))) + .collect() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + use super::*; + + fn error_for(toml_text: &str) -> String { + parse(toml_text) + .expect_err("config should be invalid") + .to_string() + } + + #[test] + fn full_config_maps_into_validated_types() { + let config = parse( + r#" + [node] + admin_endpoint = "tcp://localhost:9001" + state_dir = "/var/lib/spidernet" + + [yggdrasil] + manage = true + binary = "/usr/local/bin/yggdrasil" + peers = ["tcp://[200:1234::1]:1337", "socks://p:1/h:2"] + listen = ["tls://[::]:1337"] + allow_keys = ["00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"] + no_multicast = true + ifname = "eth0" + + [exit] + enabled = true + bind = "127.0.0.1:18099" + allow = [] + quota_mib = 2048 + window_hours = 12 + "#, + ) + .unwrap(); + + assert_eq!( + config.admin_endpoint(), + &AdminEndpoint::parse("tcp://localhost:9001").unwrap() + ); + assert_eq!(config.state_dir(), Path::new("/var/lib/spidernet")); + assert!(config.manage()); + assert_eq!(config.yggdrasil_binary(), "/usr/local/bin/yggdrasil"); + + let settings = config.node_settings(); + assert_eq!(settings.peers.len(), 2); + assert_eq!(settings.peers[0].as_str(), "tcp://[200:1234::1]:1337"); + assert_eq!(settings.peers[1].as_str(), "socks://p:1/h:2"); + assert_eq!(settings.listen.len(), 1); + assert_eq!(settings.listen[0].as_str(), "tls://[::]:1337"); + assert_eq!(settings.allowed_public_keys.len(), 1); + assert!(matches!( + settings.if_name, + Some(IfName::Named(ref name)) if name == "eth0" + )); + assert_eq!( + settings.private_key_path.as_deref(), + Some(Path::new("/var/lib/spidernet/node.key")) + ); + + let exit = config.exit().unwrap(); + assert_eq!( + exit.bind(), + ExitBind::Explicit("127.0.0.1:18099".parse().unwrap()) + ); + assert!(exit.allowlist().is_loopback_only()); + let quota = exit.quota().unwrap(); + assert_eq!(quota.max_bytes(), 2048 * 1024 * 1024); + assert_eq!(quota.window(), Duration::from_hours(12)); + } + + #[test] + fn minimal_config_defaults_to_leech_mode() { + let config = parse("[exit]\nenabled = false\n").unwrap(); + assert!(config.manage()); + assert_eq!(config.yggdrasil_binary(), DEFAULT_YGGDRASIL_BINARY); + assert_eq!(config.state_dir(), Path::new(DEFAULT_STATE_DIR)); + assert!(config.exit().is_none()); + assert!(config.node_settings().peers.is_empty()); + } + + #[test] + fn load_missing_file_is_an_error() { + let error = load(Path::new("/nonexistent/spidernet-node.toml")).unwrap_err(); + assert!(error.to_string().contains("cannot read config file")); + } + + #[test] + fn load_valid_file_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("node.toml"); + std::fs::write(&path, "[yggdrasil]\nmanage = true\n").unwrap(); + assert!(load(&path).is_ok()); + } + + #[test] + fn broken_toml_gives_readable_error() { + let error = error_for("[node]\nadmin_endpoint = \n"); + assert!(error.contains("cannot parse config"), "{error}"); + } + + #[test] + fn exit_without_allowlist_requires_loopback_bind() { + let no_bind = error_for("[exit]\nenabled = true\n"); + assert!( + no_bind.contains("exit enabled but no neighbor allowlist"), + "{no_bind}" + ); + + let non_loopback = error_for("[exit]\nenabled = true\nbind = \"192.168.1.5:8080\"\n"); + assert!( + non_loopback.contains("exit enabled but no neighbor allowlist"), + "{non_loopback}" + ); + } + + #[test] + fn exit_with_loopback_bind_is_valid_dev_mode() { + let config = parse("[exit]\nenabled = true\nbind = \"127.0.0.1:18099\"\n").unwrap(); + let exit = config.exit().unwrap(); + assert!(exit.allowlist().is_loopback_only()); + assert_eq!( + exit.bind(), + ExitBind::Explicit("127.0.0.1:18099".parse().unwrap()) + ); + } + + #[test] + fn manage_false_and_exit_disabled_is_rejected() { + let error = error_for("[yggdrasil]\nmanage = false\n"); + assert!(error.contains("nothing to do"), "{error}"); + } + + #[test] + fn quota_zero_disables_quota() { + let config = + parse("[exit]\nenabled = true\nbind = \"[::1]:8080\"\nquota_mib = 0\n").unwrap(); + assert_eq!(config.exit().unwrap().quota(), None); + } + + #[test] + fn overlay_bind_defaults_to_port_8080_and_can_be_overridden() { + let config = parse("[exit]\nenabled = true\nallow = [\"200:5678::2\"]\n").unwrap(); + let bind = config.exit().unwrap().bind(); + assert_eq!(bind, ExitBind::Overlay { port: 8080 }); + + let overlay: std::net::Ipv6Addr = "200:1111::1".parse().unwrap(); + assert_eq!( + bind.resolve(YggAddr::new(overlay)), + SocketAddr::new(IpAddr::V6(overlay), 8080) + ); + + let config = + parse("[exit]\nenabled = true\nallow = [\"200:5678::2\"]\nport = 9099\n").unwrap(); + let bind = config.exit().unwrap().bind(); + assert_eq!( + bind.resolve(YggAddr::new(overlay)), + SocketAddr::new(IpAddr::V6(overlay), 9099) + ); + } + + #[test] + fn invalid_entries_surface_reused_type_errors() { + for invalid in [ + "[yggdrasil]\npeers = [\"banana\"]\n", + "[yggdrasil]\nlisten = [\"socks://[::]:1337\"]\n", + "[yggdrasil]\nallow_keys = [\"nothex\"]\n", + "[yggdrasil]\nifname = \"\"\n", + "[node]\nadmin_endpoint = \"banana\"\n", + "[exit]\nallow = [\"banana\"]\n", + "[exit]\nbind = \"banana\"\nenabled = true\n", + "[exit]\nport = 70000\n", + ] { + let error = parse(invalid).expect_err("should be invalid").to_string(); + assert!(error.contains("invalid daemon config"), "{error}"); + } + } +} diff --git a/server/crates/sn-node/src/error.rs b/server/crates/sn-node/src/error.rs index cb12821..45f0455 100644 --- a/server/crates/sn-node/src/error.rs +++ b/server/crates/sn-node/src/error.rs @@ -31,4 +31,25 @@ pub enum NodeError { #[error("admin socket operation timed out")] Timeout, + + #[error("invalid daemon config: {0}")] + DaemonConfig(String), + + #[error("overlay did not come up within {timeout_secs}s (admin endpoint {endpoint})")] + OverlayNotUp { endpoint: String, timeout_secs: u64 }, + + #[error("yggdrasil sidecar exited unexpectedly: {0}")] + SidecarExited(String), + + #[error("exit service cannot listen on {addr}: {source}")] + ExitListen { + addr: std::net::SocketAddr, + source: std::io::Error, + }, + + #[error("exit service error: {0}")] + Exit(#[from] sn_exit::error::ExitError), + + #[error("invalid interface name `{0}`: expected `auto`, `none` or an interface name")] + InvalidIfName(String), } diff --git a/server/crates/sn-node/src/lib.rs b/server/crates/sn-node/src/lib.rs index 30159d8..7f19285 100644 --- a/server/crates/sn-node/src/lib.rs +++ b/server/crates/sn-node/src/lib.rs @@ -10,6 +10,9 @@ //! - [`yggdrasil`]: client for the Yggdrasil admin socket (JSON over //! TCP/unix, default `localhost:9001`) to query node status (`getSelf`, //! `getPeers`). +//! - [`daemon_config`]: the validated TOML config of the node daemon. +//! - [`daemon`]: the node daemon — supervises the yggdrasil sidecar and +//! runs the optional `sn-exit` exit service in-process. //! //! Yggdrasil provides encrypted end-to-end routing between households. //! It is **not** an anonymity network, and `SpiderNet` never claims to @@ -24,5 +27,7 @@ #![allow(clippy::must_use_candidate, clippy::missing_errors_doc)] pub mod config; +pub mod daemon; +pub mod daemon_config; pub mod error; pub mod yggdrasil; diff --git a/server/crates/sn-node/src/main.rs b/server/crates/sn-node/src/main.rs index c028b37..9060513 100644 --- a/server/crates/sn-node/src/main.rs +++ b/server/crates/sn-node/src/main.rs @@ -8,9 +8,9 @@ use std::path::PathBuf; use std::time::Duration; use clap::{Parser, Subcommand}; -use sn_node::config::{ - self, IfName, ListenUri, MulticastMode, NodeSettings, PeerUri, PublicKeyHex, -}; +use sn_node::config::{self, ListenUri, MulticastMode, NodeSettings, PeerUri, PublicKeyHex}; +use sn_node::daemon::{self, NO_WARRANTY_BANNER}; +use sn_node::daemon_config; use sn_node::yggdrasil::{self, AdminEndpoint}; #[derive(Debug, Parser)] @@ -77,15 +77,17 @@ enum Command { #[arg(long, default_value_t = 3)] timeout_secs: u64, }, -} + /// Run the node daemon: supervise the Yggdrasil sidecar and the + /// optional in-process exit service. Stops on SIGINT/SIGTERM. + Daemon { + /// Path to the TOML config file. + #[arg(long, default_value = "node.toml")] + config: PathBuf, -fn parse_ifname(input: &str) -> Result { - match input.trim() { - "auto" => Ok(IfName::Auto), - "none" => Ok(IfName::Headless), - name if !name.is_empty() => Ok(IfName::Named(name.to_string())), - _ => Err("--ifname must be `auto`, `none` or an interface name".to_string()), - } + /// Validate the config and exit without starting anything. + #[arg(long)] + check: bool, + }, } fn parse_uris( @@ -139,6 +141,7 @@ async fn run() -> Result<(), String> { admin, timeout_secs, } => run_status(&admin, timeout_secs).await, + Command::Daemon { config, check } => run_daemon_command(&config, check).await, } } @@ -161,7 +164,7 @@ async fn run_genconf( .iter() .map(|key| PublicKeyHex::parse(key).map_err(|err| err.to_string())) .collect::, String>>()?; - let if_name = parse_ifname(&ifname)?; + let if_name = config::parse_ifname(&ifname).map_err(|err| err.to_string())?; let settings = NodeSettings { peers, @@ -227,3 +230,41 @@ async fn run_status(admin: &str, timeout_secs: u64) -> Result<(), String> { } Ok(()) } + +async fn run_daemon_command(config_path: &std::path::Path, check: bool) -> Result<(), String> { + println!("{NO_WARRANTY_BANNER}"); + + let validated = daemon_config::load(config_path).map_err(|err| err.to_string())?; + if check { + println!("config ok"); + return Ok(()); + } + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + daemon::run_daemon(validated, shutdown_signal()) + .await + .map_err(|err| err.to_string()) +} + +/// Future that resolves when SIGINT or SIGTERM (unix) arrives. +async fn shutdown_signal() { + #[cfg(unix)] + { + if let Ok(mut sigterm) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = sigterm.recv() => {} + } + return; + } + } + tokio::signal::ctrl_c().await.ok(); +} diff --git a/server/crates/sn-node/tests/daemon.rs b/server/crates/sn-node/tests/daemon.rs new file mode 100644 index 0000000..78f4bd9 --- /dev/null +++ b/server/crates/sn-node/tests/daemon.rs @@ -0,0 +1,331 @@ +//! Integration tests for the node daemon, against a *fake* yggdrasil +//! (a shell script; real yggdrasil is not assumed to be installed) and +//! a fake admin socket server speaking the documented protocol. +//! +//! Unix-only: the fake yggdrasil is a shell script and the sidecar +//! handling relies on POSIX process semantics. + +#![cfg(unix)] +#![forbid(unsafe_code)] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::net::SocketAddr; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use sn_node::daemon_config::{self, ExitBind, ValidatedDaemonConfig}; +use sn_node::error::NodeError; +use tokio::net::{TcpListener, TcpStream}; + +const FAKE_PRIVATE_KEY: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + +const SELF_RESPONSE: &str = concat!( + r#"{"request": {"request": "getself"}, "status": "success", "response": {"#, + r#""200:1111:2222:3333:4444:5555:6666:7777": {"#, + r#""address": "200:1111:2222:3333:4444:5555:6666:7777","#, + r#""key": "0000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111","#, + r#""build_name": "yggdrasil", "build_version": "0.5.12","#, + r#""subnet": "300:1111:2222:3333::/64", "coords": [1, 2, 3]}}}"# +); + +const PEERS_RESPONSE: &str = concat!( + r#"{"request": {"request": "getpeers"}, "status": "success", "response": {"#, + r#""200:1111:2222:3333:4444:5555:6666:7777": {"coords": [1]}, "#, + r#""200:9999:2222:3333:4444:5555:6666:7777": {"#, + r#""coords": [1, 2], "port": 1, "key": "ffff...", "#, + r#""remote": "tcp://[fe80::1%25eth0]:1337"}}}"# +); + +/// What the fake yggdrasil does once started with `-useconffile`. +#[derive(Clone, Copy)] +enum SidecarBehavior { + /// Keep running until killed (test shutdown paths). + RunForever, + /// Exit shortly after start (test the supervisor death path). + DieSoon, +} + +/// Fake admin server: answers `getself`/`getpeers` request lines, then +/// closes the connection (mirrors real yggdrasil's one-request behavior). +async fn fake_admin_server() -> SocketAddr { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut line = Vec::new(); + let mut byte = [0u8; 1]; + while !line.ends_with(b"\n") { + if socket.read(&mut byte).await.unwrap_or(0) == 0 { + break; + } + line.push(byte[0]); + } + let request = String::from_utf8_lossy(&line); + let response = if request.contains("getpeers") { + PEERS_RESPONSE + } else { + SELF_RESPONSE + }; + socket.write_all(response.as_bytes()).await.ok(); + socket.shutdown().await.ok(); + } + }); + addr +} + +/// Write a shell script that impersonates the yggdrasil binary: it +/// answers `-genconf -json` with a fixed minimal config and busy-waits +/// (or dies) on `-useconffile`. The script path is returned. +fn write_fake_yggdrasil(dir: &Path, behavior: SidecarBehavior) -> PathBuf { + let pidfile = dir.join("fake-yggdrasil.pid"); + let wait = match behavior { + SidecarBehavior::RunForever => "exec sleep 600", + SidecarBehavior::DieSoon => "exec sleep 0.3", + }; + let script = format!( + r#"#!/bin/sh +# fake yggdrasil for the SpiderNet daemon tests +if [ "$1" = "-genconf" ]; then + printf '%s' '{{"PrivateKey": "{FAKE_PRIVATE_KEY}", "Peers": [], "Listen": []}}' + exit 0 +fi +if [ "$1" = "-useconffile" ]; then + echo $$ > '{pidfile}' + {wait} +fi +exit 1 +"#, + pidfile = pidfile.display(), + ); + let path = dir.join("fake-yggdrasil"); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +/// Build a validated daemon config pointing at the fake infrastructure. +fn test_config( + admin: SocketAddr, + binary: &Path, + state_dir: &Path, + exit_toml: &str, +) -> ValidatedDaemonConfig { + let text = format!( + "[node]\nadmin_endpoint = \"tcp://{}:{}\"\nstate_dir = \"{}\"\n\ + [yggdrasil]\nbinary = \"{}\"\n\ + [exit]\n{exit_toml}\n", + admin.ip(), + admin.port(), + state_dir.display(), + binary.display(), + ); + daemon_config::parse(&text).unwrap() +} + +/// Poll `check` until it returns true or a deadline passes. +async fn wait_until(check: impl Fn() -> bool) -> bool { + for _ in 0..200 { + if check() { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +/// Poll a TCP connect until the daemon's exit listener is up. +async fn exit_is_accepting(addr: SocketAddr) -> bool { + wait_until_async(|| async { TcpStream::connect(addr).await.is_ok() }).await +} + +async fn wait_until_async(check: impl Fn() -> F) -> bool +where + F: std::future::Future, +{ + for _ in 0..200 { + if check().await { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +/// A shutdown future driven by a shared flag (avoids a tokio `sync` +/// dependency in tests; the 10 ms polling is plenty for tests). +async fn flag_shutdown(flag: Arc) { + loop { + if flag.load(Ordering::Relaxed) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +/// A shutdown flag plus a setter closure that flips it. +fn shutdown_flag() -> (Arc, impl Fn() + Send + Clone + 'static) { + let flag = Arc::new(AtomicBool::new(false)); + let setter = { + let flag = Arc::clone(&flag); + move || flag.store(true, Ordering::Relaxed) + }; + (flag, setter) +} + +#[tokio::test] +async fn node_key_is_generated_once_and_reused() { + let tmp = tempfile::tempdir().unwrap(); + let admin = fake_admin_server().await; + let binary = write_fake_yggdrasil(tmp.path(), SidecarBehavior::RunForever); + let state_dir = tmp.path().join("state"); + let config = test_config(admin, &binary, &state_dir, "enabled = false"); + + // First run: the daemon generates the node key, the yggdrasil config + // and spawns the fake sidecar, then waits for shutdown. + let (flag, shutdown) = shutdown_flag(); + let handle = tokio::spawn(sn_node::daemon::run_daemon( + config.clone(), + flag_shutdown(flag), + )); + + let key_path = state_dir.join("node.key"); + assert!( + wait_for_file(&key_path).await, + "node key was not generated in time" + ); + let first_key = std::fs::read_to_string(&key_path).unwrap(); + assert_eq!(first_key.trim(), FAKE_PRIVATE_KEY); + assert_eq!( + std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777, + 0o600, + "node key must be owner-only" + ); + + let conf = std::fs::read_to_string(state_dir.join("yggdrasil.conf")).unwrap(); + assert!( + conf.contains("\"PrivateKeyPath\"") && conf.contains(&key_path.display().to_string()), + "generated config must reference the node key file:\n{conf}" + ); + assert!( + conf.contains(&format!( + "\"AdminListen\": \"tcp://{}:{}\"", + admin.ip(), + admin.port() + )), + "generated config must use the configured admin endpoint:\n{conf}" + ); + + shutdown(); + let result = tokio::time::timeout(Duration::from_secs(30), handle) + .await + .unwrap() + .unwrap(); + assert!(result.is_ok(), "first run failed: {result:?}"); + + // Second run: the key must be reused, not regenerated. + let (flag, shutdown) = shutdown_flag(); + let handle = tokio::spawn(sn_node::daemon::run_daemon(config, flag_shutdown(flag))); + shutdown(); + let result = tokio::time::timeout(Duration::from_secs(30), handle) + .await + .unwrap() + .unwrap(); + assert!(result.is_ok(), "second run failed: {result:?}"); + + let second_key = std::fs::read_to_string(&key_path).unwrap(); + assert_eq!(second_key, first_key, "node identity must survive restarts"); +} + +async fn wait_for_file(path: &Path) -> bool { + wait_until(|| path.exists()).await +} + +#[tokio::test] +async fn exit_service_listens_and_accepts_connections() { + let tmp = tempfile::tempdir().unwrap(); + let admin = fake_admin_server().await; + let binary = write_fake_yggdrasil(tmp.path(), SidecarBehavior::RunForever); + let state_dir = tmp.path().join("state"); + + // Grab a free port, then release it for the daemon to bind. + let scratch = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let exit_addr = scratch.local_addr().unwrap(); + drop(scratch); + + let config = test_config( + admin, + &binary, + &state_dir, + &format!("enabled = true\nbind = \"127.0.0.1:{}\"", exit_addr.port()), + ); + + let (flag, shutdown) = shutdown_flag(); + let handle = tokio::spawn(sn_node::daemon::run_daemon(config, flag_shutdown(flag))); + + assert!( + exit_is_accepting(exit_addr).await, + "the exit service did not start listening in time" + ); + + shutdown(); + let result = tokio::time::timeout(Duration::from_secs(30), handle) + .await + .unwrap() + .unwrap(); + assert!(result.is_ok(), "daemon run failed: {result:?}"); +} + +#[tokio::test] +async fn immediate_shutdown_ends_run_daemon_cleanly() { + // No sidecar (manage = false): an already-running yggdrasil is + // assumed; the daemon must still exit cleanly on shutdown. + let config = daemon_config::parse( + "[yggdrasil]\nmanage = false\n[exit]\nenabled = true\nbind = \"127.0.0.1:0\"\n", + ) + .unwrap(); + assert!(matches!( + config.exit().unwrap().bind(), + ExitBind::Explicit(_) + )); + + let result = tokio::time::timeout( + Duration::from_secs(30), + sn_node::daemon::run_daemon(config, std::future::ready(())), + ) + .await + .unwrap(); + assert!(result.is_ok(), "daemon should end cleanly: {result:?}"); +} + +#[tokio::test] +async fn sidecar_death_fails_the_daemon() { + let tmp = tempfile::tempdir().unwrap(); + // Admin endpoint points nowhere: only the supervisor's child-death + // detection can end the run. + let config = daemon_config::parse(&format!( + "[node]\nadmin_endpoint = \"tcp://127.0.0.1:1\"\nstate_dir = \"{}\"\n\ + [yggdrasil]\nbinary = \"{}\"\n[exit]\nenabled = false\n", + tmp.path().join("state").display(), + write_fake_yggdrasil(tmp.path(), SidecarBehavior::DieSoon).display(), + )) + .unwrap(); + + let result = tokio::time::timeout( + Duration::from_secs(30), + sn_node::daemon::run_daemon(config, std::future::pending()), + ) + .await + .unwrap(); + assert!( + matches!(result, Err(NodeError::SidecarExited(_))), + "expected SidecarExited error, got {result:?}" + ); +}