Files
quicproquo/crates/quicprochat-p2p/src/config.rs
Christian Nennemann 024b6c91d1 feat(p2p): add production infrastructure modules
- 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.
2026-04-01 09:16:44 +02:00

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());
}
}