diff --git a/src/config/defaults.rs b/src/config/defaults.rs index 9851216..3ba146c 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -114,6 +114,11 @@ pub(crate) fn default_api_minimal_runtime_cache_ttl_ms() -> u64 { 1000 } +pub(crate) fn default_api_runtime_edge_enabled() -> bool { false } +pub(crate) fn default_api_runtime_edge_cache_ttl_ms() -> u64 { 1000 } +pub(crate) fn default_api_runtime_edge_top_n() -> usize { 10 } +pub(crate) fn default_api_runtime_edge_events_capacity() -> usize { 256 } + pub(crate) fn default_proxy_protocol_header_timeout_ms() -> u64 { 500 } diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 97d5e4e..c39cafa 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -312,6 +312,12 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b || old.server.api.minimal_runtime_enabled != new.server.api.minimal_runtime_enabled || old.server.api.minimal_runtime_cache_ttl_ms != new.server.api.minimal_runtime_cache_ttl_ms + || old.server.api.runtime_edge_enabled != new.server.api.runtime_edge_enabled + || old.server.api.runtime_edge_cache_ttl_ms + != new.server.api.runtime_edge_cache_ttl_ms + || old.server.api.runtime_edge_top_n != new.server.api.runtime_edge_top_n + || old.server.api.runtime_edge_events_capacity + != new.server.api.runtime_edge_events_capacity || old.server.api.read_only != new.server.api.read_only { warned = true; diff --git a/src/config/load.rs b/src/config/load.rs index 8fec710..6ce7b65 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -462,6 +462,24 @@ impl ProxyConfig { )); } + if config.server.api.runtime_edge_cache_ttl_ms > 60_000 { + return Err(ProxyError::Config( + "server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]".to_string(), + )); + } + + if !(1..=1000).contains(&config.server.api.runtime_edge_top_n) { + return Err(ProxyError::Config( + "server.api.runtime_edge_top_n must be within [1, 1000]".to_string(), + )); + } + + if !(16..=4096).contains(&config.server.api.runtime_edge_events_capacity) { + return Err(ProxyError::Config( + "server.api.runtime_edge_events_capacity must be within [16, 4096]".to_string(), + )); + } + if config.server.api.listen.parse::().is_err() { return Err(ProxyError::Config( "server.api.listen must be in IP:PORT format".to_string(), @@ -802,6 +820,22 @@ mod tests { cfg.server.api.minimal_runtime_cache_ttl_ms, default_api_minimal_runtime_cache_ttl_ms() ); + assert_eq!( + cfg.server.api.runtime_edge_enabled, + default_api_runtime_edge_enabled() + ); + assert_eq!( + cfg.server.api.runtime_edge_cache_ttl_ms, + default_api_runtime_edge_cache_ttl_ms() + ); + assert_eq!( + cfg.server.api.runtime_edge_top_n, + default_api_runtime_edge_top_n() + ); + assert_eq!( + cfg.server.api.runtime_edge_events_capacity, + default_api_runtime_edge_events_capacity() + ); assert_eq!(cfg.access.users, default_access_users()); assert_eq!( cfg.access.user_max_unique_ips_mode, @@ -918,6 +952,22 @@ mod tests { server.api.minimal_runtime_cache_ttl_ms, default_api_minimal_runtime_cache_ttl_ms() ); + assert_eq!( + server.api.runtime_edge_enabled, + default_api_runtime_edge_enabled() + ); + assert_eq!( + server.api.runtime_edge_cache_ttl_ms, + default_api_runtime_edge_cache_ttl_ms() + ); + assert_eq!( + server.api.runtime_edge_top_n, + default_api_runtime_edge_top_n() + ); + assert_eq!( + server.api.runtime_edge_events_capacity, + default_api_runtime_edge_events_capacity() + ); let access = AccessConfig::default(); assert_eq!(access.users, default_access_users()); @@ -1565,6 +1615,72 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + runtime_edge_cache_ttl_ms = 70000 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_runtime_edge_cache_ttl_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]")); + let _ = std::fs::remove_file(path); + } + + #[test] + fn api_runtime_edge_top_n_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + runtime_edge_top_n = 0 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_runtime_edge_top_n_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.runtime_edge_top_n must be within [1, 1000]")); + let _ = std::fs::remove_file(path); + } + + #[test] + fn api_runtime_edge_events_capacity_out_of_range_is_rejected() { + let toml = r#" + [server.api] + enabled = true + listen = "127.0.0.1:9091" + runtime_edge_events_capacity = 8 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_api_runtime_edge_events_capacity_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + assert!(err.contains("server.api.runtime_edge_events_capacity must be within [16, 4096]")); + let _ = std::fs::remove_file(path); + } + #[test] fn force_close_bumped_when_below_drain_ttl() { let toml = r#" diff --git a/src/config/types.rs b/src/config/types.rs index be238d3..4a33b7c 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -918,6 +918,22 @@ pub struct ApiConfig { #[serde(default = "default_api_minimal_runtime_cache_ttl_ms")] pub minimal_runtime_cache_ttl_ms: u64, + /// Enables runtime edge endpoints with optional cached aggregation. + #[serde(default = "default_api_runtime_edge_enabled")] + pub runtime_edge_enabled: bool, + + /// Cache TTL for runtime edge aggregation payloads in milliseconds. + #[serde(default = "default_api_runtime_edge_cache_ttl_ms")] + pub runtime_edge_cache_ttl_ms: u64, + + /// Top-N limit for edge connection leaderboard payloads. + #[serde(default = "default_api_runtime_edge_top_n")] + pub runtime_edge_top_n: usize, + + /// Ring-buffer capacity for runtime edge control-plane events. + #[serde(default = "default_api_runtime_edge_events_capacity")] + pub runtime_edge_events_capacity: usize, + /// Read-only mode: mutating endpoints are rejected. #[serde(default)] pub read_only: bool, @@ -933,6 +949,10 @@ impl Default for ApiConfig { request_body_limit_bytes: default_api_request_body_limit_bytes(), minimal_runtime_enabled: default_api_minimal_runtime_enabled(), minimal_runtime_cache_ttl_ms: default_api_minimal_runtime_cache_ttl_ms(), + runtime_edge_enabled: default_api_runtime_edge_enabled(), + runtime_edge_cache_ttl_ms: default_api_runtime_edge_cache_ttl_ms(), + runtime_edge_top_n: default_api_runtime_edge_top_n(), + runtime_edge_events_capacity: default_api_runtime_edge_events_capacity(), read_only: false, } }