- error.rs: Structured error types with context for all subsystems (transport, routing, crypto, protocol, store, config) - config.rs: Runtime configuration with TOML parsing and validation - metrics.rs: Counter/gauge/histogram metrics with transport-specific tracking and JSON-serializable snapshots - rate_limit.rs: Token bucket rate limiting with per-peer tracking, duty cycle enforcement for LoRa, and backpressure control These modules provide the foundation for production deployment.
461 lines
13 KiB
Rust
461 lines
13 KiB
Rust
//! Runtime configuration for mesh networking.
|
|
//!
|
|
//! This module provides centralized configuration with sensible defaults
|
|
//! and validation. Configuration can be loaded from files, environment
|
|
//! variables, or set programmatically.
|
|
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::{ConfigError, MeshResult};
|
|
use crate::transport::CryptoMode;
|
|
|
|
/// Top-level mesh node configuration.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct MeshConfig {
|
|
/// Node identity configuration.
|
|
pub identity: IdentityConfig,
|
|
/// Announce protocol configuration.
|
|
pub announce: AnnounceConfig,
|
|
/// Routing configuration.
|
|
pub routing: RoutingConfig,
|
|
/// Store-and-forward configuration.
|
|
pub store: StoreConfig,
|
|
/// Transport configuration.
|
|
pub transport: TransportConfig,
|
|
/// Crypto configuration.
|
|
pub crypto: CryptoConfig,
|
|
/// Rate limiting configuration.
|
|
pub rate_limit: RateLimitConfig,
|
|
/// Logging configuration.
|
|
pub logging: LoggingConfig,
|
|
}
|
|
|
|
impl Default for MeshConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
identity: IdentityConfig::default(),
|
|
announce: AnnounceConfig::default(),
|
|
routing: RoutingConfig::default(),
|
|
store: StoreConfig::default(),
|
|
transport: TransportConfig::default(),
|
|
crypto: CryptoConfig::default(),
|
|
rate_limit: RateLimitConfig::default(),
|
|
logging: LoggingConfig::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MeshConfig {
|
|
/// Load configuration from a TOML file.
|
|
pub fn from_file(path: &PathBuf) -> MeshResult<Self> {
|
|
let content = std::fs::read_to_string(path).map_err(|e| {
|
|
ConfigError::Parse(format!("failed to read config file: {}", e))
|
|
})?;
|
|
Self::from_toml(&content)
|
|
}
|
|
|
|
/// Parse configuration from TOML string.
|
|
pub fn from_toml(toml: &str) -> MeshResult<Self> {
|
|
let config: Self = toml::from_str(toml).map_err(|e| {
|
|
ConfigError::Parse(format!("TOML parse error: {}", e))
|
|
})?;
|
|
config.validate()?;
|
|
Ok(config)
|
|
}
|
|
|
|
/// Serialize to TOML string.
|
|
pub fn to_toml(&self) -> MeshResult<String> {
|
|
toml::to_string_pretty(self).map_err(|e| {
|
|
ConfigError::Parse(format!("TOML serialize error: {}", e)).into()
|
|
})
|
|
}
|
|
|
|
/// Validate configuration values.
|
|
pub fn validate(&self) -> MeshResult<()> {
|
|
self.announce.validate()?;
|
|
self.routing.validate()?;
|
|
self.store.validate()?;
|
|
self.rate_limit.validate()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a minimal config for constrained devices.
|
|
pub fn constrained() -> Self {
|
|
Self {
|
|
store: StoreConfig {
|
|
max_messages: 100,
|
|
max_keypackages: 50,
|
|
..Default::default()
|
|
},
|
|
routing: RoutingConfig {
|
|
max_entries: 100,
|
|
..Default::default()
|
|
},
|
|
announce: AnnounceConfig {
|
|
interval: Duration::from_secs(1800), // 30 min
|
|
..Default::default()
|
|
},
|
|
crypto: CryptoConfig {
|
|
default_mode: CryptoMode::MlsLiteUnsigned,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Identity configuration.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct IdentityConfig {
|
|
/// Path to persist identity keypair.
|
|
pub keypair_path: Option<PathBuf>,
|
|
/// Whether to auto-generate keypair if missing.
|
|
pub auto_generate: bool,
|
|
}
|
|
|
|
impl Default for IdentityConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
keypair_path: None,
|
|
auto_generate: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Announce protocol configuration.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct AnnounceConfig {
|
|
/// Interval between periodic announcements.
|
|
#[serde(with = "humantime_serde")]
|
|
pub interval: Duration,
|
|
/// Maximum age before announce is considered stale.
|
|
#[serde(with = "humantime_serde")]
|
|
pub max_age: Duration,
|
|
/// Maximum propagation hops.
|
|
pub max_hops: u8,
|
|
/// Capabilities to advertise.
|
|
pub capabilities: u16,
|
|
/// Whether to include KeyPackage hash in announces.
|
|
pub include_keypackage: bool,
|
|
}
|
|
|
|
impl Default for AnnounceConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
interval: Duration::from_secs(600), // 10 min
|
|
max_age: Duration::from_secs(1800), // 30 min
|
|
max_hops: 8,
|
|
capabilities: 0x0003, // CAP_RELAY | CAP_STORE
|
|
include_keypackage: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AnnounceConfig {
|
|
fn validate(&self) -> MeshResult<()> {
|
|
if self.interval < Duration::from_secs(10) {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "announce.interval".to_string(),
|
|
reason: "must be at least 10 seconds".to_string(),
|
|
}.into());
|
|
}
|
|
if self.max_hops == 0 || self.max_hops > 32 {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "announce.max_hops".to_string(),
|
|
reason: "must be between 1 and 32".to_string(),
|
|
}.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Routing configuration.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct RoutingConfig {
|
|
/// Maximum routing table entries.
|
|
pub max_entries: usize,
|
|
/// Default route TTL.
|
|
#[serde(with = "humantime_serde")]
|
|
pub default_ttl: Duration,
|
|
/// How often to garbage collect expired routes.
|
|
#[serde(with = "humantime_serde")]
|
|
pub gc_interval: Duration,
|
|
}
|
|
|
|
impl Default for RoutingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_entries: 10_000,
|
|
default_ttl: Duration::from_secs(1800), // 30 min
|
|
gc_interval: Duration::from_secs(60),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RoutingConfig {
|
|
fn validate(&self) -> MeshResult<()> {
|
|
if self.max_entries == 0 {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "routing.max_entries".to_string(),
|
|
reason: "must be at least 1".to_string(),
|
|
}.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Store-and-forward configuration.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct StoreConfig {
|
|
/// Maximum messages in store.
|
|
pub max_messages: usize,
|
|
/// Maximum messages per recipient.
|
|
pub max_per_recipient: usize,
|
|
/// Maximum cached KeyPackages.
|
|
pub max_keypackages: usize,
|
|
/// Maximum KeyPackages per address.
|
|
pub max_keypackages_per_addr: usize,
|
|
/// Default message TTL.
|
|
#[serde(with = "humantime_serde")]
|
|
pub default_ttl: Duration,
|
|
/// Path for persistent storage (None = in-memory only).
|
|
pub persistence_path: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for StoreConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_messages: 10_000,
|
|
max_per_recipient: 100,
|
|
max_keypackages: 1_000,
|
|
max_keypackages_per_addr: 3,
|
|
default_ttl: Duration::from_secs(24 * 3600), // 24 hours
|
|
persistence_path: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl StoreConfig {
|
|
fn validate(&self) -> MeshResult<()> {
|
|
if self.max_messages == 0 {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "store.max_messages".to_string(),
|
|
reason: "must be at least 1".to_string(),
|
|
}.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Transport configuration.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TransportConfig {
|
|
/// Enable iroh/QUIC transport.
|
|
pub enable_iroh: bool,
|
|
/// Enable TCP transport.
|
|
pub enable_tcp: bool,
|
|
/// TCP listen address.
|
|
pub tcp_listen: Option<String>,
|
|
/// Enable LoRa transport.
|
|
pub enable_lora: bool,
|
|
/// LoRa device path (e.g., /dev/ttyUSB0).
|
|
pub lora_device: Option<String>,
|
|
/// LoRa spreading factor (7-12).
|
|
pub lora_sf: u8,
|
|
/// LoRa bandwidth in kHz.
|
|
pub lora_bw: u32,
|
|
/// Connection timeout.
|
|
#[serde(with = "humantime_serde")]
|
|
pub connect_timeout: Duration,
|
|
/// Send timeout.
|
|
#[serde(with = "humantime_serde")]
|
|
pub send_timeout: Duration,
|
|
}
|
|
|
|
impl Default for TransportConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enable_iroh: true,
|
|
enable_tcp: true,
|
|
tcp_listen: None,
|
|
enable_lora: false,
|
|
lora_device: None,
|
|
lora_sf: 10,
|
|
lora_bw: 125,
|
|
connect_timeout: Duration::from_secs(10),
|
|
send_timeout: Duration::from_secs(30),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Crypto configuration.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct CryptoConfig {
|
|
/// Default crypto mode.
|
|
pub default_mode: CryptoMode,
|
|
/// Whether to auto-upgrade to better crypto when available.
|
|
pub auto_upgrade: bool,
|
|
/// Whether to sign MLS-Lite messages.
|
|
pub mls_lite_sign: bool,
|
|
/// Enable post-quantum hybrid mode.
|
|
pub enable_pq: bool,
|
|
}
|
|
|
|
impl Default for CryptoConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
default_mode: CryptoMode::MlsClassical,
|
|
auto_upgrade: true,
|
|
mls_lite_sign: true,
|
|
enable_pq: false, // PQ is large, opt-in
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rate limiting configuration.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct RateLimitConfig {
|
|
/// Maximum announces per peer per minute.
|
|
pub announce_per_peer_per_min: u32,
|
|
/// Maximum messages per peer per minute.
|
|
pub message_per_peer_per_min: u32,
|
|
/// Maximum KeyPackage requests per minute.
|
|
pub keypackage_requests_per_min: u32,
|
|
/// LoRa duty cycle limit (0.0-1.0, e.g., 0.01 = 1%).
|
|
pub lora_duty_cycle: f32,
|
|
}
|
|
|
|
impl Default for RateLimitConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
announce_per_peer_per_min: 10,
|
|
message_per_peer_per_min: 60,
|
|
keypackage_requests_per_min: 20,
|
|
lora_duty_cycle: 0.01, // EU868 1% default
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RateLimitConfig {
|
|
fn validate(&self) -> MeshResult<()> {
|
|
if self.lora_duty_cycle < 0.0 || self.lora_duty_cycle > 1.0 {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "rate_limit.lora_duty_cycle".to_string(),
|
|
reason: "must be between 0.0 and 1.0".to_string(),
|
|
}.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Logging configuration.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct LoggingConfig {
|
|
/// Log level (trace, debug, info, warn, error).
|
|
pub level: String,
|
|
/// Whether to log to file.
|
|
pub file: Option<PathBuf>,
|
|
/// Whether to include timestamps.
|
|
pub timestamps: bool,
|
|
/// Whether to include span context.
|
|
pub spans: bool,
|
|
}
|
|
|
|
impl Default for LoggingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
level: "info".to_string(),
|
|
file: None,
|
|
timestamps: true,
|
|
spans: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Serde helper for CryptoMode
|
|
impl Serialize for CryptoMode {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
let s = match self {
|
|
CryptoMode::MlsHybrid => "mls-hybrid",
|
|
CryptoMode::MlsClassical => "mls-classical",
|
|
CryptoMode::MlsLiteSigned => "mls-lite-signed",
|
|
CryptoMode::MlsLiteUnsigned => "mls-lite-unsigned",
|
|
};
|
|
serializer.serialize_str(s)
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for CryptoMode {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let s = String::deserialize(deserializer)?;
|
|
match s.as_str() {
|
|
"mls-hybrid" => Ok(CryptoMode::MlsHybrid),
|
|
"mls-classical" => Ok(CryptoMode::MlsClassical),
|
|
"mls-lite-signed" => Ok(CryptoMode::MlsLiteSigned),
|
|
"mls-lite-unsigned" => Ok(CryptoMode::MlsLiteUnsigned),
|
|
_ => Err(serde::de::Error::unknown_variant(
|
|
&s,
|
|
&["mls-hybrid", "mls-classical", "mls-lite-signed", "mls-lite-unsigned"],
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_config_is_valid() {
|
|
let config = MeshConfig::default();
|
|
assert!(config.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn constrained_config_is_valid() {
|
|
let config = MeshConfig::constrained();
|
|
assert!(config.validate().is_ok());
|
|
assert_eq!(config.store.max_messages, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn toml_roundtrip() {
|
|
let config = MeshConfig::default();
|
|
let toml = config.to_toml().expect("serialize");
|
|
let restored = MeshConfig::from_toml(&toml).expect("parse");
|
|
assert_eq!(config.announce.max_hops, restored.announce.max_hops);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_announce_interval() {
|
|
let mut config = MeshConfig::default();
|
|
config.announce.interval = Duration::from_secs(1); // Too short
|
|
assert!(config.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_duty_cycle() {
|
|
let mut config = MeshConfig::default();
|
|
config.rate_limit.lora_duty_cycle = 2.0; // > 1.0
|
|
assert!(config.validate().is_err());
|
|
}
|
|
}
|