From 7c69e0de083c99e99312a574220d7069fb393d36 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:08:20 +0200 Subject: [PATCH] [feat](trx-rs): add --check-config to the server and client Validating a config meant starting the daemon and reading the first error it died on, fixing that, and repeating. Add --check-config, which loads the config through the real loader, reports every problem at once and exits 0/1: $ trx-server --check-config --config trx-rs.toml trx-rs.toml warning: unknown config key 'listen.prot' (did you mean 'listen.port'?) error: [general].log_level 'verbose' is invalid (expected one of: ...) error: [rig.access].baud must be > 0 for serial access error: [audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60 error: [listen] and rig "default" [audio] would both bind 127.0.0.1:4530 Validation grows validate_all()/validate_resolved_all() alongside the existing first-error entry points; validate() is now the first element of validate_all(). Sockets are built by one helper shared by startup and the check, so the two cannot disagree about what --listen overrides. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- src/trx-client/src/main.rs | 66 ++++++++++ src/trx-config/src/client.rs | 234 ++++++++++++++++++++--------------- src/trx-config/src/server.rs | 141 +++++++++++++-------- src/trx-server/src/main.rs | 104 ++++++++++++---- 4 files changed, 374 insertions(+), 171 deletions(-) diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index ac42fe5b..9f4f5e3b 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -53,6 +53,9 @@ struct Cli { /// Treat unknown configuration keys as a fatal error #[arg(long = "strict-config")] strict_config: bool, + /// Validate the configuration and exit without starting anything + #[arg(long = "check-config")] + check_config: bool, /// Remote server URL (host:port) #[arg(short = 'u', long = "url")] url: Option, @@ -112,6 +115,59 @@ async fn main() -> DynResult<()> { Ok(()) } +/// `--check-config`: report everything wrong with the configuration and exit. +/// +/// Unlike startup, this reports every problem it finds rather than stopping at +/// the first, so a config can be fixed in one pass. The file is checked as +/// written, without CLI overrides. +fn check_config(loaded: &trx_config::ConfigLoad) -> DynResult<()> { + match &loaded.path { + Some(path) => println!("{}", path.display()), + None => println!("(no config file found; checking built-in defaults)"), + } + + let mut warnings: Vec = loaded.unknown_keys.iter().map(|k| k.to_string()).collect(); + + let cfg = &loaded.config; + let remotes = cfg.resolved_remotes(); + if remotes.is_empty() { + warnings.push( + "no remotes configured; --url will be required at startup (add [[remotes]] entries)" + .to_string(), + ); + } + + let mut errors = cfg.validate_all(); + if !remotes.is_empty() { + errors.extend(cfg.validate_resolved_all(&remotes)); + } + + for w in &warnings { + println!(" warning: {}", w); + } + for e in &errors { + println!(" error: {}", e); + } + + if errors.is_empty() { + println!( + " OK: {} remote(s) configured: {}", + remotes.len(), + remotes + .iter() + .map(|r| r.name.as_str()) + .collect::>() + .join(", ") + ); + if !warnings.is_empty() { + println!(" {} warning(s)", warnings.len()); + } + Ok(()) + } else { + Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into()) + } +} + /// Holds the state needed after async initialization completes. struct AppState { shutdown_tx: watch::Sender, @@ -145,6 +201,16 @@ async fn async_init() -> DynResult { }; let config_path = loaded.path.clone(); + if cli.check_config { + match check_config(&loaded) { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + } + } + // Logging comes up before any config complaint so the warnings are visible. init_logging(loaded.config.general.log_level.as_deref()); diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index 8edb50f2..d6c35127 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -419,6 +419,16 @@ impl ClientConfig { /// keyed by a remote's short name; a typo used to spawn a listener routing /// to a rig that does not exist, in silence. pub fn validate_resolved(&self, remotes: &[RemoteEntry]) -> Result<(), String> { + self.validate_resolved_all(remotes) + .into_iter() + .next() + .map_or(Ok(()), Err) + } + + /// As `validate_resolved`, but reporting every problem rather than the + /// first, which is what `--check-config` wants. + pub fn validate_resolved_all(&self, remotes: &[RemoteEntry]) -> Vec { + let mut errors = Vec::new(); let names: std::collections::HashSet<&str> = remotes.iter().map(|r| r.name.as_str()).collect(); let known = || { @@ -426,33 +436,35 @@ impl ClientConfig { names.sort_unstable(); names.join(", ") }; - let check = |value: &str, path: &str| -> Result<(), String> { - if names.contains(value) { - return Ok(()); + let mut check = |value: &str, path: &str| { + if !names.contains(value) { + errors.push(format!( + "{path} refers to unknown remote \"{value}\" (configured remotes: {})", + known() + )); } - Err(format!( - "{path} refers to unknown remote \"{value}\" (configured remotes: {})", - known() - )) }; if let Some(name) = &self.frontends.http.default_rig_name { - check(name, "[frontends.http].default_rig_name")?; + check(name, "[frontends.http].default_rig_name"); } for name in self.frontends.rigctl.rig_ports.keys() { - check(name, "[frontends.rigctl].rig_ports")?; + check(name, "[frontends.rigctl].rig_ports"); } for name in self.frontends.audio.rig_urls.keys() { - check(name, "[frontends.audio].rig_urls")?; + check(name, "[frontends.audio].rig_urls"); } for name in self.frontends.audio.rig_ports.keys() { - check(name, "[frontends.audio].rig_ports")?; + check(name, "[frontends.audio].rig_ports"); } for name in self.frontends.http.decode_history_retention_min_by_rig.keys() { - check(name, "[frontends.http].decode_history_retention_min_by_rig")?; + check(name, "[frontends.http].decode_history_retention_min_by_rig"); } - check_socket_conflicts(&self.bound_sockets()) + if let Err(e) = check_socket_conflicts(&self.bound_sockets()) { + errors.push(e); + } + errors } /// Sockets the enabled frontends will bind. @@ -484,51 +496,96 @@ impl ClientConfig { sockets } + /// Validate the configuration, reporting the first problem found. pub fn validate(&self) -> Result<(), String> { - validate_log_level(self.general.log_level.as_deref())?; + self.validate_all().into_iter().next().map_or(Ok(()), Err) + } - // Validate [[remotes]] entries - { - let mut seen_names = std::collections::HashSet::new(); - for (i, entry) in self.remotes.iter().enumerate() { - if entry.name.trim().is_empty() { - return Err(format!("[[remotes]][{}].name must not be empty", i)); - } - if !seen_names.insert(&entry.name) { - return Err(format!("[[remotes]] duplicate name \"{}\"", entry.name)); - } - if entry.url.trim().is_empty() { + /// Validate the configuration, reporting every section that has a problem. + /// + /// Each section stops at its own first error, so one mistake does not hide + /// the rest of the file. + pub fn validate_all(&self) -> Vec { + let mut errors = Vec::new(); + for result in [ + validate_log_level(self.general.log_level.as_deref()), + self.validate_general(), + self.validate_remotes(), + self.validate_http_frontend(), + self.validate_rigctl_frontend(), + self.validate_audio_frontend(), + validate_tokens( + "[frontends.http_json.auth].tokens", + &self.frontends.http_json.auth.tokens, + ), + validate_http_auth(&self.frontends.http.auth), + ] { + if let Err(e) = result { + errors.push(e); + } + } + errors + } + + fn validate_general(&self) -> Result<(), String> { + if let Some(url) = &self.general.website_url { + if url.trim().is_empty() { + return Err("[general].website_url must not be empty when set".to_string()); + } + } + if let Some(name) = &self.general.website_name { + if name.trim().is_empty() { + return Err("[general].website_name must not be empty when set".to_string()); + } + } + if let Some(url) = &self.general.ais_vessel_url_base { + if url.trim().is_empty() { + return Err("[general].ais_vessel_url_base must not be empty when set".to_string()); + } + } + Ok(()) + } + + fn validate_remotes(&self) -> Result<(), String> { + let mut seen_names = std::collections::HashSet::new(); + for (i, entry) in self.remotes.iter().enumerate() { + if entry.name.trim().is_empty() { + return Err(format!("[[remotes]][{}].name must not be empty", i)); + } + if !seen_names.insert(&entry.name) { + return Err(format!("[[remotes]] duplicate name \"{}\"", entry.name)); + } + if entry.url.trim().is_empty() { + return Err(format!( + "[[remotes]][{}].url must not be empty (name \"{}\")", + i, entry.name + )); + } + if let Some(rig_id) = &entry.rig_id { + if rig_id.trim().is_empty() { return Err(format!( - "[[remotes]][{}].url must not be empty (name \"{}\")", - i, entry.name - )); - } - if let Some(rig_id) = &entry.rig_id { - if rig_id.trim().is_empty() { - return Err(format!( - "[[remotes]][{}].rig_id must not be empty when set (name \"{}\")", - i, entry.name - )); - } - } - if let Some(token) = &entry.auth.token { - if token.trim().is_empty() { - return Err(format!( - "[[remotes]][{}].auth.token must not be empty when set (name \"{}\")", - i, entry.name - )); - } - } - if entry.poll_interval_ms == 0 { - return Err(format!( - "[[remotes]][{}].poll_interval_ms must be > 0 (name \"{}\")", + "[[remotes]][{}].rig_id must not be empty when set (name \"{}\")", i, entry.name )); } } + if let Some(token) = &entry.auth.token { + if token.trim().is_empty() { + return Err(format!( + "[[remotes]][{}].auth.token must not be empty when set (name \"{}\")", + i, entry.name + )); + } + } + if entry.poll_interval_ms == 0 { + return Err(format!( + "[[remotes]][{}].poll_interval_ms must be > 0 (name \"{}\")", + i, entry.name + )); + } } - // Validate legacy [remote] (kept for backward compat) + // Legacy [remote], kept for backward compatibility. if self.remote.poll_interval_ms == 0 { return Err("[remote].poll_interval_ms must be > 0".to_string()); } @@ -547,46 +604,33 @@ impl ClientConfig { return Err("[remote.auth].token must not be empty when set".to_string()); } } - if let Some(url) = &self.general.website_url { - if url.trim().is_empty() { - return Err("[general].website_url must not be empty when set".to_string()); - } - } - if let Some(name) = &self.general.website_name { - if name.trim().is_empty() { - return Err("[general].website_name must not be empty when set".to_string()); - } - } - if let Some(url) = &self.general.ais_vessel_url_base { - if url.trim().is_empty() { - return Err("[general].ais_vessel_url_base must not be empty when set".to_string()); - } - } + Ok(()) + } - if self.frontends.http.enabled && self.frontends.http.port == 0 { + fn validate_http_frontend(&self) -> Result<(), String> { + let http = &self.frontends.http; + if http.enabled && http.port == 0 { return Err("[frontends.http].port must be > 0 when enabled".to_string()); } - if let Some(rig_id) = &self.frontends.http.default_rig_name { + if let Some(rig_id) = &http.default_rig_name { if rig_id.trim().is_empty() { return Err( "[frontends.http].default_rig_name must not be empty when set".to_string(), ); } } - if self.frontends.http.initial_map_zoom == 0 { + if http.initial_map_zoom == 0 { return Err("[frontends.http].initial_map_zoom must be > 0".to_string()); } - if self.frontends.http.spectrum_coverage_margin_hz == 0 { + if http.spectrum_coverage_margin_hz == 0 { return Err("[frontends.http].spectrum_coverage_margin_hz must be > 0".to_string()); } - if !(self.frontends.http.spectrum_usable_span_ratio > 0.0 - && self.frontends.http.spectrum_usable_span_ratio <= 1.0) - { + if !(http.spectrum_usable_span_ratio > 0.0 && http.spectrum_usable_span_ratio <= 1.0) { return Err( "[frontends.http].spectrum_usable_span_ratio must be > 0.0 and <= 1.0".to_string(), ); } - match self.frontends.http.bandplan_region.as_str() { + match http.bandplan_region.as_str() { "iaru_r1" | "iaru_r2" | "iaru_r3" => {} other => { return Err(format!( @@ -595,10 +639,10 @@ impl ClientConfig { )); } } - if self.frontends.http.decode_history_retention_min == 0 { + if http.decode_history_retention_min == 0 { return Err("[frontends.http].decode_history_retention_min must be > 0".to_string()); } - for (rig_id, minutes) in &self.frontends.http.decode_history_retention_min_by_rig { + for (rig_id, minutes) in &http.decode_history_retention_min_by_rig { if rig_id.trim().is_empty() { return Err( "[frontends.http].decode_history_retention_min_by_rig keys must not be empty" @@ -612,13 +656,18 @@ impl ClientConfig { )); } } - if self.frontends.rigctl.enabled && self.frontends.rigctl.rig_ports.is_empty() { + Ok(()) + } + + fn validate_rigctl_frontend(&self) -> Result<(), String> { + let rigctl = &self.frontends.rigctl; + if rigctl.enabled && rigctl.rig_ports.is_empty() { return Err( "[frontends.rigctl].rig_ports must contain at least one rig when enabled" .to_string(), ); } - for (rig_id, port) in &self.frontends.rigctl.rig_ports { + for (rig_id, port) in &rigctl.rig_ports { if rig_id.trim().is_empty() { return Err("[frontends.rigctl].rig_ports keys must not be empty".to_string()); } @@ -629,24 +678,26 @@ impl ClientConfig { )); } } - if let Some(url) = &self.frontends.audio.server_url { + Ok(()) + } + + fn validate_audio_frontend(&self) -> Result<(), String> { + let audio = &self.frontends.audio; + if let Some(url) = &audio.server_url { crate::url::parse_audio_url(url) .map_err(|e| format!("[frontends.audio].server_url {e}"))?; } - if self.frontends.audio.enabled - && self.frontends.audio.server_url.is_none() - && self.frontends.audio.server_port == 0 - { + if audio.enabled && audio.server_url.is_none() && audio.server_port == 0 { return Err("[frontends.audio].server_port must be > 0 when enabled".to_string()); } - for (rig_id, url) in &self.frontends.audio.rig_urls { + for (rig_id, url) in &audio.rig_urls { if rig_id.trim().is_empty() { return Err("[frontends.audio].rig_urls keys must not be empty".to_string()); } crate::url::parse_audio_url(url) .map_err(|e| format!("[frontends.audio].rig_urls[\"{rig_id}\"] {e}"))?; } - for (rig_id, port) in &self.frontends.audio.rig_ports { + for (rig_id, port) in &audio.rig_ports { if rig_id.trim().is_empty() { return Err("[frontends.audio].rig_ports keys must not be empty".to_string()); } @@ -657,26 +708,15 @@ impl ClientConfig { )); } } - if !self.frontends.audio.bridge.rx_gain.is_finite() - || self.frontends.audio.bridge.rx_gain < 0.0 - { + if !audio.bridge.rx_gain.is_finite() || audio.bridge.rx_gain < 0.0 { return Err("[frontends.audio.bridge].rx_gain must be finite and >= 0".to_string()); } - if !self.frontends.audio.bridge.tx_gain.is_finite() - || self.frontends.audio.bridge.tx_gain < 0.0 - { + if !audio.bridge.tx_gain.is_finite() || audio.bridge.tx_gain < 0.0 { return Err("[frontends.audio.bridge].tx_gain must be finite and >= 0".to_string()); } - if self.frontends.audio.bridge.bitrate_bps == 0 { + if audio.bridge.bitrate_bps == 0 { return Err("[frontends.audio.bridge].bitrate_bps must be > 0".to_string()); } - validate_tokens( - "[frontends.http_json.auth].tokens", - &self.frontends.http_json.auth.tokens, - )?; - - validate_http_auth(&self.frontends.http.auth)?; - Ok(()) } diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index b7834d62..55c86401 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -471,27 +471,43 @@ impl Default for SdrChannelConfig { } impl ServerConfig { - /// Validate the whole configuration. + /// Validate the whole configuration, reporting the first problem found. + pub fn validate(&self) -> Result<(), String> { + self.validate_all().into_iter().next().map_or(Ok(()), Err) + } + + /// Validate the whole configuration, reporting every problem. /// /// Everything that belongs to a rig is checked through `resolved_rigs()`, /// so the legacy flat `[rig]` / `[audio]` / … layout and `[[rigs]]` entries /// are held to exactly the same rules. - pub fn validate(&self) -> Result<(), String> { - validate_log_level(self.general.log_level.as_deref())?; - validate_coordinates(self.general.latitude, self.general.longitude)?; + pub fn validate_all(&self) -> Vec { + let mut errors = Vec::new(); + let multi = !self.rigs.is_empty(); - validate_tokens("[listen.auth].tokens", &self.listen.auth.tokens)?; + for result in [ + validate_log_level(self.general.log_level.as_deref()), + validate_coordinates(self.general.latitude, self.general.longitude), + validate_tokens("[listen.auth].tokens", &self.listen.auth.tokens), + self.validate_listen(), + self.validate_rig_uniqueness(), + ] { + if let Err(e) = result { + errors.push(e); + } + } + + for rig in &self.resolved_rigs() { + validate_rig_instance(&rig_prefix(multi, rig), rig, &self.general, &mut errors); + } + + errors + } + + fn validate_listen(&self) -> Result<(), String> { if self.listen.enabled && self.listen.port == 0 { return Err("[listen].port must be > 0 when listener is enabled".to_string()); } - - self.validate_rig_uniqueness()?; - - let multi = !self.rigs.is_empty(); - for rig in &self.resolved_rigs() { - validate_rig_instance(&rig_prefix(multi, rig), rig, &self.general)?; - } - Ok(()) } @@ -505,16 +521,34 @@ impl ServerConfig { rigs: &[RigInstanceConfig], sockets: &[BoundSocket], ) -> Result<(), String> { + self.validate_resolved_all(rigs, sockets) + .into_iter() + .next() + .map_or(Ok(()), Err) + } + + /// As `validate_resolved`, but reporting every problem rather than the + /// first, which is what `--check-config` wants. + pub fn validate_resolved_all( + &self, + rigs: &[RigInstanceConfig], + sockets: &[BoundSocket], + ) -> Vec { + let mut errors = Vec::new(); + // Auto-generated ids can collide with explicitly configured ones, which // only shows up after resolution. let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); for rig in rigs { if !seen.insert(rig.id.as_str()) { - return Err(format!("duplicate rig id after resolution: \"{}\"", rig.id)); + errors.push(format!("duplicate rig id after resolution: \"{}\"", rig.id)); } } - check_socket_conflicts(sockets) + if let Err(e) = check_socket_conflicts(sockets) { + errors.push(e); + } + errors } /// Check that enabled `[[rigs]]` entries do not collide with one another. @@ -686,72 +720,75 @@ fn rig_prefix(multi: bool, rig: &RigInstanceConfig) -> String { /// Validate everything that belongs to a single rig. /// /// Called once per entry of `resolved_rigs()`, which is what makes the flat -/// layout and `[[rigs]]` entries obey the same rules. +/// layout and `[[rigs]]` entries obey the same rules. Every problem is +/// reported, so one bad field does not hide the rest of the rig. fn validate_rig_instance( prefix: &str, rig: &RigInstanceConfig, general: &GeneralConfig, -) -> Result<(), String> { + errors: &mut Vec, +) { if rig.rig.initial_freq_hz == 0 { - return Err(format!("{prefix}[rig].initial_freq_hz must be > 0")); + errors.push(format!("{prefix}[rig].initial_freq_hz must be > 0")); } - validate_access(prefix, &rig.rig.access)?; + if let Err(e) = validate_access(prefix, &rig.rig.access) { + errors.push(e); + } if rig.behavior.poll_interval_ms == 0 { - return Err(format!("{prefix}[behavior].poll_interval_ms must be > 0")); + errors.push(format!("{prefix}[behavior].poll_interval_ms must be > 0")); } if rig.behavior.poll_interval_tx_ms == 0 { - return Err(format!("{prefix}[behavior].poll_interval_tx_ms must be > 0")); + errors.push(format!("{prefix}[behavior].poll_interval_tx_ms must be > 0")); } if rig.behavior.max_retries == 0 { - return Err(format!("{prefix}[behavior].max_retries must be > 0")); + errors.push(format!("{prefix}[behavior].max_retries must be > 0")); } if rig.behavior.retry_base_delay_ms == 0 { - return Err(format!("{prefix}[behavior].retry_base_delay_ms must be > 0")); + errors.push(format!("{prefix}[behavior].retry_base_delay_ms must be > 0")); } if rig.audio.enabled { if rig.audio.port == 0 { - return Err(format!("{prefix}[audio].port must be > 0 when audio is enabled")); + errors.push(format!( + "{prefix}[audio].port must be > 0 when audio is enabled" + )); } if !rig.audio.rx_enabled && !rig.audio.tx_enabled { - return Err(format!( + errors.push(format!( "{prefix}[audio] enabled but both rx_enabled and tx_enabled are false" )); } if rig.audio.sample_rate < 8_000 || rig.audio.sample_rate > 192_000 { - return Err(format!( + errors.push(format!( "{prefix}[audio].sample_rate must be in range 8000..=192000" )); } if !(1..=2).contains(&rig.audio.channels) { - return Err(format!("{prefix}[audio].channels must be 1 or 2")); + errors.push(format!("{prefix}[audio].channels must be 1 or 2")); } - match rig.audio.frame_duration_ms { - 3 | 5 | 10 | 20 | 40 | 60 => {} - _ => { - return Err(format!( - "{prefix}[audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60" - )) - } + if !matches!(rig.audio.frame_duration_ms, 3 | 5 | 10 | 20 | 40 | 60) { + errors.push(format!( + "{prefix}[audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60" + )); } if rig.audio.bitrate_bps == 0 { - return Err(format!("{prefix}[audio].bitrate_bps must be > 0")); + errors.push(format!("{prefix}[audio].bitrate_bps must be > 0")); } } if rig.pskreporter.enabled { if rig.pskreporter.host.trim().is_empty() { - return Err(format!("{prefix}[pskreporter].host must not be empty")); + errors.push(format!("{prefix}[pskreporter].host must not be empty")); } if rig.pskreporter.port == 0 { - return Err(format!("{prefix}[pskreporter].port must be > 0")); + errors.push(format!("{prefix}[pskreporter].port must be > 0")); } if rig.pskreporter.receiver_locator.is_none() && (general.latitude.is_none() || general.longitude.is_none()) { - return Err(format!( + errors.push(format!( "{prefix}[pskreporter] enabled requires either [pskreporter].receiver_locator \ or [general].latitude and [general].longitude" )); @@ -760,27 +797,33 @@ fn validate_rig_instance( if rig.aprsfi.enabled { if rig.aprsfi.host.trim().is_empty() { - return Err(format!("{prefix}[aprsfi].host must not be empty")); + errors.push(format!("{prefix}[aprsfi].host must not be empty")); } if rig.aprsfi.port == 0 { - return Err(format!("{prefix}[aprsfi].port must be > 0")); + errors.push(format!("{prefix}[aprsfi].port must be > 0")); } } if let Some(max_gain) = rig.sdr.gain.max_value { if !max_gain.is_finite() { - return Err(format!("{prefix}[sdr.gain].max_value must be finite")); - } - if max_gain < 0.0 { - return Err(format!("{prefix}[sdr.gain].max_value must be >= 0")); + errors.push(format!("{prefix}[sdr.gain].max_value must be finite")); + } else if max_gain < 0.0 { + errors.push(format!("{prefix}[sdr.gain].max_value must be >= 0")); } } - validate_sdr_squelch_config(&format!("{prefix}[sdr.squelch]"), &rig.sdr.squelch)?; - validate_sdr_nb_config(&format!("{prefix}[sdr.noise_blanker]"), &rig.sdr.noise_blanker)?; + if let Err(e) = validate_sdr_squelch_config(&format!("{prefix}[sdr.squelch]"), &rig.sdr.squelch) + { + errors.push(e); + } + if let Err(e) = + validate_sdr_nb_config(&format!("{prefix}[sdr.noise_blanker]"), &rig.sdr.noise_blanker) + { + errors.push(e); + } if rig.decode_logs.enabled { if rig.decode_logs.dir.trim().is_empty() { - return Err(format!( + errors.push(format!( "{prefix}[decode_logs].dir must not be empty when enabled" )); } @@ -789,13 +832,11 @@ fn validate_rig_instance( || rig.decode_logs.ft8_file.trim().is_empty() || rig.decode_logs.wspr_file.trim().is_empty() { - return Err(format!( + errors.push(format!( "{prefix}[decode_logs] file names must not be empty when enabled" )); } } - - Ok(()) } /// Validate the SDR pipeline of a single rig (see SDR.md §11). diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index e1257872..11c7ac1b 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -60,6 +60,9 @@ struct Cli { /// Treat unknown configuration keys as a fatal error #[arg(long = "strict-config")] strict_config: bool, + /// Validate the configuration and exit without starting anything + #[arg(long = "check-config")] + check_config: bool, /// Rig backend to use (e.g. ft817, ft450d) #[arg(short = 'r', long = "rig")] rig: Option, @@ -886,6 +889,77 @@ fn spawn_rig_audio_stack( handles } +/// Sockets this process will bind, given the config and the CLI overrides. +/// +/// `--listen` overrides the bind address of both the control listener and every +/// rig's audio listener, so the two callers of this must agree on the rules. +fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> Vec { + let mut sockets = Vec::new(); + if cfg.listen.enabled { + sockets.push(BoundSocket::new( + cli.listen.unwrap_or(cfg.listen.listen), + cli.port.unwrap_or(cfg.listen.port), + "[listen]", + )); + } + let audio_ip = cli.listen.unwrap_or(cfg.audio.listen); + for rig in rigs { + if rig.audio.enabled { + sockets.push(BoundSocket::new( + audio_ip, + rig.audio.port, + format!("rig \"{}\" [audio]", rig.id), + )); + } + } + sockets +} + +/// `--check-config`: report everything wrong with the configuration and exit. +/// +/// Unlike startup, this reports every problem it finds rather than stopping at +/// the first, so a config can be fixed in one pass. +fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad) -> DynResult<()> { + match &loaded.path { + Some(path) => println!("{}", path.display()), + None => println!("(no config file found; checking built-in defaults)"), + } + + for key in &loaded.unknown_keys { + println!(" warning: {}", key); + } + + let cfg = &loaded.config; + let rigs = cfg.resolved_rigs(); + + let mut errors = cfg.validate_all(); + errors.extend(cfg.validate_sdr()); + errors.extend(cfg.validate_resolved_all(&rigs, &bound_sockets(cli, cfg, &rigs))); + + for e in &errors { + println!(" error: {}", e); + } + + if errors.is_empty() { + println!( + " OK: {} rig(s) configured: {}", + rigs.len(), + rigs.iter().map(|r| r.id.as_str()).collect::>().join(", ") + ); + if !loaded.unknown_keys.is_empty() { + println!(" {} warning(s)", loaded.unknown_keys.len()); + } + Ok(()) + } else { + Err(format!( + "{} error(s), {} warning(s)", + errors.len(), + loaded.unknown_keys.len() + ) + .into()) + } +} + #[tokio::main] async fn main() -> DynResult<()> { let mut bootstrap_ctx = RegistrationContext::new(); @@ -905,6 +979,10 @@ async fn main() -> DynResult<()> { }; let config_path = loaded.path.clone(); + if cli.check_config { + return check_config(&cli, &loaded); + } + // Logging comes up before any config complaint so the warnings are visible. init_logging(loaded.config.general.log_level.as_deref()); @@ -990,30 +1068,8 @@ async fn main() -> DynResult<()> { // Second validation phase: now that CLI overrides have been folded in, check // the things that need the final rig list — chiefly that no two listeners // claim the same socket. - { - let mut sockets = Vec::new(); - if cfg.listen.enabled { - sockets.push(BoundSocket::new( - cli.listen.unwrap_or(cfg.listen.listen), - cli.port.unwrap_or(cfg.listen.port), - "[listen]", - )); - } - // --listen overrides the audio bind address for every rig; otherwise the - // global [audio].listen wins over the per-rig value. - let audio_ip = cli.listen.unwrap_or(cfg.audio.listen); - for rig in &resolved_rigs { - if rig.audio.enabled { - sockets.push(BoundSocket::new( - audio_ip, - rig.audio.port, - format!("rig \"{}\" [audio]", rig.id), - )); - } - } - cfg.validate_resolved(&resolved_rigs, &sockets) - .map_err(|e| format!("Invalid server configuration: {}", e))?; - } + cfg.validate_resolved(&resolved_rigs, &bound_sockets(&cli, &cfg, &resolved_rigs)) + .map_err(|e| format!("Invalid server configuration: {}", e))?; info!( "Starting trx-server with {} rig(s): {}",