Add sn-node daemon: supervise yggdrasil sidecar + in-process exit

sn-node daemon [--config node.toml] [--check] runs one household node
from a validated TOML config: generates the yggdrasil config with a
persistent node key in the state dir (PrivateKeyPath takes precedence
per the yggdrasil config reference, so the identity survives restarts),
waits for the overlay address via the admin socket, then serves the
sn-exit proxy in-process (or runs overlay-only in leech mode).
SIGINT/SIGTERM stop the accept loop, SIGTERM the sidecar (5s grace,
then SIGKILL); unexpected sidecar death shuts the daemon down with an
error. sn-exit gains an additive serve_until() with a shutdown future;
serve() keeps its behavior. Workspace: tokio gains process+signal,
serde/toml added. 73 tests green, clippy clean.
This commit is contained in:
2026-09-24 20:12:44 +02:00
parent d8bd14ab1b
commit f2f3609b87
11 changed files with 1452 additions and 20 deletions
+99
View File
@@ -228,6 +228,12 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -329,6 +335,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -543,6 +555,16 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.2" version = "2.12.2"
@@ -924,6 +946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive",
] ]
[[package]] [[package]]
@@ -959,6 +982,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -997,6 +1029,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" 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]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1048,9 +1090,15 @@ name = "sn-node"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"serde",
"serde_json", "serde_json",
"sn-exit",
"tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
"toml",
"tracing",
"tracing-subscriber",
] ]
[[package]] [[package]]
@@ -1191,6 +1239,7 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -1217,6 +1266,47 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -1570,6 +1660,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
+3 -1
View File
@@ -18,8 +18,10 @@ panic = "deny"
unreachable = "deny" unreachable = "deny"
[workspace.dependencies] [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" serde_json = "1"
toml = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
+29 -7
View File
@@ -6,6 +6,7 @@
//! the exit only sees which host is contacted and how many bytes flow. //! the exit only sees which host is contacted and how many bytes flow.
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future;
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -128,17 +129,38 @@ fn roll_window(
/// Accept loop. Runs until the listener errors fatally. /// Accept loop. Runs until the listener errors fatally.
pub async fn serve(config: ExitConfig, listener: TcpListener) -> Result<(), ExitError> { 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<Output = ()>,
) -> Result<(), ExitError> {
let config = Arc::new(config); let config = Arc::new(config);
let ledger = Arc::new(QuotaLedger::new()); let ledger = Arc::new(QuotaLedger::new());
tokio::pin!(shutdown);
loop { loop {
let (stream, peer) = listener.accept().await?; tokio::select! {
let config = Arc::clone(&config); () = &mut shutdown => {
let ledger = Arc::clone(&ledger); tracing::info!("exit: accept loop stopped (shutdown)");
tokio::spawn(async move { return Ok(());
if let Err(error) = handle_connection(stream, peer.ip(), config, ledger).await {
tracing::debug!(%peer, "connection ended: {error}");
} }
}); 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}");
}
});
}
}
} }
} }
+8
View File
@@ -7,9 +7,17 @@ license.workspace = true
[dependencies] [dependencies]
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
sn-exit = { path = "../sn-exit" }
[dev-dependencies]
tempfile = { workspace = true }
[lints] [lints]
workspace = true workspace = true
+11
View File
@@ -239,6 +239,17 @@ pub fn write_config(path: &Path, json: &str) -> Result<(), NodeError> {
Ok(()) Ok(())
} }
/// Parse a TUN interface setting: `auto`, `none` (headless router-only)
/// or an explicit interface name.
pub fn parse_ifname(input: &str) -> Result<IfName, NodeError> {
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)] #[cfg(test)]
mod tests { mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)] #![allow(clippy::unwrap_used, clippy::expect_used)]
+337
View File
@@ -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 `<state_dir>/node.key`, generated config at
//! `<state_dir>/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<Output = ()> + 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<Child, NodeError> {
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<Child>,
mut shutdown: Pin<&mut (impl Future<Output = ()> + 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<Child>) -> Option<String> {
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<Child>) {
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<Child>) {
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();
}
+555
View File
@@ -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 "[<yggdrasil-address>]: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),
/// `[<overlay address>]: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<QuotaPolicy>,
}
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<QuotaPolicy> {
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<PeerUri>,
listen: Vec<ListenUri>,
allowed_keys: Vec<PublicKeyHex>,
no_multicast: bool,
if_name: IfName,
exit: Option<ExitServiceSettings>,
}
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 `<state_dir>/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<ValidatedDaemonConfig, NodeError> {
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<ValidatedDaemonConfig, NodeError> {
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<String>,
state_dir: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawYggdrasilSection {
manage: Option<bool>,
binary: Option<String>,
#[serde(default)]
peers: Option<Vec<String>>,
#[serde(default)]
listen: Option<Vec<String>>,
#[serde(default)]
allow_keys: Option<Vec<String>>,
no_multicast: Option<bool>,
ifname: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawExitSection {
enabled: Option<bool>,
bind: Option<String>,
port: Option<u16>,
#[serde(default)]
allow: Option<Vec<String>>,
quota_mib: Option<u64>,
window_hours: Option<u64>,
}
fn validate(raw: RawDaemonConfig) -> Result<ValidatedDaemonConfig, NodeError> {
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<Option<ExitServiceSettings>, 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<IpAddr> = 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<T, E: std::fmt::Display>(
inputs: Option<&[String]>,
what: &str,
parse: fn(&str) -> Result<T, E>,
) -> Result<Vec<T>, 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}");
}
}
}
+21
View File
@@ -31,4 +31,25 @@ pub enum NodeError {
#[error("admin socket operation timed out")] #[error("admin socket operation timed out")]
Timeout, 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),
} }
+5
View File
@@ -10,6 +10,9 @@
//! - [`yggdrasil`]: client for the Yggdrasil admin socket (JSON over //! - [`yggdrasil`]: client for the Yggdrasil admin socket (JSON over
//! TCP/unix, default `localhost:9001`) to query node status (`getSelf`, //! TCP/unix, default `localhost:9001`) to query node status (`getSelf`,
//! `getPeers`). //! `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. //! Yggdrasil provides encrypted end-to-end routing between households.
//! It is **not** an anonymity network, and `SpiderNet` never claims to //! It is **not** an anonymity network, and `SpiderNet` never claims to
@@ -24,5 +27,7 @@
#![allow(clippy::must_use_candidate, clippy::missing_errors_doc)] #![allow(clippy::must_use_candidate, clippy::missing_errors_doc)]
pub mod config; pub mod config;
pub mod daemon;
pub mod daemon_config;
pub mod error; pub mod error;
pub mod yggdrasil; pub mod yggdrasil;
+53 -12
View File
@@ -8,9 +8,9 @@ use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use sn_node::config::{ use sn_node::config::{self, ListenUri, MulticastMode, NodeSettings, PeerUri, PublicKeyHex};
self, IfName, ListenUri, MulticastMode, NodeSettings, PeerUri, PublicKeyHex, use sn_node::daemon::{self, NO_WARRANTY_BANNER};
}; use sn_node::daemon_config;
use sn_node::yggdrasil::{self, AdminEndpoint}; use sn_node::yggdrasil::{self, AdminEndpoint};
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
@@ -77,15 +77,17 @@ enum Command {
#[arg(long, default_value_t = 3)] #[arg(long, default_value_t = 3)]
timeout_secs: u64, 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<IfName, String> { /// Validate the config and exit without starting anything.
match input.trim() { #[arg(long)]
"auto" => Ok(IfName::Auto), check: bool,
"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()),
}
} }
fn parse_uris<T>( fn parse_uris<T>(
@@ -139,6 +141,7 @@ async fn run() -> Result<(), String> {
admin, admin,
timeout_secs, timeout_secs,
} => run_status(&admin, timeout_secs).await, } => 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() .iter()
.map(|key| PublicKeyHex::parse(key).map_err(|err| err.to_string())) .map(|key| PublicKeyHex::parse(key).map_err(|err| err.to_string()))
.collect::<Result<Vec<_>, String>>()?; .collect::<Result<Vec<_>, String>>()?;
let if_name = parse_ifname(&ifname)?; let if_name = config::parse_ifname(&ifname).map_err(|err| err.to_string())?;
let settings = NodeSettings { let settings = NodeSettings {
peers, peers,
@@ -227,3 +230,41 @@ async fn run_status(admin: &str, timeout_secs: u64) -> Result<(), String> {
} }
Ok(()) 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();
}
+331
View File
@@ -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<F>(check: impl Fn() -> F) -> bool
where
F: std::future::Future<Output = bool>,
{
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<AtomicBool>) {
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<AtomicBool>, 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:?}"
);
}