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.
This commit is contained in:
460
crates/quicprochat-p2p/src/config.rs
Normal file
460
crates/quicprochat-p2p/src/config.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user