Merge branch 'master' into next

# Conflicts:
#	src/node/lifecycle.rs
#	testing/static/scripts/rekey-test.sh
This commit is contained in:
Johnathan Corgan
2026-04-27 16:10:11 +00:00
74 changed files with 7646 additions and 328 deletions
+194 -6
View File
@@ -18,7 +18,7 @@
//! nsec: "nsec1..."
//! ```
#[cfg(feature = "gateway")]
#[cfg(target_os = "linux")]
mod gateway;
mod node;
mod peer;
@@ -30,12 +30,12 @@ use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[cfg(feature = "gateway")]
#[cfg(target_os = "linux")]
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
pub use node::{
BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig,
TreeConfig,
NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{
@@ -337,6 +337,9 @@ pub enum ConfigError {
#[error("identity error: {0}")]
Identity(#[from] IdentityError),
#[error("invalid configuration: {0}")]
Validation(String),
}
/// Identity configuration (`node.identity.*`).
@@ -378,7 +381,7 @@ pub struct Config {
pub peers: Vec<PeerConfig>,
/// Gateway configuration (`gateway`).
#[cfg(feature = "gateway")]
#[cfg(target_os = "linux")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<GatewayConfig>,
}
@@ -500,7 +503,7 @@ impl Config {
self.peers = other.peers;
}
// Merge gateway section — higher-priority config replaces entirely
#[cfg(feature = "gateway")]
#[cfg(target_os = "linux")]
if other.gateway.is_some() {
self.gateway = other.gateway;
}
@@ -552,6 +555,69 @@ impl Config {
self.peers.iter().filter(|p| p.is_auto_connect())
}
/// Validate cross-field configuration invariants.
pub fn validate(&self) -> Result<(), ConfigError> {
let nostr = &self.node.discovery.nostr;
let any_transport_advertises_on_nostr = self
.transports
.udp
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr())
|| self
.transports
.tcp
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr())
|| self
.transports
.tor
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr());
if any_transport_advertises_on_nostr && !nostr.enabled {
return Err(ConfigError::Validation(
"at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
));
}
if self.peers.iter().any(|peer| peer.via_nostr) && !nostr.enabled {
return Err(ConfigError::Validation(
"at least one peer has `via_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
));
}
for (i, peer) in self.peers.iter().enumerate() {
if peer.addresses.is_empty() && !peer.via_nostr {
return Err(ConfigError::Validation(format!(
"peers[{i}] ({}): must specify at least one address, or set `via_nostr = true` to resolve endpoints from the Nostr advert",
peer.npub
)));
}
}
let has_nat_udp_advert = self
.transports
.udp
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
if nostr.enabled && has_nat_udp_advert {
if nostr.dm_relays.is_empty() {
return Err(ConfigError::Validation(
"NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
));
}
if nostr.stun_servers.is_empty() {
return Err(ConfigError::Validation(
"NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
));
}
}
Ok(())
}
/// Serialize this configuration to YAML.
pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
serde_yaml::to_string(self)
@@ -1122,4 +1188,126 @@ peers:
assert_eq!(peer.addresses.len(), 2);
assert!(peer.is_auto_connect());
}
#[test]
fn test_parse_nostr_discovery_config() {
let yaml = r#"
node:
discovery:
nostr:
enabled: true
advertise: false
policy: configured_only
open_discovery_max_pending: 12
app: "fips.nat.test.v1"
signal_ttl_secs: 45
advert_relays:
- "wss://relay-a.example"
dm_relays:
- "wss://relay-b.example"
stun_servers:
- "stun:stun.example.org:3478"
peers:
- npub: "npub1peer"
via_nostr: true
addresses:
- transport: udp
addr: "nat"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.node.discovery.nostr.enabled);
assert!(!config.node.discovery.nostr.advertise);
assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
assert_eq!(
config.node.discovery.nostr.policy,
NostrDiscoveryPolicy::ConfiguredOnly
);
assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
assert_eq!(
config.node.discovery.nostr.advert_relays,
vec!["wss://relay-a.example".to_string()]
);
assert_eq!(
config.node.discovery.nostr.dm_relays,
vec!["wss://relay-b.example".to_string()]
);
assert_eq!(
config.node.discovery.nostr.stun_servers,
vec!["stun:stun.example.org:3478".to_string()]
);
assert_eq!(
config.peers[0].addresses[0].addr, "nat",
"udp:nat address should parse without special-casing in YAML"
);
assert!(config.peers[0].via_nostr);
}
#[test]
fn test_validate_transport_advert_requires_nostr_enabled() {
let mut config = Config::default();
config.transports.udp = TransportInstances::Single(UdpConfig {
advertise_on_nostr: Some(true),
..Default::default()
});
config.node.discovery.nostr.enabled = false;
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("advertise_on_nostr"));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_validate_peer_via_nostr_requires_nostr_enabled() {
let mut config = Config::default();
config.peers = vec![PeerConfig {
npub: "npub1peer".to_string(),
via_nostr: true,
..Default::default()
}];
config.node.discovery.nostr.enabled = false;
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("via_nostr"));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_validate_peer_addresses_required_unless_via_nostr() {
// Empty addresses + via_nostr=false → error.
let mut config = Config::default();
config.peers = vec![PeerConfig {
npub: "npub1peer".to_string(),
..Default::default()
}];
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("at least one address"));
// Empty addresses + via_nostr=true + nostr.enabled=true → ok.
config.peers[0].via_nostr = true;
config.node.discovery.nostr.enabled = true;
config
.validate()
.expect("via_nostr should allow empty addresses");
}
#[test]
fn test_validate_nat_udp_advert_requires_relays_and_stun() {
let mut config = Config::default();
config.node.discovery.nostr.enabled = true;
config.node.discovery.nostr.dm_relays.clear();
config.transports.udp = TransportInstances::Single(UdpConfig {
advertise_on_nostr: Some(true),
public: Some(false),
..Default::default()
});
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("dm_relays"));
config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
config.node.discovery.nostr.stun_servers.clear();
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("stun_servers"));
}
}
+228 -27
View File
@@ -192,14 +192,20 @@ pub struct DiscoveryConfig {
/// Hop limit for LookupRequest flood (`node.discovery.ttl`).
#[serde(default = "DiscoveryConfig::default_ttl")]
pub ttl: u8,
/// Lookup completion timeout in seconds (`node.discovery.timeout_secs`).
#[serde(default = "DiscoveryConfig::default_timeout_secs")]
pub timeout_secs: u64,
/// Per-attempt timeouts in seconds (`node.discovery.attempt_timeouts_secs`).
/// Each entry is the time to wait for a response before sending the next
/// LookupRequest (with a fresh request_id). Sequence length determines the
/// total number of attempts before declaring the destination unreachable.
/// Default `[1, 2, 4, 8]` gives 4 attempts and a 15s total budget.
#[serde(default = "DiscoveryConfig::default_attempt_timeouts_secs")]
pub attempt_timeouts_secs: Vec<u64>,
/// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`).
#[serde(default = "DiscoveryConfig::default_recent_expiry_secs")]
pub recent_expiry_secs: u64,
/// Base backoff after first lookup failure in seconds (`node.discovery.backoff_base_secs`).
/// Doubles per consecutive failure up to `backoff_max_secs`.
/// Base backoff after lookup failure in seconds (`node.discovery.backoff_base_secs`).
/// Doubles per consecutive failure up to `backoff_max_secs`. Defaults to 0
/// (no post-failure suppression); the per-attempt sequence in
/// `attempt_timeouts_secs` provides the only retry pacing.
#[serde(default = "DiscoveryConfig::default_backoff_base_secs")]
pub backoff_base_secs: u64,
/// Maximum backoff cap in seconds (`node.discovery.backoff_max_secs`).
@@ -210,28 +216,21 @@ pub struct DiscoveryConfig {
/// Defense-in-depth against misbehaving nodes.
#[serde(default = "DiscoveryConfig::default_forward_min_interval_secs")]
pub forward_min_interval_secs: u64,
/// Retry interval within the timeout window in seconds
/// (`node.discovery.retry_interval_secs`).
/// After this interval without a response, resend the lookup.
#[serde(default = "DiscoveryConfig::default_retry_interval_secs")]
pub retry_interval_secs: u64,
/// Maximum attempts per lookup (`node.discovery.max_attempts`).
/// 1 = no retry, 2 = one retry, etc.
#[serde(default = "DiscoveryConfig::default_max_attempts")]
pub max_attempts: u8,
/// Nostr-mediated overlay endpoint discovery.
#[serde(default = "DiscoveryConfig::default_nostr")]
pub nostr: NostrDiscoveryConfig,
}
impl Default for DiscoveryConfig {
fn default() -> Self {
Self {
ttl: 64,
timeout_secs: 10,
attempt_timeouts_secs: vec![1, 2, 4, 8],
recent_expiry_secs: 10,
backoff_base_secs: 30,
backoff_max_secs: 300,
backoff_base_secs: 0,
backoff_max_secs: 0,
forward_min_interval_secs: 2,
retry_interval_secs: 5,
max_attempts: 2,
nostr: NostrDiscoveryConfig::default(),
}
}
}
@@ -240,26 +239,228 @@ impl DiscoveryConfig {
fn default_ttl() -> u8 {
64
}
fn default_timeout_secs() -> u64 {
10
fn default_attempt_timeouts_secs() -> Vec<u64> {
vec![1, 2, 4, 8]
}
fn default_recent_expiry_secs() -> u64 {
10
}
fn default_backoff_base_secs() -> u64 {
30
0
}
fn default_backoff_max_secs() -> u64 {
300
0
}
fn default_forward_min_interval_secs() -> u64 {
2
}
fn default_retry_interval_secs() -> u64 {
5
fn default_nostr() -> NostrDiscoveryConfig {
NostrDiscoveryConfig::default()
}
fn default_max_attempts() -> u8 {
2
}
/// Nostr advert discovery policy.
///
/// Controls how overlay endpoint adverts are consumed:
/// - `disabled`: ignore advert-derived endpoints for all peers
/// - `configured_only`: allow advert fallback only for configured peers with
/// `peers[].via_nostr = true`
/// - `open`: also consider adverts for non-configured peers
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NostrDiscoveryPolicy {
Disabled,
#[default]
ConfiguredOnly,
Open,
}
/// Nostr-mediated overlay endpoint discovery (`node.discovery.nostr.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NostrDiscoveryConfig {
/// Enable Nostr-signaled traversal bootstrap.
#[serde(default)]
pub enabled: bool,
/// Publish service advertisements so remote peers can bootstrap inbound.
#[serde(default = "NostrDiscoveryConfig::default_advertise")]
pub advertise: bool,
/// Relay URLs used for service advertisements.
#[serde(default = "NostrDiscoveryConfig::default_advert_relays")]
pub advert_relays: Vec<String>,
/// Relay URLs used for encrypted signaling events.
#[serde(default = "NostrDiscoveryConfig::default_dm_relays")]
pub dm_relays: Vec<String>,
/// STUN servers used for local reflexive address discovery.
/// Outbound observation uses only this local list; peer-advertised STUN
/// values are informational and are not treated as egress targets.
#[serde(default = "NostrDiscoveryConfig::default_stun_servers")]
pub stun_servers: Vec<String>,
/// Whether to advertise local (RFC 1918 / ULA) interface addresses as
/// host candidates in the traversal offer.
///
/// Off by default: in most deployments the relevant peers are not on the
/// same broadcast domain, and sharing private host candidates causes
/// misleading punch successes when an asymmetric L3 path (corporate VPN,
/// Tailscale subnet route, overlapping address space, etc.) makes a
/// peer's private IP one-way reachable from this node. Enable only when
/// peers are on the same physical LAN and same-LAN punching is wanted.
#[serde(default)]
pub share_local_candidates: bool,
/// Traversal application namespace and advert identifier suffix.
#[serde(default = "NostrDiscoveryConfig::default_app")]
pub app: String,
/// Signaling TTL in seconds.
#[serde(default = "NostrDiscoveryConfig::default_signal_ttl_secs")]
pub signal_ttl_secs: u64,
/// Policy for advert-derived endpoint discovery.
#[serde(default)]
pub policy: NostrDiscoveryPolicy,
/// Max number of open-discovery peers queued for outbound retry/connection
/// at once. Prevents unbounded queue growth from ambient advert traffic.
#[serde(default = "NostrDiscoveryConfig::default_open_discovery_max_pending")]
pub open_discovery_max_pending: usize,
/// Max concurrent inbound traversal offers processed at once.
/// Acts as a rate limit against offer spam from relays.
#[serde(default = "NostrDiscoveryConfig::default_max_concurrent_incoming_offers")]
pub max_concurrent_incoming_offers: usize,
/// Max cached overlay adverts retained from relay traffic.
/// Bounds memory under ambient advert volume.
#[serde(default = "NostrDiscoveryConfig::default_advert_cache_max_entries")]
pub advert_cache_max_entries: usize,
/// Max seen-session IDs retained for replay detection.
/// Oldest entries are evicted when the cap is exceeded.
#[serde(default = "NostrDiscoveryConfig::default_seen_sessions_max_entries")]
pub seen_sessions_max_entries: usize,
/// Overall punch attempt timeout in seconds.
#[serde(default = "NostrDiscoveryConfig::default_attempt_timeout_secs")]
pub attempt_timeout_secs: u64,
/// Replay tracking retention window in seconds.
#[serde(default = "NostrDiscoveryConfig::default_replay_window_secs")]
pub replay_window_secs: u64,
/// Delay before punch traffic starts.
#[serde(default = "NostrDiscoveryConfig::default_punch_start_delay_ms")]
pub punch_start_delay_ms: u64,
/// Interval between punch packets.
#[serde(default = "NostrDiscoveryConfig::default_punch_interval_ms")]
pub punch_interval_ms: u64,
/// How long to keep punching before failure.
#[serde(default = "NostrDiscoveryConfig::default_punch_duration_ms")]
pub punch_duration_ms: u64,
/// Advert TTL in seconds.
#[serde(default = "NostrDiscoveryConfig::default_advert_ttl_secs")]
pub advert_ttl_secs: u64,
/// How often adverts are refreshed in seconds.
#[serde(default = "NostrDiscoveryConfig::default_advert_refresh_secs")]
pub advert_refresh_secs: u64,
}
impl Default for NostrDiscoveryConfig {
fn default() -> Self {
Self {
enabled: false,
advertise: Self::default_advertise(),
advert_relays: Self::default_advert_relays(),
dm_relays: Self::default_dm_relays(),
stun_servers: Self::default_stun_servers(),
share_local_candidates: false,
app: Self::default_app(),
signal_ttl_secs: Self::default_signal_ttl_secs(),
policy: NostrDiscoveryPolicy::default(),
open_discovery_max_pending: Self::default_open_discovery_max_pending(),
max_concurrent_incoming_offers: Self::default_max_concurrent_incoming_offers(),
advert_cache_max_entries: Self::default_advert_cache_max_entries(),
seen_sessions_max_entries: Self::default_seen_sessions_max_entries(),
attempt_timeout_secs: Self::default_attempt_timeout_secs(),
replay_window_secs: Self::default_replay_window_secs(),
punch_start_delay_ms: Self::default_punch_start_delay_ms(),
punch_interval_ms: Self::default_punch_interval_ms(),
punch_duration_ms: Self::default_punch_duration_ms(),
advert_ttl_secs: Self::default_advert_ttl_secs(),
advert_refresh_secs: Self::default_advert_refresh_secs(),
}
}
}
impl NostrDiscoveryConfig {
fn default_advertise() -> bool {
true
}
fn default_advert_relays() -> Vec<String> {
vec![
"wss://relay.damus.io".to_string(),
"wss://nos.lol".to_string(),
"wss://offchain.pub".to_string(),
]
}
fn default_dm_relays() -> Vec<String> {
vec![
"wss://relay.damus.io".to_string(),
"wss://nos.lol".to_string(),
"wss://offchain.pub".to_string(),
]
}
fn default_stun_servers() -> Vec<String> {
vec![
"stun:stun.l.google.com:19302".to_string(),
"stun:stun.cloudflare.com:3478".to_string(),
"stun:global.stun.twilio.com:3478".to_string(),
]
}
fn default_app() -> String {
"fips-overlay-v1".to_string()
}
fn default_signal_ttl_secs() -> u64 {
120
}
fn default_open_discovery_max_pending() -> usize {
64
}
fn default_max_concurrent_incoming_offers() -> usize {
16
}
fn default_advert_cache_max_entries() -> usize {
2048
}
fn default_seen_sessions_max_entries() -> usize {
2048
}
fn default_attempt_timeout_secs() -> u64 {
10
}
fn default_replay_window_secs() -> u64 {
300
}
fn default_punch_start_delay_ms() -> u64 {
2_000
}
fn default_punch_interval_ms() -> u64 {
200
}
fn default_punch_duration_ms() -> u64 {
10_000
}
fn default_advert_ttl_secs() -> u64 {
3_600
}
fn default_advert_refresh_secs() -> u64 {
1_800
}
}
+14 -1
View File
@@ -94,7 +94,11 @@ pub struct PeerConfig {
pub alias: Option<String>,
/// Transport addresses for reaching this peer.
/// At least one address is required.
///
/// At least one address is required unless `via_nostr` is `true`,
/// in which case the address list may be empty and endpoints are
/// resolved from the peer's Nostr advert at dial time.
#[serde(default)]
pub addresses: Vec<PeerAddress>,
/// Connection policy for this peer.
@@ -106,6 +110,13 @@ pub struct PeerConfig {
/// backoff after MMP removes this peer due to liveness timeout.
#[serde(default = "default_auto_reconnect")]
pub auto_reconnect: bool,
/// Whether to append Nostr-advertised endpoints when dialing this peer.
///
/// Static addresses are still attempted first; advert-derived endpoints are
/// appended as fallback candidates.
#[serde(default)]
pub via_nostr: bool,
}
impl Default for PeerConfig {
@@ -116,6 +127,7 @@ impl Default for PeerConfig {
addresses: Vec::new(),
connect_policy: ConnectPolicy::default(),
auto_reconnect: default_auto_reconnect(),
via_nostr: false,
}
}
}
@@ -133,6 +145,7 @@ impl PeerConfig {
addresses: vec![PeerAddress::new(transport, addr)],
connect_policy: ConnectPolicy::default(),
auto_reconnect: default_auto_reconnect(),
via_nostr: false,
}
}
+43
View File
@@ -38,6 +38,19 @@ pub struct UdpConfig {
/// UDP send buffer size in bytes (`send_buf_size`). Defaults to 2 MB.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send_buf_size: Option<usize>,
/// Whether this transport should be advertised on Nostr overlay discovery.
/// Default: false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advertise_on_nostr: Option<bool>,
/// Whether UDP should be advertised as directly reachable (`host:port`) on
/// Nostr. When false and advertised, UDP is emitted as `addr: "nat"` to
/// trigger rendezvous traversal.
///
/// Default: false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public: Option<bool>,
}
impl UdpConfig {
@@ -60,6 +73,16 @@ impl UdpConfig {
pub fn send_buf_size(&self) -> usize {
self.send_buf_size.unwrap_or(DEFAULT_UDP_SEND_BUF)
}
/// Whether this UDP transport should be advertised on Nostr discovery.
pub fn advertise_on_nostr(&self) -> bool {
self.advertise_on_nostr.unwrap_or(false)
}
/// Whether this UDP transport should be advertised as directly reachable.
pub fn is_public(&self) -> bool {
self.public.unwrap_or(false)
}
}
/// Transport instances - either a single config or named instances.
@@ -293,6 +316,11 @@ pub struct TcpConfig {
/// Maximum simultaneous inbound connections. Defaults to 256.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_inbound_connections: Option<usize>,
/// Whether this transport should be advertised on Nostr overlay discovery.
/// Default: false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advertise_on_nostr: Option<bool>,
}
impl TcpConfig {
@@ -332,6 +360,11 @@ impl TcpConfig {
self.max_inbound_connections
.unwrap_or(DEFAULT_TCP_MAX_INBOUND)
}
/// Whether this TCP transport should be advertised on Nostr discovery.
pub fn advertise_on_nostr(&self) -> bool {
self.advertise_on_nostr.unwrap_or(false)
}
}
// ============================================================================
@@ -420,6 +453,11 @@ pub struct TorConfig {
/// in torrc; fips reads the .onion hostname from a file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory_service: Option<DirectoryServiceConfig>,
/// Whether this transport should be advertised on Nostr overlay discovery.
/// Default: false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advertise_on_nostr: Option<bool>,
}
/// Directory-mode onion service configuration.
@@ -507,6 +545,11 @@ impl TorConfig {
self.max_inbound_connections
.unwrap_or(DEFAULT_TOR_MAX_INBOUND)
}
/// Whether this Tor transport should be advertised on Nostr discovery.
pub fn advertise_on_nostr(&self) -> bool {
self.advertise_on_nostr.unwrap_or(false)
}
}
// ============================================================================