diff --git a/Cargo.lock b/Cargo.lock index 203bd09..18a84b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", "phf", + "serde", ] [[package]] @@ -1284,7 +1285,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.7", ] [[package]] @@ -1614,6 +1615,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1687,6 +1697,7 @@ dependencies = [ "syslog_rfc5424", "tokio", "tokio-util", + "toml", "tracing", "tracing-subscriber", ] @@ -1927,6 +1938,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.3" @@ -1936,6 +1968,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + [[package]] name = "toml_edit" version = "0.23.7" @@ -1943,7 +1989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.7.3", "toml_parser", "winnow", ] @@ -1957,6 +2003,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index b779140..6bdbdff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,8 +26,8 @@ prometheus-http-client = { path = "prometheus-http-client", default-features = f async-trait = "0.1" chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } -chrono-tz = "0.10" -conf = "0.4" +chrono-tz = { version = "0.10", features = ["serde"] } +conf = { version = "0.4", features = ["serde"] } conf-extra = "0.1" displaydoc = "0.2" dotenvy = "0.15" @@ -46,6 +46,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" syslog_rfc5424 = "0.10" thiserror = "2" +toml = "0.8" tokio = { version = "1", features = ["macros", "net", "sync", "time"] } tokio-util = { version = "0.7", features = ["codec"] } tracing = "0.1" diff --git a/signal-gateway-bin/Cargo.toml b/signal-gateway-bin/Cargo.toml index 399b9cf..9383f87 100644 --- a/signal-gateway-bin/Cargo.toml +++ b/signal-gateway-bin/Cargo.toml @@ -33,3 +33,6 @@ tokio = { workspace = true, features = ["rt-multi-thread", "signal"] } tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } + +[dev-dependencies] +toml = { workspace = true } diff --git a/signal-gateway-bin/src/admin_http/mod.rs b/signal-gateway-bin/src/admin_http/mod.rs index ffb5999..da49d0f 100644 --- a/signal-gateway-bin/src/admin_http/mod.rs +++ b/signal-gateway-bin/src/admin_http/mod.rs @@ -12,6 +12,7 @@ use std::time::Duration; /// Configuration for the admin HTTP client #[derive(Clone, Conf, Debug)] +#[conf(serde)] pub struct AdminHttpConfig { /// URL to POST admin commands to #[conf(long, env)] diff --git a/signal-gateway-bin/src/admin_netcat/mod.rs b/signal-gateway-bin/src/admin_netcat/mod.rs index 231f812..4b4f73a 100644 --- a/signal-gateway-bin/src/admin_netcat/mod.rs +++ b/signal-gateway-bin/src/admin_netcat/mod.rs @@ -17,6 +17,7 @@ use tokio::{ /// Configuration for the admin netcat TCP client #[derive(Clone, Conf, Debug)] +#[conf(serde)] pub struct AdminNetcatConfig { /// TCP address to forward admin commands to #[conf(long, env)] diff --git a/signal-gateway-bin/src/json/mod.rs b/signal-gateway-bin/src/json/mod.rs index 65482c3..90173b7 100644 --- a/signal-gateway-bin/src/json/mod.rs +++ b/signal-gateway-bin/src/json/mod.rs @@ -26,6 +26,7 @@ use json_lines::read_json_lines_value; /// /// See [`JsonLogMessage`] for the JSON schema. #[derive(Clone, Conf, Debug)] +#[conf(serde)] pub struct JsonConfig { /// Socket address to listen for JSON log messages. /// Both UDP and TCP listeners are started on this address. diff --git a/signal-gateway-bin/src/main.rs b/signal-gateway-bin/src/main.rs index 483afeb..a496b2e 100644 --- a/signal-gateway-bin/src/main.rs +++ b/signal-gateway-bin/src/main.rs @@ -26,6 +26,7 @@ use json::JsonConfig; /// Admin message handler configuration - select how non-command messages are handled #[derive(Clone, Debug, Subcommands)] +#[conf(serde)] enum AdminHandlerCommand { /// Forward (unhandled) admin messages to a TCP endpoint (netcat-style) /// Useful if an http server would be heavy in the target process @@ -45,8 +46,10 @@ impl AdminHandlerCommand { } } +/// Top-level configuration for signal-gateway. #[derive(Conf, Debug)] -struct Config { +#[conf(serde)] +pub struct Config { /// If true, just validate config and don't start #[conf(long)] dry_run: bool, @@ -60,7 +63,7 @@ struct Config { /// Optional admin message handler (netcat or http) #[conf(subcommands)] admin_handler: Option, - #[conf(flatten)] + #[conf(flatten, serde(flatten))] gateway: GatewayConfig, } @@ -173,3 +176,84 @@ fn start_http_task(listener: TcpListener, gateway: Arc) -> tokio::task: } }) } + +#[cfg(test)] +mod tests { + use super::*; + use conf::Conf; + + #[test] + fn test_toml_config() { + let toml_config = r#" +http_listen_addr = "0.0.0.0:8080" +signal_account = "+15551234567" +signal_cli_tcp_addr = "127.0.0.1:7583" +signal_cli_retry_delay = "10s" + +[admin_safety_numbers] +"abc-123-uuid" = ["12345 67890 12345 67890 12345 67890"] +"def-456-uuid" = [] + +[syslog] +listen_addr = "0.0.0.0:1514" +sd_id = "tracing-meta@64700" + +[json] +listen_addr = "0.0.0.0:5000" + +[log_handler] +log_buffer_size = 128 +overall_limits = [{ threshold = ">= 100 / 1h" }] + +[[log_handler.route]] +alert_level = "warn" +msg_contains = "critical" + +[[log_handler.route]] +alert_level = "error" + +[[log_handler.route]] +msg_contains = "connection reset" +limits = [ + { threshold = ">= 5 / 1m" }, + { threshold = ">= 20 / 1h" }, +] +"#; + + // Parse TOML to a generic value, then use conf's builder to parse it + let doc: toml::Value = toml::from_str(toml_config).expect("Failed to parse TOML"); + let empty_env: [(&str, &str); 0] = []; + let config: Config = Config::conf_builder() + .args(["."]) + .env(empty_env) + .doc("test.toml", doc) + .try_parse() + .expect("Failed to parse config"); + + assert_eq!( + config.http_listen_addr, + "0.0.0.0:8080".parse().unwrap() + ); + assert_eq!(config.gateway.signal_account, "+15551234567"); + assert_eq!( + config.gateway.signal_cli_tcp_addr, + Some("127.0.0.1:7583".parse().unwrap()) + ); + assert_eq!( + config.gateway.signal_cli_retry_delay, + Duration::from_secs(10) + ); + assert_eq!(config.gateway.admin_safety_numbers.len(), 2); + assert!(config.gateway.admin_safety_numbers.contains_key("abc-123-uuid")); + + let syslog = config.syslog.expect("syslog should be present"); + assert_eq!(syslog.listen_addr, "0.0.0.0:1514".parse().unwrap()); + + let json = config.json.expect("json should be present"); + assert_eq!(json.listen_addr, "0.0.0.0:5000".parse().unwrap()); + + assert_eq!(config.gateway.log_handler.log_buffer_size, 128); + assert_eq!(config.gateway.log_handler.routes.len(), 3); + assert_eq!(config.gateway.log_handler.overall_limits.len(), 1); + } +} diff --git a/signal-gateway-bin/src/syslog/mod.rs b/signal-gateway-bin/src/syslog/mod.rs index dd87fd8..06ba5de 100644 --- a/signal-gateway-bin/src/syslog/mod.rs +++ b/signal-gateway-bin/src/syslog/mod.rs @@ -15,6 +15,7 @@ use tracing::{error, info, trace}; /// Configuration for the syslog listener (supports both UDP and TCP). #[derive(Clone, Conf, Debug)] +#[conf(serde)] pub struct SyslogConfig { /// Socket address to listen for syslog messages. /// Both UDP and TCP listeners are started on this address. diff --git a/signal-gateway/src/concurrent_map.rs b/signal-gateway/src/concurrent_map.rs index 54c8a95..723b047 100644 --- a/signal-gateway/src/concurrent_map.rs +++ b/signal-gateway/src/concurrent_map.rs @@ -153,12 +153,6 @@ where } } -impl std::fmt::Debug for LazyMap { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("LazyMap").finish_non_exhaustive() - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/signal-gateway/src/gateway/log_handler.rs b/signal-gateway/src/gateway/log_handler.rs index 7418ef5..01d2c69 100644 --- a/signal-gateway/src/gateway/log_handler.rs +++ b/signal-gateway/src/gateway/log_handler.rs @@ -49,9 +49,10 @@ impl fmt::Display for SuppressionReason { /// Config options related to the log handler, and what log messages it chooses to alert on. #[derive(Clone, Conf, Debug)] +#[conf(serde)] pub struct LogHandlerConfig { /// Routes for matching and rate-limiting log messages. - #[conf(long, env, value_parser = serde_json::from_str, default_value = "[]")] + #[conf(long, env, value_parser = serde_json::from_str, default_value = "[]", serde(alias = "route"))] pub routes: Vec, /// Overall rate limits applied after route checks pass. #[conf(long, env, value_parser = serde_json::from_str, default_value = "[]")] diff --git a/signal-gateway/src/gateway/mod.rs b/signal-gateway/src/gateway/mod.rs index 5030695..10f0a4e 100644 --- a/signal-gateway/src/gateway/mod.rs +++ b/signal-gateway/src/gateway/mod.rs @@ -49,6 +49,7 @@ pub use crate::rate_limiter::{Limiter, RateThreshold}; /// Configuration for the gateway. #[derive(Conf, Debug)] +#[conf(serde)] #[cfg_attr(unix, conf(one_of_fields(signal_cli_tcp_addr, signal_cli_socket_path)))] #[cfg_attr(not(unix), conf(one_of_fields(signal_cli_tcp_addr)))] pub struct GatewayConfig { @@ -62,6 +63,9 @@ pub struct GatewayConfig { /// The phone number or UUID of the Signal account to use. #[conf(long, env)] pub signal_account: String, + /// Delay before retrying connection to signal-cli after an error. + #[conf(long, env, default_value = "5s", value_parser = conf_extra::parse_duration, serde(use_value_parser))] + pub signal_cli_retry_delay: Duration, /// Admin UUIDs mapped to their safety numbers (can be empty). /// Example: `{"uuid1": ["12345...", "67890..."], "uuid2": []}` #[conf(long, env, value_parser = serde_json::from_str)] @@ -279,7 +283,7 @@ impl Gateway { if let Err(err) = self.connect_and_run(&mut alert_rx).await { error!("Error with signal-cli, reconnecting: {err}"); - tokio::time::sleep(Duration::from_secs(5)).await; + tokio::time::sleep(self.config.signal_cli_retry_delay).await; } } } diff --git a/signal-gateway/src/gateway/route.rs b/signal-gateway/src/gateway/route.rs index b1b0dab..abc9e15 100644 --- a/signal-gateway/src/gateway/route.rs +++ b/signal-gateway/src/gateway/route.rs @@ -64,13 +64,13 @@ pub struct Route { /// Rate limits applied per-origin for messages matching this route. /// Each limit specifies a filter and threshold for suppressing repeated alerts. - #[serde(default)] - pub limit: Vec, + #[serde(default, alias = "limit")] + pub limits: Vec, /// Global rate limits applied across all origins for this route. /// Each limit specifies a filter and threshold for suppressing repeated alerts. - #[serde(default)] - pub global_limit: Vec, + #[serde(default, alias = "global_limit")] + pub global_limits: Vec, } fn default_alert_level() -> Level { @@ -80,8 +80,8 @@ fn default_alert_level() -> Level { impl Route { /// Create a limiter set from this route's limit configurations. pub fn make_limiter_set(&self) -> LimiterSet { - let limits = self.limit.clone(); - let global_limiters = self.global_limit.iter().map(|l| l.make_limiter()).collect(); + let limits = self.limits.clone(); + let global_limiters = self.global_limits.iter().map(|l| l.make_limiter()).collect(); LimiterSet::new( move || limits.iter().map(|l| l.make_limiter()).collect(), global_limiters, @@ -95,8 +95,8 @@ impl Default for Route { alert_level: default_alert_level(), filter: LogFilter::default(), destination: None, - limit: Vec::new(), - global_limit: Vec::new(), + limits: Vec::new(), + global_limits: Vec::new(), } } } diff --git a/signal-gateway/src/log_format.rs b/signal-gateway/src/log_format.rs index 197b044..b404aa0 100644 --- a/signal-gateway/src/log_format.rs +++ b/signal-gateway/src/log_format.rs @@ -11,6 +11,7 @@ use tracing::error; /// Configuration for log message formatting. #[derive(Clone, Conf, Debug, Default)] +#[conf(serde)] pub struct LogFormatConfig { /// Include the module path in formatted output. #[conf(long, env)] diff --git a/signal-gateway/src/prometheus/mod.rs b/signal-gateway/src/prometheus/mod.rs index 2e184ec..c7e3de2 100644 --- a/signal-gateway/src/prometheus/mod.rs +++ b/signal-gateway/src/prometheus/mod.rs @@ -22,6 +22,7 @@ use std::{path::PathBuf, str::FromStr, time::Duration}; /// Configures our prometheus API client #[derive(Clone, Conf, Debug)] +#[conf(serde)] pub struct PrometheusConfig { /// Address of prometheus host. Should start with http and usually indicate port 9090 #[conf(long, env)] diff --git a/signal-gateway/src/prometheus/plot.rs b/signal-gateway/src/prometheus/plot.rs index b68733d..99ff51e 100644 --- a/signal-gateway/src/prometheus/plot.rs +++ b/signal-gateway/src/prometheus/plot.rs @@ -14,7 +14,7 @@ pub use prometheus_http_client::{ /// Configuration for plots generated from prometheus data #[derive(Clone, Conf, Debug)] -#[conf(at_most_one_of_fields(utc_offset, timezone))] +#[conf(serde, at_most_one_of_fields(utc_offset, timezone))] pub struct PlotConfig { /// Working directory for temporary plot files #[conf(long, env, default_value = "/tmp")] @@ -24,11 +24,11 @@ pub struct PlotConfig { age_limit: Duration, /// Fixed timezone offset for plot timestamps, e.g. "+05:30" or "-08:00". /// Mutually exclusive with --timezone. - #[conf(long, env)] + #[conf(long, env, serde(use_value_parser))] utc_offset: Option, /// Timezone name for plot timestamps, e.g. "US/Mountain" or "Europe/Paris". /// Mutually exclusive with --utc-offset. - #[conf(long, env)] + #[conf(long, env, serde(use_value_parser))] timezone: Option, /// Stroke width for plot lines #[conf(long, env, default_value = "2")]