From da58a004fe80edc7a97975addef6402418aa985c Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 20:44:42 +0200 Subject: [PATCH 01/13] [refactor](trx-config): extract client/server config into a shared crate The setup wizard, the server and the client each carried their own idea of what a valid config looks like: trx-configurator validated with hand-written toml_edit key lists while the binaries validated with serde plus their own validate(). Nothing kept the three in sync. Move ServerConfig, ClientConfig, the section loader, the shared validators and the endpoint-URL parsing into a new trx-config crate that all three depend on, so there is one definition of the config to drift from. The binaries keep a thin crate::config re-export so their internal paths are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- Cargo.lock | 21 +- Cargo.toml | 1 + src/trx-app/Cargo.toml | 4 - src/trx-app/src/lib.rs | 8 +- src/trx-client/Cargo.toml | 1 + src/trx-client/src/config.rs | 1219 +------------ src/trx-client/src/remote_client.rs | 102 +- src/trx-config/Cargo.toml | 19 + src/trx-config/src/client.rs | 1221 +++++++++++++ .../src/config.rs => trx-config/src/file.rs} | 0 src/trx-config/src/lib.rs | 22 + src/trx-config/src/server.rs | 1525 +++++++++++++++++ .../src/shared.rs} | 0 src/trx-config/src/url.rs | 173 ++ src/trx-configurator/Cargo.toml | 1 + src/trx-server/Cargo.toml | 1 + src/trx-server/src/config.rs | 1523 +--------------- 17 files changed, 2999 insertions(+), 2842 deletions(-) create mode 100644 src/trx-config/Cargo.toml create mode 100644 src/trx-config/src/client.rs rename src/{trx-app/src/config.rs => trx-config/src/file.rs} (100%) create mode 100644 src/trx-config/src/lib.rs create mode 100644 src/trx-config/src/server.rs rename src/{trx-app/src/shared_config.rs => trx-config/src/shared.rs} (100%) create mode 100644 src/trx-config/src/url.rs diff --git a/Cargo.lock b/Cargo.lock index 1d7d099b..17174b46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,10 +3031,6 @@ dependencies = [ name = "trx-app" version = "0.1.0" dependencies = [ - "dirs", - "serde", - "thiserror 2.0.18", - "toml", "tracing", "tracing-subscriber", ] @@ -3115,6 +3111,7 @@ dependencies = [ "toml", "tracing", "trx-app", + "trx-config", "trx-core", "trx-frontend", "trx-frontend-http", @@ -3124,6 +3121,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "trx-config" +version = "0.1.0" +dependencies = [ + "dirs", + "serde", + "thiserror 2.0.18", + "toml", + "tracing", + "trx-core", + "trx-decode-log", + "trx-reporting", +] + [[package]] name = "trx-configurator" version = "0.1.0" @@ -3133,6 +3144,7 @@ dependencies = [ "tempfile", "tokio-serial", "toml_edit 0.22.27", + "trx-config", ] [[package]] @@ -3294,6 +3306,7 @@ dependencies = [ "trx-app", "trx-aprs", "trx-backend", + "trx-config", "trx-core", "trx-cw", "trx-decode-log", diff --git a/Cargo.toml b/Cargo.toml index 22e62b6c..18246b51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "src/trx-core", "src/trx-protocol", "src/trx-app", + "src/trx-config", "src/trx-reporting", "src/trx-server", "src/trx-server/trx-backend", diff --git a/src/trx-app/Cargo.toml b/src/trx-app/Cargo.toml index 917dde4d..7f68abe6 100644 --- a/src/trx-app/Cargo.toml +++ b/src/trx-app/Cargo.toml @@ -9,9 +9,5 @@ edition = "2021" license = "GPL-2.0-or-later" [dependencies] -serde = { workspace = true } -toml = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -dirs = "6" -thiserror = "2" diff --git a/src/trx-app/src/lib.rs b/src/trx-app/src/lib.rs index fcf9fc96..6193513a 100644 --- a/src/trx-app/src/lib.rs +++ b/src/trx-app/src/lib.rs @@ -2,12 +2,12 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -pub mod config; +//! Shared application helpers. +//! +//! Configuration types and their loader live in the `trx-config` crate. + pub mod logging; -pub mod shared_config; pub mod util; -pub use config::{ConfigError, ConfigFile}; pub use logging::init_logging; -pub use shared_config::{validate_log_level, validate_tokens}; pub use util::normalize_name; diff --git a/src/trx-client/Cargo.toml b/src/trx-client/Cargo.toml index 2cf591c2..b6b831f8 100644 --- a/src/trx-client/Cargo.toml +++ b/src/trx-client/Cargo.toml @@ -21,6 +21,7 @@ uuid = { workspace = true } cpal = "0.15" opus = "0.3" trx-app = { path = "../trx-app" } +trx-config = { path = "../trx-config" } trx-core = { path = "../trx-core" } trx-protocol = { path = "../trx-protocol" } trx-frontend = { path = "trx-frontend" } diff --git a/src/trx-client/src/config.rs b/src/trx-client/src/config.rs index 9e7daf10..4ee139fa 100644 --- a/src/trx-client/src/config.rs +++ b/src/trx-client/src/config.rs @@ -2,1219 +2,10 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -//! Configuration file support for trx-client. +//! Client configuration types. //! -//! Config is loaded from the `[trx-client]` section of `trx-rs.toml`. -//! Default search order: -//! 1. Path specified via `--config` CLI argument -//! 2. `./trx-rs.toml` -//! 3. `~/.config/trx-rs/trx-rs.toml` -//! 4. `/etc/trx-rs/trx-rs.toml` +//! The definitions live in the shared `trx-config` crate so that +//! `trx-configurator` checks a config with exactly the same code the client +//! loads it with. This module re-exports them under the binary's own path. -use std::collections::HashMap; -use std::net::IpAddr; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; -use trx_app::{validate_log_level, validate_tokens, ConfigError, ConfigFile}; - -/// Top-level client configuration structure. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct ClientConfig { - /// General settings - pub general: GeneralConfig, - /// Legacy single remote connection settings. - /// Kept for backward compatibility; prefer `[[remotes]]`. - pub remote: RemoteConfig, - /// Named remote connections (one per short-name / rig). - /// Each entry maps a user-chosen short name to a (server, rig_id) pair. - pub remotes: Vec, - /// Frontend configurations - pub frontends: FrontendsConfig, -} - -/// General application settings. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct GeneralConfig { - /// Callsign or owner label to display in frontends - pub callsign: Option, - /// Optional website URL to use as the web UI header title link. - pub website_url: Option, - /// Optional website name to use as the web UI header title label. - pub website_name: Option, - /// Optional base URL used to link AIS vessel names as ``. - pub ais_vessel_url_base: Option, - /// Log level (trace, debug, info, warn, error) - pub log_level: Option, -} - -impl Default for GeneralConfig { - fn default() -> Self { - Self { - callsign: Some("N0CALL".to_string()), - website_url: None, - website_name: None, - ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()), - log_level: None, - } - } -} - -/// Remote connection configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct RemoteConfig { - /// Remote URL (host:port or tcp://host:port). - pub url: Option, - /// Optional target rig ID on the remote multi-rig server. - pub rig_id: Option, - /// Remote auth settings. - pub auth: RemoteAuthConfig, - /// Poll interval in milliseconds. - pub poll_interval_ms: u64, -} - -impl Default for RemoteConfig { - fn default() -> Self { - Self { - url: None, - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - } - } -} - -/// Authentication settings for remote connection. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct RemoteAuthConfig { - /// Bearer token to send with JSON commands. - pub token: Option, -} - -/// A named remote connection entry. -/// -/// Each entry maps a user-chosen **short name** to a `(server, rig_id)` pair. -/// The short name is used as the identifier throughout frontends (HTTP rig -/// picker, rigctl ports, audio routing, etc.) instead of the server-scoped -/// `rig_id`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RemoteEntry { - /// Short name used to identify this remote in frontends and config. - pub name: String, - /// Remote server URL (`host:port` or `tcp://host:port`). - pub url: String, - /// Optional target rig ID on a multi-rig server. - /// When omitted, the server's default (or only) rig is used. - pub rig_id: Option, - /// Authentication settings. - #[serde(default)] - pub auth: RemoteAuthConfig, - /// Poll interval in milliseconds. Defaults to 750. - #[serde(default = "default_poll_interval_ms")] - pub poll_interval_ms: u64, -} - -fn default_poll_interval_ms() -> u64 { - 750 -} - -/// Frontend configurations. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct FrontendsConfig { - /// HTTP frontend settings - pub http: HttpFrontendConfig, - /// rigctl frontend settings - pub rigctl: RigctlFrontendConfig, - /// JSON TCP frontend settings - pub http_json: HttpJsonFrontendConfig, - /// Audio streaming settings - pub audio: AudioClientConfig, -} - -/// Audio streaming client configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct AudioClientConfig { - /// Whether audio streaming is enabled - pub enabled: bool, - /// Optional exact audio TCP URL override applied to all remotes. - /// When set, this takes precedence over `server_port`. - pub server_url: Option, - /// Optional per-rig audio URL overrides keyed by remote short name. - /// These take precedence over `server_url`, server-advertised ports, and - /// the legacy `rig_ports` map. - pub rig_urls: HashMap, - /// Legacy audio TCP port fallback on the remote server when no URL override - /// is configured and the server does not advertise a per-rig audio port. - pub server_port: u16, - /// Legacy per-rig audio port overrides for multi-rig servers. - /// Prefer `rig_urls` when the audio endpoint differs by host as well. - pub rig_ports: HashMap, - /// Local audio bridge (virtual device integration) - pub bridge: AudioBridgeConfig, -} - -impl Default for AudioClientConfig { - fn default() -> Self { - Self { - enabled: true, - server_url: None, - rig_urls: HashMap::new(), - server_port: 4531, - rig_ports: HashMap::new(), - bridge: AudioBridgeConfig::default(), - } - } -} - -/// Local audio bridge configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct AudioBridgeConfig { - /// Enable local cpal bridge between remote stream and local audio devices. - pub enabled: bool, - /// Local output device for remote RX playback. - pub rx_output_device: Option, - /// Local input device for TX uplink capture. - pub tx_input_device: Option, - /// Opus bitrate in bits per second for TX uplink capture. - pub bitrate_bps: u32, - /// RX playback gain multiplier. - pub rx_gain: f32, - /// TX capture gain multiplier. - pub tx_gain: f32, -} - -impl Default for AudioBridgeConfig { - fn default() -> Self { - Self { - enabled: false, - rx_output_device: None, - tx_input_device: None, - bitrate_bps: 192000, - rx_gain: 1.0, - tx_gain: 1.0, - } - } -} - -/// Cookie SameSite attribute options. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] -#[serde(rename_all = "PascalCase")] -pub enum CookieSameSite { - /// Strict: cookie only sent in same-site context - Strict, - /// Lax: cookie sent with top-level navigation (default) - #[default] - Lax, - /// None: cookie sent in all contexts (requires Secure=true) - None, -} - -impl AsRef for CookieSameSite { - fn as_ref(&self) -> &str { - match self { - Self::Strict => "Strict", - Self::Lax => "Lax", - Self::None => "None", - } - } -} - -/// HTTP frontend authentication configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct HttpAuthConfig { - /// Enable HTTP frontend authentication - pub enabled: bool, - /// Passphrase for read-only access (rx role) - pub rx_passphrase: Option, - /// Passphrase for full control access (control role) - pub control_passphrase: Option, - /// Enforce TX/PTT access control (hide from unauthenticated/rx users) - pub tx_access_control_enabled: bool, - /// Session time-to-live in minutes - pub session_ttl_min: u64, - /// Set Secure flag on session cookie (required for HTTPS) - pub cookie_secure: bool, - /// SameSite attribute for session cookie - pub cookie_same_site: CookieSameSite, -} - -impl Default for HttpAuthConfig { - fn default() -> Self { - Self { - enabled: false, - rx_passphrase: None, - control_passphrase: None, - tx_access_control_enabled: true, - session_ttl_min: 480, - cookie_secure: false, - cookie_same_site: CookieSameSite::Lax, - } - } -} - -impl HttpAuthConfig { - /// Convert session TTL from minutes to Duration. - pub fn session_ttl(&self) -> Duration { - Duration::from_secs(self.session_ttl_min * 60) - } -} - -/// HTTP frontend configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct HttpFrontendConfig { - /// Whether HTTP frontend is enabled - pub enabled: bool, - /// Listen address - pub listen: IpAddr, - /// Listen port - pub port: u16, - /// Default rig selected in the web UI on startup. - #[serde(alias = "default_rig_id")] - pub default_rig_name: Option, - /// Initial zoom level for the APRS map when receiver coordinates are known. - pub initial_map_zoom: u8, - /// Spectrum center-retune guard margin on each side of the selected bandwidth. - pub spectrum_coverage_margin_hz: u32, - /// Fraction of the sampled spectrum span treated as usable for center-retune logic. - pub spectrum_usable_span_ratio: f32, - /// Whether to expose the RF Gain control in the web UI. - pub show_sdr_gain_control: bool, - /// Whether the bandplan strip is shown by default in the spectrum display. - pub bandplan_enabled: bool, - /// Default bandplan region: "iaru_r1", "iaru_r2", or "iaru_r3". - pub bandplan_region: String, - /// Default decode history retention in minutes for the active rig. - pub decode_history_retention_min: u64, - /// Optional per-rig decode history retention overrides in minutes. - pub decode_history_retention_min_by_rig: HashMap, - /// Authentication settings - pub auth: HttpAuthConfig, -} - -impl Default for HttpFrontendConfig { - fn default() -> Self { - Self { - enabled: true, - listen: IpAddr::from([127, 0, 0, 1]), - port: 8080, - default_rig_name: None, - initial_map_zoom: 10, - spectrum_coverage_margin_hz: 50_000, - spectrum_usable_span_ratio: 0.92, - show_sdr_gain_control: true, - bandplan_enabled: true, - bandplan_region: "iaru_r1".to_string(), - decode_history_retention_min: 24 * 60, - decode_history_retention_min_by_rig: HashMap::new(), - auth: HttpAuthConfig::default(), - } - } -} - -/// rigctl frontend configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct RigctlFrontendConfig { - /// Whether rigctl frontend is enabled - pub enabled: bool, - /// Listen address - pub listen: IpAddr, - /// Legacy shared-listener port. Ignored; per-rig ports must be configured. - pub port: u16, - /// Per-rig rigctl listener ports. - /// Maps rig ID -> local rigctl port. One rigctl listener is spawned per - /// entry, each routing commands to its assigned rig. - pub rig_ports: HashMap, -} - -impl Default for RigctlFrontendConfig { - fn default() -> Self { - Self { - enabled: false, - listen: IpAddr::from([127, 0, 0, 1]), - port: 4532, - rig_ports: HashMap::new(), - } - } -} - -/// JSON TCP frontend configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct HttpJsonFrontendConfig { - /// Whether JSON TCP frontend is enabled - pub enabled: bool, - /// Listen address - pub listen: IpAddr, - /// Listen port (0 = ephemeral) - pub port: u16, - /// Authorization settings - pub auth: HttpJsonAuthConfig, -} - -impl Default for HttpJsonFrontendConfig { - fn default() -> Self { - Self { - enabled: true, - listen: IpAddr::from([127, 0, 0, 1]), - port: 0, - auth: HttpJsonAuthConfig::default(), - } - } -} - -/// Authorization settings for JSON TCP frontend. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct HttpJsonAuthConfig { - /// Accepted bearer tokens. - pub tokens: Vec, -} - -impl ClientConfig { - /// Return the effective list of remote entries. - /// - /// If `[[remotes]]` is non-empty, return it directly. Otherwise, - /// synthesize a single entry from the legacy `[remote]` section (if it - /// has a `url`). Returns an empty `Vec` when neither is configured - /// (caller should check CLI overrides). - pub fn resolved_remotes(&self) -> Vec { - if !self.remotes.is_empty() { - return self.remotes.clone(); - } - // Legacy fallback - if let Some(url) = &self.remote.url { - let name = self - .remote - .rig_id - .clone() - .unwrap_or_else(|| "default".to_string()); - vec![RemoteEntry { - name, - url: url.clone(), - rig_id: self.remote.rig_id.clone(), - auth: self.remote.auth.clone(), - poll_interval_ms: self.remote.poll_interval_ms, - }] - } else { - Vec::new() - } - } - - pub fn validate(&self) -> Result<(), String> { - validate_log_level(self.general.log_level.as_deref())?; - - // 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() { - 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 \"{}\")", - i, entry.name - )); - } - } - } - - // Validate legacy [remote] (kept for backward compat) - if self.remote.poll_interval_ms == 0 { - return Err("[remote].poll_interval_ms must be > 0".to_string()); - } - if let Some(url) = &self.remote.url { - if url.trim().is_empty() { - return Err("[remote].url must not be empty when set".to_string()); - } - } - if let Some(rig_id) = &self.remote.rig_id { - if rig_id.trim().is_empty() { - return Err("[remote].rig_id must not be empty when set".to_string()); - } - } - if let Some(token) = &self.remote.auth.token { - if token.trim().is_empty() { - 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()); - } - } - - if self.frontends.http.enabled && self.frontends.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 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 { - return Err("[frontends.http].initial_map_zoom must be > 0".to_string()); - } - if self.frontends.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) - { - 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() { - "iaru_r1" | "iaru_r2" | "iaru_r3" => {} - other => { - return Err(format!( - "[frontends.http].bandplan_region must be \"iaru_r1\", \"iaru_r2\", or \"iaru_r3\", got \"{}\"", - other - )); - } - } - if self.frontends.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 { - if rig_id.trim().is_empty() { - return Err( - "[frontends.http].decode_history_retention_min_by_rig keys must not be empty" - .to_string(), - ); - } - if *minutes == 0 { - return Err(format!( - "[frontends.http].decode_history_retention_min_by_rig[\"{}\"] must be > 0", - rig_id - )); - } - } - if self.frontends.rigctl.enabled && self.frontends.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 { - if rig_id.trim().is_empty() { - return Err("[frontends.rigctl].rig_ports keys must not be empty".to_string()); - } - if *port == 0 { - return Err(format!( - "[frontends.rigctl].rig_ports[\"{}\"] must be > 0", - rig_id - )); - } - } - if let Some(url) = &self.frontends.audio.server_url { - crate::remote_client::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 - { - return Err("[frontends.audio].server_port must be > 0 when enabled".to_string()); - } - for (rig_id, url) in &self.frontends.audio.rig_urls { - if rig_id.trim().is_empty() { - return Err("[frontends.audio].rig_urls keys must not be empty".to_string()); - } - crate::remote_client::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 { - if rig_id.trim().is_empty() { - return Err("[frontends.audio].rig_ports keys must not be empty".to_string()); - } - if *port == 0 { - return Err(format!( - "[frontends.audio].rig_ports[\"{}\"] must be > 0", - rig_id - )); - } - } - if !self.frontends.audio.bridge.rx_gain.is_finite() - || self.frontends.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 - { - return Err("[frontends.audio.bridge].tx_gain must be finite and >= 0".to_string()); - } - if self.frontends.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(()) - } - - /// Load configuration from a specific file path. - pub fn load_from_file(path: &Path) -> Result { - ::load_from_file(path) - } - - /// Load configuration from the default search paths. - /// Returns default config if no config file is found. - pub fn load_from_default_paths() -> Result<(Self, Option), ConfigError> { - ::load_from_default_paths() - } - - /// Generate an example configuration wrapped under the `[trx-client]` - /// section header, suitable for use in a combined `trx-rs.toml` file. - pub fn example_combined_toml() -> String { - #[derive(serde::Serialize)] - struct Wrapper { - #[serde(rename = "trx-client")] - inner: ClientConfig, - } - let example = ClientConfig { - general: GeneralConfig { - callsign: Some("N0CALL".to_string()), - website_url: Some("https://haxx.space".to_string()), - website_name: Some("haxx.space".to_string()), - ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()), - log_level: Some("info".to_string()), - }, - remote: RemoteConfig::default(), - remotes: vec![ - RemoteEntry { - name: "home-hf".to_string(), - url: "192.168.1.100:4530".to_string(), - rig_id: Some("hf".to_string()), - auth: RemoteAuthConfig { - token: Some("my-token".to_string()), - }, - poll_interval_ms: 750, - }, - RemoteEntry { - name: "home-vhf".to_string(), - url: "192.168.1.100:4530".to_string(), - rig_id: Some("vhf".to_string()), - auth: RemoteAuthConfig { - token: Some("my-token".to_string()), - }, - poll_interval_ms: 750, - }, - ], - frontends: FrontendsConfig { - http: HttpFrontendConfig { - enabled: true, - listen: IpAddr::from([127, 0, 0, 1]), - port: 8080, - default_rig_name: Some("home-hf".to_string()), - initial_map_zoom: 10, - spectrum_coverage_margin_hz: 50_000, - spectrum_usable_span_ratio: 0.92, - show_sdr_gain_control: true, - bandplan_enabled: true, - bandplan_region: "iaru_r1".to_string(), - decode_history_retention_min: 24 * 60, - decode_history_retention_min_by_rig: HashMap::new(), - auth: HttpAuthConfig { - enabled: false, - rx_passphrase: Some("rx-passphrase-example".to_string()), - control_passphrase: Some("control-passphrase-example".to_string()), - tx_access_control_enabled: true, - session_ttl_min: 480, - cookie_secure: false, - cookie_same_site: CookieSameSite::Lax, - }, - }, - rigctl: RigctlFrontendConfig { - enabled: false, - listen: IpAddr::from([127, 0, 0, 1]), - port: 4532, - rig_ports: HashMap::new(), - }, - http_json: HttpJsonFrontendConfig::default(), - audio: AudioClientConfig::default(), - }, - }; - toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() - } -} - -fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> { - if !auth.enabled { - return Ok(()); - } - - // If enabled, require at least one passphrase - if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() { - return Err( - "[frontends.http.auth] enabled=true requires at least one passphrase \ - (rx_passphrase and/or control_passphrase)" - .to_string(), - ); - } - - // Validate passphrases are not empty strings - if let Some(rx) = &auth.rx_passphrase { - if rx.trim().is_empty() { - return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string()); - } - } - if let Some(ctrl) = &auth.control_passphrase { - if ctrl.trim().is_empty() { - return Err( - "[frontends.http.auth].control_passphrase must not be empty if set".to_string(), - ); - } - } - - // Session TTL must be > 0 - if auth.session_ttl_min == 0 { - return Err("[frontends.http.auth].session_ttl_min must be > 0".to_string()); - } - - Ok(()) -} - -impl ConfigFile for ClientConfig { - fn section_key() -> &'static str { - "trx-client" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_default_config() { - let config = ClientConfig::default(); - assert!(config.frontends.http.enabled); - assert!(!config.frontends.rigctl.enabled); - assert_eq!(config.frontends.http.port, 8080); - assert_eq!(config.frontends.http.initial_map_zoom, 10); - assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 50_000); - assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.92); - assert!(config.frontends.http.bandplan_enabled); - assert_eq!(config.frontends.http.bandplan_region, "iaru_r1"); - assert_eq!(config.frontends.http.decode_history_retention_min, 1440); - assert!(config - .frontends - .http - .decode_history_retention_min_by_rig - .is_empty()); - assert_eq!(config.frontends.rigctl.port, 4532); - assert!(config.frontends.http_json.enabled); - assert_eq!(config.frontends.http_json.port, 0); - assert!(config.remote.url.is_none()); - assert!(config.general.website_url.is_none()); - assert!(config.general.website_name.is_none()); - assert_eq!( - config.general.ais_vessel_url_base, - Some("https://www.vesselfinder.com/?mmsi=".to_string()) - ); - assert_eq!(config.remote.poll_interval_ms, 750); - assert!(config.frontends.audio.enabled); - assert!(config.frontends.audio.server_url.is_none()); - assert!(config.frontends.audio.rig_urls.is_empty()); - assert_eq!(config.frontends.audio.server_port, 4531); - assert!(config.frontends.audio.rig_ports.is_empty()); - assert!(!config.frontends.audio.bridge.enabled); - assert_eq!(config.frontends.audio.bridge.rx_gain, 1.0); - assert_eq!(config.frontends.audio.bridge.tx_gain, 1.0); - } - - #[test] - fn test_parse_client_toml() { - let toml_str = r#" -[general] -callsign = "W1AW" -website_url = "https://example.com" -website_name = "Example" -ais_vessel_url_base = "https://example.com/vessel/" - -[remote] -url = "192.168.1.100:9000" -rig_id = "hf" -auth.token = "my-token" -poll_interval_ms = 500 - -[frontends.http] -enabled = true -listen = "127.0.0.1" -port = 8080 -initial_map_zoom = 12 -spectrum_coverage_margin_hz = 40000 -spectrum_usable_span_ratio = 0.9 -decode_history_retention_min = 720 - -[frontends.http.decode_history_retention_min_by_rig] -vhf = 180 -uhf = 60 - -"#; - - let config: ClientConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.general.callsign, Some("W1AW".to_string())); - assert_eq!( - config.general.website_url, - Some("https://example.com".to_string()) - ); - assert_eq!(config.general.website_name, Some("Example".to_string())); - assert_eq!( - config.general.ais_vessel_url_base, - Some("https://example.com/vessel/".to_string()) - ); - assert_eq!(config.remote.url, Some("192.168.1.100:9000".to_string())); - assert_eq!(config.remote.rig_id, Some("hf".to_string())); - assert_eq!(config.remote.auth.token, Some("my-token".to_string())); - assert_eq!(config.remote.poll_interval_ms, 500); - assert!(config.frontends.http.enabled); - assert_eq!(config.frontends.http.initial_map_zoom, 12); - assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 40_000); - assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.9); - // bandplan fields not set in TOML → defaults - assert!(config.frontends.http.bandplan_enabled); - assert_eq!(config.frontends.http.bandplan_region, "iaru_r1"); - assert_eq!(config.frontends.http.decode_history_retention_min, 720); - assert_eq!( - config - .frontends - .http - .decode_history_retention_min_by_rig - .get("vhf"), - Some(&180) - ); - assert_eq!( - config - .frontends - .http - .decode_history_retention_min_by_rig - .get("uhf"), - Some(&60) - ); - } - - #[test] - fn test_parse_client_toml_with_audio_urls() { - let toml_str = r#" -[frontends.audio] -enabled = true -server_url = "tcp://audio.example.com" - -[frontends.audio.rig_urls] -home-hf = "audio://10.0.0.5:4600" -"#; - - let config: ClientConfig = toml::from_str(toml_str).unwrap(); - assert_eq!( - config.frontends.audio.server_url, - Some("tcp://audio.example.com".to_string()) - ); - assert_eq!( - config.frontends.audio.rig_urls.get("home-hf"), - Some(&"audio://10.0.0.5:4600".to_string()) - ); - } - - #[test] - fn test_example_combined_toml_parses() { - let example = ClientConfig::example_combined_toml(); - let table: toml::Table = toml::from_str(&example).unwrap(); - let section = toml::to_string(table.get("trx-client").unwrap()).unwrap(); - let _config: ClientConfig = toml::from_str(§ion).unwrap(); - } - - #[test] - fn test_validate_rejects_zero_poll_interval() { - let mut config = ClientConfig::default(); - config.remote.poll_interval_ms = 0; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_empty_remote_rig_id() { - let mut config = ClientConfig::default(); - config.remote.rig_id = Some(" ".to_string()); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_empty_http_json_token() { - let mut config = ClientConfig::default(); - config.frontends.http_json.auth.tokens = vec!["".to_string()]; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_zero_audio_rig_port() { - let mut config = ClientConfig::default(); - config - .frontends - .audio - .rig_ports - .insert("ft817".to_string(), 0); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_invalid_audio_url() { - let mut config = ClientConfig::default(); - config.frontends.audio.server_url = Some("tcp://:4531".to_string()); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_accepts_audio_url_without_server_port() { - let mut config = ClientConfig::default(); - config.frontends.audio.server_url = Some("audio.example.com".to_string()); - config.frontends.audio.server_port = 0; - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_rejects_http_auth_enabled_without_passphrases() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_accepts_http_auth_with_rx_passphrase() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_accepts_http_auth_with_control_passphrase() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.control_passphrase = Some("control-secret".to_string()); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_accepts_http_auth_with_both_passphrases() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); - config.frontends.http.auth.control_passphrase = Some("control-secret".to_string()); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_rejects_empty_rx_passphrase() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.rx_passphrase = Some("".to_string()); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_zero_session_ttl() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); - config.frontends.http.auth.session_ttl_min = 0; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_auth_disabled_ignores_passphrases() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = false; - config.frontends.http.auth.rx_passphrase = Some("".to_string()); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_http_auth_config_default() { - let auth = HttpAuthConfig::default(); - assert!(!auth.enabled); - assert!(auth.rx_passphrase.is_none()); - assert!(auth.control_passphrase.is_none()); - assert!(auth.tx_access_control_enabled); - assert_eq!(auth.session_ttl_min, 480); - assert!(!auth.cookie_secure); - assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax)); - } - - #[test] - fn test_http_auth_session_ttl_conversion() { - let auth = HttpAuthConfig { - session_ttl_min: 60, - ..Default::default() - }; - assert_eq!(auth.session_ttl().as_secs(), 3600); - } - - #[test] - fn test_parse_remotes_toml() { - let toml_str = r#" -[[remotes]] -name = "home-hf" -url = "192.168.1.10:4530" -rig_id = "hf" -poll_interval_ms = 500 - -[remotes.auth] -token = "secret" - -[[remotes]] -name = "remote" -url = "remote.example.com:4530" -"#; - - let config: ClientConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.remotes.len(), 2); - assert_eq!(config.remotes[0].name, "home-hf"); - assert_eq!(config.remotes[0].url, "192.168.1.10:4530"); - assert_eq!(config.remotes[0].rig_id, Some("hf".to_string())); - assert_eq!(config.remotes[0].auth.token, Some("secret".to_string())); - assert_eq!(config.remotes[0].poll_interval_ms, 500); - assert_eq!(config.remotes[1].name, "remote"); - assert_eq!(config.remotes[1].url, "remote.example.com:4530"); - assert!(config.remotes[1].rig_id.is_none()); - assert!(config.remotes[1].auth.token.is_none()); - assert_eq!(config.remotes[1].poll_interval_ms, 750); // default - assert!(config.validate().is_ok()); - } - - #[test] - fn test_resolved_remotes_from_remotes() { - let config = ClientConfig { - remotes: vec![RemoteEntry { - name: "hf".to_string(), - url: "host:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }], - ..Default::default() - }; - let resolved = config.resolved_remotes(); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].name, "hf"); - } - - #[test] - fn test_resolved_remotes_legacy_fallback() { - let config = ClientConfig { - remote: RemoteConfig { - url: Some("host:4530".to_string()), - rig_id: Some("hf".to_string()), - auth: RemoteAuthConfig { - token: Some("tok".to_string()), - }, - poll_interval_ms: 750, - }, - ..Default::default() - }; - let resolved = config.resolved_remotes(); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].name, "hf"); - assert_eq!(resolved[0].url, "host:4530"); - assert_eq!(resolved[0].rig_id, Some("hf".to_string())); - assert_eq!(resolved[0].auth.token, Some("tok".to_string())); - } - - #[test] - fn test_resolved_remotes_legacy_default_name() { - let config = ClientConfig { - remote: RemoteConfig { - url: Some("host:4530".to_string()), - rig_id: None, - ..Default::default() - }, - ..Default::default() - }; - let resolved = config.resolved_remotes(); - assert_eq!(resolved[0].name, "default"); - } - - #[test] - fn test_resolved_remotes_prefers_remotes_over_legacy() { - let config = ClientConfig { - remote: RemoteConfig { - url: Some("old:4530".to_string()), - ..Default::default() - }, - remotes: vec![RemoteEntry { - name: "new".to_string(), - url: "new:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }], - ..Default::default() - }; - let resolved = config.resolved_remotes(); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].name, "new"); - } - - #[test] - fn test_validate_rejects_duplicate_remote_names() { - let config = ClientConfig { - remotes: vec![ - RemoteEntry { - name: "dup".to_string(), - url: "a:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }, - RemoteEntry { - name: "dup".to_string(), - url: "b:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }, - ], - ..Default::default() - }; - assert!(config.validate().unwrap_err().contains("duplicate name")); - } - - #[test] - fn test_validate_rejects_empty_remote_name() { - let config = ClientConfig { - remotes: vec![RemoteEntry { - name: "".to_string(), - url: "a:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }], - ..Default::default() - }; - assert!(config - .validate() - .unwrap_err() - .contains("name must not be empty")); - } - - #[test] - fn test_validate_rejects_empty_remote_url() { - let config = ClientConfig { - remotes: vec![RemoteEntry { - name: "hf".to_string(), - url: " ".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }], - ..Default::default() - }; - assert!(config - .validate() - .unwrap_err() - .contains("url must not be empty")); - } - - #[test] - fn test_validate_rejects_zero_remote_poll_interval() { - let config = ClientConfig { - remotes: vec![RemoteEntry { - name: "hf".to_string(), - url: "a:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 0, - }], - ..Default::default() - }; - assert!(config - .validate() - .unwrap_err() - .contains("poll_interval_ms must be > 0")); - } - - #[test] - fn test_validate_rejects_invalid_bandplan_region() { - let mut config = ClientConfig::default(); - config.frontends.http.bandplan_region = "invalid".to_string(); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_accepts_all_bandplan_regions() { - for region in &["iaru_r1", "iaru_r2", "iaru_r3"] { - let mut config = ClientConfig::default(); - config.frontends.http.bandplan_region = region.to_string(); - assert!( - config.validate().is_ok(), - "region {} should be valid", - region - ); - } - } - - #[test] - fn test_parse_bandplan_config_from_toml() { - let toml_str = r#" -[frontends.http] -bandplan_enabled = false -bandplan_region = "iaru_r2" -"#; - let config: ClientConfig = toml::from_str(toml_str).unwrap(); - assert!(!config.frontends.http.bandplan_enabled); - assert_eq!(config.frontends.http.bandplan_region, "iaru_r2"); - } -} +pub use trx_config::client::*; diff --git a/src/trx-client/src/remote_client.rs b/src/trx-client/src/remote_client.rs index 5e235f72..2f2d741d 100644 --- a/src/trx-client/src/remote_client.rs +++ b/src/trx-client/src/remote_client.rs @@ -22,30 +22,16 @@ use trx_protocol::rig_command_to_client; use trx_protocol::types::RigEntry; use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate}; -const DEFAULT_REMOTE_PORT: u16 = 4530; -const DEFAULT_AUDIO_PORT: u16 = 4531; +// Endpoint parsing lives in `trx-config` so config validation and the +// connection code agree on what a URL means. +pub use trx_config::url::{parse_audio_url, parse_remote_url, RemoteEndpoint}; + const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const IO_TIMEOUT: Duration = Duration::from_secs(15); const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3); const MAX_JSON_LINE_BYTES: usize = 256 * 1024; const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RemoteEndpoint { - pub host: String, - pub port: u16, -} - -impl RemoteEndpoint { - pub fn connect_addr(&self) -> String { - if self.host.contains(':') && !self.host.starts_with('[') { - format!("[{}]:{}", self.host, self.port) - } else { - format!("{}:{}", self.host, self.port) - } - } -} - // Keep remote spectrum reasonably responsive without returning to the old // timeout churn caused by a much tighter request cadence. const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50); @@ -1195,86 +1181,6 @@ async fn read_limited_line( } } -pub fn parse_remote_url(url: &str) -> Result { - parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote") -} - -pub fn parse_audio_url(url: &str) -> Result { - parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio") -} - -fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result { - let trimmed = url.trim(); - if trimmed.is_empty() { - return Err(format!("{kind} url is empty")); - } - - let addr = trimmed - .strip_prefix("tcp://") - .or_else(|| trimmed.strip_prefix("http-json://")) - .or_else(|| trimmed.strip_prefix("audio://")) - .unwrap_or(trimmed); - - parse_host_port(addr, default_port, kind) -} - -fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result { - if let Some(rest) = input.strip_prefix('[') { - let closing = rest - .find(']') - .ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?; - let host = &rest[..closing]; - let remainder = &rest[closing + 1..]; - if host.is_empty() { - return Err(format!("invalid {kind} url: host is empty")); - } - let port = if remainder.is_empty() { - default_port - } else if let Some(port_str) = remainder.strip_prefix(':') { - parse_port(port_str, kind)? - } else { - return Err(format!("invalid {kind} url: expected ':' after ']'")); - }; - return Ok(RemoteEndpoint { - host: host.to_string(), - port, - }); - } - - if input.contains(':') { - if input.matches(':').count() > 1 { - return Err(format!( - "invalid {kind} url: IPv6 host must be bracketed like [::1]:4532" - )); - } - let (host, port_str) = input - .rsplit_once(':') - .ok_or_else(|| format!("invalid {kind} url: expected host:port"))?; - if host.is_empty() { - return Err(format!("invalid {kind} url: host is empty")); - } - return Ok(RemoteEndpoint { - host: host.to_string(), - port: parse_port(port_str, kind)?, - }); - } - - Ok(RemoteEndpoint { - host: input.to_string(), - port: default_port, - }) -} - -fn parse_port(port_str: &str, kind: &str) -> Result { - let port: u16 = port_str - .parse() - .map_err(|_| format!("invalid {kind} port: '{port_str}'"))?; - if port == 0 { - return Err(format!("invalid {kind} port: 0")); - } - Ok(port) -} - #[cfg(test)] mod tests { #[allow(unused_imports)] diff --git a/src/trx-config/Cargo.toml b/src/trx-config/Cargo.toml new file mode 100644 index 00000000..6e23f93c --- /dev/null +++ b/src/trx-config/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Stan Grams +# +# SPDX-License-Identifier: GPL-2.0-or-later + +[package] +name = "trx-config" +version.workspace = true +edition = "2021" +license = "GPL-2.0-or-later" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true } +tracing = { workspace = true } +dirs = "6" +thiserror = "2" +trx-core = { path = "../trx-core" } +trx-decode-log = { path = "../decoders/trx-decode-log" } +trx-reporting = { path = "../trx-reporting" } diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs new file mode 100644 index 00000000..6722119d --- /dev/null +++ b/src/trx-config/src/client.rs @@ -0,0 +1,1221 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Configuration file support for trx-client. +//! +//! Config is loaded from the `[trx-client]` section of `trx-rs.toml`. +//! Default search order: +//! 1. Path specified via `--config` CLI argument +//! 2. `./trx-rs.toml` +//! 3. `~/.config/trx-rs/trx-rs.toml` +//! 4. `/etc/trx-rs/trx-rs.toml` + +use std::collections::HashMap; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use crate::file::{ConfigError, ConfigFile}; +use crate::shared::{validate_log_level, validate_tokens}; + +/// Top-level client configuration structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ClientConfig { + /// General settings + pub general: GeneralConfig, + /// Legacy single remote connection settings. + /// Kept for backward compatibility; prefer `[[remotes]]`. + pub remote: RemoteConfig, + /// Named remote connections (one per short-name / rig). + /// Each entry maps a user-chosen short name to a (server, rig_id) pair. + pub remotes: Vec, + /// Frontend configurations + pub frontends: FrontendsConfig, +} + +/// General application settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct GeneralConfig { + /// Callsign or owner label to display in frontends + pub callsign: Option, + /// Optional website URL to use as the web UI header title link. + pub website_url: Option, + /// Optional website name to use as the web UI header title label. + pub website_name: Option, + /// Optional base URL used to link AIS vessel names as ``. + pub ais_vessel_url_base: Option, + /// Log level (trace, debug, info, warn, error) + pub log_level: Option, +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + callsign: Some("N0CALL".to_string()), + website_url: None, + website_name: None, + ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()), + log_level: None, + } + } +} + +/// Remote connection configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RemoteConfig { + /// Remote URL (host:port or tcp://host:port). + pub url: Option, + /// Optional target rig ID on the remote multi-rig server. + pub rig_id: Option, + /// Remote auth settings. + pub auth: RemoteAuthConfig, + /// Poll interval in milliseconds. + pub poll_interval_ms: u64, +} + +impl Default for RemoteConfig { + fn default() -> Self { + Self { + url: None, + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + } + } +} + +/// Authentication settings for remote connection. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RemoteAuthConfig { + /// Bearer token to send with JSON commands. + pub token: Option, +} + +/// A named remote connection entry. +/// +/// Each entry maps a user-chosen **short name** to a `(server, rig_id)` pair. +/// The short name is used as the identifier throughout frontends (HTTP rig +/// picker, rigctl ports, audio routing, etc.) instead of the server-scoped +/// `rig_id`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteEntry { + /// Short name used to identify this remote in frontends and config. + pub name: String, + /// Remote server URL (`host:port` or `tcp://host:port`). + pub url: String, + /// Optional target rig ID on a multi-rig server. + /// When omitted, the server's default (or only) rig is used. + pub rig_id: Option, + /// Authentication settings. + #[serde(default)] + pub auth: RemoteAuthConfig, + /// Poll interval in milliseconds. Defaults to 750. + #[serde(default = "default_poll_interval_ms")] + pub poll_interval_ms: u64, +} + +fn default_poll_interval_ms() -> u64 { + 750 +} + +/// Frontend configurations. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct FrontendsConfig { + /// HTTP frontend settings + pub http: HttpFrontendConfig, + /// rigctl frontend settings + pub rigctl: RigctlFrontendConfig, + /// JSON TCP frontend settings + pub http_json: HttpJsonFrontendConfig, + /// Audio streaming settings + pub audio: AudioClientConfig, +} + +/// Audio streaming client configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AudioClientConfig { + /// Whether audio streaming is enabled + pub enabled: bool, + /// Optional exact audio TCP URL override applied to all remotes. + /// When set, this takes precedence over `server_port`. + pub server_url: Option, + /// Optional per-rig audio URL overrides keyed by remote short name. + /// These take precedence over `server_url`, server-advertised ports, and + /// the legacy `rig_ports` map. + pub rig_urls: HashMap, + /// Legacy audio TCP port fallback on the remote server when no URL override + /// is configured and the server does not advertise a per-rig audio port. + pub server_port: u16, + /// Legacy per-rig audio port overrides for multi-rig servers. + /// Prefer `rig_urls` when the audio endpoint differs by host as well. + pub rig_ports: HashMap, + /// Local audio bridge (virtual device integration) + pub bridge: AudioBridgeConfig, +} + +impl Default for AudioClientConfig { + fn default() -> Self { + Self { + enabled: true, + server_url: None, + rig_urls: HashMap::new(), + server_port: 4531, + rig_ports: HashMap::new(), + bridge: AudioBridgeConfig::default(), + } + } +} + +/// Local audio bridge configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AudioBridgeConfig { + /// Enable local cpal bridge between remote stream and local audio devices. + pub enabled: bool, + /// Local output device for remote RX playback. + pub rx_output_device: Option, + /// Local input device for TX uplink capture. + pub tx_input_device: Option, + /// Opus bitrate in bits per second for TX uplink capture. + pub bitrate_bps: u32, + /// RX playback gain multiplier. + pub rx_gain: f32, + /// TX capture gain multiplier. + pub tx_gain: f32, +} + +impl Default for AudioBridgeConfig { + fn default() -> Self { + Self { + enabled: false, + rx_output_device: None, + tx_input_device: None, + bitrate_bps: 192000, + rx_gain: 1.0, + tx_gain: 1.0, + } + } +} + +/// Cookie SameSite attribute options. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub enum CookieSameSite { + /// Strict: cookie only sent in same-site context + Strict, + /// Lax: cookie sent with top-level navigation (default) + #[default] + Lax, + /// None: cookie sent in all contexts (requires Secure=true) + None, +} + +impl AsRef for CookieSameSite { + fn as_ref(&self) -> &str { + match self { + Self::Strict => "Strict", + Self::Lax => "Lax", + Self::None => "None", + } + } +} + +/// HTTP frontend authentication configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpAuthConfig { + /// Enable HTTP frontend authentication + pub enabled: bool, + /// Passphrase for read-only access (rx role) + pub rx_passphrase: Option, + /// Passphrase for full control access (control role) + pub control_passphrase: Option, + /// Enforce TX/PTT access control (hide from unauthenticated/rx users) + pub tx_access_control_enabled: bool, + /// Session time-to-live in minutes + pub session_ttl_min: u64, + /// Set Secure flag on session cookie (required for HTTPS) + pub cookie_secure: bool, + /// SameSite attribute for session cookie + pub cookie_same_site: CookieSameSite, +} + +impl Default for HttpAuthConfig { + fn default() -> Self { + Self { + enabled: false, + rx_passphrase: None, + control_passphrase: None, + tx_access_control_enabled: true, + session_ttl_min: 480, + cookie_secure: false, + cookie_same_site: CookieSameSite::Lax, + } + } +} + +impl HttpAuthConfig { + /// Convert session TTL from minutes to Duration. + pub fn session_ttl(&self) -> Duration { + Duration::from_secs(self.session_ttl_min * 60) + } +} + +/// HTTP frontend configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpFrontendConfig { + /// Whether HTTP frontend is enabled + pub enabled: bool, + /// Listen address + pub listen: IpAddr, + /// Listen port + pub port: u16, + /// Default rig selected in the web UI on startup. + #[serde(alias = "default_rig_id")] + pub default_rig_name: Option, + /// Initial zoom level for the APRS map when receiver coordinates are known. + pub initial_map_zoom: u8, + /// Spectrum center-retune guard margin on each side of the selected bandwidth. + pub spectrum_coverage_margin_hz: u32, + /// Fraction of the sampled spectrum span treated as usable for center-retune logic. + pub spectrum_usable_span_ratio: f32, + /// Whether to expose the RF Gain control in the web UI. + pub show_sdr_gain_control: bool, + /// Whether the bandplan strip is shown by default in the spectrum display. + pub bandplan_enabled: bool, + /// Default bandplan region: "iaru_r1", "iaru_r2", or "iaru_r3". + pub bandplan_region: String, + /// Default decode history retention in minutes for the active rig. + pub decode_history_retention_min: u64, + /// Optional per-rig decode history retention overrides in minutes. + pub decode_history_retention_min_by_rig: HashMap, + /// Authentication settings + pub auth: HttpAuthConfig, +} + +impl Default for HttpFrontendConfig { + fn default() -> Self { + Self { + enabled: true, + listen: IpAddr::from([127, 0, 0, 1]), + port: 8080, + default_rig_name: None, + initial_map_zoom: 10, + spectrum_coverage_margin_hz: 50_000, + spectrum_usable_span_ratio: 0.92, + show_sdr_gain_control: true, + bandplan_enabled: true, + bandplan_region: "iaru_r1".to_string(), + decode_history_retention_min: 24 * 60, + decode_history_retention_min_by_rig: HashMap::new(), + auth: HttpAuthConfig::default(), + } + } +} + +/// rigctl frontend configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RigctlFrontendConfig { + /// Whether rigctl frontend is enabled + pub enabled: bool, + /// Listen address + pub listen: IpAddr, + /// Legacy shared-listener port. Ignored; per-rig ports must be configured. + pub port: u16, + /// Per-rig rigctl listener ports. + /// Maps rig ID -> local rigctl port. One rigctl listener is spawned per + /// entry, each routing commands to its assigned rig. + pub rig_ports: HashMap, +} + +impl Default for RigctlFrontendConfig { + fn default() -> Self { + Self { + enabled: false, + listen: IpAddr::from([127, 0, 0, 1]), + port: 4532, + rig_ports: HashMap::new(), + } + } +} + +/// JSON TCP frontend configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpJsonFrontendConfig { + /// Whether JSON TCP frontend is enabled + pub enabled: bool, + /// Listen address + pub listen: IpAddr, + /// Listen port (0 = ephemeral) + pub port: u16, + /// Authorization settings + pub auth: HttpJsonAuthConfig, +} + +impl Default for HttpJsonFrontendConfig { + fn default() -> Self { + Self { + enabled: true, + listen: IpAddr::from([127, 0, 0, 1]), + port: 0, + auth: HttpJsonAuthConfig::default(), + } + } +} + +/// Authorization settings for JSON TCP frontend. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpJsonAuthConfig { + /// Accepted bearer tokens. + pub tokens: Vec, +} + +impl ClientConfig { + /// Return the effective list of remote entries. + /// + /// If `[[remotes]]` is non-empty, return it directly. Otherwise, + /// synthesize a single entry from the legacy `[remote]` section (if it + /// has a `url`). Returns an empty `Vec` when neither is configured + /// (caller should check CLI overrides). + pub fn resolved_remotes(&self) -> Vec { + if !self.remotes.is_empty() { + return self.remotes.clone(); + } + // Legacy fallback + if let Some(url) = &self.remote.url { + let name = self + .remote + .rig_id + .clone() + .unwrap_or_else(|| "default".to_string()); + vec![RemoteEntry { + name, + url: url.clone(), + rig_id: self.remote.rig_id.clone(), + auth: self.remote.auth.clone(), + poll_interval_ms: self.remote.poll_interval_ms, + }] + } else { + Vec::new() + } + } + + pub fn validate(&self) -> Result<(), String> { + validate_log_level(self.general.log_level.as_deref())?; + + // 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() { + 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 \"{}\")", + i, entry.name + )); + } + } + } + + // Validate legacy [remote] (kept for backward compat) + if self.remote.poll_interval_ms == 0 { + return Err("[remote].poll_interval_ms must be > 0".to_string()); + } + if let Some(url) = &self.remote.url { + if url.trim().is_empty() { + return Err("[remote].url must not be empty when set".to_string()); + } + } + if let Some(rig_id) = &self.remote.rig_id { + if rig_id.trim().is_empty() { + return Err("[remote].rig_id must not be empty when set".to_string()); + } + } + if let Some(token) = &self.remote.auth.token { + if token.trim().is_empty() { + 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()); + } + } + + if self.frontends.http.enabled && self.frontends.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 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 { + return Err("[frontends.http].initial_map_zoom must be > 0".to_string()); + } + if self.frontends.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) + { + 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() { + "iaru_r1" | "iaru_r2" | "iaru_r3" => {} + other => { + return Err(format!( + "[frontends.http].bandplan_region must be \"iaru_r1\", \"iaru_r2\", or \"iaru_r3\", got \"{}\"", + other + )); + } + } + if self.frontends.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 { + if rig_id.trim().is_empty() { + return Err( + "[frontends.http].decode_history_retention_min_by_rig keys must not be empty" + .to_string(), + ); + } + if *minutes == 0 { + return Err(format!( + "[frontends.http].decode_history_retention_min_by_rig[\"{}\"] must be > 0", + rig_id + )); + } + } + if self.frontends.rigctl.enabled && self.frontends.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 { + if rig_id.trim().is_empty() { + return Err("[frontends.rigctl].rig_ports keys must not be empty".to_string()); + } + if *port == 0 { + return Err(format!( + "[frontends.rigctl].rig_ports[\"{}\"] must be > 0", + rig_id + )); + } + } + if let Some(url) = &self.frontends.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 + { + return Err("[frontends.audio].server_port must be > 0 when enabled".to_string()); + } + for (rig_id, url) in &self.frontends.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 { + if rig_id.trim().is_empty() { + return Err("[frontends.audio].rig_ports keys must not be empty".to_string()); + } + if *port == 0 { + return Err(format!( + "[frontends.audio].rig_ports[\"{}\"] must be > 0", + rig_id + )); + } + } + if !self.frontends.audio.bridge.rx_gain.is_finite() + || self.frontends.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 + { + return Err("[frontends.audio.bridge].tx_gain must be finite and >= 0".to_string()); + } + if self.frontends.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(()) + } + + /// Load configuration from a specific file path. + pub fn load_from_file(path: &Path) -> Result { + ::load_from_file(path) + } + + /// Load configuration from the default search paths. + /// Returns default config if no config file is found. + pub fn load_from_default_paths() -> Result<(Self, Option), ConfigError> { + ::load_from_default_paths() + } + + /// Generate an example configuration wrapped under the `[trx-client]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-client")] + inner: ClientConfig, + } + let example = ClientConfig { + general: GeneralConfig { + callsign: Some("N0CALL".to_string()), + website_url: Some("https://haxx.space".to_string()), + website_name: Some("haxx.space".to_string()), + ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()), + log_level: Some("info".to_string()), + }, + remote: RemoteConfig::default(), + remotes: vec![ + RemoteEntry { + name: "home-hf".to_string(), + url: "192.168.1.100:4530".to_string(), + rig_id: Some("hf".to_string()), + auth: RemoteAuthConfig { + token: Some("my-token".to_string()), + }, + poll_interval_ms: 750, + }, + RemoteEntry { + name: "home-vhf".to_string(), + url: "192.168.1.100:4530".to_string(), + rig_id: Some("vhf".to_string()), + auth: RemoteAuthConfig { + token: Some("my-token".to_string()), + }, + poll_interval_ms: 750, + }, + ], + frontends: FrontendsConfig { + http: HttpFrontendConfig { + enabled: true, + listen: IpAddr::from([127, 0, 0, 1]), + port: 8080, + default_rig_name: Some("home-hf".to_string()), + initial_map_zoom: 10, + spectrum_coverage_margin_hz: 50_000, + spectrum_usable_span_ratio: 0.92, + show_sdr_gain_control: true, + bandplan_enabled: true, + bandplan_region: "iaru_r1".to_string(), + decode_history_retention_min: 24 * 60, + decode_history_retention_min_by_rig: HashMap::new(), + auth: HttpAuthConfig { + enabled: false, + rx_passphrase: Some("rx-passphrase-example".to_string()), + control_passphrase: Some("control-passphrase-example".to_string()), + tx_access_control_enabled: true, + session_ttl_min: 480, + cookie_secure: false, + cookie_same_site: CookieSameSite::Lax, + }, + }, + rigctl: RigctlFrontendConfig { + enabled: false, + listen: IpAddr::from([127, 0, 0, 1]), + port: 4532, + rig_ports: HashMap::new(), + }, + http_json: HttpJsonFrontendConfig::default(), + audio: AudioClientConfig::default(), + }, + }; + toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } +} + +fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> { + if !auth.enabled { + return Ok(()); + } + + // If enabled, require at least one passphrase + if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() { + return Err( + "[frontends.http.auth] enabled=true requires at least one passphrase \ + (rx_passphrase and/or control_passphrase)" + .to_string(), + ); + } + + // Validate passphrases are not empty strings + if let Some(rx) = &auth.rx_passphrase { + if rx.trim().is_empty() { + return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string()); + } + } + if let Some(ctrl) = &auth.control_passphrase { + if ctrl.trim().is_empty() { + return Err( + "[frontends.http.auth].control_passphrase must not be empty if set".to_string(), + ); + } + } + + // Session TTL must be > 0 + if auth.session_ttl_min == 0 { + return Err("[frontends.http.auth].session_ttl_min must be > 0".to_string()); + } + + Ok(()) +} + +impl ConfigFile for ClientConfig { + fn section_key() -> &'static str { + "trx-client" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = ClientConfig::default(); + assert!(config.frontends.http.enabled); + assert!(!config.frontends.rigctl.enabled); + assert_eq!(config.frontends.http.port, 8080); + assert_eq!(config.frontends.http.initial_map_zoom, 10); + assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 50_000); + assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.92); + assert!(config.frontends.http.bandplan_enabled); + assert_eq!(config.frontends.http.bandplan_region, "iaru_r1"); + assert_eq!(config.frontends.http.decode_history_retention_min, 1440); + assert!(config + .frontends + .http + .decode_history_retention_min_by_rig + .is_empty()); + assert_eq!(config.frontends.rigctl.port, 4532); + assert!(config.frontends.http_json.enabled); + assert_eq!(config.frontends.http_json.port, 0); + assert!(config.remote.url.is_none()); + assert!(config.general.website_url.is_none()); + assert!(config.general.website_name.is_none()); + assert_eq!( + config.general.ais_vessel_url_base, + Some("https://www.vesselfinder.com/?mmsi=".to_string()) + ); + assert_eq!(config.remote.poll_interval_ms, 750); + assert!(config.frontends.audio.enabled); + assert!(config.frontends.audio.server_url.is_none()); + assert!(config.frontends.audio.rig_urls.is_empty()); + assert_eq!(config.frontends.audio.server_port, 4531); + assert!(config.frontends.audio.rig_ports.is_empty()); + assert!(!config.frontends.audio.bridge.enabled); + assert_eq!(config.frontends.audio.bridge.rx_gain, 1.0); + assert_eq!(config.frontends.audio.bridge.tx_gain, 1.0); + } + + #[test] + fn test_parse_client_toml() { + let toml_str = r#" +[general] +callsign = "W1AW" +website_url = "https://example.com" +website_name = "Example" +ais_vessel_url_base = "https://example.com/vessel/" + +[remote] +url = "192.168.1.100:9000" +rig_id = "hf" +auth.token = "my-token" +poll_interval_ms = 500 + +[frontends.http] +enabled = true +listen = "127.0.0.1" +port = 8080 +initial_map_zoom = 12 +spectrum_coverage_margin_hz = 40000 +spectrum_usable_span_ratio = 0.9 +decode_history_retention_min = 720 + +[frontends.http.decode_history_retention_min_by_rig] +vhf = 180 +uhf = 60 + +"#; + + let config: ClientConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.general.callsign, Some("W1AW".to_string())); + assert_eq!( + config.general.website_url, + Some("https://example.com".to_string()) + ); + assert_eq!(config.general.website_name, Some("Example".to_string())); + assert_eq!( + config.general.ais_vessel_url_base, + Some("https://example.com/vessel/".to_string()) + ); + assert_eq!(config.remote.url, Some("192.168.1.100:9000".to_string())); + assert_eq!(config.remote.rig_id, Some("hf".to_string())); + assert_eq!(config.remote.auth.token, Some("my-token".to_string())); + assert_eq!(config.remote.poll_interval_ms, 500); + assert!(config.frontends.http.enabled); + assert_eq!(config.frontends.http.initial_map_zoom, 12); + assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 40_000); + assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.9); + // bandplan fields not set in TOML → defaults + assert!(config.frontends.http.bandplan_enabled); + assert_eq!(config.frontends.http.bandplan_region, "iaru_r1"); + assert_eq!(config.frontends.http.decode_history_retention_min, 720); + assert_eq!( + config + .frontends + .http + .decode_history_retention_min_by_rig + .get("vhf"), + Some(&180) + ); + assert_eq!( + config + .frontends + .http + .decode_history_retention_min_by_rig + .get("uhf"), + Some(&60) + ); + } + + #[test] + fn test_parse_client_toml_with_audio_urls() { + let toml_str = r#" +[frontends.audio] +enabled = true +server_url = "tcp://audio.example.com" + +[frontends.audio.rig_urls] +home-hf = "audio://10.0.0.5:4600" +"#; + + let config: ClientConfig = toml::from_str(toml_str).unwrap(); + assert_eq!( + config.frontends.audio.server_url, + Some("tcp://audio.example.com".to_string()) + ); + assert_eq!( + config.frontends.audio.rig_urls.get("home-hf"), + Some(&"audio://10.0.0.5:4600".to_string()) + ); + } + + #[test] + fn test_example_combined_toml_parses() { + let example = ClientConfig::example_combined_toml(); + let table: toml::Table = toml::from_str(&example).unwrap(); + let section = toml::to_string(table.get("trx-client").unwrap()).unwrap(); + let _config: ClientConfig = toml::from_str(§ion).unwrap(); + } + + #[test] + fn test_validate_rejects_zero_poll_interval() { + let mut config = ClientConfig::default(); + config.remote.poll_interval_ms = 0; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_empty_remote_rig_id() { + let mut config = ClientConfig::default(); + config.remote.rig_id = Some(" ".to_string()); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_empty_http_json_token() { + let mut config = ClientConfig::default(); + config.frontends.http_json.auth.tokens = vec!["".to_string()]; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_zero_audio_rig_port() { + let mut config = ClientConfig::default(); + config + .frontends + .audio + .rig_ports + .insert("ft817".to_string(), 0); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_invalid_audio_url() { + let mut config = ClientConfig::default(); + config.frontends.audio.server_url = Some("tcp://:4531".to_string()); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_accepts_audio_url_without_server_port() { + let mut config = ClientConfig::default(); + config.frontends.audio.server_url = Some("audio.example.com".to_string()); + config.frontends.audio.server_port = 0; + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_rejects_http_auth_enabled_without_passphrases() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_accepts_http_auth_with_rx_passphrase() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_accepts_http_auth_with_control_passphrase() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.control_passphrase = Some("control-secret".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_accepts_http_auth_with_both_passphrases() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); + config.frontends.http.auth.control_passphrase = Some("control-secret".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_rejects_empty_rx_passphrase() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.rx_passphrase = Some("".to_string()); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_zero_session_ttl() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); + config.frontends.http.auth.session_ttl_min = 0; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_auth_disabled_ignores_passphrases() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = false; + config.frontends.http.auth.rx_passphrase = Some("".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_http_auth_config_default() { + let auth = HttpAuthConfig::default(); + assert!(!auth.enabled); + assert!(auth.rx_passphrase.is_none()); + assert!(auth.control_passphrase.is_none()); + assert!(auth.tx_access_control_enabled); + assert_eq!(auth.session_ttl_min, 480); + assert!(!auth.cookie_secure); + assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax)); + } + + #[test] + fn test_http_auth_session_ttl_conversion() { + let auth = HttpAuthConfig { + session_ttl_min: 60, + ..Default::default() + }; + assert_eq!(auth.session_ttl().as_secs(), 3600); + } + + #[test] + fn test_parse_remotes_toml() { + let toml_str = r#" +[[remotes]] +name = "home-hf" +url = "192.168.1.10:4530" +rig_id = "hf" +poll_interval_ms = 500 + +[remotes.auth] +token = "secret" + +[[remotes]] +name = "remote" +url = "remote.example.com:4530" +"#; + + let config: ClientConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.remotes.len(), 2); + assert_eq!(config.remotes[0].name, "home-hf"); + assert_eq!(config.remotes[0].url, "192.168.1.10:4530"); + assert_eq!(config.remotes[0].rig_id, Some("hf".to_string())); + assert_eq!(config.remotes[0].auth.token, Some("secret".to_string())); + assert_eq!(config.remotes[0].poll_interval_ms, 500); + assert_eq!(config.remotes[1].name, "remote"); + assert_eq!(config.remotes[1].url, "remote.example.com:4530"); + assert!(config.remotes[1].rig_id.is_none()); + assert!(config.remotes[1].auth.token.is_none()); + assert_eq!(config.remotes[1].poll_interval_ms, 750); // default + assert!(config.validate().is_ok()); + } + + #[test] + fn test_resolved_remotes_from_remotes() { + let config = ClientConfig { + remotes: vec![RemoteEntry { + name: "hf".to_string(), + url: "host:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }], + ..Default::default() + }; + let resolved = config.resolved_remotes(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "hf"); + } + + #[test] + fn test_resolved_remotes_legacy_fallback() { + let config = ClientConfig { + remote: RemoteConfig { + url: Some("host:4530".to_string()), + rig_id: Some("hf".to_string()), + auth: RemoteAuthConfig { + token: Some("tok".to_string()), + }, + poll_interval_ms: 750, + }, + ..Default::default() + }; + let resolved = config.resolved_remotes(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "hf"); + assert_eq!(resolved[0].url, "host:4530"); + assert_eq!(resolved[0].rig_id, Some("hf".to_string())); + assert_eq!(resolved[0].auth.token, Some("tok".to_string())); + } + + #[test] + fn test_resolved_remotes_legacy_default_name() { + let config = ClientConfig { + remote: RemoteConfig { + url: Some("host:4530".to_string()), + rig_id: None, + ..Default::default() + }, + ..Default::default() + }; + let resolved = config.resolved_remotes(); + assert_eq!(resolved[0].name, "default"); + } + + #[test] + fn test_resolved_remotes_prefers_remotes_over_legacy() { + let config = ClientConfig { + remote: RemoteConfig { + url: Some("old:4530".to_string()), + ..Default::default() + }, + remotes: vec![RemoteEntry { + name: "new".to_string(), + url: "new:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }], + ..Default::default() + }; + let resolved = config.resolved_remotes(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "new"); + } + + #[test] + fn test_validate_rejects_duplicate_remote_names() { + let config = ClientConfig { + remotes: vec![ + RemoteEntry { + name: "dup".to_string(), + url: "a:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }, + RemoteEntry { + name: "dup".to_string(), + url: "b:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }, + ], + ..Default::default() + }; + assert!(config.validate().unwrap_err().contains("duplicate name")); + } + + #[test] + fn test_validate_rejects_empty_remote_name() { + let config = ClientConfig { + remotes: vec![RemoteEntry { + name: "".to_string(), + url: "a:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }], + ..Default::default() + }; + assert!(config + .validate() + .unwrap_err() + .contains("name must not be empty")); + } + + #[test] + fn test_validate_rejects_empty_remote_url() { + let config = ClientConfig { + remotes: vec![RemoteEntry { + name: "hf".to_string(), + url: " ".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }], + ..Default::default() + }; + assert!(config + .validate() + .unwrap_err() + .contains("url must not be empty")); + } + + #[test] + fn test_validate_rejects_zero_remote_poll_interval() { + let config = ClientConfig { + remotes: vec![RemoteEntry { + name: "hf".to_string(), + url: "a:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 0, + }], + ..Default::default() + }; + assert!(config + .validate() + .unwrap_err() + .contains("poll_interval_ms must be > 0")); + } + + #[test] + fn test_validate_rejects_invalid_bandplan_region() { + let mut config = ClientConfig::default(); + config.frontends.http.bandplan_region = "invalid".to_string(); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_accepts_all_bandplan_regions() { + for region in &["iaru_r1", "iaru_r2", "iaru_r3"] { + let mut config = ClientConfig::default(); + config.frontends.http.bandplan_region = region.to_string(); + assert!( + config.validate().is_ok(), + "region {} should be valid", + region + ); + } + } + + #[test] + fn test_parse_bandplan_config_from_toml() { + let toml_str = r#" +[frontends.http] +bandplan_enabled = false +bandplan_region = "iaru_r2" +"#; + let config: ClientConfig = toml::from_str(toml_str).unwrap(); + assert!(!config.frontends.http.bandplan_enabled); + assert_eq!(config.frontends.http.bandplan_region, "iaru_r2"); + } +} diff --git a/src/trx-app/src/config.rs b/src/trx-config/src/file.rs similarity index 100% rename from src/trx-app/src/config.rs rename to src/trx-config/src/file.rs diff --git a/src/trx-config/src/lib.rs b/src/trx-config/src/lib.rs new file mode 100644 index 00000000..5bb554b2 --- /dev/null +++ b/src/trx-config/src/lib.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Configuration types shared by `trx-server`, `trx-client` and +//! `trx-configurator`. +//! +//! Keeping the structs, the loader and the validators in one crate means the +//! setup wizard checks a config with exactly the same code the binaries load +//! it with, so the two can never drift apart. + +pub mod client; +pub mod file; +pub mod server; +pub mod shared; +pub mod url; + +pub use client::ClientConfig; +pub use file::{ConfigError, ConfigFile}; +pub use server::ServerConfig; +pub use shared::{validate_log_level, validate_tokens}; +pub use url::{parse_audio_url, parse_remote_url, RemoteEndpoint}; diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs new file mode 100644 index 00000000..d504e0a5 --- /dev/null +++ b/src/trx-config/src/server.rs @@ -0,0 +1,1525 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Configuration file support for trx-server. +//! +//! Config is loaded from the `[trx-server]` section of `trx-rs.toml`. +//! Default search order: +//! 1. Path specified via `--config` CLI argument +//! 2. `./trx-rs.toml` +//! 3. `~/.config/trx-rs/trx-rs.toml` +//! 4. `/etc/trx-rs/trx-rs.toml` + +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use crate::file::{ConfigError, ConfigFile}; +use crate::shared::{validate_log_level, validate_tokens}; +pub use trx_decode_log::DecodeLogsConfig; + +use trx_core::rig::state::RigMode; + +/// Per-rig instance configuration for multi-rig setups. +/// +/// Each entry in `[[rigs]]` becomes one of these. The flat top-level +/// `[rig]` / `[audio]` / `[sdr]` / `[pskreporter]` / `[aprsfi]` / +/// `[behavior]` / `[decode_logs]` fields are still supported via +/// `ServerConfig::resolved_rigs()` which synthesises a single-element list +/// with `id = "default"` when `rigs` is empty. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RigInstanceConfig { + /// Whether this rig instance should be started. + /// Defaults to true so existing configs remain unchanged. + pub enable: bool, + /// Stable rig identifier used in protocol routing. + pub id: String, + /// Display name for the rig (e.g., "HF Transceiver", "VHF/UHF SDR"). + /// If not specified, defaults to the rig id. + pub name: Option, + /// Rig backend configuration. + pub rig: RigConfig, + /// Polling and retry behavior. + pub behavior: BehaviorConfig, + /// Audio streaming configuration for this rig. + pub audio: AudioConfig, + /// SDR pipeline configuration (only used when [rigs.rig.access] type = "sdr"). + pub sdr: SdrConfig, + /// PSK Reporter uplink for this rig. + pub pskreporter: PskReporterConfig, + /// APRS-IS IGate uplink for this rig. + pub aprsfi: AprsFiConfig, + /// Decoder file logging for this rig. + pub decode_logs: DecodeLogsConfig, +} + +impl Default for RigInstanceConfig { + fn default() -> Self { + Self { + enable: true, + id: String::new(), + name: None, + rig: RigConfig::default(), + behavior: BehaviorConfig::default(), + audio: AudioConfig::default(), + sdr: SdrConfig::default(), + pskreporter: PskReporterConfig::default(), + aprsfi: AprsFiConfig::default(), + decode_logs: DecodeLogsConfig::default(), + } + } +} + +impl RigInstanceConfig { + /// Get the display name for this rig. + /// Returns the configured name if set, otherwise the id. + pub fn display_name(&self) -> &str { + self.name.as_deref().unwrap_or(&self.id) + } +} + +/// Top-level server configuration structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ServerConfig { + /// General settings + pub general: GeneralConfig, + /// Rig backend configuration (legacy flat; use [[rigs]] for multi-rig) + pub rig: RigConfig, + /// Polling and retry behavior (legacy flat) + pub behavior: BehaviorConfig, + /// TCP listener configuration + pub listen: ListenConfig, + /// Audio streaming configuration (legacy flat) + pub audio: AudioConfig, + /// PSK Reporter uplink configuration (legacy flat) + pub pskreporter: PskReporterConfig, + /// APRS-IS IGate uplink configuration (legacy flat) + pub aprsfi: AprsFiConfig, + /// Decoder file logging configuration (legacy flat) + pub decode_logs: DecodeLogsConfig, + /// SDR pipeline configuration (legacy flat; used when [rig.access] type = "sdr"). + pub sdr: SdrConfig, + /// Timeout and buffer-size tuning knobs. + pub timeouts: TimeoutsConfig, + /// Multi-rig instance list. When non-empty, takes priority over the flat fields. + #[serde(rename = "rigs", default)] + pub rigs: Vec, +} + +/// General application settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct GeneralConfig { + /// Callsign or owner label + pub callsign: Option, + /// Log level (trace, debug, info, warn, error) + pub log_level: Option, + /// Receiver latitude (decimal degrees, WGS84) + pub latitude: Option, + /// Receiver longitude (decimal degrees, WGS84) + pub longitude: Option, +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + callsign: Some("N0CALL".to_string()), + log_level: None, + latitude: None, + longitude: None, + } + } +} + +/// Rig backend configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RigConfig { + /// Rig model (e.g., "ft817", "ft450d", "ic7300") + pub model: Option, + /// Initial frequency (Hz) for the rig state before first CAT read + pub initial_freq_hz: u64, + /// Initial mode for the rig state before first CAT read + pub initial_mode: RigMode, + /// Access method configuration + pub access: AccessConfig, +} + +/// Access method configuration for reaching the rig. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct AccessConfig { + /// Access type: "serial" or "tcp" + #[serde(rename = "type")] + pub access_type: Option, + /// Serial port path (for serial access) + pub port: Option, + /// Baud rate (for serial access) + pub baud: Option, + /// Host address (for TCP access) + pub host: Option, + /// TCP port (for TCP access) + pub tcp_port: Option, + /// SoapySDR device args string (for sdr access), e.g. "driver=rtlsdr". + pub args: Option, +} + +impl Default for RigConfig { + fn default() -> Self { + Self { + model: None, + initial_freq_hz: 144_300_000, + initial_mode: RigMode::USB, + access: AccessConfig::default(), + } + } +} + +/// Behavior configuration for polling and retries. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct BehaviorConfig { + /// Polling interval in milliseconds when idle + pub poll_interval_ms: u64, + /// Polling interval in milliseconds when transmitting + pub poll_interval_tx_ms: u64, + /// Maximum retry attempts for transient errors + pub max_retries: u32, + /// Base delay for exponential backoff in milliseconds + pub retry_base_delay_ms: u64, + /// Whether to prime both VFOs on startup by toggling and reading each. + /// Defaults to true for rigs with dual VFOs (e.g. FT-817). + pub vfo_prime: bool, +} + +impl Default for BehaviorConfig { + fn default() -> Self { + Self { + poll_interval_ms: 500, + poll_interval_tx_ms: 100, + max_retries: 3, + retry_base_delay_ms: 100, + vfo_prime: true, + } + } +} + +/// Timeout and buffer-size tuning knobs. +/// +/// All durations are in milliseconds. The defaults match the previously +/// hard-coded values, so existing deployments are unaffected. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct TimeoutsConfig { + /// Maximum time (ms) to wait for a single rig command to complete. + pub command_exec_timeout_ms: u64, + /// Maximum time (ms) for a CAT poll refresh cycle. + pub poll_refresh_timeout_ms: u64, + /// Maximum time (ms) for low-level listener I/O operations (read/write/flush). + pub io_timeout_ms: u64, + /// Maximum time (ms) to wait for a rig command response in the listener. + pub request_timeout_ms: u64, + /// Capacity of the per-rig command channel (number of queued requests). + pub rig_task_channel_buffer: usize, +} + +impl Default for TimeoutsConfig { + fn default() -> Self { + Self { + command_exec_timeout_ms: 10_000, + poll_refresh_timeout_ms: 8_000, + io_timeout_ms: 10_000, + request_timeout_ms: 12_000, + rig_task_channel_buffer: 32, + } + } +} + +/// TCP listener configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ListenConfig { + /// Whether the listener is enabled + pub enabled: bool, + /// IP address to listen on + pub listen: IpAddr, + /// TCP port to listen on + pub port: u16, + /// Authentication configuration + pub auth: AuthConfig, +} + +impl Default for ListenConfig { + fn default() -> Self { + Self { + enabled: true, + listen: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + port: 4530, + auth: AuthConfig::default(), + } + } +} + +/// Authentication configuration for the TCP listener. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct AuthConfig { + /// Valid authentication tokens (empty = no auth required) + pub tokens: Vec, +} + +/// Audio streaming configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AudioConfig { + /// Whether audio streaming is enabled + pub enabled: bool, + /// IP address to listen on for audio connections + pub listen: IpAddr, + /// TCP port for audio connections + pub port: u16, + /// Whether RX audio capture is enabled + pub rx_enabled: bool, + /// Whether TX audio playback is enabled + pub tx_enabled: bool, + /// Audio input device name (None = system default) + pub device: Option, + /// Sample rate in Hz + pub sample_rate: u32, + /// Number of audio channels + pub channels: u8, + /// Opus frame duration in milliseconds + pub frame_duration_ms: u16, + /// Opus bitrate in bits per second + pub bitrate_bps: u32, +} + +impl Default for AudioConfig { + fn default() -> Self { + Self { + enabled: true, + listen: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + port: 4531, + rx_enabled: true, + tx_enabled: true, + device: None, + sample_rate: 48000, + channels: 2, + frame_duration_ms: 20, + bitrate_bps: 256000, + } + } +} + +pub use trx_reporting::{AprsFiConfig, PskReporterConfig}; + +/// Top-level SDR configuration (only used when [rig.access] type = "sdr"). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrConfig { + /// SoapySDR IQ capture sample rate (Hz). Must be supported by the device. + pub sample_rate: u32, + /// Hardware IF filter bandwidth (Hz). + pub bandwidth: u32, + /// WFM deemphasis time constant in microseconds (50 or 75). + pub wfm_deemphasis_us: u32, + /// SDR tunes this many Hz below the dial frequency to keep signal off DC. + pub center_offset_hz: i64, + /// Gain configuration. + pub gain: SdrGainConfig, + /// Virtual software squelch applied to demodulated audio except WFM. + pub squelch: SdrSquelchConfig, + /// Noise blanker for impulse noise suppression on IQ samples. + pub noise_blanker: SdrNoiseBlankerConfig, + /// Virtual receiver channels (at least one required when SDR backend is active). + pub channels: Vec, + /// Maximum number of simultaneous virtual channels (including the primary). + /// Default: 4. + #[serde(default = "default_max_virtual_channels")] + pub max_virtual_channels: usize, +} + +fn default_max_virtual_channels() -> usize { + 4 +} + +impl Default for SdrConfig { + fn default() -> Self { + Self { + sample_rate: 1_920_000, + bandwidth: 1_500_000, + wfm_deemphasis_us: 50, + center_offset_hz: 100_000, + gain: SdrGainConfig::default(), + squelch: SdrSquelchConfig::default(), + noise_blanker: SdrNoiseBlankerConfig::default(), + channels: Vec::new(), + max_virtual_channels: default_max_virtual_channels(), + } + } +} + +/// Virtual squelch settings for SoapySDR demodulated audio (except WFM mode). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrSquelchConfig { + /// Enables software squelch for demodulated audio except WFM. + pub enabled: bool, + /// Open threshold in dBFS (typical range: -120..0). + pub threshold_db: f32, + /// Hysteresis in dB used when closing the squelch. + pub hysteresis_db: f32, + /// Tail hold time after dropping below threshold. + pub tail_ms: u32, +} + +impl Default for SdrSquelchConfig { + fn default() -> Self { + Self { + enabled: false, + threshold_db: -65.0, + hysteresis_db: 3.0, + tail_ms: 180, + } + } +} + +/// Noise blanker settings for impulse noise suppression on IQ samples. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrNoiseBlankerConfig { + /// Enables the noise blanker. + pub enabled: bool, + /// Threshold multiplier for impulse detection (typical range: 1..100). + /// A sample whose magnitude exceeds threshold × running RMS is blanked. + pub threshold: f64, +} + +impl Default for SdrNoiseBlankerConfig { + fn default() -> Self { + Self { + enabled: false, + threshold: 10.0, + } + } +} + +/// Gain control mode for the SDR device. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrGainConfig { + /// "auto" (hardware AGC) or "manual" (fixed dB). + pub mode: String, + /// Gain in dB; effective only when mode = "manual". + pub value: f64, + /// Optional hard ceiling for the applied hardware gain in dB. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_value: Option, +} + +impl Default for SdrGainConfig { + fn default() -> Self { + Self { + mode: "auto".to_string(), + value: 30.0, + max_value: None, + } + } +} + +/// One virtual receiver channel within the wideband IQ stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrChannelConfig { + /// Human-readable identifier used in logs. + pub id: String, + /// Frequency offset from the dial frequency (Hz). Primary channel should use 0. + pub offset_hz: i64, + /// Demodulation mode: "auto" (follows RigCat set_mode) or a fixed RigMode string + /// (e.g. "USB", "FM"). + pub mode: String, + /// One-sided bandwidth of the post-demod audio BPF (Hz). + pub audio_bandwidth_hz: u32, + /// CW tone centre frequency in the audio domain (Hz). Default 700. + pub cw_center_hz: u32, + /// Pre-demod bandwidth for WFM only (Hz). Default 75000. + pub wfm_bandwidth_hz: u32, + /// Decoder names that receive this channel's PCM frames. + /// Valid values: "ft8", "wspr", "aprs", "cw". + pub decoders: Vec, + /// If true, encode this channel's audio as Opus and stream over TCP. + /// At most one channel may set this to true. + pub stream_opus: bool, +} + +impl Default for SdrChannelConfig { + fn default() -> Self { + Self { + id: String::new(), + offset_hz: 0, + mode: "auto".to_string(), + audio_bandwidth_hz: 3000, + cw_center_hz: 700, + wfm_bandwidth_hz: 75_000, + decoders: Vec::new(), + stream_opus: false, + } + } +} + +impl ServerConfig { + pub fn validate(&self) -> Result<(), String> { + validate_log_level(self.general.log_level.as_deref())?; + validate_coordinates(self.general.latitude, self.general.longitude)?; + + if self.rig.initial_freq_hz == 0 { + return Err("[rig].initial_freq_hz must be > 0".to_string()); + } + + validate_access(&self.rig.access)?; + + if self.behavior.poll_interval_ms == 0 { + return Err("[behavior].poll_interval_ms must be > 0".to_string()); + } + if self.behavior.poll_interval_tx_ms == 0 { + return Err("[behavior].poll_interval_tx_ms must be > 0".to_string()); + } + if self.behavior.max_retries == 0 { + return Err("[behavior].max_retries must be > 0".to_string()); + } + if self.behavior.retry_base_delay_ms == 0 { + return Err("[behavior].retry_base_delay_ms must be > 0".to_string()); + } + + validate_tokens("[listen.auth].tokens", &self.listen.auth.tokens)?; + if self.listen.enabled && self.listen.port == 0 { + return Err("[listen].port must be > 0 when listener is enabled".to_string()); + } + + if self.audio.enabled { + if self.audio.port == 0 { + return Err("[audio].port must be > 0 when audio is enabled".to_string()); + } + if !self.audio.rx_enabled && !self.audio.tx_enabled { + return Err( + "[audio] enabled but both rx_enabled and tx_enabled are false".to_string(), + ); + } + if self.audio.sample_rate < 8_000 || self.audio.sample_rate > 192_000 { + return Err("[audio].sample_rate must be in range 8000..=192000".to_string()); + } + if !(1..=2).contains(&self.audio.channels) { + return Err("[audio].channels must be 1 or 2".to_string()); + } + match self.audio.frame_duration_ms { + 3 | 5 | 10 | 20 | 40 | 60 => {} + _ => { + return Err( + "[audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60" + .to_string(), + ) + } + } + if self.audio.bitrate_bps == 0 { + return Err("[audio].bitrate_bps must be > 0".to_string()); + } + } + + if self.pskreporter.enabled { + if self.pskreporter.host.trim().is_empty() { + return Err("[pskreporter].host must not be empty".to_string()); + } + if self.pskreporter.port == 0 { + return Err("[pskreporter].port must be > 0".to_string()); + } + if self.pskreporter.receiver_locator.is_none() + && (self.general.latitude.is_none() || self.general.longitude.is_none()) + { + return Err( + "[pskreporter] enabled requires either [pskreporter].receiver_locator \ + or [general].latitude and [general].longitude" + .to_string(), + ); + } + } + + if self.aprsfi.enabled { + if self.aprsfi.host.trim().is_empty() { + return Err("[aprsfi].host must not be empty".to_string()); + } + if self.aprsfi.port == 0 { + return Err("[aprsfi].port must be > 0".to_string()); + } + } + + if let Some(max_gain) = self.sdr.gain.max_value { + if !max_gain.is_finite() { + return Err("[sdr.gain].max_value must be finite".to_string()); + } + if max_gain < 0.0 { + return Err("[sdr.gain].max_value must be >= 0".to_string()); + } + } + validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?; + validate_sdr_nb_config("[sdr.noise_blanker]", &self.sdr.noise_blanker)?; + + // Multi-rig uniqueness checks. + if !self.rigs.is_empty() { + let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); + let mut seen_ports: std::collections::HashSet = std::collections::HashSet::new(); + let mut enabled_count = 0usize; + for rig in &self.rigs { + if !rig.enable { + continue; + } + enabled_count += 1; + // Check for explicit duplicate IDs (empty IDs are auto-generated later). + if !rig.id.trim().is_empty() && !seen_ids.insert(rig.id.clone()) { + return Err(format!("[[rigs]] duplicate rig id: \"{}\"", rig.id)); + } + if rig.audio.enabled && !seen_ports.insert(rig.audio.port) { + return Err(format!( + "[[rigs]] duplicate audio port {} (rig id: \"{}\")", + rig.audio.port, rig.id + )); + } + if let Some(max_gain) = rig.sdr.gain.max_value { + if !max_gain.is_finite() { + return Err(format!( + "[[rigs]] [sdr.gain].max_value must be finite (rig id: \"{}\")", + rig.id + )); + } + if max_gain < 0.0 { + return Err(format!( + "[[rigs]] [sdr.gain].max_value must be >= 0 (rig id: \"{}\")", + rig.id + )); + } + } + validate_sdr_squelch_config( + &format!("[[rigs]] [sdr.squelch] (rig id: \"{}\")", rig.id), + &rig.sdr.squelch, + )?; + validate_sdr_nb_config( + &format!("[[rigs]] [sdr.noise_blanker] (rig id: \"{}\")", rig.id), + &rig.sdr.noise_blanker, + )?; + } + if enabled_count == 0 { + return Err( + "[[rigs]] has no enabled entries; set at least one [[rigs]].enable = true" + .to_string(), + ); + } + } + + if self.decode_logs.enabled { + if self.decode_logs.dir.trim().is_empty() { + return Err("[decode_logs].dir must not be empty when enabled".to_string()); + } + if self.decode_logs.aprs_file.trim().is_empty() + || self.decode_logs.cw_file.trim().is_empty() + || self.decode_logs.ft8_file.trim().is_empty() + || self.decode_logs.wspr_file.trim().is_empty() + { + return Err("[decode_logs] file names must not be empty when enabled".to_string()); + } + } + + Ok(()) + } + + /// Validate SDR-specific config rules (see SDR.md §11). + /// Returns a Vec of error strings; empty means valid. + pub fn validate_sdr(&self) -> Vec { + let mut errors = Vec::new(); + + // Only validate if access type is "sdr" + let is_sdr = self.rig.access.access_type.as_deref() == Some("sdr"); + if !is_sdr { + return errors; + } + + // args must be non-empty + if self + .rig + .access + .args + .as_deref() + .map(str::is_empty) + .unwrap_or(true) + { + errors.push("[rig.access] args must be non-empty for type = \"sdr\"".into()); + } + + // sample_rate must be non-zero + if self.sdr.sample_rate == 0 { + errors.push("[sdr] sample_rate must be > 0".into()); + } + + // Every channel's IF must fit within the captured bandwidth + let half_rate = self.sdr.sample_rate as i64 / 2; + for ch in &self.sdr.channels { + let channel_if = self.sdr.center_offset_hz + ch.offset_hz; + if channel_if.abs() >= half_rate { + errors.push(format!( + "[sdr.channels] id=\"{}\" IF frequency {} Hz exceeds Nyquist limit ±{} Hz", + ch.id, channel_if, half_rate + )); + } + } + + // At most one channel may have stream_opus = true + let opus_count = self.sdr.channels.iter().filter(|c| c.stream_opus).count(); + if opus_count > 1 { + errors.push(format!( + "[sdr.channels] at most one channel may have stream_opus = true (found {})", + opus_count + )); + } + + // tx_enabled must be false with SDR backend + if self.audio.tx_enabled { + errors.push("[audio] tx_enabled must be false when using the soapysdr backend".into()); + } + + // Decoder names must not appear in more than one channel + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for ch in &self.sdr.channels { + for dec in &ch.decoders { + if let Some(prev_id) = seen.get(dec) { + errors.push(format!( + "[sdr.channels] decoder \"{}\" appears in both \"{}\" and \"{}\"", + dec, prev_id, ch.id + )); + } else { + seen.insert(dec.clone(), ch.id.clone()); + } + } + } + + errors + } + + /// Load configuration from a specific file path. + pub fn load_from_file(path: &Path) -> Result { + ::load_from_file(path) + } + + /// Load configuration from the default search paths. + /// Returns default config if no config file is found. + pub fn load_from_default_paths() -> Result<(Self, Option), ConfigError> { + ::load_from_default_paths() + } + + /// Return the effective list of rig instances to spawn. + /// + /// When `[[rigs]]` entries are present, only enabled entries are returned. + /// Otherwise the legacy flat `[rig]` / `[audio]` / … fields are synthesised + /// into a single `RigInstanceConfig` with `id = "default"`. + pub fn resolved_rigs(&self) -> Vec { + if !self.rigs.is_empty() { + // Auto-generate IDs for rigs that don't have explicit ones. + return self + .rigs + .iter() + .enumerate() + .filter(|(_, rig)| rig.enable) + .map(|(idx, rig)| { + let id = if rig.id.trim().is_empty() { + // Generate ID from model name with counter. + let model = rig.rig.model.as_deref().unwrap_or("unknown").to_lowercase(); + format!("{}_{}", model, idx) + } else { + rig.id.clone() + }; + + RigInstanceConfig { id, ..rig.clone() } + }) + .collect(); + } + vec![RigInstanceConfig { + enable: true, + id: "default".to_string(), + name: None, + rig: self.rig.clone(), + behavior: self.behavior.clone(), + audio: self.audio.clone(), + sdr: self.sdr.clone(), + pskreporter: self.pskreporter.clone(), + aprsfi: self.aprsfi.clone(), + decode_logs: self.decode_logs.clone(), + }] + } + + /// Generate an example configuration wrapped under the `[trx-server]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-server")] + inner: ServerConfig, + } + let example = ServerConfig { + general: GeneralConfig { + callsign: Some("N0CALL".to_string()), + log_level: Some("info".to_string()), + latitude: Some(52.2297), + longitude: Some(21.0122), + }, + rig: RigConfig { + model: Some("ft817".to_string()), + initial_freq_hz: 144_300_000, + initial_mode: RigMode::USB, + access: AccessConfig { + access_type: Some("serial".to_string()), + port: Some("/dev/ttyUSB0".to_string()), + baud: Some(9600), + host: None, + tcp_port: None, + args: None, + }, + }, + behavior: BehaviorConfig::default(), + listen: ListenConfig::default(), + audio: AudioConfig::default(), + pskreporter: PskReporterConfig::default(), + aprsfi: AprsFiConfig::default(), + decode_logs: DecodeLogsConfig::default(), + sdr: SdrConfig::default(), + timeouts: TimeoutsConfig::default(), + rigs: Vec::new(), + }; + toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } +} + +fn validate_coordinates(latitude: Option, longitude: Option) -> Result<(), String> { + match (latitude, longitude) { + (Some(lat), Some(lon)) => { + if !(-90.0..=90.0).contains(&lat) { + return Err("[general].latitude must be in range -90..=90".to_string()); + } + if !(-180.0..=180.0).contains(&lon) { + return Err("[general].longitude must be in range -180..=180".to_string()); + } + Ok(()) + } + (None, None) => Ok(()), + _ => Err( + "[general].latitude and [general].longitude must be set together or both omitted" + .to_string(), + ), + } +} + +fn validate_access(access: &AccessConfig) -> Result<(), String> { + let serial_fields_set = access.port.is_some() || access.baud.is_some(); + let tcp_fields_set = access.host.is_some() || access.tcp_port.is_some(); + + if access.access_type.is_none() && !serial_fields_set && !tcp_fields_set { + return Ok(()); + } + + match access.access_type.as_deref().unwrap_or("serial") { + "serial" => { + if access.port.as_deref().unwrap_or("").trim().is_empty() { + return Err( + "[rig.access].port must be set for serial access ([rig.access].type='serial')" + .to_string(), + ); + } + if access.baud.unwrap_or(0) == 0 { + return Err( + "[rig.access].baud must be > 0 for serial access ([rig.access].type='serial')" + .to_string(), + ); + } + } + "tcp" => { + if access.host.as_deref().unwrap_or("").trim().is_empty() { + return Err( + "[rig.access].host must be set for tcp access ([rig.access].type='tcp')" + .to_string(), + ); + } + if access.tcp_port.unwrap_or(0) == 0 { + return Err( + "[rig.access].tcp_port must be > 0 for tcp access ([rig.access].type='tcp')" + .to_string(), + ); + } + } + "sdr" => { + // SDR-specific validation is handled by validate_sdr() + } + other => { + return Err(format!( + "[rig.access].type '{}' is invalid (expected 'serial', 'tcp', or 'sdr')", + other + )) + } + } + Ok(()) +} + +fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> { + if !squelch.threshold_db.is_finite() { + return Err(format!("{path}.threshold_db must be finite")); + } + if !(-140.0..=0.0).contains(&squelch.threshold_db) { + return Err(format!("{path}.threshold_db must be in range -140..=0")); + } + if !squelch.hysteresis_db.is_finite() { + return Err(format!("{path}.hysteresis_db must be finite")); + } + if !(0.0..=40.0).contains(&squelch.hysteresis_db) { + return Err(format!("{path}.hysteresis_db must be in range 0..=40")); + } + if squelch.tail_ms > 10_000 { + return Err(format!("{path}.tail_ms must be <= 10000")); + } + Ok(()) +} + +fn validate_sdr_nb_config(path: &str, nb: &SdrNoiseBlankerConfig) -> Result<(), String> { + if !nb.threshold.is_finite() { + return Err(format!("{path}.threshold must be finite")); + } + if !(1.0..=100.0).contains(&nb.threshold) { + return Err(format!("{path}.threshold must be in range 1..=100")); + } + Ok(()) +} + +impl ConfigFile for ServerConfig { + fn section_key() -> &'static str { + "trx-server" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = ServerConfig::default(); + assert_eq!(config.rig.initial_freq_hz, 144_300_000); + assert_eq!(config.rig.initial_mode, RigMode::USB); + assert_eq!(config.behavior.poll_interval_ms, 500); + assert_eq!(config.behavior.max_retries, 3); + assert!(config.listen.enabled); + assert_eq!(config.listen.port, 4530); + assert!(config.listen.auth.tokens.is_empty()); + assert!(config.audio.enabled); + assert_eq!(config.audio.port, 4531); + assert_eq!(config.audio.sample_rate, 48000); + assert!(!config.pskreporter.enabled); + assert_eq!(config.pskreporter.port, 4739); + assert!(!config.aprsfi.enabled); + assert_eq!(config.aprsfi.host, "rotate.aprs.net"); + assert_eq!(config.aprsfi.port, 14580); + assert_eq!(config.aprsfi.passcode, -1); + assert!(!config.decode_logs.enabled); + assert!(std::path::Path::new(&config.decode_logs.dir) + .ends_with(std::path::Path::new("decoders"))); + } + + #[test] + fn test_parse_minimal_toml() { + let toml_str = r#" +[rig] +model = "ft817" + +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +"#; + + let config: ServerConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.rig.model, Some("ft817".to_string())); + assert_eq!(config.rig.access.port, Some("/dev/ttyUSB0".to_string())); + assert_eq!(config.rig.access.baud, Some(9600)); + } + + #[test] + fn test_parse_full_toml() { + let toml_str = r#" +[general] +callsign = "W1AW" +log_level = "debug" + +[rig] +model = "ft817" +initial_freq_hz = 7100000 +initial_mode = "LSB" + +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 + +[behavior] +poll_interval_ms = 1000 +poll_interval_tx_ms = 200 +max_retries = 5 +retry_base_delay_ms = 50 + +[listen] +enabled = true +listen = "0.0.0.0" +port = 5000 + +[listen.auth] +tokens = ["secret123"] +"#; + + let config: ServerConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.general.callsign, Some("W1AW".to_string())); + assert_eq!(config.general.log_level, Some("debug".to_string())); + assert_eq!(config.rig.initial_freq_hz, 7_100_000); + assert_eq!(config.rig.initial_mode, RigMode::LSB); + assert_eq!(config.behavior.poll_interval_ms, 1000); + assert_eq!(config.behavior.max_retries, 5); + assert!(config.listen.enabled); + assert_eq!( + config.listen.listen, + std::net::IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0)) + ); + assert_eq!(config.listen.port, 5000); + assert_eq!(config.listen.auth.tokens, vec!["secret123".to_string()]); + } + + #[test] + fn test_example_combined_toml_parses() { + let example = ServerConfig::example_combined_toml(); + let table: toml::Table = toml::from_str(&example).unwrap(); + let section = toml::to_string(table.get("trx-server").unwrap()).unwrap(); + let _config: ServerConfig = toml::from_str(§ion).unwrap(); + } + + #[test] + fn test_validate_rejects_invalid_coordinates() { + let mut config = ServerConfig::default(); + config.general.latitude = Some(120.0); + config.general.longitude = Some(10.0); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_invalid_audio_frame_duration() { + let mut config = ServerConfig::default(); + config.rig.access.port = Some("/dev/ttyUSB0".to_string()); + config.rig.access.baud = Some(9600); + config.audio.frame_duration_ms = 7; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_pskreporter_requires_locator_source() { + let mut config = ServerConfig::default(); + config.rig.access.port = Some("/dev/ttyUSB0".to_string()); + config.rig.access.baud = Some(9600); + config.pskreporter.enabled = true; + config.pskreporter.receiver_locator = None; + config.general.latitude = None; + config.general.longitude = None; + assert!(config.validate().is_err()); + + config.general.latitude = Some(52.0); + config.general.longitude = Some(21.0); + assert!(config.validate().is_ok()); + } + + // --- SDR-11: validate_sdr() unit tests --- + + fn sdr_config_with_access(args: &str) -> ServerConfig { + let mut cfg = ServerConfig::default(); + cfg.rig.access.access_type = Some("sdr".to_string()); + cfg.rig.access.args = Some(args.to_string()); + cfg.audio.tx_enabled = false; + cfg.sdr.sample_rate = 1_920_000; + cfg.sdr.center_offset_hz = 200_000; + cfg + } + + fn add_channel( + cfg: &mut ServerConfig, + id: &str, + offset_hz: i64, + stream_opus: bool, + decoders: Vec, + ) { + cfg.sdr.channels.push(SdrChannelConfig { + id: id.to_string(), + offset_hz, + stream_opus, + decoders, + ..SdrChannelConfig::default() + }); + } + + #[test] + fn test_sdr_validate_ok_minimal() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + add_channel(&mut cfg, "primary", 0, false, vec![]); + let errors = cfg.validate_sdr(); + assert!(errors.is_empty(), "expected no errors, got: {:?}", errors); + } + + #[test] + fn test_sdr_validate_non_sdr_skips() { + let cfg = ServerConfig::default(); + let errors = cfg.validate_sdr(); + assert!( + errors.is_empty(), + "expected no errors for non-sdr config, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_empty_args() { + let cfg = sdr_config_with_access(""); + let errors = cfg.validate_sdr(); + assert_eq!( + errors.len(), + 1, + "expected exactly 1 error, got: {:?}", + errors + ); + assert!( + errors[0].contains("args"), + "expected error to mention 'args', got: {}", + errors[0] + ); + } + + #[test] + fn test_sdr_validate_missing_args() { + let mut cfg = sdr_config_with_access("placeholder"); + cfg.rig.access.args = None; + let errors = cfg.validate_sdr(); + assert_eq!( + errors.len(), + 1, + "expected exactly 1 error, got: {:?}", + errors + ); + assert!( + errors[0].contains("args"), + "expected error to mention 'args', got: {}", + errors[0] + ); + } + + #[test] + fn test_sdr_validate_zero_sample_rate() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 0; + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("sample_rate")), + "expected error mentioning 'sample_rate', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_channel_if_out_of_range() { + // sample_rate=1_000_000 => Nyquist=500_000 + // center_offset_hz=0, offset_hz=600_000 => IF=600_000 > 500_000 + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 1_000_000; + cfg.sdr.center_offset_hz = 0; + add_channel(&mut cfg, "ch_high", 600_000, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ch_high") && (e.contains("Nyquist") || e.contains("exceeds"))), + "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_channel_if_negative_out_of_range() { + // sample_rate=1_000_000 => Nyquist=500_000 + // center_offset_hz=0, offset_hz=-600_000 => IF=-600_000, abs=600_000 > 500_000 + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 1_000_000; + cfg.sdr.center_offset_hz = 0; + add_channel(&mut cfg, "ch_low", -600_000, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ch_low") && (e.contains("Nyquist") || e.contains("exceeds"))), + "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_channel_if_exactly_nyquist_is_invalid() { + // sample_rate=1_000_000 => Nyquist=500_000 + // IF=500_000 is NOT strictly less than 500_000 => invalid + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 1_000_000; + cfg.sdr.center_offset_hz = 0; + add_channel(&mut cfg, "ch_nyquist", 500_000, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ch_nyquist") + && (e.contains("Nyquist") || e.contains("exceeds"))), + "expected error for IF exactly at Nyquist, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_dual_stream_opus() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + add_channel(&mut cfg, "ch1", 0, true, vec![]); + add_channel(&mut cfg, "ch2", 10_000, true, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("stream_opus")), + "expected error mentioning 'stream_opus', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_tx_enabled_with_sdr() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.audio.tx_enabled = true; + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("tx_enabled")), + "expected error mentioning 'tx_enabled', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_duplicate_decoder() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + add_channel(&mut cfg, "ch1", 0, false, vec!["ft8".to_string()]); + add_channel(&mut cfg, "ch2", 10_000, false, vec!["ft8".to_string()]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ft8") || e.contains("decoder")), + "expected error mentioning 'ft8' or 'decoder', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_multiple_errors() { + let mut cfg = sdr_config_with_access("placeholder"); + cfg.rig.access.args = None; + cfg.sdr.sample_rate = 0; + cfg.audio.tx_enabled = true; + let errors = cfg.validate_sdr(); + assert_eq!( + errors.len(), + 3, + "expected exactly 3 errors, got: {:?}", + errors + ); + } + + #[test] + fn test_validate_rejects_invalid_sdr_squelch_threshold() { + let mut cfg = ServerConfig::default(); + cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); + cfg.rig.access.baud = Some(9600); + cfg.sdr.squelch.threshold_db = 10.0; + let err = cfg + .validate() + .expect_err("expected squelch threshold validation error"); + assert!( + err.contains("squelch") && err.contains("threshold_db"), + "unexpected validation error: {err}" + ); + } + + #[test] + fn test_validate_rejects_invalid_sdr_squelch_hysteresis() { + let mut cfg = ServerConfig::default(); + cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); + cfg.rig.access.baud = Some(9600); + cfg.sdr.squelch.hysteresis_db = 99.0; + let err = cfg + .validate() + .expect_err("expected squelch hysteresis validation error"); + assert!( + err.contains("squelch") && err.contains("hysteresis_db"), + "unexpected validation error: {err}" + ); + } + + // --- MR-08: multi-rig config tests --- + + #[test] + fn test_resolved_rigs_legacy_flat_fields() { + let mut cfg = ServerConfig::default(); + cfg.rig.model = Some("ft817".to_string()); + cfg.rig.access.access_type = Some("serial".to_string()); + cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); + cfg.rig.access.baud = Some(9600); + + let rigs = cfg.resolved_rigs(); + assert_eq!(rigs.len(), 1); + assert_eq!(rigs[0].id, "default"); + assert_eq!(rigs[0].rig.model, Some("ft817".to_string())); + } + + #[test] + fn test_resolved_rigs_multi_rig_toml() { + let toml_str = r#" +[general] +callsign = "W1AW" + +[[rigs]] +id = "hf" + +[rigs.rig] +model = "ft450d" +initial_freq_hz = 14074000 + +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 + +[rigs.audio] +port = 4531 + +[[rigs]] +id = "sdr" + +[rigs.rig] +model = "soapysdr" + +[rigs.rig.access] +type = "sdr" +args = "driver=rtlsdr" + +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let rigs = cfg.resolved_rigs(); + assert_eq!(rigs.len(), 2); + assert_eq!(rigs[0].id, "hf"); + assert_eq!(rigs[0].rig.model, Some("ft450d".to_string())); + assert_eq!(rigs[0].audio.port, 4531); + assert_eq!(rigs[1].id, "sdr"); + assert_eq!(rigs[1].rig.model, Some("soapysdr".to_string())); + assert_eq!(rigs[1].audio.port, 4532); + } + + #[test] + fn test_resolved_rigs_skips_disabled_entries() { + let toml_str = r#" +[[rigs]] +id = "disabled" +enable = false +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "enabled" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let rigs = cfg.resolved_rigs(); + assert_eq!(rigs.len(), 1); + assert_eq!(rigs[0].id, "enabled"); + } + + #[test] + fn test_validate_rejects_duplicate_rig_ids() { + let toml_str = r#" +[[rigs]] +id = "rig1" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "rig1" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let result = cfg.validate(); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("duplicate rig id"), + "expected error about duplicate rig id" + ); + } + + #[test] + fn test_validate_rejects_duplicate_audio_ports() { + let toml_str = r#" +[[rigs]] +id = "rig1" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "rig2" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4531 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let result = cfg.validate(); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("duplicate audio port"), + "expected error about duplicate audio port" + ); + } + + #[test] + fn test_validate_allows_disabled_duplicate_rig_ids() { + let toml_str = r#" +[[rigs]] +id = "rig1" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "rig1" +enable = false +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + assert!( + cfg.validate().is_ok(), + "expected Ok because disabled rigs are excluded from uniqueness checks" + ); + } + + #[test] + fn test_validate_rejects_when_all_multi_rigs_disabled() { + let toml_str = r#" +[[rigs]] +id = "rig1" +enable = false +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let result = cfg.validate(); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("no enabled entries"), + "expected error about enabled rig entries" + ); + } + + #[test] + fn test_validate_accepts_multi_rig_unique_ids_and_ports() { + let toml_str = r#" +[[rigs]] +id = "hf" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "sdr" +[rigs.rig] +model = "soapysdr" +[rigs.rig.access] +type = "sdr" +args = "driver=rtlsdr" +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + // validate() uses the flat [rig] field for rig-level checks; multi-rig + // validation focuses on ID/port uniqueness. The flat [rig] is default + // (no model), so the access check is skipped when both fields are absent. + assert!( + cfg.validate().is_ok(), + "expected Ok for valid multi-rig config" + ); + } +} diff --git a/src/trx-app/src/shared_config.rs b/src/trx-config/src/shared.rs similarity index 100% rename from src/trx-app/src/shared_config.rs rename to src/trx-config/src/shared.rs diff --git a/src/trx-config/src/url.rs b/src/trx-config/src/url.rs new file mode 100644 index 00000000..4c3ba821 --- /dev/null +++ b/src/trx-config/src/url.rs @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Parsing for the `host:port` endpoint URLs used by the client's `[[remotes]]` +//! and `[frontends.audio]` settings. +//! +//! These live next to the config structs because validation needs them; the +//! client re-exports them for its connection code. + +/// Default port for the server's JSON control listener. +pub const DEFAULT_REMOTE_PORT: u16 = 4530; +/// Default port for the server's Opus audio listener. +pub const DEFAULT_AUDIO_PORT: u16 = 4531; + +/// A resolved `host:port` pair. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RemoteEndpoint { + pub host: String, + pub port: u16, +} + +impl RemoteEndpoint { + /// Format as a connect string, bracketing bare IPv6 hosts. + pub fn connect_addr(&self) -> String { + if self.host.contains(':') && !self.host.starts_with('[') { + format!("[{}]:{}", self.host, self.port) + } else { + format!("{}:{}", self.host, self.port) + } + } +} + +/// Parse a remote control URL, defaulting to port 4530. +pub fn parse_remote_url(url: &str) -> Result { + parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote") +} + +/// Parse an audio stream URL, defaulting to port 4531. +pub fn parse_audio_url(url: &str) -> Result { + parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio") +} + +fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err(format!("{kind} url is empty")); + } + + let addr = trimmed + .strip_prefix("tcp://") + .or_else(|| trimmed.strip_prefix("http-json://")) + .or_else(|| trimmed.strip_prefix("audio://")) + .unwrap_or(trimmed); + + parse_host_port(addr, default_port, kind) +} + +fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result { + if let Some(rest) = input.strip_prefix('[') { + let closing = rest + .find(']') + .ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?; + let host = &rest[..closing]; + let remainder = &rest[closing + 1..]; + if host.is_empty() { + return Err(format!("invalid {kind} url: host is empty")); + } + let port = if remainder.is_empty() { + default_port + } else if let Some(port_str) = remainder.strip_prefix(':') { + parse_port(port_str, kind)? + } else { + return Err(format!("invalid {kind} url: expected ':' after ']'")); + }; + return Ok(RemoteEndpoint { + host: host.to_string(), + port, + }); + } + + if input.contains(':') { + if input.matches(':').count() > 1 { + return Err(format!( + "invalid {kind} url: IPv6 host must be bracketed like [::1]:4532" + )); + } + let (host, port_str) = input + .rsplit_once(':') + .ok_or_else(|| format!("invalid {kind} url: expected host:port"))?; + if host.is_empty() { + return Err(format!("invalid {kind} url: host is empty")); + } + return Ok(RemoteEndpoint { + host: host.to_string(), + port: parse_port(port_str, kind)?, + }); + } + + Ok(RemoteEndpoint { + host: input.to_string(), + port: default_port, + }) +} + +fn parse_port(port_str: &str, kind: &str) -> Result { + let port: u16 = port_str + .parse() + .map_err(|_| format!("invalid {kind} port: '{port_str}'"))?; + if port == 0 { + return Err(format!("invalid {kind} port: 0")); + } + Ok(port) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_remote_url_defaults_port() { + let ep = parse_remote_url("example.com").unwrap(); + assert_eq!(ep.host, "example.com"); + assert_eq!(ep.port, DEFAULT_REMOTE_PORT); + } + + #[test] + fn test_parse_audio_url_defaults_port() { + let ep = parse_audio_url("example.com").unwrap(); + assert_eq!(ep.port, DEFAULT_AUDIO_PORT); + } + + #[test] + fn test_parse_strips_schemes() { + for url in &[ + "tcp://host:9000", + "http-json://host:9000", + "audio://host:9000", + ] { + let ep = parse_remote_url(url).unwrap(); + assert_eq!(ep.host, "host"); + assert_eq!(ep.port, 9000); + } + } + + #[test] + fn test_parse_bracketed_ipv6() { + let ep = parse_remote_url("[::1]:4532").unwrap(); + assert_eq!(ep.host, "::1"); + assert_eq!(ep.port, 4532); + assert_eq!(ep.connect_addr(), "[::1]:4532"); + } + + #[test] + fn test_parse_rejects_unbracketed_ipv6() { + assert!(parse_remote_url("::1:4532").is_err()); + } + + #[test] + fn test_parse_rejects_empty_host() { + assert!(parse_remote_url(":4530").is_err()); + } + + #[test] + fn test_parse_rejects_zero_port() { + assert!(parse_remote_url("host:0").is_err()); + } + + #[test] + fn test_parse_rejects_empty_url() { + assert!(parse_remote_url(" ").is_err()); + } +} diff --git a/src/trx-configurator/Cargo.toml b/src/trx-configurator/Cargo.toml index f8250cd5..1db800a0 100644 --- a/src/trx-configurator/Cargo.toml +++ b/src/trx-configurator/Cargo.toml @@ -16,6 +16,7 @@ clap = { workspace = true, features = ["derive"] } dialoguer = "0.11" tokio-serial = { workspace = true } toml_edit = "0.22" +trx-config = { path = "../trx-config" } [dev-dependencies] tempfile = "3" diff --git a/src/trx-server/Cargo.toml b/src/trx-server/Cargo.toml index b5a83373..90740a89 100644 --- a/src/trx-server/Cargo.toml +++ b/src/trx-server/Cargo.toml @@ -32,6 +32,7 @@ cpal = "0.15" num-complex = "0.4" opus = "0.3" trx-app = { path = "../trx-app" } +trx-config = { path = "../trx-config" } trx-backend = { path = "trx-backend", features = ["soapysdr"] } trx-ais = { path = "../decoders/trx-ais" } trx-vdes = { path = "../decoders/trx-vdes" } diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index 99453a8e..924d810c 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -2,1523 +2,10 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -//! Configuration file support for trx-server. +//! Server configuration types. //! -//! Config is loaded from the `[trx-server]` section of `trx-rs.toml`. -//! Default search order: -//! 1. Path specified via `--config` CLI argument -//! 2. `./trx-rs.toml` -//! 3. `~/.config/trx-rs/trx-rs.toml` -//! 4. `/etc/trx-rs/trx-rs.toml` +//! The definitions live in the shared `trx-config` crate so that +//! `trx-configurator` checks a config with exactly the same code the server +//! loads it with. This module re-exports them under the binary's own path. -use std::net::IpAddr; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; -use trx_app::{validate_log_level, validate_tokens, ConfigError, ConfigFile}; -pub use trx_decode_log::DecodeLogsConfig; - -use trx_core::rig::state::RigMode; - -/// Per-rig instance configuration for multi-rig setups. -/// -/// Each entry in `[[rigs]]` becomes one of these. The flat top-level -/// `[rig]` / `[audio]` / `[sdr]` / `[pskreporter]` / `[aprsfi]` / -/// `[behavior]` / `[decode_logs]` fields are still supported via -/// `ServerConfig::resolved_rigs()` which synthesises a single-element list -/// with `id = "default"` when `rigs` is empty. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct RigInstanceConfig { - /// Whether this rig instance should be started. - /// Defaults to true so existing configs remain unchanged. - pub enable: bool, - /// Stable rig identifier used in protocol routing. - pub id: String, - /// Display name for the rig (e.g., "HF Transceiver", "VHF/UHF SDR"). - /// If not specified, defaults to the rig id. - pub name: Option, - /// Rig backend configuration. - pub rig: RigConfig, - /// Polling and retry behavior. - pub behavior: BehaviorConfig, - /// Audio streaming configuration for this rig. - pub audio: AudioConfig, - /// SDR pipeline configuration (only used when [rigs.rig.access] type = "sdr"). - pub sdr: SdrConfig, - /// PSK Reporter uplink for this rig. - pub pskreporter: PskReporterConfig, - /// APRS-IS IGate uplink for this rig. - pub aprsfi: AprsFiConfig, - /// Decoder file logging for this rig. - pub decode_logs: DecodeLogsConfig, -} - -impl Default for RigInstanceConfig { - fn default() -> Self { - Self { - enable: true, - id: String::new(), - name: None, - rig: RigConfig::default(), - behavior: BehaviorConfig::default(), - audio: AudioConfig::default(), - sdr: SdrConfig::default(), - pskreporter: PskReporterConfig::default(), - aprsfi: AprsFiConfig::default(), - decode_logs: DecodeLogsConfig::default(), - } - } -} - -impl RigInstanceConfig { - /// Get the display name for this rig. - /// Returns the configured name if set, otherwise the id. - pub fn display_name(&self) -> &str { - self.name.as_deref().unwrap_or(&self.id) - } -} - -/// Top-level server configuration structure. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct ServerConfig { - /// General settings - pub general: GeneralConfig, - /// Rig backend configuration (legacy flat; use [[rigs]] for multi-rig) - pub rig: RigConfig, - /// Polling and retry behavior (legacy flat) - pub behavior: BehaviorConfig, - /// TCP listener configuration - pub listen: ListenConfig, - /// Audio streaming configuration (legacy flat) - pub audio: AudioConfig, - /// PSK Reporter uplink configuration (legacy flat) - pub pskreporter: PskReporterConfig, - /// APRS-IS IGate uplink configuration (legacy flat) - pub aprsfi: AprsFiConfig, - /// Decoder file logging configuration (legacy flat) - pub decode_logs: DecodeLogsConfig, - /// SDR pipeline configuration (legacy flat; used when [rig.access] type = "sdr"). - pub sdr: SdrConfig, - /// Timeout and buffer-size tuning knobs. - pub timeouts: TimeoutsConfig, - /// Multi-rig instance list. When non-empty, takes priority over the flat fields. - #[serde(rename = "rigs", default)] - pub rigs: Vec, -} - -/// General application settings. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct GeneralConfig { - /// Callsign or owner label - pub callsign: Option, - /// Log level (trace, debug, info, warn, error) - pub log_level: Option, - /// Receiver latitude (decimal degrees, WGS84) - pub latitude: Option, - /// Receiver longitude (decimal degrees, WGS84) - pub longitude: Option, -} - -impl Default for GeneralConfig { - fn default() -> Self { - Self { - callsign: Some("N0CALL".to_string()), - log_level: None, - latitude: None, - longitude: None, - } - } -} - -/// Rig backend configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct RigConfig { - /// Rig model (e.g., "ft817", "ft450d", "ic7300") - pub model: Option, - /// Initial frequency (Hz) for the rig state before first CAT read - pub initial_freq_hz: u64, - /// Initial mode for the rig state before first CAT read - pub initial_mode: RigMode, - /// Access method configuration - pub access: AccessConfig, -} - -/// Access method configuration for reaching the rig. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct AccessConfig { - /// Access type: "serial" or "tcp" - #[serde(rename = "type")] - pub access_type: Option, - /// Serial port path (for serial access) - pub port: Option, - /// Baud rate (for serial access) - pub baud: Option, - /// Host address (for TCP access) - pub host: Option, - /// TCP port (for TCP access) - pub tcp_port: Option, - /// SoapySDR device args string (for sdr access), e.g. "driver=rtlsdr". - pub args: Option, -} - -impl Default for RigConfig { - fn default() -> Self { - Self { - model: None, - initial_freq_hz: 144_300_000, - initial_mode: RigMode::USB, - access: AccessConfig::default(), - } - } -} - -/// Behavior configuration for polling and retries. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct BehaviorConfig { - /// Polling interval in milliseconds when idle - pub poll_interval_ms: u64, - /// Polling interval in milliseconds when transmitting - pub poll_interval_tx_ms: u64, - /// Maximum retry attempts for transient errors - pub max_retries: u32, - /// Base delay for exponential backoff in milliseconds - pub retry_base_delay_ms: u64, - /// Whether to prime both VFOs on startup by toggling and reading each. - /// Defaults to true for rigs with dual VFOs (e.g. FT-817). - pub vfo_prime: bool, -} - -impl Default for BehaviorConfig { - fn default() -> Self { - Self { - poll_interval_ms: 500, - poll_interval_tx_ms: 100, - max_retries: 3, - retry_base_delay_ms: 100, - vfo_prime: true, - } - } -} - -/// Timeout and buffer-size tuning knobs. -/// -/// All durations are in milliseconds. The defaults match the previously -/// hard-coded values, so existing deployments are unaffected. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct TimeoutsConfig { - /// Maximum time (ms) to wait for a single rig command to complete. - pub command_exec_timeout_ms: u64, - /// Maximum time (ms) for a CAT poll refresh cycle. - pub poll_refresh_timeout_ms: u64, - /// Maximum time (ms) for low-level listener I/O operations (read/write/flush). - pub io_timeout_ms: u64, - /// Maximum time (ms) to wait for a rig command response in the listener. - pub request_timeout_ms: u64, - /// Capacity of the per-rig command channel (number of queued requests). - pub rig_task_channel_buffer: usize, -} - -impl Default for TimeoutsConfig { - fn default() -> Self { - Self { - command_exec_timeout_ms: 10_000, - poll_refresh_timeout_ms: 8_000, - io_timeout_ms: 10_000, - request_timeout_ms: 12_000, - rig_task_channel_buffer: 32, - } - } -} - -/// TCP listener configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct ListenConfig { - /// Whether the listener is enabled - pub enabled: bool, - /// IP address to listen on - pub listen: IpAddr, - /// TCP port to listen on - pub port: u16, - /// Authentication configuration - pub auth: AuthConfig, -} - -impl Default for ListenConfig { - fn default() -> Self { - Self { - enabled: true, - listen: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - port: 4530, - auth: AuthConfig::default(), - } - } -} - -/// Authentication configuration for the TCP listener. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct AuthConfig { - /// Valid authentication tokens (empty = no auth required) - pub tokens: Vec, -} - -/// Audio streaming configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct AudioConfig { - /// Whether audio streaming is enabled - pub enabled: bool, - /// IP address to listen on for audio connections - pub listen: IpAddr, - /// TCP port for audio connections - pub port: u16, - /// Whether RX audio capture is enabled - pub rx_enabled: bool, - /// Whether TX audio playback is enabled - pub tx_enabled: bool, - /// Audio input device name (None = system default) - pub device: Option, - /// Sample rate in Hz - pub sample_rate: u32, - /// Number of audio channels - pub channels: u8, - /// Opus frame duration in milliseconds - pub frame_duration_ms: u16, - /// Opus bitrate in bits per second - pub bitrate_bps: u32, -} - -impl Default for AudioConfig { - fn default() -> Self { - Self { - enabled: true, - listen: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - port: 4531, - rx_enabled: true, - tx_enabled: true, - device: None, - sample_rate: 48000, - channels: 2, - frame_duration_ms: 20, - bitrate_bps: 256000, - } - } -} - -pub use trx_reporting::{AprsFiConfig, PskReporterConfig}; - -/// Top-level SDR configuration (only used when [rig.access] type = "sdr"). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrConfig { - /// SoapySDR IQ capture sample rate (Hz). Must be supported by the device. - pub sample_rate: u32, - /// Hardware IF filter bandwidth (Hz). - pub bandwidth: u32, - /// WFM deemphasis time constant in microseconds (50 or 75). - pub wfm_deemphasis_us: u32, - /// SDR tunes this many Hz below the dial frequency to keep signal off DC. - pub center_offset_hz: i64, - /// Gain configuration. - pub gain: SdrGainConfig, - /// Virtual software squelch applied to demodulated audio except WFM. - pub squelch: SdrSquelchConfig, - /// Noise blanker for impulse noise suppression on IQ samples. - pub noise_blanker: SdrNoiseBlankerConfig, - /// Virtual receiver channels (at least one required when SDR backend is active). - pub channels: Vec, - /// Maximum number of simultaneous virtual channels (including the primary). - /// Default: 4. - #[serde(default = "default_max_virtual_channels")] - pub max_virtual_channels: usize, -} - -fn default_max_virtual_channels() -> usize { - 4 -} - -impl Default for SdrConfig { - fn default() -> Self { - Self { - sample_rate: 1_920_000, - bandwidth: 1_500_000, - wfm_deemphasis_us: 50, - center_offset_hz: 100_000, - gain: SdrGainConfig::default(), - squelch: SdrSquelchConfig::default(), - noise_blanker: SdrNoiseBlankerConfig::default(), - channels: Vec::new(), - max_virtual_channels: default_max_virtual_channels(), - } - } -} - -/// Virtual squelch settings for SoapySDR demodulated audio (except WFM mode). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrSquelchConfig { - /// Enables software squelch for demodulated audio except WFM. - pub enabled: bool, - /// Open threshold in dBFS (typical range: -120..0). - pub threshold_db: f32, - /// Hysteresis in dB used when closing the squelch. - pub hysteresis_db: f32, - /// Tail hold time after dropping below threshold. - pub tail_ms: u32, -} - -impl Default for SdrSquelchConfig { - fn default() -> Self { - Self { - enabled: false, - threshold_db: -65.0, - hysteresis_db: 3.0, - tail_ms: 180, - } - } -} - -/// Noise blanker settings for impulse noise suppression on IQ samples. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrNoiseBlankerConfig { - /// Enables the noise blanker. - pub enabled: bool, - /// Threshold multiplier for impulse detection (typical range: 1..100). - /// A sample whose magnitude exceeds threshold × running RMS is blanked. - pub threshold: f64, -} - -impl Default for SdrNoiseBlankerConfig { - fn default() -> Self { - Self { - enabled: false, - threshold: 10.0, - } - } -} - -/// Gain control mode for the SDR device. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrGainConfig { - /// "auto" (hardware AGC) or "manual" (fixed dB). - pub mode: String, - /// Gain in dB; effective only when mode = "manual". - pub value: f64, - /// Optional hard ceiling for the applied hardware gain in dB. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_value: Option, -} - -impl Default for SdrGainConfig { - fn default() -> Self { - Self { - mode: "auto".to_string(), - value: 30.0, - max_value: None, - } - } -} - -/// One virtual receiver channel within the wideband IQ stream. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrChannelConfig { - /// Human-readable identifier used in logs. - pub id: String, - /// Frequency offset from the dial frequency (Hz). Primary channel should use 0. - pub offset_hz: i64, - /// Demodulation mode: "auto" (follows RigCat set_mode) or a fixed RigMode string - /// (e.g. "USB", "FM"). - pub mode: String, - /// One-sided bandwidth of the post-demod audio BPF (Hz). - pub audio_bandwidth_hz: u32, - /// CW tone centre frequency in the audio domain (Hz). Default 700. - pub cw_center_hz: u32, - /// Pre-demod bandwidth for WFM only (Hz). Default 75000. - pub wfm_bandwidth_hz: u32, - /// Decoder names that receive this channel's PCM frames. - /// Valid values: "ft8", "wspr", "aprs", "cw". - pub decoders: Vec, - /// If true, encode this channel's audio as Opus and stream over TCP. - /// At most one channel may set this to true. - pub stream_opus: bool, -} - -impl Default for SdrChannelConfig { - fn default() -> Self { - Self { - id: String::new(), - offset_hz: 0, - mode: "auto".to_string(), - audio_bandwidth_hz: 3000, - cw_center_hz: 700, - wfm_bandwidth_hz: 75_000, - decoders: Vec::new(), - stream_opus: false, - } - } -} - -impl ServerConfig { - pub fn validate(&self) -> Result<(), String> { - validate_log_level(self.general.log_level.as_deref())?; - validate_coordinates(self.general.latitude, self.general.longitude)?; - - if self.rig.initial_freq_hz == 0 { - return Err("[rig].initial_freq_hz must be > 0".to_string()); - } - - validate_access(&self.rig.access)?; - - if self.behavior.poll_interval_ms == 0 { - return Err("[behavior].poll_interval_ms must be > 0".to_string()); - } - if self.behavior.poll_interval_tx_ms == 0 { - return Err("[behavior].poll_interval_tx_ms must be > 0".to_string()); - } - if self.behavior.max_retries == 0 { - return Err("[behavior].max_retries must be > 0".to_string()); - } - if self.behavior.retry_base_delay_ms == 0 { - return Err("[behavior].retry_base_delay_ms must be > 0".to_string()); - } - - validate_tokens("[listen.auth].tokens", &self.listen.auth.tokens)?; - if self.listen.enabled && self.listen.port == 0 { - return Err("[listen].port must be > 0 when listener is enabled".to_string()); - } - - if self.audio.enabled { - if self.audio.port == 0 { - return Err("[audio].port must be > 0 when audio is enabled".to_string()); - } - if !self.audio.rx_enabled && !self.audio.tx_enabled { - return Err( - "[audio] enabled but both rx_enabled and tx_enabled are false".to_string(), - ); - } - if self.audio.sample_rate < 8_000 || self.audio.sample_rate > 192_000 { - return Err("[audio].sample_rate must be in range 8000..=192000".to_string()); - } - if !(1..=2).contains(&self.audio.channels) { - return Err("[audio].channels must be 1 or 2".to_string()); - } - match self.audio.frame_duration_ms { - 3 | 5 | 10 | 20 | 40 | 60 => {} - _ => { - return Err( - "[audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60" - .to_string(), - ) - } - } - if self.audio.bitrate_bps == 0 { - return Err("[audio].bitrate_bps must be > 0".to_string()); - } - } - - if self.pskreporter.enabled { - if self.pskreporter.host.trim().is_empty() { - return Err("[pskreporter].host must not be empty".to_string()); - } - if self.pskreporter.port == 0 { - return Err("[pskreporter].port must be > 0".to_string()); - } - if self.pskreporter.receiver_locator.is_none() - && (self.general.latitude.is_none() || self.general.longitude.is_none()) - { - return Err( - "[pskreporter] enabled requires either [pskreporter].receiver_locator \ - or [general].latitude and [general].longitude" - .to_string(), - ); - } - } - - if self.aprsfi.enabled { - if self.aprsfi.host.trim().is_empty() { - return Err("[aprsfi].host must not be empty".to_string()); - } - if self.aprsfi.port == 0 { - return Err("[aprsfi].port must be > 0".to_string()); - } - } - - if let Some(max_gain) = self.sdr.gain.max_value { - if !max_gain.is_finite() { - return Err("[sdr.gain].max_value must be finite".to_string()); - } - if max_gain < 0.0 { - return Err("[sdr.gain].max_value must be >= 0".to_string()); - } - } - validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?; - validate_sdr_nb_config("[sdr.noise_blanker]", &self.sdr.noise_blanker)?; - - // Multi-rig uniqueness checks. - if !self.rigs.is_empty() { - let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); - let mut seen_ports: std::collections::HashSet = std::collections::HashSet::new(); - let mut enabled_count = 0usize; - for rig in &self.rigs { - if !rig.enable { - continue; - } - enabled_count += 1; - // Check for explicit duplicate IDs (empty IDs are auto-generated later). - if !rig.id.trim().is_empty() && !seen_ids.insert(rig.id.clone()) { - return Err(format!("[[rigs]] duplicate rig id: \"{}\"", rig.id)); - } - if rig.audio.enabled && !seen_ports.insert(rig.audio.port) { - return Err(format!( - "[[rigs]] duplicate audio port {} (rig id: \"{}\")", - rig.audio.port, rig.id - )); - } - if let Some(max_gain) = rig.sdr.gain.max_value { - if !max_gain.is_finite() { - return Err(format!( - "[[rigs]] [sdr.gain].max_value must be finite (rig id: \"{}\")", - rig.id - )); - } - if max_gain < 0.0 { - return Err(format!( - "[[rigs]] [sdr.gain].max_value must be >= 0 (rig id: \"{}\")", - rig.id - )); - } - } - validate_sdr_squelch_config( - &format!("[[rigs]] [sdr.squelch] (rig id: \"{}\")", rig.id), - &rig.sdr.squelch, - )?; - validate_sdr_nb_config( - &format!("[[rigs]] [sdr.noise_blanker] (rig id: \"{}\")", rig.id), - &rig.sdr.noise_blanker, - )?; - } - if enabled_count == 0 { - return Err( - "[[rigs]] has no enabled entries; set at least one [[rigs]].enable = true" - .to_string(), - ); - } - } - - if self.decode_logs.enabled { - if self.decode_logs.dir.trim().is_empty() { - return Err("[decode_logs].dir must not be empty when enabled".to_string()); - } - if self.decode_logs.aprs_file.trim().is_empty() - || self.decode_logs.cw_file.trim().is_empty() - || self.decode_logs.ft8_file.trim().is_empty() - || self.decode_logs.wspr_file.trim().is_empty() - { - return Err("[decode_logs] file names must not be empty when enabled".to_string()); - } - } - - Ok(()) - } - - /// Validate SDR-specific config rules (see SDR.md §11). - /// Returns a Vec of error strings; empty means valid. - pub fn validate_sdr(&self) -> Vec { - let mut errors = Vec::new(); - - // Only validate if access type is "sdr" - let is_sdr = self.rig.access.access_type.as_deref() == Some("sdr"); - if !is_sdr { - return errors; - } - - // args must be non-empty - if self - .rig - .access - .args - .as_deref() - .map(str::is_empty) - .unwrap_or(true) - { - errors.push("[rig.access] args must be non-empty for type = \"sdr\"".into()); - } - - // sample_rate must be non-zero - if self.sdr.sample_rate == 0 { - errors.push("[sdr] sample_rate must be > 0".into()); - } - - // Every channel's IF must fit within the captured bandwidth - let half_rate = self.sdr.sample_rate as i64 / 2; - for ch in &self.sdr.channels { - let channel_if = self.sdr.center_offset_hz + ch.offset_hz; - if channel_if.abs() >= half_rate { - errors.push(format!( - "[sdr.channels] id=\"{}\" IF frequency {} Hz exceeds Nyquist limit ±{} Hz", - ch.id, channel_if, half_rate - )); - } - } - - // At most one channel may have stream_opus = true - let opus_count = self.sdr.channels.iter().filter(|c| c.stream_opus).count(); - if opus_count > 1 { - errors.push(format!( - "[sdr.channels] at most one channel may have stream_opus = true (found {})", - opus_count - )); - } - - // tx_enabled must be false with SDR backend - if self.audio.tx_enabled { - errors.push("[audio] tx_enabled must be false when using the soapysdr backend".into()); - } - - // Decoder names must not appear in more than one channel - let mut seen: std::collections::HashMap = std::collections::HashMap::new(); - for ch in &self.sdr.channels { - for dec in &ch.decoders { - if let Some(prev_id) = seen.get(dec) { - errors.push(format!( - "[sdr.channels] decoder \"{}\" appears in both \"{}\" and \"{}\"", - dec, prev_id, ch.id - )); - } else { - seen.insert(dec.clone(), ch.id.clone()); - } - } - } - - errors - } - - /// Load configuration from a specific file path. - pub fn load_from_file(path: &Path) -> Result { - ::load_from_file(path) - } - - /// Load configuration from the default search paths. - /// Returns default config if no config file is found. - pub fn load_from_default_paths() -> Result<(Self, Option), ConfigError> { - ::load_from_default_paths() - } - - /// Return the effective list of rig instances to spawn. - /// - /// When `[[rigs]]` entries are present, only enabled entries are returned. - /// Otherwise the legacy flat `[rig]` / `[audio]` / … fields are synthesised - /// into a single `RigInstanceConfig` with `id = "default"`. - pub fn resolved_rigs(&self) -> Vec { - if !self.rigs.is_empty() { - // Auto-generate IDs for rigs that don't have explicit ones. - return self - .rigs - .iter() - .enumerate() - .filter(|(_, rig)| rig.enable) - .map(|(idx, rig)| { - let id = if rig.id.trim().is_empty() { - // Generate ID from model name with counter. - let model = rig.rig.model.as_deref().unwrap_or("unknown").to_lowercase(); - format!("{}_{}", model, idx) - } else { - rig.id.clone() - }; - - RigInstanceConfig { id, ..rig.clone() } - }) - .collect(); - } - vec![RigInstanceConfig { - enable: true, - id: "default".to_string(), - name: None, - rig: self.rig.clone(), - behavior: self.behavior.clone(), - audio: self.audio.clone(), - sdr: self.sdr.clone(), - pskreporter: self.pskreporter.clone(), - aprsfi: self.aprsfi.clone(), - decode_logs: self.decode_logs.clone(), - }] - } - - /// Generate an example configuration wrapped under the `[trx-server]` - /// section header, suitable for use in a combined `trx-rs.toml` file. - pub fn example_combined_toml() -> String { - #[derive(serde::Serialize)] - struct Wrapper { - #[serde(rename = "trx-server")] - inner: ServerConfig, - } - let example = ServerConfig { - general: GeneralConfig { - callsign: Some("N0CALL".to_string()), - log_level: Some("info".to_string()), - latitude: Some(52.2297), - longitude: Some(21.0122), - }, - rig: RigConfig { - model: Some("ft817".to_string()), - initial_freq_hz: 144_300_000, - initial_mode: RigMode::USB, - access: AccessConfig { - access_type: Some("serial".to_string()), - port: Some("/dev/ttyUSB0".to_string()), - baud: Some(9600), - host: None, - tcp_port: None, - args: None, - }, - }, - behavior: BehaviorConfig::default(), - listen: ListenConfig::default(), - audio: AudioConfig::default(), - pskreporter: PskReporterConfig::default(), - aprsfi: AprsFiConfig::default(), - decode_logs: DecodeLogsConfig::default(), - sdr: SdrConfig::default(), - timeouts: TimeoutsConfig::default(), - rigs: Vec::new(), - }; - toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() - } -} - -fn validate_coordinates(latitude: Option, longitude: Option) -> Result<(), String> { - match (latitude, longitude) { - (Some(lat), Some(lon)) => { - if !(-90.0..=90.0).contains(&lat) { - return Err("[general].latitude must be in range -90..=90".to_string()); - } - if !(-180.0..=180.0).contains(&lon) { - return Err("[general].longitude must be in range -180..=180".to_string()); - } - Ok(()) - } - (None, None) => Ok(()), - _ => Err( - "[general].latitude and [general].longitude must be set together or both omitted" - .to_string(), - ), - } -} - -fn validate_access(access: &AccessConfig) -> Result<(), String> { - let serial_fields_set = access.port.is_some() || access.baud.is_some(); - let tcp_fields_set = access.host.is_some() || access.tcp_port.is_some(); - - if access.access_type.is_none() && !serial_fields_set && !tcp_fields_set { - return Ok(()); - } - - match access.access_type.as_deref().unwrap_or("serial") { - "serial" => { - if access.port.as_deref().unwrap_or("").trim().is_empty() { - return Err( - "[rig.access].port must be set for serial access ([rig.access].type='serial')" - .to_string(), - ); - } - if access.baud.unwrap_or(0) == 0 { - return Err( - "[rig.access].baud must be > 0 for serial access ([rig.access].type='serial')" - .to_string(), - ); - } - } - "tcp" => { - if access.host.as_deref().unwrap_or("").trim().is_empty() { - return Err( - "[rig.access].host must be set for tcp access ([rig.access].type='tcp')" - .to_string(), - ); - } - if access.tcp_port.unwrap_or(0) == 0 { - return Err( - "[rig.access].tcp_port must be > 0 for tcp access ([rig.access].type='tcp')" - .to_string(), - ); - } - } - "sdr" => { - // SDR-specific validation is handled by validate_sdr() - } - other => { - return Err(format!( - "[rig.access].type '{}' is invalid (expected 'serial', 'tcp', or 'sdr')", - other - )) - } - } - Ok(()) -} - -fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> { - if !squelch.threshold_db.is_finite() { - return Err(format!("{path}.threshold_db must be finite")); - } - if !(-140.0..=0.0).contains(&squelch.threshold_db) { - return Err(format!("{path}.threshold_db must be in range -140..=0")); - } - if !squelch.hysteresis_db.is_finite() { - return Err(format!("{path}.hysteresis_db must be finite")); - } - if !(0.0..=40.0).contains(&squelch.hysteresis_db) { - return Err(format!("{path}.hysteresis_db must be in range 0..=40")); - } - if squelch.tail_ms > 10_000 { - return Err(format!("{path}.tail_ms must be <= 10000")); - } - Ok(()) -} - -fn validate_sdr_nb_config(path: &str, nb: &SdrNoiseBlankerConfig) -> Result<(), String> { - if !nb.threshold.is_finite() { - return Err(format!("{path}.threshold must be finite")); - } - if !(1.0..=100.0).contains(&nb.threshold) { - return Err(format!("{path}.threshold must be in range 1..=100")); - } - Ok(()) -} - -impl ConfigFile for ServerConfig { - fn section_key() -> &'static str { - "trx-server" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_default_config() { - let config = ServerConfig::default(); - assert_eq!(config.rig.initial_freq_hz, 144_300_000); - assert_eq!(config.rig.initial_mode, RigMode::USB); - assert_eq!(config.behavior.poll_interval_ms, 500); - assert_eq!(config.behavior.max_retries, 3); - assert!(config.listen.enabled); - assert_eq!(config.listen.port, 4530); - assert!(config.listen.auth.tokens.is_empty()); - assert!(config.audio.enabled); - assert_eq!(config.audio.port, 4531); - assert_eq!(config.audio.sample_rate, 48000); - assert!(!config.pskreporter.enabled); - assert_eq!(config.pskreporter.port, 4739); - assert!(!config.aprsfi.enabled); - assert_eq!(config.aprsfi.host, "rotate.aprs.net"); - assert_eq!(config.aprsfi.port, 14580); - assert_eq!(config.aprsfi.passcode, -1); - assert!(!config.decode_logs.enabled); - assert!(std::path::Path::new(&config.decode_logs.dir) - .ends_with(std::path::Path::new("decoders"))); - } - - #[test] - fn test_parse_minimal_toml() { - let toml_str = r#" -[rig] -model = "ft817" - -[rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -"#; - - let config: ServerConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.rig.model, Some("ft817".to_string())); - assert_eq!(config.rig.access.port, Some("/dev/ttyUSB0".to_string())); - assert_eq!(config.rig.access.baud, Some(9600)); - } - - #[test] - fn test_parse_full_toml() { - let toml_str = r#" -[general] -callsign = "W1AW" -log_level = "debug" - -[rig] -model = "ft817" -initial_freq_hz = 7100000 -initial_mode = "LSB" - -[rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 - -[behavior] -poll_interval_ms = 1000 -poll_interval_tx_ms = 200 -max_retries = 5 -retry_base_delay_ms = 50 - -[listen] -enabled = true -listen = "0.0.0.0" -port = 5000 - -[listen.auth] -tokens = ["secret123"] -"#; - - let config: ServerConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.general.callsign, Some("W1AW".to_string())); - assert_eq!(config.general.log_level, Some("debug".to_string())); - assert_eq!(config.rig.initial_freq_hz, 7_100_000); - assert_eq!(config.rig.initial_mode, RigMode::LSB); - assert_eq!(config.behavior.poll_interval_ms, 1000); - assert_eq!(config.behavior.max_retries, 5); - assert!(config.listen.enabled); - assert_eq!( - config.listen.listen, - std::net::IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0)) - ); - assert_eq!(config.listen.port, 5000); - assert_eq!(config.listen.auth.tokens, vec!["secret123".to_string()]); - } - - #[test] - fn test_example_combined_toml_parses() { - let example = ServerConfig::example_combined_toml(); - let table: toml::Table = toml::from_str(&example).unwrap(); - let section = toml::to_string(table.get("trx-server").unwrap()).unwrap(); - let _config: ServerConfig = toml::from_str(§ion).unwrap(); - } - - #[test] - fn test_validate_rejects_invalid_coordinates() { - let mut config = ServerConfig::default(); - config.general.latitude = Some(120.0); - config.general.longitude = Some(10.0); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_invalid_audio_frame_duration() { - let mut config = ServerConfig::default(); - config.rig.access.port = Some("/dev/ttyUSB0".to_string()); - config.rig.access.baud = Some(9600); - config.audio.frame_duration_ms = 7; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_pskreporter_requires_locator_source() { - let mut config = ServerConfig::default(); - config.rig.access.port = Some("/dev/ttyUSB0".to_string()); - config.rig.access.baud = Some(9600); - config.pskreporter.enabled = true; - config.pskreporter.receiver_locator = None; - config.general.latitude = None; - config.general.longitude = None; - assert!(config.validate().is_err()); - - config.general.latitude = Some(52.0); - config.general.longitude = Some(21.0); - assert!(config.validate().is_ok()); - } - - // --- SDR-11: validate_sdr() unit tests --- - - fn sdr_config_with_access(args: &str) -> ServerConfig { - let mut cfg = ServerConfig::default(); - cfg.rig.access.access_type = Some("sdr".to_string()); - cfg.rig.access.args = Some(args.to_string()); - cfg.audio.tx_enabled = false; - cfg.sdr.sample_rate = 1_920_000; - cfg.sdr.center_offset_hz = 200_000; - cfg - } - - fn add_channel( - cfg: &mut ServerConfig, - id: &str, - offset_hz: i64, - stream_opus: bool, - decoders: Vec, - ) { - cfg.sdr.channels.push(SdrChannelConfig { - id: id.to_string(), - offset_hz, - stream_opus, - decoders, - ..SdrChannelConfig::default() - }); - } - - #[test] - fn test_sdr_validate_ok_minimal() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - add_channel(&mut cfg, "primary", 0, false, vec![]); - let errors = cfg.validate_sdr(); - assert!(errors.is_empty(), "expected no errors, got: {:?}", errors); - } - - #[test] - fn test_sdr_validate_non_sdr_skips() { - let cfg = ServerConfig::default(); - let errors = cfg.validate_sdr(); - assert!( - errors.is_empty(), - "expected no errors for non-sdr config, got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_empty_args() { - let cfg = sdr_config_with_access(""); - let errors = cfg.validate_sdr(); - assert_eq!( - errors.len(), - 1, - "expected exactly 1 error, got: {:?}", - errors - ); - assert!( - errors[0].contains("args"), - "expected error to mention 'args', got: {}", - errors[0] - ); - } - - #[test] - fn test_sdr_validate_missing_args() { - let mut cfg = sdr_config_with_access("placeholder"); - cfg.rig.access.args = None; - let errors = cfg.validate_sdr(); - assert_eq!( - errors.len(), - 1, - "expected exactly 1 error, got: {:?}", - errors - ); - assert!( - errors[0].contains("args"), - "expected error to mention 'args', got: {}", - errors[0] - ); - } - - #[test] - fn test_sdr_validate_zero_sample_rate() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.sdr.sample_rate = 0; - let errors = cfg.validate_sdr(); - assert!( - errors.iter().any(|e| e.contains("sample_rate")), - "expected error mentioning 'sample_rate', got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_channel_if_out_of_range() { - // sample_rate=1_000_000 => Nyquist=500_000 - // center_offset_hz=0, offset_hz=600_000 => IF=600_000 > 500_000 - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.sdr.sample_rate = 1_000_000; - cfg.sdr.center_offset_hz = 0; - add_channel(&mut cfg, "ch_high", 600_000, false, vec![]); - let errors = cfg.validate_sdr(); - assert!( - errors - .iter() - .any(|e| e.contains("ch_high") && (e.contains("Nyquist") || e.contains("exceeds"))), - "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_channel_if_negative_out_of_range() { - // sample_rate=1_000_000 => Nyquist=500_000 - // center_offset_hz=0, offset_hz=-600_000 => IF=-600_000, abs=600_000 > 500_000 - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.sdr.sample_rate = 1_000_000; - cfg.sdr.center_offset_hz = 0; - add_channel(&mut cfg, "ch_low", -600_000, false, vec![]); - let errors = cfg.validate_sdr(); - assert!( - errors - .iter() - .any(|e| e.contains("ch_low") && (e.contains("Nyquist") || e.contains("exceeds"))), - "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_channel_if_exactly_nyquist_is_invalid() { - // sample_rate=1_000_000 => Nyquist=500_000 - // IF=500_000 is NOT strictly less than 500_000 => invalid - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.sdr.sample_rate = 1_000_000; - cfg.sdr.center_offset_hz = 0; - add_channel(&mut cfg, "ch_nyquist", 500_000, false, vec![]); - let errors = cfg.validate_sdr(); - assert!( - errors - .iter() - .any(|e| e.contains("ch_nyquist") - && (e.contains("Nyquist") || e.contains("exceeds"))), - "expected error for IF exactly at Nyquist, got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_dual_stream_opus() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - add_channel(&mut cfg, "ch1", 0, true, vec![]); - add_channel(&mut cfg, "ch2", 10_000, true, vec![]); - let errors = cfg.validate_sdr(); - assert!( - errors.iter().any(|e| e.contains("stream_opus")), - "expected error mentioning 'stream_opus', got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_tx_enabled_with_sdr() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.audio.tx_enabled = true; - let errors = cfg.validate_sdr(); - assert!( - errors.iter().any(|e| e.contains("tx_enabled")), - "expected error mentioning 'tx_enabled', got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_duplicate_decoder() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - add_channel(&mut cfg, "ch1", 0, false, vec!["ft8".to_string()]); - add_channel(&mut cfg, "ch2", 10_000, false, vec!["ft8".to_string()]); - let errors = cfg.validate_sdr(); - assert!( - errors - .iter() - .any(|e| e.contains("ft8") || e.contains("decoder")), - "expected error mentioning 'ft8' or 'decoder', got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_multiple_errors() { - let mut cfg = sdr_config_with_access("placeholder"); - cfg.rig.access.args = None; - cfg.sdr.sample_rate = 0; - cfg.audio.tx_enabled = true; - let errors = cfg.validate_sdr(); - assert_eq!( - errors.len(), - 3, - "expected exactly 3 errors, got: {:?}", - errors - ); - } - - #[test] - fn test_validate_rejects_invalid_sdr_squelch_threshold() { - let mut cfg = ServerConfig::default(); - cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); - cfg.rig.access.baud = Some(9600); - cfg.sdr.squelch.threshold_db = 10.0; - let err = cfg - .validate() - .expect_err("expected squelch threshold validation error"); - assert!( - err.contains("squelch") && err.contains("threshold_db"), - "unexpected validation error: {err}" - ); - } - - #[test] - fn test_validate_rejects_invalid_sdr_squelch_hysteresis() { - let mut cfg = ServerConfig::default(); - cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); - cfg.rig.access.baud = Some(9600); - cfg.sdr.squelch.hysteresis_db = 99.0; - let err = cfg - .validate() - .expect_err("expected squelch hysteresis validation error"); - assert!( - err.contains("squelch") && err.contains("hysteresis_db"), - "unexpected validation error: {err}" - ); - } - - // --- MR-08: multi-rig config tests --- - - #[test] - fn test_resolved_rigs_legacy_flat_fields() { - let mut cfg = ServerConfig::default(); - cfg.rig.model = Some("ft817".to_string()); - cfg.rig.access.access_type = Some("serial".to_string()); - cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); - cfg.rig.access.baud = Some(9600); - - let rigs = cfg.resolved_rigs(); - assert_eq!(rigs.len(), 1); - assert_eq!(rigs[0].id, "default"); - assert_eq!(rigs[0].rig.model, Some("ft817".to_string())); - } - - #[test] - fn test_resolved_rigs_multi_rig_toml() { - let toml_str = r#" -[general] -callsign = "W1AW" - -[[rigs]] -id = "hf" - -[rigs.rig] -model = "ft450d" -initial_freq_hz = 14074000 - -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 - -[rigs.audio] -port = 4531 - -[[rigs]] -id = "sdr" - -[rigs.rig] -model = "soapysdr" - -[rigs.rig.access] -type = "sdr" -args = "driver=rtlsdr" - -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let rigs = cfg.resolved_rigs(); - assert_eq!(rigs.len(), 2); - assert_eq!(rigs[0].id, "hf"); - assert_eq!(rigs[0].rig.model, Some("ft450d".to_string())); - assert_eq!(rigs[0].audio.port, 4531); - assert_eq!(rigs[1].id, "sdr"); - assert_eq!(rigs[1].rig.model, Some("soapysdr".to_string())); - assert_eq!(rigs[1].audio.port, 4532); - } - - #[test] - fn test_resolved_rigs_skips_disabled_entries() { - let toml_str = r#" -[[rigs]] -id = "disabled" -enable = false -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "enabled" -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB1" -baud = 9600 -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let rigs = cfg.resolved_rigs(); - assert_eq!(rigs.len(), 1); - assert_eq!(rigs[0].id, "enabled"); - } - - #[test] - fn test_validate_rejects_duplicate_rig_ids() { - let toml_str = r#" -[[rigs]] -id = "rig1" -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "rig1" -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB1" -baud = 9600 -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let result = cfg.validate(); - assert!(result.is_err()); - assert!( - result.unwrap_err().contains("duplicate rig id"), - "expected error about duplicate rig id" - ); - } - - #[test] - fn test_validate_rejects_duplicate_audio_ports() { - let toml_str = r#" -[[rigs]] -id = "rig1" -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "rig2" -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB1" -baud = 9600 -[rigs.audio] -port = 4531 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let result = cfg.validate(); - assert!(result.is_err()); - assert!( - result.unwrap_err().contains("duplicate audio port"), - "expected error about duplicate audio port" - ); - } - - #[test] - fn test_validate_allows_disabled_duplicate_rig_ids() { - let toml_str = r#" -[[rigs]] -id = "rig1" -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "rig1" -enable = false -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB1" -baud = 9600 -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - assert!( - cfg.validate().is_ok(), - "expected Ok because disabled rigs are excluded from uniqueness checks" - ); - } - - #[test] - fn test_validate_rejects_when_all_multi_rigs_disabled() { - let toml_str = r#" -[[rigs]] -id = "rig1" -enable = false -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let result = cfg.validate(); - assert!(result.is_err()); - assert!( - result.unwrap_err().contains("no enabled entries"), - "expected error about enabled rig entries" - ); - } - - #[test] - fn test_validate_accepts_multi_rig_unique_ids_and_ports() { - let toml_str = r#" -[[rigs]] -id = "hf" -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "sdr" -[rigs.rig] -model = "soapysdr" -[rigs.rig.access] -type = "sdr" -args = "driver=rtlsdr" -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - // validate() uses the flat [rig] field for rig-level checks; multi-rig - // validation focuses on ID/port uniqueness. The flat [rig] is default - // (no model), so the access check is skipped when both fields are absent. - assert!( - cfg.validate().is_ok(), - "expected Ok for valid multi-rig config" - ); - } -} +pub use trx_config::server::*; -- 2.55.0 From bede2e34fe8e78eaca82a29b590c324e29239b89 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 20:46:59 +0200 Subject: [PATCH 02/13] [fix](trx-config): accept both sectioned and bare config files trx-configurator wrote standalone configs with [general]/[rig] at the root while the loader required a [trx-server] section header, so every config the wizard generated with --type server or --type client was rejected by the binary it was generated for: $ trx-server --config trx-server.toml Error: ParseError("trx-server.toml", "missing [trx-server] section") Teach the loader to fall back to the document root when no section header is present, so hand-written standalone files keep working, and have the wizard emit the same sectioned shape --print-config does. A file carrying only the *other* component's section still reports the missing section rather than silently loading defaults. Round-trip tests now load every document the wizard can generate through the real loader and validator. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- src/trx-config/src/file.rs | 79 ++++++++++++++++++++++++++++-- src/trx-configurator/src/writer.rs | 68 ++++++++++++++++++++++--- 2 files changed, 135 insertions(+), 12 deletions(-) diff --git a/src/trx-config/src/file.rs b/src/trx-config/src/file.rs index d0b1ea7a..beeaea14 100644 --- a/src/trx-config/src/file.rs +++ b/src/trx-config/src/file.rs @@ -2,10 +2,28 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +//! Loading a config section out of a TOML file. +//! +//! Two file shapes are accepted: +//! +//! - **Sectioned** — a combined `trx-rs.toml` with `[trx-server]` and/or +//! `[trx-client]` tables. This is what `--print-config` and +//! `trx-configurator` emit. +//! - **Bare** — a standalone file whose root *is* the section, i.e. `[general]` +//! and `[rig]` at the top level. Hand-written per-binary configs use this. +//! +//! A file that carries some other component's section but not ours is treated +//! as "section absent" rather than as a bare file, so loading a client-only +//! config with the server reports the missing section instead of silently +//! falling back to defaults. + use serde::de::DeserializeOwned; use std::path::{Path, PathBuf}; use thiserror::Error; +/// Every section key that may appear at the root of a combined config file. +pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"]; + #[derive(Debug, Error)] pub enum ConfigError { #[error("Failed to read config file {0}: {1}")] @@ -26,6 +44,22 @@ fn config_search_paths() -> Vec { paths } +/// Pick the table holding `key`'s settings out of a parsed document. +/// +/// Returns the named section when present, the whole document when it carries +/// no section headers at all (a bare standalone file), or `None` when the file +/// is sectioned but has no section for `key`. +fn select_section(table: &toml::Table, key: &str) -> Option { + if let Some(section) = table.get(key) { + return Some(section.clone()); + } + let is_sectioned = SECTION_KEYS.iter().any(|k| table.contains_key(*k)); + if is_sectioned { + return None; + } + Some(toml::Value::Table(table.clone())) +} + /// Extract and deserialize a named section from a TOML file. /// /// Returns `Ok(Some(cfg))` when the section is present and parses cleanly, @@ -40,12 +74,12 @@ fn load_section_from_file( let table: toml::Table = toml::from_str(&content) .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; - let Some(section) = table.get(key) else { + let Some(section) = select_section(&table, key) else { return Ok(None); }; // Re-serialize the section then parse as T so all serde defaults apply. - let section_toml = toml::to_string(section) + let section_toml = toml::to_string(§ion) .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; let cfg = toml::from_str::(§ion_toml) .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; @@ -59,8 +93,10 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned { /// Load the section from a specific file path. /// - /// Returns an error if the file cannot be read, is not valid TOML, or - /// does not contain the expected `[]` header. + /// Accepts both a sectioned file (`[]` at the root) and a bare + /// file whose root is the section itself. Returns an error if the file + /// cannot be read, is not valid TOML, or is sectioned for some other + /// component only. fn load_from_file(path: &Path) -> Result { load_section_from_file::(path, Self::section_key())?.ok_or_else(|| { ConfigError::ParseError( @@ -86,3 +122,38 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned { Ok((Self::default(), None)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn table(s: &str) -> toml::Table { + toml::from_str(s).unwrap() + } + + #[test] + fn test_select_section_prefers_named_section() { + let t = table("[trx-server]\n[trx-server.general]\ncallsign = \"W1AW\"\n"); + let section = select_section(&t, "trx-server").unwrap(); + assert!(section.get("general").is_some()); + } + + #[test] + fn test_select_section_falls_back_to_root_for_bare_file() { + let t = table("[general]\ncallsign = \"W1AW\"\n"); + let section = select_section(&t, "trx-server").unwrap(); + assert!(section.get("general").is_some()); + } + + #[test] + fn test_select_section_absent_when_other_section_present() { + let t = table("[trx-client]\n[trx-client.general]\ncallsign = \"W1AW\"\n"); + assert!(select_section(&t, "trx-server").is_none()); + } + + #[test] + fn test_select_section_empty_file_is_bare() { + let t = table(""); + assert!(select_section(&t, "trx-server").is_some()); + } +} diff --git a/src/trx-configurator/src/writer.rs b/src/trx-configurator/src/writer.rs index e5837a1a..fc1de6f7 100644 --- a/src/trx-configurator/src/writer.rs +++ b/src/trx-configurator/src/writer.rs @@ -211,10 +211,13 @@ pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup) let mut doc = DocumentMut::new(); doc.decor_mut() .set_prefix("# trx-server configuration\n# Generated by trx-configurator\n"); - let tables = build_server_tables(general, rig, listen); - for (key, item) in tables.iter() { - doc.insert(key, item.clone()); - } + // Emit the sectioned shape (`[trx-server]`) that trx-server writes with + // --print-config, so a generated file can be dropped into a combined + // trx-rs.toml unchanged. + doc.insert( + "trx-server", + Item::Table(build_server_tables(general, rig, listen)), + ); doc } @@ -350,10 +353,10 @@ pub fn build_client( let mut doc = DocumentMut::new(); doc.decor_mut() .set_prefix("# trx-client configuration\n# Generated by trx-configurator\n"); - let tables = build_client_tables(general, remote, frontends); - for (key, item) in tables.iter() { - doc.insert(key, item.clone()); - } + doc.insert( + "trx-client", + Item::Table(build_client_tables(general, remote, frontends)), + ); doc } @@ -453,3 +456,52 @@ pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> { println!("Wrote {}", path.display()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use trx_config::{ClientConfig, ServerConfig}; + + fn write_temp(doc: &DocumentMut) -> tempfile::NamedTempFile { + let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap(); + std::fs::write(file.path(), doc.to_string()).unwrap(); + file + } + + /// The wizard used to emit root-level `[general]` / `[rig]` tables while the + /// loader demanded a `[trx-server]` section, so every generated standalone + /// config was rejected by the binary it was generated for. + #[test] + fn test_generated_server_config_loads_and_validates() { + let file = write_temp(&build_default(ConfigType::Server)); + let cfg = ServerConfig::load_from_file(file.path()).expect("generated config must load"); + cfg.validate().expect("generated config must validate"); + assert_eq!(cfg.rig.model.as_deref(), Some("ft817")); + assert_eq!(cfg.listen.port, 4530); + } + + #[test] + fn test_generated_client_config_loads_and_validates() { + let file = write_temp(&build_default(ConfigType::Client)); + let cfg = ClientConfig::load_from_file(file.path()).expect("generated config must load"); + cfg.validate().expect("generated config must validate"); + assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530")); + } + + #[test] + fn test_generated_combined_config_loads_both_sections() { + let file = write_temp(&build_default(ConfigType::Combined)); + let server = ServerConfig::load_from_file(file.path()).expect("server section must load"); + server.validate().expect("server section must validate"); + let client = ClientConfig::load_from_file(file.path()).expect("client section must load"); + client.validate().expect("client section must validate"); + } + + #[test] + fn test_generated_docs_are_sectioned() { + let doc = build_default(ConfigType::Server); + assert!(doc.as_table().contains_key("trx-server")); + let doc = build_default(ConfigType::Client); + assert!(doc.as_table().contains_key("trx-client")); + } +} -- 2.55.0 From 335922feccbedb30b376397b05ebc714d7081137 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 20:55:01 +0200 Subject: [PATCH 03/13] [feat](trx-config): report unknown configuration keys Every config struct is #[serde(default)], so a misspelled key was dropped in silence and the setting kept its default. Writing `prot = 9999` under [listen] started the server on 4530 without a word. Collect the ignored key paths with serde_ignored and pair each with the closest known key at the same level: WARN unknown config key 'listen.prot' (did you mean 'listen.port'?) Warnings by default, so a config written for a newer version still runs on an older binary; --strict-config makes them fatal for CI. Logging now starts before validation so these warnings are actually visible. trx-configurator --check drops its hand-maintained key lists and re-implemented range checks in favour of the real loader and validators, so it no longer passes configs the binaries reject. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- Cargo.lock | 12 + src/trx-client/src/main.rs | 19 +- src/trx-config/Cargo.toml | 1 + src/trx-config/src/client.rs | 24 +- src/trx-config/src/file.rs | 101 +++++-- src/trx-config/src/lib.rs | 4 +- src/trx-config/src/server.rs | 18 +- src/trx-config/src/unknown.rs | 256 ++++++++++++++++ src/trx-configurator/Cargo.toml | 1 + src/trx-configurator/src/check.rs | 455 +++++++++-------------------- src/trx-configurator/src/writer.rs | 16 +- src/trx-server/src/main.rs | 25 +- 12 files changed, 563 insertions(+), 369 deletions(-) create mode 100644 src/trx-config/src/unknown.rs diff --git a/Cargo.lock b/Cargo.lock index 17174b46..2c6caee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2412,6 +2412,16 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -3127,6 +3137,7 @@ version = "0.1.0" dependencies = [ "dirs", "serde", + "serde_ignored", "thiserror 2.0.18", "toml", "tracing", @@ -3143,6 +3154,7 @@ dependencies = [ "dialoguer", "tempfile", "tokio-serial", + "toml", "toml_edit 0.22.27", "trx-config", ] diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 161f929b..5cef2e98 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -50,6 +50,9 @@ struct Cli { /// Print example configuration and exit #[arg(long = "print-config")] print_config: bool, + /// Treat unknown configuration keys as a fatal error + #[arg(long = "strict-config")] + strict_config: bool, /// Remote server URL (host:port) #[arg(short = 'u', long = "url")] url: Option, @@ -135,20 +138,24 @@ async fn async_init() -> DynResult { std::process::exit(0); } - let (cfg, config_path) = if let Some(ref path) = cli.config { - let cfg = ClientConfig::load_from_file(path)?; - (cfg, Some(path.clone())) + let loaded = if let Some(ref path) = cli.config { + ClientConfig::load_from_file(path)? } else { ClientConfig::load_from_default_paths()? }; - cfg.validate() - .map_err(|e| format!("Invalid client configuration: {}", e))?; + let config_path = loaded.path.clone(); - init_logging(cfg.general.log_level.as_deref()); + // Logging comes up before any config complaint so the warnings are visible. + init_logging(loaded.config.general.log_level.as_deref()); if let Some(ref path) = config_path { info!("Loaded configuration from {}", path.display()); } + loaded.report_unknown_keys(cli.strict_config)?; + + let cfg = loaded.config; + cfg.validate() + .map_err(|e| format!("Invalid client configuration: {}", e))?; frontend_runtime.http_auth.tokens = cfg .frontends diff --git a/src/trx-config/Cargo.toml b/src/trx-config/Cargo.toml index 6e23f93c..77f5f466 100644 --- a/src/trx-config/Cargo.toml +++ b/src/trx-config/Cargo.toml @@ -17,3 +17,4 @@ thiserror = "2" trx-core = { path = "../trx-core" } trx-decode-log = { path = "../decoders/trx-decode-log" } trx-reporting = { path = "../trx-reporting" } +serde_ignored = "0.1" diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index 6722119d..24c6ff82 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -13,11 +13,11 @@ use std::collections::HashMap; use std::net::IpAddr; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::file::{ConfigError, ConfigFile}; +use crate::file::{ConfigError, ConfigFile, ConfigLoad}; use crate::shared::{validate_log_level, validate_tokens}; /// Top-level client configuration structure. @@ -609,13 +609,13 @@ impl ClientConfig { } /// Load configuration from a specific file path. - pub fn load_from_file(path: &Path) -> Result { + pub fn load_from_file(path: &Path) -> Result, ConfigError> { ::load_from_file(path) } /// Load configuration from the default search paths. /// Returns default config if no config file is found. - pub fn load_from_default_paths() -> Result<(Self, Option), ConfigError> { + pub fn load_from_default_paths() -> Result, ConfigError> { ::load_from_default_paths() } @@ -734,6 +734,22 @@ impl ConfigFile for ClientConfig { fn section_key() -> &'static str { "trx-client" } + + /// Include one `[[remotes]]` entry so unknown keys nested inside a remote + /// entry still get a suggestion. + fn reference_value() -> toml::Value { + let cfg = ClientConfig { + remotes: vec![RemoteEntry { + name: "example".to_string(), + url: "127.0.0.1:4530".to_string(), + rig_id: Some("hf".to_string()), + auth: RemoteAuthConfig::default(), + poll_interval_ms: default_poll_interval_ms(), + }], + ..Default::default() + }; + toml::Value::try_from(cfg).unwrap_or_else(|_| toml::Value::Table(toml::Table::new())) + } } #[cfg(test)] diff --git a/src/trx-config/src/file.rs b/src/trx-config/src/file.rs index beeaea14..3e6feddd 100644 --- a/src/trx-config/src/file.rs +++ b/src/trx-config/src/file.rs @@ -18,12 +18,44 @@ //! falling back to defaults. use serde::de::DeserializeOwned; +use serde::Serialize; use std::path::{Path, PathBuf}; use thiserror::Error; +use crate::unknown::{describe, UnknownKey}; + /// Every section key that may appear at the root of a combined config file. pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"]; +/// A loaded config plus what the loader noticed on the way in. +#[derive(Debug, Clone)] +pub struct ConfigLoad { + /// The deserialized configuration. + pub config: T, + /// File the config came from; `None` when nothing was found and defaults + /// were used. + pub path: Option, + /// Keys present in the file that no config field claimed. + pub unknown_keys: Vec, +} + +impl ConfigLoad { + /// Log every unknown key as a warning. With `strict`, also return an error + /// so the caller can refuse to start. + pub fn report_unknown_keys(&self, strict: bool) -> Result<(), String> { + for key in &self.unknown_keys { + tracing::warn!("{}", key); + } + if strict && !self.unknown_keys.is_empty() { + return Err(format!( + "{} unknown config key(s); refusing to start because --strict-config is set", + self.unknown_keys.len() + )); + } + Ok(()) + } +} + #[derive(Debug, Error)] pub enum ConfigError { #[error("Failed to read config file {0}: {1}")] @@ -62,12 +94,13 @@ fn select_section(table: &toml::Table, key: &str) -> Option { /// Extract and deserialize a named section from a TOML file. /// -/// Returns `Ok(Some(cfg))` when the section is present and parses cleanly, -/// `Ok(None)` when the section is absent, or `Err` on I/O / parse failure. -fn load_section_from_file( +/// Returns `Ok(Some((cfg, unknown_keys)))` when the section is present and +/// parses cleanly, `Ok(None)` when the section is absent, or `Err` on I/O / +/// parse failure. +fn load_section_from_file( path: &Path, key: &str, -) -> Result, ConfigError> { +) -> Result)>, ConfigError> { let content = std::fs::read_to_string(path) .map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?; @@ -78,48 +111,72 @@ fn load_section_from_file( return Ok(None); }; - // Re-serialize the section then parse as T so all serde defaults apply. - let section_toml = toml::to_string(§ion) + // Deserialize straight from the TOML value so serde applies every default, + // recording any key no field claimed. + let mut ignored: Vec = Vec::new(); + let cfg: T = serde_ignored::deserialize(section, |path| ignored.push(path.to_string())) .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; - let cfg = toml::from_str::(§ion_toml) - .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; - Ok(Some(cfg)) + + Ok(Some((cfg, describe(&ignored, &T::reference_value())))) } /// Trait for loading configuration from a `trx-rs.toml` section. -pub trait ConfigFile: Sized + Default + DeserializeOwned { +pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize { /// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`). fn section_key() -> &'static str; + /// A TOML rendering of a populated config, used to suggest corrections for + /// unknown keys. Implementations should fill in list-valued sections such + /// as `[[rigs]]` so keys nested inside them can be suggested too. + fn reference_value() -> toml::Value { + toml::Value::try_from(Self::default()) + .unwrap_or_else(|_| toml::Value::Table(toml::Table::new())) + } + /// Load the section from a specific file path. /// /// Accepts both a sectioned file (`[]` at the root) and a bare /// file whose root is the section itself. Returns an error if the file /// cannot be read, is not valid TOML, or is sectioned for some other /// component only. - fn load_from_file(path: &Path) -> Result { - load_section_from_file::(path, Self::section_key())?.ok_or_else(|| { - ConfigError::ParseError( - path.to_path_buf(), - format!("missing [{}] section", Self::section_key()), - ) + fn load_from_file(path: &Path) -> Result, ConfigError> { + let (config, unknown_keys) = load_section_from_file::(path, Self::section_key())? + .ok_or_else(|| { + ConfigError::ParseError( + path.to_path_buf(), + format!("missing [{}] section", Self::section_key()), + ) + })?; + Ok(ConfigLoad { + config, + path: Some(path.to_path_buf()), + unknown_keys, }) } /// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load /// the first file that contains the expected section. /// - /// Returns `(config, path_where_found)` or `(Default::default(), None)` - /// when no config file is found. - fn load_from_default_paths() -> Result<(Self, Option), ConfigError> { + /// Falls back to `Self::default()` with no path when nothing is found. + fn load_from_default_paths() -> Result, ConfigError> { for path in config_search_paths() { if path.exists() { - if let Some(cfg) = load_section_from_file::(&path, Self::section_key())? { - return Ok((cfg, Some(path))); + if let Some((config, unknown_keys)) = + load_section_from_file::(&path, Self::section_key())? + { + return Ok(ConfigLoad { + config, + path: Some(path), + unknown_keys, + }); } } } - Ok((Self::default(), None)) + Ok(ConfigLoad { + config: Self::default(), + path: None, + unknown_keys: Vec::new(), + }) } } diff --git a/src/trx-config/src/lib.rs b/src/trx-config/src/lib.rs index 5bb554b2..4f0508b5 100644 --- a/src/trx-config/src/lib.rs +++ b/src/trx-config/src/lib.rs @@ -13,10 +13,12 @@ pub mod client; pub mod file; pub mod server; pub mod shared; +pub mod unknown; pub mod url; pub use client::ClientConfig; -pub use file::{ConfigError, ConfigFile}; +pub use file::{ConfigError, ConfigFile, ConfigLoad}; pub use server::ServerConfig; pub use shared::{validate_log_level, validate_tokens}; +pub use unknown::UnknownKey; pub use url::{parse_audio_url, parse_remote_url, RemoteEndpoint}; diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index d504e0a5..3139874e 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -12,10 +12,10 @@ //! 4. `/etc/trx-rs/trx-rs.toml` use std::net::IpAddr; -use std::path::{Path, PathBuf}; +use std::path::Path; use serde::{Deserialize, Serialize}; -use crate::file::{ConfigError, ConfigFile}; +use crate::file::{ConfigError, ConfigFile, ConfigLoad}; use crate::shared::{validate_log_level, validate_tokens}; pub use trx_decode_log::DecodeLogsConfig; @@ -706,13 +706,13 @@ impl ServerConfig { } /// Load configuration from a specific file path. - pub fn load_from_file(path: &Path) -> Result { + pub fn load_from_file(path: &Path) -> Result, ConfigError> { ::load_from_file(path) } /// Load configuration from the default search paths. /// Returns default config if no config file is found. - pub fn load_from_default_paths() -> Result<(Self, Option), ConfigError> { + pub fn load_from_default_paths() -> Result, ConfigError> { ::load_from_default_paths() } @@ -900,6 +900,16 @@ impl ConfigFile for ServerConfig { fn section_key() -> &'static str { "trx-server" } + + /// Include one `[[rigs]]` entry so unknown keys nested inside a rig entry + /// still get a suggestion. + fn reference_value() -> toml::Value { + let cfg = ServerConfig { + rigs: vec![RigInstanceConfig::default()], + ..Default::default() + }; + toml::Value::try_from(cfg).unwrap_or_else(|_| toml::Value::Table(toml::Table::new())) + } } #[cfg(test)] diff --git a/src/trx-config/src/unknown.rs b/src/trx-config/src/unknown.rs new file mode 100644 index 00000000..6d9e22ad --- /dev/null +++ b/src/trx-config/src/unknown.rs @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Reporting for config keys the deserializer ignored. +//! +//! Every config struct is `#[serde(default)]`, so a misspelled key used to be +//! dropped without a word and the setting silently kept its default. The +//! loader now collects the ignored key paths and pairs each with the closest +//! known key at the same level, so `prot = 9999` reads as a typo instead of +//! looking like it worked. + +use std::collections::BTreeSet; +use std::fmt; + +/// A config key that the deserializer did not recognise. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnknownKey { + /// Dotted path of the key, e.g. `listen.prot` or `rigs.0.audio.prot`. + pub path: String, + /// Closest known key at the same level, when one is near enough to suggest. + pub suggestion: Option, +} + +impl fmt::Display for UnknownKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.suggestion { + Some(s) => write!(f, "unknown config key '{}' (did you mean '{}'?)", self.path, s), + None => write!(f, "unknown config key '{}'", self.path), + } + } +} + +/// Flatten a TOML value into the set of dotted key paths it contains. +/// +/// Array indices are normalised to `0` so a path inside `[[rigs]]` matches +/// whichever entry it came from. +pub fn flatten_paths(value: &toml::Value) -> BTreeSet { + let mut paths = BTreeSet::new(); + walk(value, "", &mut paths); + paths +} + +fn walk(value: &toml::Value, prefix: &str, paths: &mut BTreeSet) { + let join = |seg: &str| { + if prefix.is_empty() { + seg.to_string() + } else { + format!("{prefix}.{seg}") + } + }; + match value { + toml::Value::Table(table) => { + for (key, child) in table { + let path = join(key); + paths.insert(path.clone()); + walk(child, &path, paths); + } + } + toml::Value::Array(items) => { + // Every entry of an array of tables has the same shape, so collapse + // them onto index 0 and let one entry stand for all. + for item in items { + let path = join("0"); + walk(item, &path, paths); + } + } + _ => {} + } +} + +/// Replace numeric path segments with `0` so array entries compare equal. +fn normalize(path: &str) -> String { + path.split('.') + .map(|seg| { + if !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()) { + "0" + } else { + seg + } + }) + .collect::>() + .join(".") +} + +/// Suggest the closest known key that sits at the same level as `path`. +/// +/// Returns `None` when nothing is close enough to be worth printing. +pub fn suggest(path: &str, known: &BTreeSet) -> Option { + let normalized = normalize(path); + let (parent, leaf) = match normalized.rsplit_once('.') { + Some((parent, leaf)) => (parent, leaf), + None => ("", normalized.as_str()), + }; + + // Anything longer than this is a different word, not a typo. + let limit = (leaf.chars().count() / 3).clamp(1, 3); + let mut best: Option<(usize, &str)> = None; + + for candidate in known { + let (cand_parent, cand_leaf) = match candidate.rsplit_once('.') { + Some((p, l)) => (p, l), + None => ("", candidate.as_str()), + }; + if cand_parent != parent || cand_leaf == leaf { + continue; + } + let distance = edit_distance(leaf, cand_leaf); + if distance <= limit && best.is_none_or(|(best_d, _)| distance < best_d) { + best = Some((distance, cand_leaf)); + } + } + + best.map(|(_, leaf)| { + if parent.is_empty() { + leaf.to_string() + } else { + format!("{parent}.{leaf}") + } + }) +} + +/// Pair each ignored path with a suggestion drawn from `reference`. +pub fn describe(paths: &[String], reference: &toml::Value) -> Vec { + let known = flatten_paths(reference); + paths + .iter() + .map(|path| UnknownKey { + path: path.clone(), + suggestion: suggest(path, &known), + }) + .collect() +} + +/// Optimal string alignment distance: Levenshtein plus transpositions, so the +/// common `port` → `prot` slip counts as one mistake rather than two. +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + if a.is_empty() { + return b.len(); + } + if b.is_empty() { + return a.len(); + } + + let mut rows = vec![vec![0usize; b.len() + 1]; a.len() + 1]; + for (i, row) in rows.iter_mut().enumerate() { + row[0] = i; + } + for (j, cell) in rows[0].iter_mut().enumerate() { + *cell = j; + } + + for i in 1..=a.len() { + for j in 1..=b.len() { + let cost = usize::from(a[i - 1] != b[j - 1]); + let mut best = (rows[i - 1][j] + 1) + .min(rows[i][j - 1] + 1) + .min(rows[i - 1][j - 1] + cost); + if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] { + best = best.min(rows[i - 2][j - 2] + 1); + } + rows[i][j] = best; + } + } + rows[a.len()][b.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference() -> toml::Value { + toml::from_str( + r#" +[general] +callsign = "N0CALL" +log_level = "info" + +[listen] +enabled = true +port = 4530 + +[[rigs]] +id = "hf" +[rigs.audio] +port = 4531 +sample_rate = 48000 +"#, + ) + .unwrap() + } + + #[test] + fn test_flatten_collects_nested_paths() { + let paths = flatten_paths(&reference()); + assert!(paths.contains("general.callsign")); + assert!(paths.contains("listen.port")); + assert!(paths.contains("rigs.0.audio.sample_rate")); + } + + #[test] + fn test_suggest_finds_close_sibling() { + let known = flatten_paths(&reference()); + assert_eq!(suggest("listen.prot", &known).as_deref(), Some("listen.port")); + } + + #[test] + fn test_suggest_inside_array_entry() { + let known = flatten_paths(&reference()); + assert_eq!( + suggest("rigs.1.audio.prot", &known).as_deref(), + Some("rigs.0.audio.port") + ); + } + + #[test] + fn test_suggest_ignores_distant_names() { + let known = flatten_paths(&reference()); + assert_eq!(suggest("listen.bananas", &known), None); + } + + #[test] + fn test_suggest_does_not_cross_levels() { + let known = flatten_paths(&reference()); + // `port` exists under [listen], but not under [general]. + assert_eq!(suggest("general.port", &known), None); + } + + #[test] + fn test_describe_formats_message() { + let described = describe(&["listen.prot".to_string()], &reference()); + assert_eq!( + described[0].to_string(), + "unknown config key 'listen.prot' (did you mean 'listen.port'?)" + ); + } + + #[test] + fn test_describe_without_suggestion() { + let described = describe(&["listen.bananas".to_string()], &reference()); + assert_eq!( + described[0].to_string(), + "unknown config key 'listen.bananas'" + ); + } + + #[test] + fn test_edit_distance_counts_transposition_once() { + assert_eq!(edit_distance("port", "prot"), 1); + assert_eq!(edit_distance("port", "port"), 0); + assert_eq!(edit_distance("", "port"), 4); + assert_eq!(edit_distance("sample_rat", "sample_rate"), 1); + } +} diff --git a/src/trx-configurator/Cargo.toml b/src/trx-configurator/Cargo.toml index 1db800a0..dc8523fe 100644 --- a/src/trx-configurator/Cargo.toml +++ b/src/trx-configurator/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" clap = { workspace = true, features = ["derive"] } dialoguer = "0.11" tokio-serial = { workspace = true } +toml = "0.8" toml_edit = "0.22" trx-config = { path = "../trx-config" } diff --git a/src/trx-configurator/src/check.rs b/src/trx-configurator/src/check.rs index fa94ed57..50b670ea 100644 --- a/src/trx-configurator/src/check.rs +++ b/src/trx-configurator/src/check.rs @@ -2,80 +2,37 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +//! `trx-configurator --check`: run a config file through the same loader and +//! validators the binaries use. +//! +//! This used to be a second, hand-maintained implementation — lists of known +//! keys and a handful of re-implemented range checks — which drifted out of +//! date as soon as a field was added. It now defers entirely to `trx-config`, +//! so a config that checks clean here is one the binaries will accept. + use std::fmt::Write as _; use std::path::Path; -use toml_edit::DocumentMut; +use trx_config::{ClientConfig, ServerConfig}; -/// Known top-level keys for a standalone server config. -const SERVER_KEYS: &[&str] = &[ - "general", +/// Top-level keys that only appear in a server config. Used solely to guess +/// what a section-less file is meant to be; the real key checking is done by +/// the loader. +const SERVER_MARKERS: &[&str] = &[ "rig", "rigs", - "behavior", "listen", - "audio", + "behavior", "sdr", "pskreporter", "aprsfi", "decode_logs", + "timeouts", + "audio", ]; -/// Known top-level keys for a standalone client config. -const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"]; - -/// Known top-level keys for a combined trx-rs.toml. -const COMBINED_KEYS: &[&str] = &["trx-server", "trx-client"]; - -/// Known sub-keys within [general] (server). -const SERVER_GENERAL_KEYS: &[&str] = &["callsign", "log_level", "latitude", "longitude"]; - -/// Known sub-keys within [general] (client). -const CLIENT_GENERAL_KEYS: &[&str] = &[ - "callsign", - "log_level", - "website_url", - "website_name", - "ais_vessel_url_base", -]; - -/// Known sub-keys within [rig]. -const RIG_KEYS: &[&str] = &["model", "initial_freq_hz", "initial_mode", "access"]; - -/// Known sub-keys within [rig.access]. -const ACCESS_KEYS: &[&str] = &["type", "port", "baud", "host", "tcp_port", "args"]; - -/// Known sub-keys within [listen]. -const LISTEN_KEYS: &[&str] = &["enabled", "listen", "port", "auth"]; - -/// Known sub-keys within [audio] (server). -const AUDIO_KEYS: &[&str] = &[ - "enabled", - "listen", - "port", - "rx_enabled", - "tx_enabled", - "device", - "sample_rate", - "channels", - "frame_duration_ms", - "bitrate_bps", -]; - -/// Known sub-keys within [behavior]. -const BEHAVIOR_KEYS: &[&str] = &[ - "poll_interval_ms", - "poll_interval_tx_ms", - "max_retries", - "retry_base_delay_ms", - "vfo_prime", -]; - -/// Known sub-keys within [remote]. -const REMOTE_KEYS: &[&str] = &["url", "rig_id", "auth", "poll_interval_ms"]; - -/// Known sub-keys within [frontends]. -const FRONTENDS_KEYS: &[&str] = &["http", "rigctl", "http_json", "audio"]; +/// Top-level keys that only appear in a client config. +const CLIENT_MARKERS: &[&str] = &["remote", "remotes", "frontends"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DetectedType { @@ -100,49 +57,38 @@ pub fn check_file(path: &Path) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; - // Step 1: TOML syntax check - let doc: DocumentMut = content - .parse() + let table: toml::Table = toml::from_str(&content) .map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?; + let detected = detect_type(&table); + let mut report = String::new(); let mut warnings: Vec = Vec::new(); let mut errors: Vec = Vec::new(); - let table = doc.as_table(); - - // Step 2: Detect config type - let detected = detect_type(table); writeln!(report, "{}: valid TOML", path.display()).unwrap(); writeln!(report, " Detected type: {}", detected).unwrap(); - // Step 3: Structural validation match detected { - DetectedType::Server => { - check_unknown_keys(table, SERVER_KEYS, "", &mut warnings); - check_server_sections(table, "", &mut warnings, &mut errors); - } - DetectedType::Client => { - check_unknown_keys(table, CLIENT_KEYS, "", &mut warnings); - check_client_sections(table, "", &mut warnings, &mut errors); - } + DetectedType::Server => check_server(path, &mut warnings, &mut errors), + DetectedType::Client => check_client(path, &mut warnings, &mut errors), DetectedType::Combined => { - check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings); - if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) { - check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings); - check_server_sections(server, "[trx-server].", &mut warnings, &mut errors); + if table.contains_key("trx-server") { + check_server(path, &mut warnings, &mut errors); } - if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) { - check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings); - check_client_sections(client, "[trx-client].", &mut warnings, &mut errors); + if table.contains_key("trx-client") { + check_client(path, &mut warnings, &mut errors); } } DetectedType::Unknown => { - warnings.push("Could not detect config type. Expected server, client, or combined (trx-rs.toml) layout.".to_string()); + warnings.push( + "Could not detect config type. Expected server, client, or combined \ + (trx-rs.toml) layout." + .to_string(), + ); } } - // Step 4: Format report for w in &warnings { writeln!(report, " warning: {}", w).unwrap(); } @@ -169,23 +115,54 @@ pub fn check_file(path: &Path) -> Result { } } -fn detect_type(table: &toml_edit::Table) -> DetectedType { +fn check_server(path: &Path, warnings: &mut Vec, errors: &mut Vec) { + match ServerConfig::load_from_file(path) { + Ok(loaded) => { + warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string())); + if let Err(e) = loaded.config.validate() { + errors.push(format!("[trx-server] {}", e)); + } + errors.extend( + loaded + .config + .validate_sdr() + .into_iter() + .map(|e| format!("[trx-server] {}", e)), + ); + } + Err(e) => errors.push(e.to_string()), + } +} + +fn check_client(path: &Path, warnings: &mut Vec, errors: &mut Vec) { + match ClientConfig::load_from_file(path) { + Ok(loaded) => { + warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string())); + if let Err(e) = loaded.config.validate() { + errors.push(format!("[trx-client] {}", e)); + } + } + Err(e) => errors.push(e.to_string()), + } +} + +fn detect_type(table: &toml::Table) -> DetectedType { if table.contains_key("trx-server") || table.contains_key("trx-client") { return DetectedType::Combined; } - let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect(); + let keys: Vec<&str> = table.keys().map(|k| k.as_str()).collect(); - let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count(); - let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count(); - - // Use distinguishing keys to break ties - if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") { + // Distinguishing keys first, then a simple majority. + if keys.iter().any(|k| ["rig", "rigs", "listen"].contains(k)) { return DetectedType::Server; } - if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") { + if keys.iter().any(|k| CLIENT_MARKERS.contains(k)) { return DetectedType::Client; } + let server_score = keys.iter().filter(|k| SERVER_MARKERS.contains(k)).count(); + let client_score = keys.iter().filter(|k| CLIENT_MARKERS.contains(k)).count(); + if server_score > client_score { DetectedType::Server } else if client_score > server_score { @@ -197,194 +174,6 @@ fn detect_type(table: &toml_edit::Table) -> DetectedType { } } -fn check_unknown_keys( - table: &toml_edit::Table, - known: &[&str], - prefix: &str, - warnings: &mut Vec, -) { - for (key, _) in table.iter() { - if !known.contains(&key) { - warnings.push(format!("{}unknown key '{}'", prefix, key)); - } - } -} - -fn check_server_sections( - table: &toml_edit::Table, - prefix: &str, - warnings: &mut Vec, - errors: &mut Vec, -) { - if let Some(general) = table.get("general").and_then(|v| v.as_table()) { - check_unknown_keys( - general, - SERVER_GENERAL_KEYS, - &format!("{}[general].", prefix), - warnings, - ); - validate_log_level(general, &format!("{}[general]", prefix), errors); - validate_coordinates(general, &format!("{}[general]", prefix), errors); - } - - if let Some(rig) = table.get("rig").and_then(|v| v.as_table()) { - check_unknown_keys(rig, RIG_KEYS, &format!("{}[rig].", prefix), warnings); - if let Some(access) = rig.get("access").and_then(|v| v.as_table()) { - check_unknown_keys( - access, - ACCESS_KEYS, - &format!("{}[rig.access].", prefix), - warnings, - ); - validate_access(access, &format!("{}[rig.access]", prefix), errors); - } - } - - if let Some(listen) = table.get("listen").and_then(|v| v.as_table()) { - check_unknown_keys( - listen, - LISTEN_KEYS, - &format!("{}[listen].", prefix), - warnings, - ); - validate_port(listen, "port", &format!("{}[listen]", prefix), errors); - } - - if let Some(audio) = table.get("audio").and_then(|v| v.as_table()) { - check_unknown_keys(audio, AUDIO_KEYS, &format!("{}[audio].", prefix), warnings); - validate_port(audio, "port", &format!("{}[audio]", prefix), errors); - } - - if let Some(behavior) = table.get("behavior").and_then(|v| v.as_table()) { - check_unknown_keys( - behavior, - BEHAVIOR_KEYS, - &format!("{}[behavior].", prefix), - warnings, - ); - } -} - -fn check_client_sections( - table: &toml_edit::Table, - prefix: &str, - warnings: &mut Vec, - errors: &mut Vec, -) { - if let Some(general) = table.get("general").and_then(|v| v.as_table()) { - check_unknown_keys( - general, - CLIENT_GENERAL_KEYS, - &format!("{}[general].", prefix), - warnings, - ); - validate_log_level(general, &format!("{}[general]", prefix), errors); - } - - if let Some(remote) = table.get("remote").and_then(|v| v.as_table()) { - check_unknown_keys( - remote, - REMOTE_KEYS, - &format!("{}[remote].", prefix), - warnings, - ); - } - - if let Some(frontends) = table.get("frontends").and_then(|v| v.as_table()) { - check_unknown_keys( - frontends, - FRONTENDS_KEYS, - &format!("{}[frontends].", prefix), - warnings, - ); - if let Some(http) = frontends.get("http").and_then(|v| v.as_table()) { - validate_port(http, "port", &format!("{}[frontends.http]", prefix), errors); - } - if let Some(rigctl) = frontends.get("rigctl").and_then(|v| v.as_table()) { - validate_port( - rigctl, - "port", - &format!("{}[frontends.rigctl]", prefix), - errors, - ); - } - } -} - -// ── Value validators ──────────────────────────────────────────────────── - -fn validate_log_level(table: &toml_edit::Table, context: &str, errors: &mut Vec) { - if let Some(level) = table.get("log_level").and_then(|v| v.as_str()) { - if !["trace", "debug", "info", "warn", "error"].contains(&level) { - errors.push(format!( - "{}.log_level '{}' is invalid (expected: trace, debug, info, warn, error)", - context, level - )); - } - } -} - -fn validate_coordinates(table: &toml_edit::Table, context: &str, errors: &mut Vec) { - if let Some(lat) = table - .get("latitude") - .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) - { - if !(-90.0..=90.0).contains(&lat) { - errors.push(format!( - "{}.latitude {} is out of range (-90..90)", - context, lat - )); - } - } - if let Some(lon) = table - .get("longitude") - .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) - { - if !(-180.0..=180.0).contains(&lon) { - errors.push(format!( - "{}.longitude {} is out of range (-180..180)", - context, lon - )); - } - } - - let has_lat = table.contains_key("latitude"); - let has_lon = table.contains_key("longitude"); - if has_lat != has_lon { - errors.push(format!( - "{}: latitude and longitude must be set together or both omitted", - context - )); - } -} - -fn validate_port(table: &toml_edit::Table, key: &str, context: &str, errors: &mut Vec) { - if let Some(port) = table.get(key).and_then(|v| v.as_integer()) { - if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) { - if enabled && port <= 0 { - errors.push(format!("{}.{} must be > 0 when enabled", context, key)); - } - } - if !(0..=65535).contains(&port) { - errors.push(format!( - "{}.{} {} is out of range (0..65535)", - context, key, port - )); - } - } -} - -fn validate_access(table: &toml_edit::Table, context: &str, errors: &mut Vec) { - if let Some(access_type) = table.get("type").and_then(|v| v.as_str()) { - if !["serial", "tcp", "sdr"].contains(&access_type) { - errors.push(format!( - "{}.type '{}' is invalid (expected: serial, tcp, sdr)", - context, access_type - )); - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -418,8 +207,7 @@ enabled = true port = 4530 "#, ); - assert!(result.is_ok()); - let report = result.unwrap(); + let report = result.expect("expected a clean report"); assert!(report.contains("Detected type: server")); assert!(report.contains("No issues found")); } @@ -432,41 +220,40 @@ port = 4530 callsign = "W1AW" [remote] -url = "localhost:4530" +url = "192.168.1.10:4530" [frontends.http] enabled = true port = 8080 "#, ); - assert!(result.is_ok()); - let report = result.unwrap(); + let report = result.expect("expected a clean report"); assert!(report.contains("Detected type: client")); + assert!(report.contains("No issues found")); } #[test] fn test_valid_combined_config() { let result = check_toml( r#" -[trx-server.general] -callsign = "W1AW" - -[trx-client.general] -callsign = "W1AW" +[trx-server.rig] +model = "ft817" +[trx-server.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 [trx-client.remote] -url = "localhost:4530" +url = "127.0.0.1:4530" "#, ); - assert!(result.is_ok()); - let report = result.unwrap(); + let report = result.expect("expected a clean report"); assert!(report.contains("Detected type: combined")); } #[test] fn test_invalid_toml_syntax() { - let result = check_toml("this is not [valid toml"); - assert!(result.is_err()); + let result = check_toml("[general\ncallsign = \"W1AW\"\n"); assert!(result.unwrap_err().contains("TOML syntax error")); } @@ -474,19 +261,22 @@ url = "localhost:4530" fn test_unknown_key_warning() { let result = check_toml( r#" -[general] -callsign = "W1AW" - [rig] model = "ft817" +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 -[bogus_section] -foo = "bar" +[listen] +prot = 4530 "#, ); - assert!(result.is_ok()); - let report = result.unwrap(); - assert!(report.contains("unknown key 'bogus_section'")); + let report = result.expect("unknown keys are warnings, not errors"); + assert!( + report.contains("unknown config key 'listen.prot' (did you mean 'listen.port'?)"), + "unexpected report: {report}" + ); } #[test] @@ -498,11 +288,13 @@ log_level = "verbose" [rig] model = "ft817" +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 "#, ); - assert!(result.is_err()); - let report = result.unwrap_err(); - assert!(report.contains("log_level 'verbose' is invalid")); + assert!(result.unwrap_err().contains("log_level")); } #[test] @@ -510,15 +302,17 @@ model = "ft817" let result = check_toml( r#" [general] -latitude = 45.0 +latitude = 52.0 [rig] model = "ft817" +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 "#, ); - assert!(result.is_err()); - let report = result.unwrap_err(); - assert!(report.contains("latitude and longitude must be set together")); + assert!(result.unwrap_err().contains("longitude")); } #[test] @@ -526,16 +320,18 @@ model = "ft817" let result = check_toml( r#" [general] -latitude = 95.0 +latitude = 120.0 longitude = 10.0 [rig] model = "ft817" +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 "#, ); - assert!(result.is_err()); - let report = result.unwrap_err(); - assert!(report.contains("latitude 95 is out of range")); + assert!(result.unwrap_err().contains("latitude")); } #[test] @@ -547,10 +343,31 @@ model = "ft817" [rig.access] type = "usb" +port = "/dev/ttyUSB0" +baud = 9600 "#, ); - assert!(result.is_err()); - let report = result.unwrap_err(); - assert!(report.contains("type 'usb' is invalid")); + assert!(result.unwrap_err().contains("access")); + } + + /// The old checker only knew a fixed list of top-level keys and a few range + /// rules, so it passed configs the server rejects at startup. + #[test] + fn test_catches_errors_the_key_list_checker_missed() { + let result = check_toml( + r#" +[rig] +model = "ft817" +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 + +[audio] +enabled = true +frame_duration_ms = 7 +"#, + ); + assert!(result.unwrap_err().contains("frame_duration_ms")); } } diff --git a/src/trx-configurator/src/writer.rs b/src/trx-configurator/src/writer.rs index fc1de6f7..96edaf32 100644 --- a/src/trx-configurator/src/writer.rs +++ b/src/trx-configurator/src/writer.rs @@ -474,7 +474,9 @@ mod tests { #[test] fn test_generated_server_config_loads_and_validates() { let file = write_temp(&build_default(ConfigType::Server)); - let cfg = ServerConfig::load_from_file(file.path()).expect("generated config must load"); + let cfg = ServerConfig::load_from_file(file.path()) + .expect("generated config must load") + .config; cfg.validate().expect("generated config must validate"); assert_eq!(cfg.rig.model.as_deref(), Some("ft817")); assert_eq!(cfg.listen.port, 4530); @@ -483,7 +485,9 @@ mod tests { #[test] fn test_generated_client_config_loads_and_validates() { let file = write_temp(&build_default(ConfigType::Client)); - let cfg = ClientConfig::load_from_file(file.path()).expect("generated config must load"); + let cfg = ClientConfig::load_from_file(file.path()) + .expect("generated config must load") + .config; cfg.validate().expect("generated config must validate"); assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530")); } @@ -491,9 +495,13 @@ mod tests { #[test] fn test_generated_combined_config_loads_both_sections() { let file = write_temp(&build_default(ConfigType::Combined)); - let server = ServerConfig::load_from_file(file.path()).expect("server section must load"); + let server = ServerConfig::load_from_file(file.path()) + .expect("server section must load") + .config; server.validate().expect("server section must validate"); - let client = ClientConfig::load_from_file(file.path()).expect("client section must load"); + let client = ClientConfig::load_from_file(file.path()) + .expect("client section must load") + .config; client.validate().expect("client section must validate"); } diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 2a42d940..8e9b156f 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -56,6 +56,9 @@ struct Cli { /// Print example configuration and exit #[arg(long = "print-config")] print_config: bool, + /// Treat unknown configuration keys as a fatal error + #[arg(long = "strict-config")] + strict_config: bool, /// Rig backend to use (e.g. ft817, ft450d) #[arg(short = 'r', long = "rig")] rig: Option, @@ -894,12 +897,22 @@ async fn main() -> DynResult<()> { return Ok(()); } - let (cfg, config_path) = if let Some(ref path) = cli.config { - let cfg = ServerConfig::load_from_file(path)?; - (cfg, Some(path.clone())) + let loaded = if let Some(ref path) = cli.config { + ServerConfig::load_from_file(path)? } else { ServerConfig::load_from_default_paths()? }; + let config_path = loaded.path.clone(); + + // Logging comes up before any config complaint so the warnings are visible. + init_logging(loaded.config.general.log_level.as_deref()); + + if let Some(ref path) = config_path { + info!("Loaded configuration from {}", path.display()); + } + loaded.report_unknown_keys(cli.strict_config)?; + + let cfg = loaded.config; cfg.validate() .map_err(|e| format!("Invalid server configuration: {}", e))?; @@ -912,12 +925,6 @@ async fn main() -> DynResult<()> { std::process::exit(1); } - init_logging(cfg.general.log_level.as_deref()); - - if let Some(ref path) = config_path { - info!("Loaded configuration from {}", path.display()); - } - let registry = Arc::new(bootstrap_ctx); // --- Resolve the effective rig list --- -- 2.55.0 From d42ca4f0303426820e1b94233db95a3c75ed4e25 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 20:57:59 +0200 Subject: [PATCH 04/13] [fix](trx-config): validate every rig, not just the legacy flat one ServerConfig::validate() checked the flat [rig]/[audio]/[behavior] fields and gave [[rigs]] entries only an id/audio-port uniqueness pass, and validate_sdr() returned early unless the *flat* access type was "sdr". A multi-rig SDR station therefore got no Nyquist, stream_opus, duplicate-decoder or tx_enabled checking at all, and a rig entry with frame_duration_ms = 7 or a missing baud rate started and failed at runtime. Move the per-rig rules into validate_rig_instance() and validate_sdr_instance() and run them over resolved_rigs(), which already synthesises the flat layout as a single entry. Both layouts now go through the same code, and multi-rig messages name the rig they came from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- src/trx-config/src/server.rs | 615 ++++++++++++++++++++++------------- 1 file changed, 388 insertions(+), 227 deletions(-) diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 3139874e..b5654570 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -471,238 +471,71 @@ impl Default for SdrChannelConfig { } impl ServerConfig { + /// Validate the whole configuration. + /// + /// 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)?; - if self.rig.initial_freq_hz == 0 { - return Err("[rig].initial_freq_hz must be > 0".to_string()); - } - - validate_access(&self.rig.access)?; - - if self.behavior.poll_interval_ms == 0 { - return Err("[behavior].poll_interval_ms must be > 0".to_string()); - } - if self.behavior.poll_interval_tx_ms == 0 { - return Err("[behavior].poll_interval_tx_ms must be > 0".to_string()); - } - if self.behavior.max_retries == 0 { - return Err("[behavior].max_retries must be > 0".to_string()); - } - if self.behavior.retry_base_delay_ms == 0 { - return Err("[behavior].retry_base_delay_ms must be > 0".to_string()); - } - validate_tokens("[listen.auth].tokens", &self.listen.auth.tokens)?; if self.listen.enabled && self.listen.port == 0 { return Err("[listen].port must be > 0 when listener is enabled".to_string()); } - if self.audio.enabled { - if self.audio.port == 0 { - return Err("[audio].port must be > 0 when audio is enabled".to_string()); - } - if !self.audio.rx_enabled && !self.audio.tx_enabled { - return Err( - "[audio] enabled but both rx_enabled and tx_enabled are false".to_string(), - ); - } - if self.audio.sample_rate < 8_000 || self.audio.sample_rate > 192_000 { - return Err("[audio].sample_rate must be in range 8000..=192000".to_string()); - } - if !(1..=2).contains(&self.audio.channels) { - return Err("[audio].channels must be 1 or 2".to_string()); - } - match self.audio.frame_duration_ms { - 3 | 5 | 10 | 20 | 40 | 60 => {} - _ => { - return Err( - "[audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60" - .to_string(), - ) - } - } - if self.audio.bitrate_bps == 0 { - return Err("[audio].bitrate_bps must be > 0".to_string()); - } - } + self.validate_rig_uniqueness()?; - if self.pskreporter.enabled { - if self.pskreporter.host.trim().is_empty() { - return Err("[pskreporter].host must not be empty".to_string()); - } - if self.pskreporter.port == 0 { - return Err("[pskreporter].port must be > 0".to_string()); - } - if self.pskreporter.receiver_locator.is_none() - && (self.general.latitude.is_none() || self.general.longitude.is_none()) - { - return Err( - "[pskreporter] enabled requires either [pskreporter].receiver_locator \ - or [general].latitude and [general].longitude" - .to_string(), - ); - } - } - - if self.aprsfi.enabled { - if self.aprsfi.host.trim().is_empty() { - return Err("[aprsfi].host must not be empty".to_string()); - } - if self.aprsfi.port == 0 { - return Err("[aprsfi].port must be > 0".to_string()); - } - } - - if let Some(max_gain) = self.sdr.gain.max_value { - if !max_gain.is_finite() { - return Err("[sdr.gain].max_value must be finite".to_string()); - } - if max_gain < 0.0 { - return Err("[sdr.gain].max_value must be >= 0".to_string()); - } - } - validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?; - validate_sdr_nb_config("[sdr.noise_blanker]", &self.sdr.noise_blanker)?; - - // Multi-rig uniqueness checks. - if !self.rigs.is_empty() { - let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); - let mut seen_ports: std::collections::HashSet = std::collections::HashSet::new(); - let mut enabled_count = 0usize; - for rig in &self.rigs { - if !rig.enable { - continue; - } - enabled_count += 1; - // Check for explicit duplicate IDs (empty IDs are auto-generated later). - if !rig.id.trim().is_empty() && !seen_ids.insert(rig.id.clone()) { - return Err(format!("[[rigs]] duplicate rig id: \"{}\"", rig.id)); - } - if rig.audio.enabled && !seen_ports.insert(rig.audio.port) { - return Err(format!( - "[[rigs]] duplicate audio port {} (rig id: \"{}\")", - rig.audio.port, rig.id - )); - } - if let Some(max_gain) = rig.sdr.gain.max_value { - if !max_gain.is_finite() { - return Err(format!( - "[[rigs]] [sdr.gain].max_value must be finite (rig id: \"{}\")", - rig.id - )); - } - if max_gain < 0.0 { - return Err(format!( - "[[rigs]] [sdr.gain].max_value must be >= 0 (rig id: \"{}\")", - rig.id - )); - } - } - validate_sdr_squelch_config( - &format!("[[rigs]] [sdr.squelch] (rig id: \"{}\")", rig.id), - &rig.sdr.squelch, - )?; - validate_sdr_nb_config( - &format!("[[rigs]] [sdr.noise_blanker] (rig id: \"{}\")", rig.id), - &rig.sdr.noise_blanker, - )?; - } - if enabled_count == 0 { - return Err( - "[[rigs]] has no enabled entries; set at least one [[rigs]].enable = true" - .to_string(), - ); - } - } - - if self.decode_logs.enabled { - if self.decode_logs.dir.trim().is_empty() { - return Err("[decode_logs].dir must not be empty when enabled".to_string()); - } - if self.decode_logs.aprs_file.trim().is_empty() - || self.decode_logs.cw_file.trim().is_empty() - || self.decode_logs.ft8_file.trim().is_empty() - || self.decode_logs.wspr_file.trim().is_empty() - { - return Err("[decode_logs] file names must not be empty when enabled".to_string()); - } + let multi = !self.rigs.is_empty(); + for rig in &self.resolved_rigs() { + validate_rig_instance(&rig_prefix(multi, rig), rig, &self.general)?; } Ok(()) } - /// Validate SDR-specific config rules (see SDR.md §11). - /// Returns a Vec of error strings; empty means valid. - pub fn validate_sdr(&self) -> Vec { - let mut errors = Vec::new(); - - // Only validate if access type is "sdr" - let is_sdr = self.rig.access.access_type.as_deref() == Some("sdr"); - if !is_sdr { - return errors; + /// Check that enabled `[[rigs]]` entries do not collide with one another. + fn validate_rig_uniqueness(&self) -> Result<(), String> { + if self.rigs.is_empty() { + return Ok(()); } - // args must be non-empty - if self - .rig - .access - .args - .as_deref() - .map(str::is_empty) - .unwrap_or(true) - { - errors.push("[rig.access] args must be non-empty for type = \"sdr\"".into()); - } + let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut seen_ports: std::collections::HashSet = std::collections::HashSet::new(); + let mut enabled_count = 0usize; - // sample_rate must be non-zero - if self.sdr.sample_rate == 0 { - errors.push("[sdr] sample_rate must be > 0".into()); - } - - // Every channel's IF must fit within the captured bandwidth - let half_rate = self.sdr.sample_rate as i64 / 2; - for ch in &self.sdr.channels { - let channel_if = self.sdr.center_offset_hz + ch.offset_hz; - if channel_if.abs() >= half_rate { - errors.push(format!( - "[sdr.channels] id=\"{}\" IF frequency {} Hz exceeds Nyquist limit ±{} Hz", - ch.id, channel_if, half_rate + for rig in self.rigs.iter().filter(|r| r.enable) { + enabled_count += 1; + // Empty ids are auto-generated later, so only explicit ones collide. + if !rig.id.trim().is_empty() && !seen_ids.insert(rig.id.as_str()) { + return Err(format!("[[rigs]] duplicate rig id: \"{}\"", rig.id)); + } + if rig.audio.enabled && !seen_ports.insert(rig.audio.port) { + return Err(format!( + "[[rigs]] duplicate audio port {} (rig id: \"{}\")", + rig.audio.port, rig.id )); } } - // At most one channel may have stream_opus = true - let opus_count = self.sdr.channels.iter().filter(|c| c.stream_opus).count(); - if opus_count > 1 { - errors.push(format!( - "[sdr.channels] at most one channel may have stream_opus = true (found {})", - opus_count - )); + if enabled_count == 0 { + return Err( + "[[rigs]] has no enabled entries; set at least one [[rigs]].enable = true" + .to_string(), + ); } + Ok(()) + } - // tx_enabled must be false with SDR backend - if self.audio.tx_enabled { - errors.push("[audio] tx_enabled must be false when using the soapysdr backend".into()); - } - - // Decoder names must not appear in more than one channel - let mut seen: std::collections::HashMap = std::collections::HashMap::new(); - for ch in &self.sdr.channels { - for dec in &ch.decoders { - if let Some(prev_id) = seen.get(dec) { - errors.push(format!( - "[sdr.channels] decoder \"{}\" appears in both \"{}\" and \"{}\"", - dec, prev_id, ch.id - )); - } else { - seen.insert(dec.clone(), ch.id.clone()); - } - } - } - - errors + /// Validate SDR-specific config rules for every rig (see SDR.md §11). + /// Returns a Vec of error strings; empty means valid. + pub fn validate_sdr(&self) -> Vec { + let multi = !self.rigs.is_empty(); + self.resolved_rigs() + .iter() + .flat_map(|rig| validate_sdr_instance(&rig_prefix(multi, rig), rig)) + .collect() } /// Load configuration from a specific file path. @@ -817,7 +650,205 @@ fn validate_coordinates(latitude: Option, longitude: Option) -> Result } } -fn validate_access(access: &AccessConfig) -> Result<(), String> { +/// Message prefix naming the rig a problem belongs to. +/// +/// Empty for the legacy flat layout, so its messages read exactly as before. +fn rig_prefix(multi: bool, rig: &RigInstanceConfig) -> String { + if multi { + format!("[[rigs]] \"{}\": ", rig.id) + } else { + String::new() + } +} + +/// 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. +fn validate_rig_instance( + prefix: &str, + rig: &RigInstanceConfig, + general: &GeneralConfig, +) -> Result<(), String> { + if rig.rig.initial_freq_hz == 0 { + return Err(format!("{prefix}[rig].initial_freq_hz must be > 0")); + } + + validate_access(prefix, &rig.rig.access)?; + + if rig.behavior.poll_interval_ms == 0 { + return Err(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")); + } + if rig.behavior.max_retries == 0 { + return Err(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")); + } + + if rig.audio.enabled { + if rig.audio.port == 0 { + return Err(format!("{prefix}[audio].port must be > 0 when audio is enabled")); + } + if !rig.audio.rx_enabled && !rig.audio.tx_enabled { + return Err(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!( + "{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")); + } + 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 rig.audio.bitrate_bps == 0 { + return Err(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")); + } + if rig.pskreporter.port == 0 { + return Err(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!( + "{prefix}[pskreporter] enabled requires either [pskreporter].receiver_locator \ + or [general].latitude and [general].longitude" + )); + } + } + + if rig.aprsfi.enabled { + if rig.aprsfi.host.trim().is_empty() { + return Err(format!("{prefix}[aprsfi].host must not be empty")); + } + if rig.aprsfi.port == 0 { + return Err(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")); + } + } + 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 rig.decode_logs.enabled { + if rig.decode_logs.dir.trim().is_empty() { + return Err(format!( + "{prefix}[decode_logs].dir must not be empty when enabled" + )); + } + if rig.decode_logs.aprs_file.trim().is_empty() + || rig.decode_logs.cw_file.trim().is_empty() + || rig.decode_logs.ft8_file.trim().is_empty() + || rig.decode_logs.wspr_file.trim().is_empty() + { + return Err(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). +/// +/// Returns every problem found rather than stopping at the first, so a +/// misconfigured channel list can be fixed in one pass. +fn validate_sdr_instance(prefix: &str, rig: &RigInstanceConfig) -> Vec { + let mut errors = Vec::new(); + + if rig.rig.access.access_type.as_deref() != Some("sdr") { + return errors; + } + + if rig + .rig + .access + .args + .as_deref() + .map(str::is_empty) + .unwrap_or(true) + { + errors.push(format!( + "{prefix}[rig.access] args must be non-empty for type = \"sdr\"" + )); + } + + if rig.sdr.sample_rate == 0 { + errors.push(format!("{prefix}[sdr] sample_rate must be > 0")); + } + + // Every channel's IF must fit within the captured bandwidth. + let half_rate = rig.sdr.sample_rate as i64 / 2; + for ch in &rig.sdr.channels { + let channel_if = rig.sdr.center_offset_hz + ch.offset_hz; + if channel_if.abs() >= half_rate { + errors.push(format!( + "{prefix}[sdr.channels] id=\"{}\" IF frequency {} Hz exceeds Nyquist limit ±{} Hz", + ch.id, channel_if, half_rate + )); + } + } + + let opus_count = rig.sdr.channels.iter().filter(|c| c.stream_opus).count(); + if opus_count > 1 { + errors.push(format!( + "{prefix}[sdr.channels] at most one channel may have stream_opus = true (found {})", + opus_count + )); + } + + if rig.audio.tx_enabled { + errors.push(format!( + "{prefix}[audio] tx_enabled must be false when using the soapysdr backend" + )); + } + + // A decoder may only be fed by one channel. + let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + for ch in &rig.sdr.channels { + for dec in &ch.decoders { + if let Some(prev_id) = seen.get(dec.as_str()) { + errors.push(format!( + "{prefix}[sdr.channels] decoder \"{}\" appears in both \"{}\" and \"{}\"", + dec, prev_id, ch.id + )); + } else { + seen.insert(dec.as_str(), ch.id.as_str()); + } + } + } + + errors +} + +fn validate_access(prefix: &str, access: &AccessConfig) -> Result<(), String> { let serial_fields_set = access.port.is_some() || access.baud.is_some(); let tcp_fields_set = access.host.is_some() || access.tcp_port.is_some(); @@ -828,38 +859,34 @@ fn validate_access(access: &AccessConfig) -> Result<(), String> { match access.access_type.as_deref().unwrap_or("serial") { "serial" => { if access.port.as_deref().unwrap_or("").trim().is_empty() { - return Err( - "[rig.access].port must be set for serial access ([rig.access].type='serial')" - .to_string(), - ); + return Err(format!( + "{prefix}[rig.access].port must be set for serial access ([rig.access].type='serial')" + )); } if access.baud.unwrap_or(0) == 0 { - return Err( - "[rig.access].baud must be > 0 for serial access ([rig.access].type='serial')" - .to_string(), - ); + return Err(format!( + "{prefix}[rig.access].baud must be > 0 for serial access ([rig.access].type='serial')" + )); } } "tcp" => { if access.host.as_deref().unwrap_or("").trim().is_empty() { - return Err( - "[rig.access].host must be set for tcp access ([rig.access].type='tcp')" - .to_string(), - ); + return Err(format!( + "{prefix}[rig.access].host must be set for tcp access ([rig.access].type='tcp')" + )); } if access.tcp_port.unwrap_or(0) == 0 { - return Err( - "[rig.access].tcp_port must be > 0 for tcp access ([rig.access].type='tcp')" - .to_string(), - ); + return Err(format!( + "{prefix}[rig.access].tcp_port must be > 0 for tcp access ([rig.access].type='tcp')" + )); } } "sdr" => { - // SDR-specific validation is handled by validate_sdr() + // SDR-specific validation is handled by validate_sdr_instance(). } other => { return Err(format!( - "[rig.access].type '{}' is invalid (expected 'serial', 'tcp', or 'sdr')", + "{prefix}[rig.access].type '{}' is invalid (expected 'serial', 'tcp', or 'sdr')", other )) } @@ -1499,6 +1526,140 @@ port = 4531 ); } + // --- Per-rig validation: [[rigs]] entries obey the same rules as the + // legacy flat layout, which they previously escaped entirely. --- + + #[test] + fn test_validate_rejects_bad_audio_in_rig_entry() { + let toml_str = r#" +[[rigs]] +id = "hf" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 +frame_duration_ms = 7 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let err = cfg + .validate() + .expect_err("expected the rig entry's audio config to be validated"); + assert!( + err.contains("frame_duration_ms") && err.contains("hf"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_validate_rejects_incomplete_access_in_rig_entry() { + let toml_str = r#" +[[rigs]] +id = "hf" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +[rigs.audio] +port = 4531 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let err = cfg + .validate() + .expect_err("expected the rig entry's access config to be validated"); + assert!(err.contains("baud"), "unexpected error: {err}"); + } + + #[test] + fn test_validate_sdr_checks_rig_entries() { + // sample_rate 1_000_000 => Nyquist 500_000; the channel sits beyond it, + // two channels claim the Opus stream, and TX is on with an SDR backend. + let toml_str = r#" +[[rigs]] +id = "sdr" +[rigs.rig] +model = "soapysdr" +[rigs.rig.access] +type = "sdr" +args = "driver=rtlsdr" +[rigs.audio] +port = 4532 +tx_enabled = true +[rigs.sdr] +sample_rate = 1000000 +center_offset_hz = 0 +[[rigs.sdr.channels]] +id = "ch_high" +offset_hz = 600000 +stream_opus = true +[[rigs.sdr.channels]] +id = "ch_two" +offset_hz = 10000 +stream_opus = true +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("Nyquist")), + "expected a Nyquist error, got: {errors:?}" + ); + assert!( + errors.iter().any(|e| e.contains("stream_opus")), + "expected a stream_opus error, got: {errors:?}" + ); + assert!( + errors.iter().any(|e| e.contains("tx_enabled")), + "expected a tx_enabled error, got: {errors:?}" + ); + assert!( + errors.iter().all(|e| e.contains("sdr")), + "every error should name the rig it belongs to: {errors:?}" + ); + } + + #[test] + fn test_validate_sdr_ignores_non_sdr_rig_entries() { + let toml_str = r#" +[[rigs]] +id = "hf" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + assert!(cfg.validate_sdr().is_empty()); + } + + #[test] + fn test_validate_rejects_bad_squelch_in_rig_entry() { + let toml_str = r#" +[[rigs]] +id = "sdr" +[rigs.rig] +model = "soapysdr" +[rigs.rig.access] +type = "sdr" +args = "driver=rtlsdr" +[rigs.audio] +port = 4532 +tx_enabled = false +[rigs.sdr.squelch] +threshold_db = 10.0 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let err = cfg.validate().expect_err("expected a squelch error"); + assert!(err.contains("threshold_db"), "unexpected error: {err}"); + } + #[test] fn test_validate_accepts_multi_rig_unique_ids_and_ports() { let toml_str = r#" -- 2.55.0 From fbc4f6e3981371f78724afac4619017e1d95a9ef Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:02:26 +0200 Subject: [PATCH 05/13] [feat](trx-config): add a resolved-config validation phase Some things can only be checked once CLI overrides have been folded in and the rig/remote lists are final, so nothing checked them at all: - The client's per-rig maps (rigctl.rig_ports, audio.rig_urls, audio.rig_ports, decode_history_retention_min_by_rig, http.default_rig_name) are keyed by a remote's short name. A typo used to spawn a rigctl listener that injected a rig_id no remote answered to, without a word in the log. - Nothing noticed two listeners claiming one socket. [listen].port and a rig's [audio].port could both be 4530; on the client, http, http_json and each rigctl rig port could collide freely. Add validate_resolved() to both configs, run after argument parsing, plus a shared socket-conflict check that treats a wildcard address as conflicting with any address on the same port and ignores port 0. 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 | 26 +++++- src/trx-config/src/client.rs | 167 ++++++++++++++++++++++++++++++++++- src/trx-config/src/server.rs | 85 +++++++++++++++++- src/trx-config/src/shared.rs | 87 ++++++++++++++++++ src/trx-server/src/main.rs | 29 ++++++ 5 files changed, 391 insertions(+), 3 deletions(-) diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 5cef2e98..ac42fe5b 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -153,7 +153,7 @@ async fn async_init() -> DynResult { } loaded.report_unknown_keys(cli.strict_config)?; - let cfg = loaded.config; + let mut cfg = loaded.config; cfg.validate() .map_err(|e| format!("Invalid client configuration: {}", e))?; @@ -269,6 +269,30 @@ async fn async_init() -> DynResult { .http_json_listen .unwrap_or(cfg.frontends.http_json.listen); let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port); + + // Fold the CLI overrides back into the config so validation and the + // frontends agree on what is about to be bound. + cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http"); + cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl"); + cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson"); + cfg.frontends.http.listen = http_listen; + cfg.frontends.http.port = http_port; + cfg.frontends.rigctl.listen = rigctl_listen; + cfg.frontends.http_json.listen = http_json_listen; + cfg.frontends.http_json.port = http_json_port; + + // Second validation phase: the per-rig maps are keyed by remote short name, + // so they can only be checked once the remote list is final. + if cli.url.is_none() { + cfg.validate_resolved(&resolved_remotes) + .map_err(|e| format!("Invalid client configuration: {}", e))?; + } else { + // --url replaces the configured remotes outright, so only the socket + // checks still apply. + trx_config::shared::check_socket_conflicts(&cfg.bound_sockets()) + .map_err(|e| format!("Invalid client configuration: {}", e))?; + } + let callsign = cli .callsign .clone() diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index 24c6ff82..8edb50f2 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -18,7 +18,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::file::{ConfigError, ConfigFile, ConfigLoad}; -use crate::shared::{validate_log_level, validate_tokens}; +use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens, BoundSocket}; /// Top-level client configuration structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -412,6 +412,78 @@ impl ClientConfig { } } + /// Checks that only make sense once the remote list is known. + /// + /// `remotes` is the CLI-merged result of `resolved_remotes()`, so this runs + /// after argument parsing rather than at load time. Every per-rig map is + /// 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> { + let names: std::collections::HashSet<&str> = + remotes.iter().map(|r| r.name.as_str()).collect(); + let known = || { + let mut names: Vec<&str> = remotes.iter().map(|r| r.name.as_str()).collect(); + names.sort_unstable(); + names.join(", ") + }; + let check = |value: &str, path: &str| -> Result<(), String> { + if names.contains(value) { + return Ok(()); + } + 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")?; + } + for name in self.frontends.rigctl.rig_ports.keys() { + check(name, "[frontends.rigctl].rig_ports")?; + } + for name in self.frontends.audio.rig_urls.keys() { + check(name, "[frontends.audio].rig_urls")?; + } + for name in self.frontends.audio.rig_ports.keys() { + 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_socket_conflicts(&self.bound_sockets()) + } + + /// Sockets the enabled frontends will bind. + pub fn bound_sockets(&self) -> Vec { + let mut sockets = Vec::new(); + if self.frontends.http.enabled { + sockets.push(BoundSocket::new( + self.frontends.http.listen, + self.frontends.http.port, + "[frontends.http]", + )); + } + if self.frontends.http_json.enabled { + sockets.push(BoundSocket::new( + self.frontends.http_json.listen, + self.frontends.http_json.port, + "[frontends.http_json]", + )); + } + if self.frontends.rigctl.enabled { + for (name, port) in &self.frontends.rigctl.rig_ports { + sockets.push(BoundSocket::new( + self.frontends.rigctl.listen, + *port, + format!("[frontends.rigctl].rig_ports.{name}"), + )); + } + } + sockets + } + pub fn validate(&self) -> Result<(), String> { validate_log_level(self.general.log_level.as_deref())?; @@ -1203,6 +1275,99 @@ url = "remote.example.com:4530" .contains("poll_interval_ms must be > 0")); } + // --- Second-phase validation against the resolved remote list --- + + fn remotes(names: &[&str]) -> Vec { + names + .iter() + .map(|name| RemoteEntry { + name: name.to_string(), + url: "127.0.0.1:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }) + .collect() + } + + #[test] + fn test_validate_resolved_accepts_matching_rig_names() { + let mut config = ClientConfig::default(); + config.frontends.http.default_rig_name = Some("hf".to_string()); + config + .frontends + .audio + .rig_ports + .insert("vhf".to_string(), 4532); + assert!(config.validate_resolved(&remotes(&["hf", "vhf"])).is_ok()); + } + + #[test] + fn test_validate_resolved_rejects_unknown_rigctl_rig() { + let mut config = ClientConfig::default(); + config.frontends.rigctl.enabled = true; + config + .frontends + .rigctl + .rig_ports + .insert("typo".to_string(), 4532); + let err = config.validate_resolved(&remotes(&["hf"])).unwrap_err(); + assert!( + err.contains("rig_ports") && err.contains("typo") && err.contains("hf"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_validate_resolved_rejects_unknown_default_rig() { + let mut config = ClientConfig::default(); + config.frontends.http.default_rig_name = Some("nope".to_string()); + assert!(config.validate_resolved(&remotes(&["hf"])).is_err()); + } + + #[test] + fn test_validate_resolved_rejects_unknown_retention_rig() { + let mut config = ClientConfig::default(); + config + .frontends + .http + .decode_history_retention_min_by_rig + .insert("ghost".to_string(), 60); + assert!(config.validate_resolved(&remotes(&["hf"])).is_err()); + } + + #[test] + fn test_validate_resolved_rejects_port_collision() { + let mut config = ClientConfig::default(); + config.frontends.http.port = 8080; + config.frontends.http_json.enabled = true; + config.frontends.http_json.port = 8080; + let err = config.validate_resolved(&remotes(&["hf"])).unwrap_err(); + assert!(err.contains("8080"), "unexpected error: {err}"); + } + + #[test] + fn test_validate_resolved_rejects_rigctl_colliding_with_http() { + let mut config = ClientConfig::default(); + config.frontends.http.port = 8080; + config.frontends.rigctl.enabled = true; + config + .frontends + .rigctl + .rig_ports + .insert("hf".to_string(), 8080); + assert!(config.validate_resolved(&remotes(&["hf"])).is_err()); + } + + #[test] + fn test_bound_sockets_skips_disabled_frontends() { + let mut config = ClientConfig::default(); + config.frontends.http.enabled = false; + config.frontends.http_json.enabled = false; + config.frontends.rigctl.enabled = false; + assert!(config.bound_sockets().is_empty()); + } + #[test] fn test_validate_rejects_invalid_bandplan_region() { let mut config = ClientConfig::default(); diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index b5654570..b7834d62 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -16,7 +16,7 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use crate::file::{ConfigError, ConfigFile, ConfigLoad}; -use crate::shared::{validate_log_level, validate_tokens}; +use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens, BoundSocket}; pub use trx_decode_log::DecodeLogsConfig; use trx_core::rig::state::RigMode; @@ -495,6 +495,28 @@ impl ServerConfig { Ok(()) } + /// Checks that only make sense once the rig list is known. + /// + /// `rigs` is the CLI-merged result of `resolved_rigs()`, and `sockets` are + /// the addresses the process is actually about to bind — the caller builds + /// them because `--listen` overrides both the control and audio addresses. + pub fn validate_resolved( + &self, + rigs: &[RigInstanceConfig], + sockets: &[BoundSocket], + ) -> Result<(), String> { + // 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)); + } + } + + check_socket_conflicts(sockets) + } + /// Check that enabled `[[rigs]]` entries do not collide with one another. fn validate_rig_uniqueness(&self) -> Result<(), String> { if self.rigs.is_empty() { @@ -1401,6 +1423,67 @@ port = 4532 assert_eq!(rigs[0].id, "enabled"); } + // --- Second-phase validation against the resolved rig list --- + + #[test] + fn test_validate_resolved_rejects_listener_audio_port_collision() { + use crate::shared::BoundSocket; + let ip: std::net::IpAddr = "127.0.0.1".parse().unwrap(); + let cfg = ServerConfig::default(); + let rigs = cfg.resolved_rigs(); + let sockets = vec![ + BoundSocket::new(ip, 4530, "[listen]"), + BoundSocket::new(ip, 4530, "rig \"default\" [audio]"), + ]; + let err = cfg.validate_resolved(&rigs, &sockets).unwrap_err(); + assert!(err.contains("4530"), "unexpected error: {err}"); + } + + #[test] + fn test_validate_resolved_accepts_distinct_ports() { + use crate::shared::BoundSocket; + let ip: std::net::IpAddr = "127.0.0.1".parse().unwrap(); + let cfg = ServerConfig::default(); + let rigs = cfg.resolved_rigs(); + let sockets = vec![ + BoundSocket::new(ip, 4530, "[listen]"), + BoundSocket::new(ip, 4531, "rig \"default\" [audio]"), + ]; + assert!(cfg.validate_resolved(&rigs, &sockets).is_ok()); + } + + #[test] + fn test_validate_resolved_rejects_id_collision_after_generation() { + // The second entry has no id, so resolution names it "_", + // which the first entry has already claimed explicitly. + let toml_str = r#" +[[rigs]] +id = "ft817_1" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let rigs = cfg.resolved_rigs(); + let err = cfg.validate_resolved(&rigs, &[]).unwrap_err(); + assert!(err.contains("ft817_1"), "unexpected error: {err}"); + } + #[test] fn test_validate_rejects_duplicate_rig_ids() { let toml_str = r#" diff --git a/src/trx-config/src/shared.rs b/src/trx-config/src/shared.rs index 60460b63..f1b998cb 100644 --- a/src/trx-config/src/shared.rs +++ b/src/trx-config/src/shared.rs @@ -19,6 +19,52 @@ //! would either bloat both binaries with unused fields or require a trait //! abstraction that adds complexity without clear benefit. +use std::net::IpAddr; + +/// A socket a component intends to bind, and what it is for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BoundSocket { + pub addr: IpAddr, + pub port: u16, + /// Human-readable owner, e.g. `[listen]` or `[frontends.http]`. + pub label: String, +} + +impl BoundSocket { + pub fn new(addr: IpAddr, port: u16, label: impl Into) -> Self { + Self { + addr, + port, + label: label.into(), + } + } +} + +/// Reject two components trying to bind the same socket. +/// +/// A wildcard address (`0.0.0.0` / `::`) conflicts with any other address on +/// the same port, since binding it claims every interface. Port 0 means "pick +/// an ephemeral port" and never conflicts. +pub fn check_socket_conflicts(sockets: &[BoundSocket]) -> Result<(), String> { + for (i, a) in sockets.iter().enumerate() { + if a.port == 0 { + continue; + } + for b in &sockets[i + 1..] { + if b.port != a.port { + continue; + } + if a.addr == b.addr || a.addr.is_unspecified() || b.addr.is_unspecified() { + return Err(format!( + "{} and {} would both bind {}:{}", + a.label, b.label, a.addr, a.port + )); + } + } + } + Ok(()) +} + /// Validate that a log level string is one of the accepted values. /// /// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere) @@ -53,6 +99,47 @@ pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> { mod tests { use super::*; + fn sock(addr: &str, port: u16, label: &str) -> BoundSocket { + BoundSocket::new(addr.parse().unwrap(), port, label) + } + + #[test] + fn test_socket_conflicts_detects_exact_duplicate() { + let err = check_socket_conflicts(&[ + sock("127.0.0.1", 8080, "[frontends.http]"), + sock("127.0.0.1", 8080, "[frontends.http_json]"), + ]) + .unwrap_err(); + assert!(err.contains("127.0.0.1:8080"), "unexpected error: {err}"); + } + + #[test] + fn test_socket_conflicts_detects_wildcard_overlap() { + assert!(check_socket_conflicts(&[ + sock("0.0.0.0", 4530, "[listen]"), + sock("127.0.0.1", 4530, "[audio]"), + ]) + .is_err()); + } + + #[test] + fn test_socket_conflicts_allows_distinct_addresses() { + assert!(check_socket_conflicts(&[ + sock("127.0.0.1", 4530, "[listen]"), + sock("192.168.1.5", 4530, "[audio]"), + ]) + .is_ok()); + } + + #[test] + fn test_socket_conflicts_ignores_ephemeral_ports() { + assert!(check_socket_conflicts(&[ + sock("127.0.0.1", 0, "[frontends.http_json]"), + sock("127.0.0.1", 0, "[frontends.http]"), + ]) + .is_ok()); + } + #[test] fn test_validate_log_level_none() { assert!(validate_log_level(None).is_ok()); diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 8e9b156f..e1257872 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -29,6 +29,7 @@ use tracing::{error, info, warn}; use trx_core::audio::AudioStreamInfo; use trx_app::{init_logging, normalize_name}; +use trx_config::shared::BoundSocket; use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess}; use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff}; use trx_core::rig::request::RigRequest; @@ -986,6 +987,34 @@ async fn main() -> DynResult<()> { (callsign, cfg.general.latitude, cfg.general.longitude) }; + // 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))?; + } + info!( "Starting trx-server with {} rig(s): {}", resolved_rigs.len(), -- 2.55.0 From 7c69e0de083c99e99312a574220d7069fb393d36 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:08:20 +0200 Subject: [PATCH 06/13] [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): {}", -- 2.55.0 From 88ed3da6cc231a29ef2fed25876d00f1b4179e97 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:22:03 +0200 Subject: [PATCH 07/13] [feat](trx-server): make the decoder set configurable per rig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every rig started nine decoders — APRS, HF APRS, CW, FT8, FT4, WSPR, LRPT, WEFAX, SSTV — whether or not anyone ever looked at the results. Two rigs on a Pi meant eighteen decoder tasks chewing CPU for modes the operator does not run. Only the SDR virtual channels had a decoder list; the analog path had no say at all. Add [decoders] per rig: [decoders] enabled = ["cw", "ft8", "wspr"] output_dir = "/var/lib/trx-rs" using the same decoder names as [sdr.channels].decoders, so there is one vocabulary. enabled defaults to every decoder, so upgrading changes nothing. An unknown name is a config error rather than a silently ignored entry. output_dir also replaces the hard-coded cache paths for the decoders that write images, so SSTV, WEFAX and LRPT output can live somewhere the operator chooses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- src/trx-config/src/server.rs | 134 +++++++++++++++++ src/trx-server/src/audio.rs | 5 +- src/trx-server/src/main.rs | 281 ++++++++++++++++++----------------- 3 files changed, 283 insertions(+), 137 deletions(-) diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 55c86401..042718eb 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -21,6 +21,56 @@ pub use trx_decode_log::DecodeLogsConfig; use trx_core::rig::state::RigMode; +/// Every decoder the server knows how to run, by config name. +/// +/// The same names are used by `[sdr.channels].decoders`, so there is one +/// vocabulary for both. +pub const DECODER_NAMES: &[&str] = &[ + "aprs", "aprs_hf", "ais", "cw", "ft2", "ft4", "ft8", "lrpt", "sstv", "vdes", "wefax", "wspr", +]; + +/// Which decoders run for a rig, and where the ones that write files put them. +/// +/// Decoders used to be started unconditionally: every rig ran ten of them +/// whether or not the operator ever looked at the results, which is real CPU on +/// a Pi. `enabled` defaults to all of them so upgrading changes nothing. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct DecodersConfig { + /// Decoder names to run; see `DECODER_NAMES`. + pub enabled: Vec, + /// Base directory for decoders that write images (sstv, wefax, lrpt). + /// Each decoder gets a subdirectory. Defaults to the user cache directory. + pub output_dir: Option, +} + +impl Default for DecodersConfig { + fn default() -> Self { + Self { + enabled: DECODER_NAMES.iter().map(|s| s.to_string()).collect(), + output_dir: None, + } + } +} + +impl DecodersConfig { + /// Whether `name` should be started for this rig. + pub fn is_enabled(&self, name: &str) -> bool { + self.enabled.iter().any(|n| n == name) + } + + /// Where `decoder` should write its files. + pub fn output_dir_for(&self, decoder: &str) -> std::path::PathBuf { + let base = match &self.output_dir { + Some(dir) => std::path::PathBuf::from(dir), + None => dirs::cache_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".cache")) + .join("trx-rs"), + }; + base.join(decoder) + } +} + /// Per-rig instance configuration for multi-rig setups. /// /// Each entry in `[[rigs]]` becomes one of these. The flat top-level @@ -53,6 +103,8 @@ pub struct RigInstanceConfig { pub aprsfi: AprsFiConfig, /// Decoder file logging for this rig. pub decode_logs: DecodeLogsConfig, + /// Which decoders to run for this rig. + pub decoders: DecodersConfig, } impl Default for RigInstanceConfig { @@ -68,6 +120,7 @@ impl Default for RigInstanceConfig { pskreporter: PskReporterConfig::default(), aprsfi: AprsFiConfig::default(), decode_logs: DecodeLogsConfig::default(), + decoders: DecodersConfig::default(), } } } @@ -100,6 +153,8 @@ pub struct ServerConfig { pub aprsfi: AprsFiConfig, /// Decoder file logging configuration (legacy flat) pub decode_logs: DecodeLogsConfig, + /// Decoder selection (legacy flat) + pub decoders: DecodersConfig, /// SDR pipeline configuration (legacy flat; used when [rig.access] type = "sdr"). pub sdr: SdrConfig, /// Timeout and buffer-size tuning knobs. @@ -642,6 +697,7 @@ impl ServerConfig { pskreporter: self.pskreporter.clone(), aprsfi: self.aprsfi.clone(), decode_logs: self.decode_logs.clone(), + decoders: self.decoders.clone(), }] } @@ -679,6 +735,7 @@ impl ServerConfig { pskreporter: PskReporterConfig::default(), aprsfi: AprsFiConfig::default(), decode_logs: DecodeLogsConfig::default(), + decoders: DecodersConfig::default(), sdr: SdrConfig::default(), timeouts: TimeoutsConfig::default(), rigs: Vec::new(), @@ -821,6 +878,16 @@ fn validate_rig_instance( errors.push(e); } + for name in &rig.decoders.enabled { + if !DECODER_NAMES.contains(&name.as_str()) { + errors.push(format!( + "{prefix}[decoders].enabled contains unknown decoder \"{}\" (valid: {})", + name, + DECODER_NAMES.join(", ") + )); + } + } + if rig.decode_logs.enabled { if rig.decode_logs.dir.trim().is_empty() { errors.push(format!( @@ -1650,6 +1717,73 @@ port = 4531 ); } + // --- Decoder selection --- + + #[test] + fn test_decoders_default_enables_everything() { + let cfg = DecodersConfig::default(); + for name in DECODER_NAMES { + assert!(cfg.is_enabled(name), "{name} should be on by default"); + } + } + + #[test] + fn test_decoders_parsed_from_rig_entry() { + let toml_str = r#" +[[rigs]] +id = "hf" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 +[rigs.decoders] +enabled = ["ft8", "wspr"] +output_dir = "/var/lib/trx-rs" +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + cfg.validate().expect("config should be valid"); + let rigs = cfg.resolved_rigs(); + assert!(rigs[0].decoders.is_enabled("ft8")); + assert!(!rigs[0].decoders.is_enabled("cw")); + assert_eq!( + rigs[0].decoders.output_dir_for("sstv"), + std::path::PathBuf::from("/var/lib/trx-rs/sstv") + ); + } + + #[test] + fn test_decoders_flat_layout_reaches_resolved_rig() { + let toml_str = r#" +[decoders] +enabled = ["cw"] +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let rigs = cfg.resolved_rigs(); + assert!(rigs[0].decoders.is_enabled("cw")); + assert!(!rigs[0].decoders.is_enabled("ft8")); + } + + #[test] + fn test_validate_rejects_unknown_decoder_name() { + let toml_str = r#" +[decoders] +enabled = ["ft8", "morse"] +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let err = cfg.validate().expect_err("expected an unknown decoder error"); + assert!(err.contains("morse"), "unexpected error: {err}"); + } + + #[test] + fn test_decoders_output_dir_defaults_under_cache() { + let cfg = DecodersConfig::default(); + assert!(cfg.output_dir_for("sstv").ends_with("trx-rs/sstv")); + } + // --- Per-rig validation: [[rigs]] entries obey the same rules as the // legacy flat layout, which they previously escaped entirely. --- diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs index 933b062e..e26054d4 100644 --- a/src/trx-server/src/audio.rs +++ b/src/trx-server/src/audio.rs @@ -2291,15 +2291,12 @@ pub async fn run_wefax_decoder( mut state_rx: watch::Receiver, decode_tx: broadcast::Sender, histories: Arc, + wefax_output_dir: std::path::PathBuf, ) { use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent}; info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels); - let wefax_output_dir = dirs::cache_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".cache")) - .join("trx-rs") - .join("wefax"); let config = WefaxConfig { output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()), ..WefaxConfig::default() diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 11c7ac1b..0621689a 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -660,38 +660,44 @@ fn spawn_rig_audio_stack( } // Spawn APRS decoder task - let aprs_pcm_rx = pcm_tx.subscribe(); - let aprs_state_rx = state_rx.clone(); - let aprs_decode_tx = decode_tx.clone(); - let aprs_sr = rig_cfg.audio.sample_rate; - let aprs_ch = rig_cfg.audio.channels; - let aprs_shutdown_rx = shutdown_rx.clone(); - let aprs_logs = decoder_logs.clone(); - let aprs_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {} - _ = wait_for_shutdown(aprs_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("aprs") { + let aprs_pcm_rx = pcm_tx.subscribe(); + let aprs_state_rx = state_rx.clone(); + let aprs_decode_tx = decode_tx.clone(); + let aprs_sr = rig_cfg.audio.sample_rate; + let aprs_ch = rig_cfg.audio.channels; + let aprs_shutdown_rx = shutdown_rx.clone(); + let aprs_logs = decoder_logs.clone(); + let aprs_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {} + _ = wait_for_shutdown(aprs_shutdown_rx) => {} + } + })); + } // Spawn HF APRS decoder task - let hf_aprs_pcm_rx = pcm_tx.subscribe(); - let hf_aprs_state_rx = state_rx.clone(); - let hf_aprs_decode_tx = decode_tx.clone(); - let hf_aprs_sr = rig_cfg.audio.sample_rate; - let hf_aprs_ch = rig_cfg.audio.channels; - let hf_aprs_shutdown_rx = shutdown_rx.clone(); - let hf_aprs_logs = decoder_logs.clone(); - let hf_aprs_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {} - _ = wait_for_shutdown(hf_aprs_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("aprs_hf") { + let hf_aprs_pcm_rx = pcm_tx.subscribe(); + let hf_aprs_state_rx = state_rx.clone(); + let hf_aprs_decode_tx = decode_tx.clone(); + let hf_aprs_sr = rig_cfg.audio.sample_rate; + let hf_aprs_ch = rig_cfg.audio.channels; + let hf_aprs_shutdown_rx = shutdown_rx.clone(); + let hf_aprs_logs = decoder_logs.clone(); + let hf_aprs_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {} + _ = wait_for_shutdown(hf_aprs_shutdown_rx) => {} + } + })); + } - if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx { + if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = + sdr_ais_pcm_rx.filter(|_| rig_cfg.decoders.is_enabled("ais")) + { let ais_state_rx = state_rx.clone(); let ais_decode_tx = decode_tx.clone(); let ais_shutdown_rx = shutdown_rx.clone(); @@ -706,7 +712,7 @@ fn spawn_rig_audio_stack( })); } - if let Some(vdes_iq_rx) = sdr_vdes_iq_rx { + if let Some(vdes_iq_rx) = sdr_vdes_iq_rx.filter(|_| rig_cfg.decoders.is_enabled("vdes")) { let vdes_state_rx = state_rx.clone(); let vdes_decode_tx = decode_tx.clone(); let vdes_shutdown_rx = shutdown_rx.clone(); @@ -728,55 +734,61 @@ fn spawn_rig_audio_stack( } // Spawn CW decoder task - let cw_pcm_rx = pcm_tx.subscribe(); - let cw_state_rx = state_rx.clone(); - let cw_decode_tx = decode_tx.clone(); - let cw_sr = rig_cfg.audio.sample_rate; - let cw_ch = rig_cfg.audio.channels; - let cw_shutdown_rx = shutdown_rx.clone(); - let cw_logs = decoder_logs.clone(); - let cw_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {} - _ = wait_for_shutdown(cw_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("cw") { + let cw_pcm_rx = pcm_tx.subscribe(); + let cw_state_rx = state_rx.clone(); + let cw_decode_tx = decode_tx.clone(); + let cw_sr = rig_cfg.audio.sample_rate; + let cw_ch = rig_cfg.audio.channels; + let cw_shutdown_rx = shutdown_rx.clone(); + let cw_logs = decoder_logs.clone(); + let cw_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {} + _ = wait_for_shutdown(cw_shutdown_rx) => {} + } + })); + } // Spawn FT8 decoder task - let ft8_pcm_rx = pcm_tx.subscribe(); - let ft8_state_rx = state_rx.clone(); - let ft8_decode_tx = decode_tx.clone(); - let ft8_sr = rig_cfg.audio.sample_rate; - let ft8_ch = rig_cfg.audio.channels; - let ft8_shutdown_rx = shutdown_rx.clone(); - let ft8_logs = decoder_logs.clone(); - let ft8_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {} - _ = wait_for_shutdown(ft8_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("ft8") { + let ft8_pcm_rx = pcm_tx.subscribe(); + let ft8_state_rx = state_rx.clone(); + let ft8_decode_tx = decode_tx.clone(); + let ft8_sr = rig_cfg.audio.sample_rate; + let ft8_ch = rig_cfg.audio.channels; + let ft8_shutdown_rx = shutdown_rx.clone(); + let ft8_logs = decoder_logs.clone(); + let ft8_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {} + _ = wait_for_shutdown(ft8_shutdown_rx) => {} + } + })); + } // Spawn FT4 decoder task - let ft4_pcm_rx = pcm_tx.subscribe(); - let ft4_state_rx = state_rx.clone(); - let ft4_decode_tx = decode_tx.clone(); - let ft4_sr = rig_cfg.audio.sample_rate; - let ft4_ch = rig_cfg.audio.channels; - let ft4_shutdown_rx = shutdown_rx.clone(); - let ft4_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {} - _ = wait_for_shutdown(ft4_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("ft4") { + let ft4_pcm_rx = pcm_tx.subscribe(); + let ft4_state_rx = state_rx.clone(); + let ft4_decode_tx = decode_tx.clone(); + let ft4_sr = rig_cfg.audio.sample_rate; + let ft4_ch = rig_cfg.audio.channels; + let ft4_shutdown_rx = shutdown_rx.clone(); + let ft4_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {} + _ = wait_for_shutdown(ft4_shutdown_rx) => {} + } + })); + } // Spawn FT2 decoder task #[cfg(feature = "ft2")] - { + if rig_cfg.decoders.is_enabled("ft2") { let ft2_pcm_rx = pcm_tx.subscribe(); let ft2_state_rx = state_rx.clone(); let ft2_decode_tx = decode_tx.clone(); @@ -793,73 +805,76 @@ fn spawn_rig_audio_stack( } // Spawn WSPR decoder task - let wspr_pcm_rx = pcm_tx.subscribe(); - let wspr_state_rx = state_rx.clone(); - let wspr_decode_tx = decode_tx.clone(); - let wspr_sr = rig_cfg.audio.sample_rate; - let wspr_ch = rig_cfg.audio.channels; - let wspr_shutdown_rx = shutdown_rx.clone(); - let wspr_logs = decoder_logs.clone(); - let wspr_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {} - _ = wait_for_shutdown(wspr_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("wspr") { + let wspr_pcm_rx = pcm_tx.subscribe(); + let wspr_state_rx = state_rx.clone(); + let wspr_decode_tx = decode_tx.clone(); + let wspr_sr = rig_cfg.audio.sample_rate; + let wspr_ch = rig_cfg.audio.channels; + let wspr_shutdown_rx = shutdown_rx.clone(); + let wspr_logs = decoder_logs.clone(); + let wspr_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {} + _ = wait_for_shutdown(wspr_shutdown_rx) => {} + } + })); + } // Spawn Meteor-M LRPT decoder task - let lrpt_pcm_rx = pcm_tx.subscribe(); - let lrpt_state_rx = state_rx.clone(); - let lrpt_decode_tx = decode_tx.clone(); - let lrpt_sr = rig_cfg.audio.sample_rate; - let lrpt_ch = rig_cfg.audio.channels; - let lrpt_shutdown_rx = shutdown_rx.clone(); - let lrpt_histories = histories.clone(); - let lrpt_output_dir = dirs::cache_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".cache")) - .join("trx-rs") - .join("lrpt"); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {} - _ = wait_for_shutdown(lrpt_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("lrpt") { + let lrpt_pcm_rx = pcm_tx.subscribe(); + let lrpt_state_rx = state_rx.clone(); + let lrpt_decode_tx = decode_tx.clone(); + let lrpt_sr = rig_cfg.audio.sample_rate; + let lrpt_ch = rig_cfg.audio.channels; + let lrpt_shutdown_rx = shutdown_rx.clone(); + let lrpt_histories = histories.clone(); + let lrpt_output_dir = rig_cfg.decoders.output_dir_for("lrpt"); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {} + _ = wait_for_shutdown(lrpt_shutdown_rx) => {} + } + })); + } // Spawn WEFAX decoder task - let wefax_pcm_rx = pcm_tx.subscribe(); - let wefax_state_rx = state_rx.clone(); - let wefax_decode_tx = decode_tx.clone(); - let wefax_sr = rig_cfg.audio.sample_rate; - let wefax_ch = rig_cfg.audio.channels; - let wefax_shutdown_rx = shutdown_rx.clone(); - let wefax_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories) => {} - _ = wait_for_shutdown(wefax_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("wefax") { + let wefax_pcm_rx = pcm_tx.subscribe(); + let wefax_state_rx = state_rx.clone(); + let wefax_decode_tx = decode_tx.clone(); + let wefax_sr = rig_cfg.audio.sample_rate; + let wefax_ch = rig_cfg.audio.channels; + let wefax_shutdown_rx = shutdown_rx.clone(); + let wefax_histories = histories.clone(); + let wefax_output_dir = rig_cfg.decoders.output_dir_for("wefax"); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories, wefax_output_dir) => {} + _ = wait_for_shutdown(wefax_shutdown_rx) => {} + } + })); + } // Spawn SSTV decoder task - let sstv_pcm_rx = pcm_tx.subscribe(); - let sstv_state_rx = state_rx.clone(); - let sstv_decode_tx = decode_tx.clone(); - let sstv_sr = rig_cfg.audio.sample_rate; - let sstv_ch = rig_cfg.audio.channels; - let sstv_shutdown_rx = shutdown_rx.clone(); - let sstv_histories = histories.clone(); - let sstv_output_dir = dirs::cache_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".cache")) - .join("trx-rs") - .join("sstv"); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {} - _ = wait_for_shutdown(sstv_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("sstv") { + let sstv_pcm_rx = pcm_tx.subscribe(); + let sstv_state_rx = state_rx.clone(); + let sstv_decode_tx = decode_tx.clone(); + let sstv_sr = rig_cfg.audio.sample_rate; + let sstv_ch = rig_cfg.audio.channels; + let sstv_shutdown_rx = shutdown_rx.clone(); + let sstv_histories = histories.clone(); + let sstv_output_dir = rig_cfg.decoders.output_dir_for("sstv"); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {} + _ = wait_for_shutdown(sstv_shutdown_rx) => {} + } + })); + } } if rig_cfg.audio.tx_enabled { -- 2.55.0 From 76bcce8c54d7658fe46fd87d6b88146aec6d1de0 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:27:18 +0200 Subject: [PATCH 08/13] [feat](trx-config): let secrets live outside the config file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens and passphrases had exactly one representation: plain text in trx-rs.toml. That is awkward for config-management tools, for a config kept in a private repo, and for anything shared between machines. Two alternatives: - ${VAR} anywhere in a config string, expanded from the environment at load. An unset variable is an error rather than an empty string — a silently blank passphrase is how authentication gets disabled by accident. - A *_file sibling for every credential: [listen.auth].tokens_file, [[remotes]].auth.token_file, [frontends.http.auth].rx_passphrase_file and .control_passphrase_file, [frontends.http_json.auth].tokens_file. Setting both forms is an error rather than a guess about which wins. Plus a nudge: a config file that holds credentials inline and is readable by group or others gets a warning naming the chmod that fixes it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- Cargo.lock | 1 + src/trx-client/src/main.rs | 17 +- src/trx-config/Cargo.toml | 3 + src/trx-config/src/client.rs | 131 +++++++++++++++ src/trx-config/src/file.rs | 7 +- src/trx-config/src/lib.rs | 1 + src/trx-config/src/secrets.rs | 298 ++++++++++++++++++++++++++++++++++ src/trx-config/src/server.rs | 51 ++++++ src/trx-server/src/main.rs | 14 +- 9 files changed, 516 insertions(+), 7 deletions(-) create mode 100644 src/trx-config/src/secrets.rs diff --git a/Cargo.lock b/Cargo.lock index 2c6caee6..5fed8d51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3138,6 +3138,7 @@ dependencies = [ "dirs", "serde", "serde_ignored", + "tempfile", "thiserror 2.0.18", "toml", "tracing", diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 9f4f5e3b..c2266af1 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -128,7 +128,12 @@ fn check_config(loaded: &trx_config::ConfigLoad) -> DynResult<()> let mut warnings: Vec = loaded.unknown_keys.iter().map(|k| k.to_string()).collect(); - let cfg = &loaded.config; + let mut cfg = loaded.config.clone(); + let mut errors = Vec::new(); + if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) { + errors.push(e); + } + let cfg = &cfg; let remotes = cfg.resolved_remotes(); if remotes.is_empty() { warnings.push( @@ -137,7 +142,7 @@ fn check_config(loaded: &trx_config::ConfigLoad) -> DynResult<()> ); } - let mut errors = cfg.validate_all(); + errors.extend(cfg.validate_all()); if !remotes.is_empty() { errors.extend(cfg.validate_resolved_all(&remotes)); } @@ -220,6 +225,9 @@ async fn async_init() -> DynResult { loaded.report_unknown_keys(cli.strict_config)?; let mut cfg = loaded.config; + // Secrets configured as *_file are read before validation, so everything + // downstream sees resolved values. + cfg.resolve_secrets(config_path.as_deref())?; cfg.validate() .map_err(|e| format!("Invalid client configuration: {}", e))?; @@ -274,7 +282,10 @@ async fn async_init() -> DynResult { name, url: url.clone(), rig_id, - auth: config::RemoteAuthConfig { token }, + auth: config::RemoteAuthConfig { + token, + token_file: None, + }, poll_interval_ms, }] } else { diff --git a/src/trx-config/Cargo.toml b/src/trx-config/Cargo.toml index 77f5f466..733ac0c6 100644 --- a/src/trx-config/Cargo.toml +++ b/src/trx-config/Cargo.toml @@ -18,3 +18,6 @@ trx-core = { path = "../trx-core" } trx-decode-log = { path = "../decoders/trx-decode-log" } trx-reporting = { path = "../trx-reporting" } serde_ignored = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index d6c35127..4cd0df93 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -95,6 +95,9 @@ impl Default for RemoteConfig { pub struct RemoteAuthConfig { /// Bearer token to send with JSON commands. pub token: Option, + /// Read the token from this file instead. Mutually exclusive with `token`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_file: Option, } /// A named remote connection entry. @@ -236,8 +239,14 @@ pub struct HttpAuthConfig { pub enabled: bool, /// Passphrase for read-only access (rx role) pub rx_passphrase: Option, + /// Read the rx passphrase from this file instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rx_passphrase_file: Option, /// Passphrase for full control access (control role) pub control_passphrase: Option, + /// Read the control passphrase from this file instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_passphrase_file: Option, /// Enforce TX/PTT access control (hide from unauthenticated/rx users) pub tx_access_control_enabled: bool, /// Session time-to-live in minutes @@ -253,7 +262,9 @@ impl Default for HttpAuthConfig { Self { enabled: false, rx_passphrase: None, + rx_passphrase_file: None, control_passphrase: None, + control_passphrase_file: None, tx_access_control_enabled: true, session_ttl_min: 480, cookie_secure: false, @@ -380,6 +391,10 @@ impl Default for HttpJsonFrontendConfig { pub struct HttpJsonAuthConfig { /// Accepted bearer tokens. pub tokens: Vec, + /// Read the tokens from this file instead, one per line. Blank lines and + /// `#` comments are ignored. Mutually exclusive with `tokens`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens_file: Option, } impl ClientConfig { @@ -720,6 +735,65 @@ impl ClientConfig { Ok(()) } + /// Read any secret that was configured as a `*_file` and warn about + /// over-permissive file modes. + /// + /// Called after loading and before validation, so the rest of the code only + /// ever sees resolved values. + pub fn resolve_secrets(&mut self, config_path: Option<&Path>) -> Result<(), String> { + use crate::secrets::{resolve_secret, resolve_secret_list}; + + resolve_secret( + &mut self.remote.auth.token, + &self.remote.auth.token_file, + "[remote.auth].token", + )?; + for (i, entry) in self.remotes.iter_mut().enumerate() { + resolve_secret( + &mut entry.auth.token, + &entry.auth.token_file, + &format!("[[remotes]][{i}].auth.token"), + )?; + } + resolve_secret( + &mut self.frontends.http.auth.rx_passphrase, + &self.frontends.http.auth.rx_passphrase_file, + "[frontends.http.auth].rx_passphrase", + )?; + resolve_secret( + &mut self.frontends.http.auth.control_passphrase, + &self.frontends.http.auth.control_passphrase_file, + "[frontends.http.auth].control_passphrase", + )?; + resolve_secret_list( + &mut self.frontends.http_json.auth.tokens, + &self.frontends.http_json.auth.tokens_file, + "[frontends.http_json.auth].tokens", + )?; + + if let Some(path) = config_path { + if self.has_inline_secrets() { + crate::secrets::warn_if_group_readable(path, "tokens/passphrases"); + } + } + Ok(()) + } + + /// Whether any credential is written in the config file itself. + fn has_inline_secrets(&self) -> bool { + self.remote.auth.token_file.is_none() && self.remote.auth.token.is_some() + || self + .remotes + .iter() + .any(|r| r.auth.token_file.is_none() && r.auth.token.is_some()) + || self.frontends.http.auth.rx_passphrase_file.is_none() + && self.frontends.http.auth.rx_passphrase.is_some() + || self.frontends.http.auth.control_passphrase_file.is_none() + && self.frontends.http.auth.control_passphrase.is_some() + || self.frontends.http_json.auth.tokens_file.is_none() + && !self.frontends.http_json.auth.tokens.is_empty() + } + /// Load configuration from a specific file path. pub fn load_from_file(path: &Path) -> Result, ConfigError> { ::load_from_file(path) @@ -755,6 +829,7 @@ impl ClientConfig { rig_id: Some("hf".to_string()), auth: RemoteAuthConfig { token: Some("my-token".to_string()), + token_file: None, }, poll_interval_ms: 750, }, @@ -764,6 +839,7 @@ impl ClientConfig { rig_id: Some("vhf".to_string()), auth: RemoteAuthConfig { token: Some("my-token".to_string()), + token_file: None, }, poll_interval_ms: 750, }, @@ -785,7 +861,9 @@ impl ClientConfig { auth: HttpAuthConfig { enabled: false, rx_passphrase: Some("rx-passphrase-example".to_string()), + rx_passphrase_file: None, control_passphrase: Some("control-passphrase-example".to_string()), + control_passphrase_file: None, tx_access_control_enabled: true, session_ttl_min: 480, cookie_secure: false, @@ -1189,6 +1267,7 @@ url = "remote.example.com:4530" rig_id: Some("hf".to_string()), auth: RemoteAuthConfig { token: Some("tok".to_string()), + token_file: None, }, poll_interval_ms: 750, }, @@ -1315,6 +1394,58 @@ url = "remote.example.com:4530" .contains("poll_interval_ms must be > 0")); } + // --- Secret indirection --- + + fn secret_file(content: &str) -> tempfile::NamedTempFile { + use std::io::Write; + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn test_passphrase_file_fills_passphrase() { + let f = secret_file("hunter2\n"); + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.control_passphrase_file = + Some(f.path().to_str().unwrap().to_string()); + config.resolve_secrets(None).unwrap(); + assert_eq!( + config.frontends.http.auth.control_passphrase.as_deref(), + Some("hunter2") + ); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_remote_token_file_fills_token() { + let f = secret_file("remote-token"); + let mut config = ClientConfig::default(); + config.remotes = vec![RemoteEntry { + name: "hf".to_string(), + url: "127.0.0.1:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig { + token: None, + token_file: Some(f.path().to_str().unwrap().to_string()), + }, + poll_interval_ms: 750, + }]; + config.resolve_secrets(None).unwrap(); + assert_eq!(config.remotes[0].auth.token.as_deref(), Some("remote-token")); + } + + #[test] + fn test_token_and_token_file_together_is_an_error() { + let f = secret_file("from-file"); + let mut config = ClientConfig::default(); + config.remote.auth.token = Some("inline".to_string()); + config.remote.auth.token_file = Some(f.path().to_str().unwrap().to_string()); + assert!(config.resolve_secrets(None).unwrap_err().contains("not both")); + } + // --- Second-phase validation against the resolved remote list --- fn remotes(names: &[&str]) -> Vec { diff --git a/src/trx-config/src/file.rs b/src/trx-config/src/file.rs index 3e6feddd..7e1211a9 100644 --- a/src/trx-config/src/file.rs +++ b/src/trx-config/src/file.rs @@ -107,10 +107,15 @@ fn load_section_from_file( let table: toml::Table = toml::from_str(&content) .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; - let Some(section) = select_section(&table, key) else { + let Some(mut section) = select_section(&table, key) else { return Ok(None); }; + // ${VAR} references are expanded before deserializing, so any string in the + // file can come from the environment. + crate::secrets::expand_env_vars(&mut section) + .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?; + // Deserialize straight from the TOML value so serde applies every default, // recording any key no field claimed. let mut ignored: Vec = Vec::new(); diff --git a/src/trx-config/src/lib.rs b/src/trx-config/src/lib.rs index 4f0508b5..e9cdf81e 100644 --- a/src/trx-config/src/lib.rs +++ b/src/trx-config/src/lib.rs @@ -11,6 +11,7 @@ pub mod client; pub mod file; +pub mod secrets; pub mod server; pub mod shared; pub mod unknown; diff --git a/src/trx-config/src/secrets.rs b/src/trx-config/src/secrets.rs new file mode 100644 index 00000000..96500e41 --- /dev/null +++ b/src/trx-config/src/secrets.rs @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Keeping credentials out of the config file. +//! +//! Tokens and passphrases used to have exactly one representation: written in +//! plain text in `trx-rs.toml`, which is awkward when the config is deployed by +//! a config-management tool, committed to a private repo, or shared between +//! machines. Two alternatives are offered: +//! +//! - `${VAR}` anywhere in a config string, expanded from the environment. +//! - A `*_file` sibling of any secret key, read from disk at startup. +//! +//! Plus a nudge: a config that holds secrets and is readable by group or others +//! gets a warning. + +use std::path::Path; + +/// Expand `${VAR}` references in every string in a TOML value. +/// +/// An unset variable is an error rather than an empty string — a silently blank +/// passphrase is the kind of thing that disables authentication by accident. +pub fn expand_env_vars(value: &mut toml::Value) -> Result<(), String> { + match value { + toml::Value::String(s) => { + if let Some(expanded) = expand_str(s)? { + *s = expanded; + } + } + toml::Value::Table(table) => { + for (_, child) in table.iter_mut() { + expand_env_vars(child)?; + } + } + toml::Value::Array(items) => { + for item in items.iter_mut() { + expand_env_vars(item)?; + } + } + _ => {} + } + Ok(()) +} + +/// Expand `${VAR}` in one string; `None` when there was nothing to expand. +fn expand_str(input: &str) -> Result, String> { + if !input.contains("${") { + return Ok(None); + } + + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find('}') else { + // Unterminated: leave the rest exactly as written. + out.push_str(&rest[start..]); + return Ok(Some(out)); + }; + let name = &after[..end]; + if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + // Not a variable reference; pass it through untouched. + out.push_str(&rest[start..start + 2 + end + 1]); + } else { + let value = std::env::var(name).map_err(|_| { + format!("config references unset environment variable ${{{name}}}") + })?; + out.push_str(&value); + } + rest = &after[end + 1..]; + } + out.push_str(rest); + Ok(Some(out)) +} + +/// Read a single secret from a file: the whole file, trimmed. +pub fn read_secret_file(path: &str, what: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?; + let secret = content.trim().to_string(); + if secret.is_empty() { + return Err(format!("{what}: secret file {path} is empty")); + } + warn_if_group_readable(Path::new(path), what); + Ok(secret) +} + +/// Read a list of secrets, one per line. Blank lines and `#` comments are +/// skipped. +pub fn read_secret_list_file(path: &str, what: &str) -> Result, String> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?; + let secrets: Vec = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_string) + .collect(); + if secrets.is_empty() { + return Err(format!("{what}: secret file {path} contains no entries")); + } + warn_if_group_readable(Path::new(path), what); + Ok(secrets) +} + +/// Fill `inline` from `file` when the config used the `*_file` form. +/// +/// Setting both is an error: which one wins would be a guess. +pub fn resolve_secret( + inline: &mut Option, + file: &Option, + what: &str, +) -> Result<(), String> { + let Some(path) = file else { + return Ok(()); + }; + if inline.is_some() { + return Err(format!( + "{what}: set either the value or its _file form, not both" + )); + } + *inline = Some(read_secret_file(path, what)?); + Ok(()) +} + +/// Fill a token list from `file` when the config used the `*_file` form. +pub fn resolve_secret_list( + inline: &mut Vec, + file: &Option, + what: &str, +) -> Result<(), String> { + let Some(path) = file else { + return Ok(()); + }; + if !inline.is_empty() { + return Err(format!( + "{what}: set either the value or its _file form, not both" + )); + } + *inline = read_secret_list_file(path, what)?; + Ok(()) +} + +/// Warn when a file holding secrets is readable beyond its owner. +/// +/// Advisory only: plenty of valid setups (a dedicated service user, an +/// immutable image) are fine, so this never fails the load. +pub fn warn_if_group_readable(path: &Path, what: &str) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let Ok(meta) = std::fs::metadata(path) else { + return; + }; + let mode = meta.permissions().mode() & 0o077; + if mode != 0 { + tracing::warn!( + "{} is readable by group or others (mode {:o}); it holds secrets ({}). \ + Consider: chmod 600 {}", + path.display(), + meta.permissions().mode() & 0o777, + what, + path.display() + ); + } + } + #[cfg(not(unix))] + { + let _ = (path, what); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_expand_leaves_plain_strings_alone() { + assert_eq!(expand_str("plain").unwrap(), None); + assert_eq!(expand_str("TRXRS-%YYYY%.log").unwrap(), None); + } + + #[test] + fn test_expand_substitutes_variable() { + std::env::set_var("TRX_TEST_TOKEN", "s3cret"); + assert_eq!( + expand_str("Bearer ${TRX_TEST_TOKEN}!").unwrap().as_deref(), + Some("Bearer s3cret!") + ); + } + + #[test] + fn test_expand_errors_on_unset_variable() { + let err = expand_str("${TRX_DEFINITELY_NOT_SET_12345}").unwrap_err(); + assert!(err.contains("unset environment variable"), "{err}"); + } + + #[test] + fn test_expand_passes_through_non_variables() { + assert_eq!( + expand_str("${not a var}").unwrap().as_deref(), + Some("${not a var}") + ); + assert_eq!( + expand_str("unterminated ${VAR").unwrap().as_deref(), + Some("unterminated ${VAR") + ); + } + + #[test] + fn test_expand_walks_nested_tables_and_arrays() { + std::env::set_var("TRX_TEST_HOST", "radio.example.com"); + let mut value: toml::Value = toml::from_str( + r#" +[remote] +url = "${TRX_TEST_HOST}:4530" +hosts = ["${TRX_TEST_HOST}"] +"#, + ) + .unwrap(); + expand_env_vars(&mut value).unwrap(); + assert_eq!( + value["remote"]["url"].as_str(), + Some("radio.example.com:4530") + ); + assert_eq!( + value["remote"]["hosts"][0].as_str(), + Some("radio.example.com") + ); + } + + fn temp_file(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn test_read_secret_file_trims() { + let f = temp_file(" hunter2\n"); + assert_eq!(read_secret_file(path_of(&f), "test").unwrap(), "hunter2"); + } + + #[test] + fn test_read_secret_file_rejects_empty() { + let f = temp_file(" \n"); + assert!(read_secret_file(path_of(&f), "test").is_err()); + } + + #[test] + fn test_read_secret_list_skips_blanks_and_comments() { + let f = temp_file("# tokens\nalpha\n\n beta \n"); + assert_eq!( + read_secret_list_file(path_of(&f), "test").unwrap(), + vec!["alpha".to_string(), "beta".to_string()] + ); + } + + #[test] + fn test_resolve_secret_fills_from_file() { + let f = temp_file("from-file"); + let mut inline = None; + resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap(); + assert_eq!(inline.as_deref(), Some("from-file")); + } + + #[test] + fn test_resolve_secret_rejects_both_forms() { + let f = temp_file("from-file"); + let mut inline = Some("inline".to_string()); + let err = resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err(); + assert!(err.contains("not both"), "{err}"); + } + + #[test] + fn test_resolve_secret_is_a_no_op_without_file() { + let mut inline = Some("inline".to_string()); + resolve_secret(&mut inline, &None, "test").unwrap(); + assert_eq!(inline.as_deref(), Some("inline")); + } + + #[test] + fn test_resolve_secret_list_rejects_both_forms() { + let f = temp_file("alpha"); + let mut inline = vec!["inline".to_string()]; + let err = + resolve_secret_list(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err(); + assert!(err.contains("not both"), "{err}"); + } + + fn path_of(f: &tempfile::NamedTempFile) -> &str { + f.path().to_str().unwrap() + } +} diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 042718eb..109e0e2e 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -324,6 +324,10 @@ impl Default for ListenConfig { pub struct AuthConfig { /// Valid authentication tokens (empty = no auth required) pub tokens: Vec, + /// Read the tokens from this file instead, one per line. Blank lines and + /// `#` comments are ignored. Mutually exclusive with `tokens`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens_file: Option, } /// Audio streaming configuration. @@ -649,6 +653,26 @@ impl ServerConfig { .collect() } + /// Read any secret that was configured as a `*_file` and warn about + /// over-permissive file modes. + /// + /// Called after loading and before validation, so the rest of the code only + /// ever sees resolved values. + pub fn resolve_secrets(&mut self, config_path: Option<&Path>) -> Result<(), String> { + crate::secrets::resolve_secret_list( + &mut self.listen.auth.tokens, + &self.listen.auth.tokens_file, + "[listen.auth].tokens", + )?; + + if let Some(path) = config_path { + if !self.listen.auth.tokens.is_empty() && self.listen.auth.tokens_file.is_none() { + crate::secrets::warn_if_group_readable(path, "[listen.auth].tokens"); + } + } + Ok(()) + } + /// Load configuration from a specific file path. pub fn load_from_file(path: &Path) -> Result, ConfigError> { ::load_from_file(path) @@ -1717,6 +1741,33 @@ port = 4531 ); } + // --- Secret indirection --- + + #[test] + fn test_tokens_file_fills_tokens() { + use std::io::Write; + let mut f = tempfile::NamedTempFile::new().unwrap(); + writeln!(f, "# tokens\nalpha\nbeta").unwrap(); + f.flush().unwrap(); + + let mut cfg = ServerConfig::default(); + cfg.listen.auth.tokens_file = Some(f.path().to_str().unwrap().to_string()); + cfg.resolve_secrets(None).unwrap(); + assert_eq!( + cfg.listen.auth.tokens, + vec!["alpha".to_string(), "beta".to_string()] + ); + } + + #[test] + fn test_tokens_and_tokens_file_together_is_an_error() { + let mut cfg = ServerConfig::default(); + cfg.listen.auth.tokens = vec!["inline".to_string()]; + cfg.listen.auth.tokens_file = Some("/nonexistent".to_string()); + let err = cfg.resolve_secrets(None).unwrap_err(); + assert!(err.contains("not both"), "unexpected error: {err}"); + } + // --- Decoder selection --- #[test] diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 0621689a..b670b03e 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -944,10 +944,15 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad) -> Dyn println!(" warning: {}", key); } - let cfg = &loaded.config; + let mut cfg = loaded.config.clone(); + let mut errors = Vec::new(); + if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) { + errors.push(e); + } + let cfg = &cfg; let rigs = cfg.resolved_rigs(); - let mut errors = cfg.validate_all(); + errors.extend(cfg.validate_all()); errors.extend(cfg.validate_sdr()); errors.extend(cfg.validate_resolved_all(&rigs, &bound_sockets(cli, cfg, &rigs))); @@ -1006,7 +1011,10 @@ async fn main() -> DynResult<()> { } loaded.report_unknown_keys(cli.strict_config)?; - let cfg = loaded.config; + let mut cfg = loaded.config; + // Secrets configured as *_file are read before validation, so everything + // downstream sees resolved values. + cfg.resolve_secrets(config_path.as_deref())?; cfg.validate() .map_err(|e| format!("Invalid server configuration: {}", e))?; -- 2.55.0 From cfaeb6ee1582f056876b1800b666e1505dc50d62 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:32:46 +0200 Subject: [PATCH 09/13] [docs](trx-rs): generate the example config and correct the manual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trx-rs.toml.example was maintained by hand and had fallen well behind: no [[rigs]], no [[remotes]], no [timeouts], no bandplan or decode-history settings, and a [frontends.http].default_rig_id that had been renamed. Generate it from the config structs instead, so a new field shows up the moment it exists, and add a test that fails when the checked-in copy drifts: cargo run -p trx-config --example generate_example Section comments come from a small table; a section without an entry is still emitted, so forgetting a comment can never drop a setting from the example. The manual was wrong about the basics. It listed five config search paths, none of which the loader has ever looked at (the real order is ./trx-rs.toml → XDG → /etc), called --print-config output "fully commented" when it carries no comments at all, and documented a TRX_PLUGIN_DIRS variable no code reads. It also still described [frontends.rigctl].port as the bind port years after rig_ports replaced it. Fixed, and the new configuration features are written up alongside. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- Cargo.lock | 1 + README.md | 13 +- docs/User-Manual.md | 155 ++++++++++-- src/trx-config/Cargo.toml | 1 + src/trx-config/examples/generate_example.rs | 29 +++ src/trx-config/src/client.rs | 28 ++- src/trx-config/src/example.rs | 255 ++++++++++++++++++++ src/trx-config/src/lib.rs | 1 + src/trx-config/src/server.rs | 28 ++- trx-rs.toml.example | 109 ++++++++- 10 files changed, 573 insertions(+), 47 deletions(-) create mode 100644 src/trx-config/examples/generate_example.rs create mode 100644 src/trx-config/src/example.rs diff --git a/Cargo.lock b/Cargo.lock index 5fed8d51..449ec57d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3141,6 +3141,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "toml_edit 0.22.27", "tracing", "trx-core", "trx-decode-log", diff --git a/README.md b/README.md index 6597a294..1d65ab18 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio settings, and frontend options, then writes `trx-server.toml` and `trx-client.toml`. -Alternatively, generate example configs and edit them by hand: +Alternatively, copy `trx-rs.toml.example` — a commented example covering every +setting — and edit it by hand: ```bash -./target/release/trx-server --print-config > trx-server.toml -./target/release/trx-client --print-config > trx-client.toml +cp trx-rs.toml.example trx-rs.toml +./target/release/trx-server --check-config --config trx-rs.toml ``` +`--check-config` reports everything wrong with a config without starting +anything. `--print-config` prints the same settings without comments. + ### 4. Run ```bash @@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand: ./target/release/trx-client --config trx-client.toml ``` +A single `trx-rs.toml` can configure both: the server reads its `[trx-server]` +section and the client reads `[trx-client]`. + Open the configured HTTP frontend address in a browser (default `http://localhost:8080`). ## How It Works diff --git a/docs/User-Manual.md b/docs/User-Manual.md index 2c4a10b0..62a235bc 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -17,30 +17,61 @@ frontends. ## Configuration -Both `trx-server` and `trx-client` use TOML configuration files. Use -`--print-config` to generate a fully commented example. +Both `trx-server` and `trx-client` read TOML. The server takes its settings +from the `[trx-server]` section and the client from `[trx-client]`, so one +`trx-rs.toml` can configure both — or each may live in its own file with the +section header left off. + +`trx-rs.toml.example` in the repository root is a complete, commented example +generated from the config definitions themselves. `--print-config` prints the +same settings without the comments. ### File Locations -**trx-server** lookup order: -1. `--config ` -2. `./trx-server.toml` -3. `~/.trx-server.toml` -4. `~/.config/trx-rs/server.toml` -5. `/etc/trx-rs/server.toml` +Both binaries use the same lookup order: -**trx-client** lookup order: 1. `--config ` -2. `./trx-client.toml` -3. `~/.config/trx-rs/client.toml` -4. `/etc/trx-rs/client.toml` +2. `./trx-rs.toml` +3. `~/.config/trx-rs/trx-rs.toml` +4. `/etc/trx-rs/trx-rs.toml` CLI arguments override config file values. -### Environment Variables +### Checking a Config -- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by - both server and client. +`--check-config` loads the file, reports every problem it finds — unknown keys, +invalid values, listeners fighting over a port — and exits without starting +anything: + +```bash +trx-server --check-config --config trx-rs.toml +trx-client --check-config --config trx-rs.toml +``` + +Unknown keys are warnings by default, so a config written for a newer version +still runs on an older binary. `--strict-config` makes them fatal. + +`trx-configurator --check ` runs the same checks. + +### Environment Variables and Secrets + +Any string in the config may reference an environment variable as `${VAR}`; +an unset variable is an error rather than an empty value. + +Credentials can be kept out of the config entirely by pointing at a file +instead. Every secret has a `*_file` sibling — set one or the other, never +both: + +| Inline key | File key | Contents | +|------------|----------|----------| +| `[listen.auth].tokens` | `tokens_file` | one token per line | +| `[[remotes]].auth.token` | `token_file` | the token | +| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase | +| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase | +| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line | + +Blank lines and `#` comments are ignored in the list files. A config that holds +credentials inline and is readable by group or others is flagged at startup. ### Server Options @@ -96,6 +127,7 @@ CLI arguments override config file values. | Field | Type | Default | Description | |-------|------|---------|-------------| | `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) | +| `tokens_file` | string | — | Read tokens from this file, one per line | #### `[audio]` @@ -197,6 +229,29 @@ Notes: Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`, `%MM%`, `%DD%` (UTC). +#### `[decoders]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | string[] | all decoders | Decoders to run for this rig | +| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images | + +Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`, +`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]` +uses. An unrecognised name is a config error. + +Every decoder runs by default, which costs real CPU on a small machine. On a +station that only works digital modes, listing just what you use is worth it: + +```toml +[decoders] +enabled = ["ft8", "ft4", "wspr"] +``` + +`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir` +named after the decoder. `ais` and `vdes` additionally require an SDR channel +configured to feed them. + #### Multi-Rig Configuration Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups: @@ -246,6 +301,25 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1 | Field | Type | Default | Description | |-------|------|---------|-------------| | `token` | string | — | Auth token (must not be empty if set) | +| `token_file` | string | — | Read the token from this file instead | + +#### `[[remotes]]` + +Preferred over the single `[remote]` section: one entry per rig, each mapping a +short name to a server and an optional server-side rig id. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | string | — | Short name used everywhere in the client | +| `url` | string | — | Server address (`host:port`) | +| `rig_id` | string | — | Rig id on a multi-rig server | +| `auth.token` | string | — | Auth token | +| `auth.token_file` | string | — | Read the token from this file instead | +| `poll_interval_ms` | u64 | `750` | State poll interval | + +The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`, +`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`. +A name in any of those maps that no remote answers to is a config error. #### `[frontends.http]` @@ -254,6 +328,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1 | `enabled` | bool | `true` | Enable web UI | | `listen` | ip | `127.0.0.1` | Bind address | | `port` | u16 | `8080` | Bind port | +| `default_rig_name` | string | — | Remote selected on startup | +| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map | +| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control | +| `bandplan_enabled` | bool | `true` | Show the bandplan strip | +| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` | +| `decode_history_retention_min` | u64 | `1440` | Decode history retention | +| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override | +| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin | +| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span | + +#### `[frontends.http.auth]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Require a passphrase | +| `rx_passphrase` | string | — | Passphrase granting receive-only access | +| `rx_passphrase_file` | string | — | Read it from this file instead | +| `control_passphrase` | string | — | Passphrase granting full control | +| `control_passphrase_file` | string | — | Read it from this file instead | +| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users | +| `session_ttl_min` | u64 | `480` | Session lifetime | +| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) | +| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` | + +With `enabled = true`, at least one passphrase must be set. #### `[frontends.rigctl]` @@ -261,7 +360,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1 |-------|------|---------|-------------| | `enabled` | bool | `false` | Enable Hamlib rigctl | | `listen` | ip | `127.0.0.1` | Bind address | -| `port` | u16 | `4532` | Bind port | +| `rig_ports` | table | `{}` | Remote name → local port; one listener each | + +One listener is started per `rig_ports` entry, each routing to its rig, so +`rig_ports` must name at least one remote when the frontend is enabled. The +older single `port` key and `--rigctl-port` are ignored. #### `[frontends.http_json]` @@ -271,13 +374,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1 | `listen` | ip | `127.0.0.1` | Bind address | | `port` | u16 | `0` | Bind port (0 = ephemeral) | | `auth.tokens` | string[] | `[]` | Allowed auth tokens | +| `auth.tokens_file` | string | — | Read tokens from this file, one per line | #### `[frontends.audio]` | Field | Type | Default | Description | |-------|------|---------|-------------| | `enabled` | bool | `true` | Enable audio client | -| `server_port` | u16 | `4531` | Server audio port | +| `server_url` | string | — | Audio endpoint for every remote | +| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) | +| `server_port` | u16 | `4531` | Fallback port when no URL is configured | +| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` | | `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge | | `bridge.rx_output_device` | string | — | Local playback device | | `bridge.tx_input_device` | string | — | Local capture device | @@ -290,13 +397,17 @@ loopback on Linux, BlackHole on macOS). ### CLI Override Summary **trx-server:** -`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`, -`--port`. SDR options are file-only. +`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`, +`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only. **trx-client:** -`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`, -`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`, -`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`. +`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`, +`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`, +`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`, +`--callsign`. + +`--listen` on the server overrides the bind address of both the control +listener and every rig's audio listener. --- diff --git a/src/trx-config/Cargo.toml b/src/trx-config/Cargo.toml index 733ac0c6..62b19c62 100644 --- a/src/trx-config/Cargo.toml +++ b/src/trx-config/Cargo.toml @@ -18,6 +18,7 @@ trx-core = { path = "../trx-core" } trx-decode-log = { path = "../decoders/trx-decode-log" } trx-reporting = { path = "../trx-reporting" } serde_ignored = "0.1" +toml_edit = "0.22" [dev-dependencies] tempfile = "3" diff --git a/src/trx-config/examples/generate_example.rs b/src/trx-config/examples/generate_example.rs new file mode 100644 index 00000000..093048ba --- /dev/null +++ b/src/trx-config/examples/generate_example.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Regenerate `trx-rs.toml.example` from the config structs. +//! +//! Run from anywhere in the workspace: +//! +//! ```text +//! cargo run -p trx-config --example generate_example +//! ``` +//! +//! A test in `trx_config::example` fails when the checked-in file no longer +//! matches, which is the reminder to run this. + +use std::path::PathBuf; + +fn main() -> std::io::Result<()> { + let target: PathBuf = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../trx-rs.toml.example") + }); + + std::fs::write(&target, trx_config::example::combined_example())?; + println!("Wrote {}", target.display()); + Ok(()) +} diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index 4cd0df93..7d2f32de 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -807,13 +807,10 @@ impl ClientConfig { /// Generate an example configuration wrapped under the `[trx-client]` /// section header, suitable for use in a combined `trx-rs.toml` file. - pub fn example_combined_toml() -> String { - #[derive(serde::Serialize)] - struct Wrapper { - #[serde(rename = "trx-client")] - inner: ClientConfig, - } - let example = ClientConfig { + /// The example configuration used by `--print-config` and by the + /// generated `trx-rs.toml.example`. + pub fn example_config() -> Self { + ClientConfig { general: GeneralConfig { callsign: Some("N0CALL".to_string()), website_url: Some("https://haxx.space".to_string()), @@ -879,8 +876,21 @@ impl ClientConfig { http_json: HttpJsonFrontendConfig::default(), audio: AudioClientConfig::default(), }, - }; - toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } + } + + /// Generate an example configuration wrapped under the `[trx-client]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-client")] + inner: ClientConfig, + } + toml::to_string_pretty(&Wrapper { + inner: ClientConfig::example_config(), + }) + .unwrap_or_default() } } diff --git a/src/trx-config/src/example.rs b/src/trx-config/src/example.rs new file mode 100644 index 00000000..9537a332 --- /dev/null +++ b/src/trx-config/src/example.rs @@ -0,0 +1,255 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Generating the example configuration from the config structs. +//! +//! `trx-rs.toml.example` used to be maintained by hand and had fallen years +//! behind the code — no `[[rigs]]`, no `[[remotes]]`, no `[timeouts]`, no +//! bandplan settings. It is now produced from the structs themselves, so a new +//! field appears in the example the moment it exists, and a test fails if the +//! checked-in copy drifts. +//! +//! Section comments come from the table below. A section without an entry is +//! still emitted — only its explanatory text is missing — so forgetting to add +//! one can never drop a setting from the example. + +use toml_edit::{DocumentMut, Item}; + +use crate::{ClientConfig, ServerConfig}; + +const HEADER: &str = "\ +# trx-rs example configuration +# +# Generated from the config structs; regenerate with: +# cargo run -p trx-config --example generate_example +# +# Both sections are optional: trx-server reads [trx-server], trx-client reads +# [trx-client], and either may live in its own file with the section header +# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of +# this file with the matching *_file keys. +# +# Check a config without starting anything: +# trx-server --check-config --config trx-rs.toml +# trx-client --check-config --config trx-rs.toml +"; + +/// Explanatory comments for config sections, keyed by dotted path. +const SECTION_COMMENTS: &[(&str, &str)] = &[ + ("trx-server", "Server: drives the radio hardware."), + ( + "trx-server.general", + "Station identity. Coordinates feed PSKReporter and the map.", + ), + ( + "trx-server.rig", + "Single-rig layout. For several radios, delete this and use [[rigs]].", + ), + ( + "trx-server.rig.access", + "How to reach the radio: serial, tcp, or sdr.", + ), + ("trx-server.behavior", "CAT polling and retry behaviour."), + ( + "trx-server.listen", + "JSON control listener that trx-client connects to.", + ), + ( + "trx-server.listen.auth", + "Tokens clients must present. Empty means no authentication.\n\ + Use tokens_file = \"/etc/trx-rs/tokens\" to keep them out of this file.", + ), + ("trx-server.audio", "Opus audio stream for trx-client."), + ( + "trx-server.decoders", + "Which decoders run. Trimming this list saves real CPU on small boxes.\n\ + Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.", + ), + ( + "trx-server.pskreporter", + "Report FT8/FT4/WSPR spots to pskreporter.info.", + ), + ("trx-server.aprsfi", "Forward received APRS frames to APRS-IS."), + ("trx-server.decode_logs", "Write decodes to JSON Lines files."), + ( + "trx-server.sdr", + "SoapySDR pipeline; used when [rig.access] type = \"sdr\".", + ), + ("trx-server.sdr.gain", "\"auto\" for hardware AGC, or \"manual\"."), + ("trx-server.sdr.squelch", "Software squelch on demodulated audio."), + ( + "trx-server.sdr.noise_blanker", + "Impulse-noise suppression on the IQ stream.", + ), + ( + "trx-server.timeouts", + "Timeout and buffer tuning. The defaults suit most setups.", + ), + ("trx-client", "Client: exposes the radio to users."), + ("trx-client.general", "Labels shown in the web UI."), + ( + "trx-client.remote", + "Legacy single-remote form; prefer [[remotes]] below.", + ), + ( + "trx-client.frontends.http", + "Web UI. default_rig_name and the per-rig maps are keyed by the\n\ + [[remotes]] name, not the server-side rig id.", + ), + ( + "trx-client.frontends.http.auth", + "Passphrase login for the web UI. rx_passphrase_file and\n\ + control_passphrase_file keep the secrets out of this file.", + ), + ( + "trx-client.frontends.rigctl", + "Hamlib-compatible TCP interface, one listener per rig.", + ), + ("trx-client.frontends.http_json", "JSON-over-TCP control interface."), + ("trx-client.frontends.audio", "Where to fetch the audio stream from."), + ( + "trx-client.frontends.audio.bridge", + "Play RX audio on a local sound device and capture TX from one.", + ), +]; + +/// Render the combined `trx-rs.toml.example` contents. +pub fn combined_example() -> String { + let mut doc = DocumentMut::new(); + doc.decor_mut().set_prefix(HEADER); + + doc.insert("trx-server", section_item(&ServerConfig::example_config())); + doc.insert("trx-client", section_item(&ClientConfig::example_config())); + + // Each section was serialized on its own, so both carry table positions + // starting at zero and would otherwise render interleaved. + renumber_tables(&mut doc); + + for (path, comment) in SECTION_COMMENTS { + annotate(&mut doc, path, comment); + } + + doc.to_string() +} + +/// Renumber every table so the document renders in tree order. +fn renumber_tables(doc: &mut DocumentMut) { + fn walk(item: &mut Item, next: &mut usize) { + match item { + Item::Table(table) => { + table.set_position(*next); + *next += 1; + for (_, child) in table.iter_mut() { + walk(child, next); + } + } + Item::ArrayOfTables(array) => { + for table in array.iter_mut() { + table.set_position(*next); + *next += 1; + for (_, child) in table.iter_mut() { + walk(child, next); + } + } + } + _ => {} + } + } + + let mut next = 0; + for (_, item) in doc.as_table_mut().iter_mut() { + walk(item, &mut next); + } +} + +/// Serialize one config into a toml_edit table. +fn section_item(config: &T) -> Item { + let rendered = toml::to_string_pretty(config).unwrap_or_default(); + let doc: DocumentMut = rendered.parse().expect("serialized config must re-parse"); + Item::Table(doc.as_table().clone()) +} + +/// Attach a comment above the table at `path`, if it exists. +fn annotate(doc: &mut DocumentMut, path: &str, comment: &str) { + let mut item: Option<&mut Item> = None; + for segment in path.split('.') { + let next = match item { + None => doc.get_mut(segment), + Some(current) => current.as_table_mut().and_then(|t| t.get_mut(segment)), + }; + match next { + Some(found) => item = Some(found), + None => return, + } + } + + let Some(table) = item.and_then(|i| i.as_table_mut()) else { + return; + }; + let body: String = comment + .lines() + .map(|line| format!("# {}\n", line.trim_start())) + .collect(); + table.decor_mut().set_prefix(format!("\n{body}")); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ConfigFile; + + /// The checked-in example must match what the structs produce, so a new + /// config field cannot land without showing up in the example. + #[test] + fn test_checked_in_example_is_up_to_date() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../trx-rs.toml.example") + .canonicalize() + .expect("example file must exist"); + let on_disk = std::fs::read_to_string(&path).expect("example file must be readable"); + assert_eq!( + on_disk, + combined_example(), + "trx-rs.toml.example is out of date; regenerate with \ + `cargo run -p trx-config --example generate_example`" + ); + } + + #[test] + fn test_example_loads_and_validates() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut file, combined_example().as_bytes()).unwrap(); + + let server = ServerConfig::load_from_file(file.path()).expect("server section loads"); + assert!( + server.unknown_keys.is_empty(), + "the generated example must not contain unknown keys: {:?}", + server.unknown_keys + ); + server.config.validate().expect("server section validates"); + + let client = ClientConfig::load_from_file(file.path()).expect("client section loads"); + assert!( + client.unknown_keys.is_empty(), + "the generated example must not contain unknown keys: {:?}", + client.unknown_keys + ); + client.config.validate().expect("client section validates"); + } + + /// Every section that gained a comment must still exist under that path. + #[test] + fn test_section_comments_match_real_sections() { + let doc: DocumentMut = combined_example().parse().unwrap(); + for (path, _) in SECTION_COMMENTS { + let mut item = None; + for segment in path.split('.') { + item = match item { + None => doc.get(segment), + Some(current) => current.as_table().and_then(|t| t.get(segment)), + }; + assert!(item.is_some(), "commented section [{path}] no longer exists"); + } + } + } +} diff --git a/src/trx-config/src/lib.rs b/src/trx-config/src/lib.rs index e9cdf81e..336e1fe2 100644 --- a/src/trx-config/src/lib.rs +++ b/src/trx-config/src/lib.rs @@ -10,6 +10,7 @@ //! it with, so the two can never drift apart. pub mod client; +pub mod example; pub mod file; pub mod secrets; pub mod server; diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 109e0e2e..586f83a2 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -727,13 +727,10 @@ impl ServerConfig { /// Generate an example configuration wrapped under the `[trx-server]` /// section header, suitable for use in a combined `trx-rs.toml` file. - pub fn example_combined_toml() -> String { - #[derive(serde::Serialize)] - struct Wrapper { - #[serde(rename = "trx-server")] - inner: ServerConfig, - } - let example = ServerConfig { + /// The example configuration used by `--print-config` and by the + /// generated `trx-rs.toml.example`. + pub fn example_config() -> Self { + ServerConfig { general: GeneralConfig { callsign: Some("N0CALL".to_string()), log_level: Some("info".to_string()), @@ -763,8 +760,21 @@ impl ServerConfig { sdr: SdrConfig::default(), timeouts: TimeoutsConfig::default(), rigs: Vec::new(), - }; - toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } + } + + /// Generate an example configuration wrapped under the `[trx-server]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-server")] + inner: ServerConfig, + } + toml::to_string_pretty(&Wrapper { + inner: ServerConfig::example_config(), + }) + .unwrap_or_default() } } diff --git a/trx-rs.toml.example b/trx-rs.toml.example index 1e7e65ee..9f552ee7 100644 --- a/trx-rs.toml.example +++ b/trx-rs.toml.example @@ -1,36 +1,60 @@ +# trx-rs example configuration +# +# Generated from the config structs; regenerate with: +# cargo run -p trx-config --example generate_example +# +# Both sections are optional: trx-server reads [trx-server], trx-client reads +# [trx-client], and either may live in its own file with the section header +# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of +# this file with the matching *_file keys. +# +# Check a config without starting anything: +# trx-server --check-config --config trx-rs.toml +# trx-client --check-config --config trx-rs.toml + +# Server: drives the radio hardware. [trx-server] rigs = [] +# Station identity. Coordinates feed PSKReporter and the map. [trx-server.general] callsign = "N0CALL" log_level = "info" latitude = 52.2297 longitude = 21.0122 +# Single-rig layout. For several radios, delete this and use [[rigs]]. [trx-server.rig] model = "ft817" initial_freq_hz = 144300000 initial_mode = "USB" +# How to reach the radio: serial, tcp, or sdr. [trx-server.rig.access] type = "serial" port = "/dev/ttyUSB0" baud = 9600 +# CAT polling and retry behaviour. [trx-server.behavior] poll_interval_ms = 500 poll_interval_tx_ms = 100 max_retries = 3 retry_base_delay_ms = 100 +vfo_prime = true +# JSON control listener that trx-client connects to. [trx-server.listen] enabled = true listen = "127.0.0.1" port = 4530 +# Tokens clients must present. Empty means no authentication. +# Use tokens_file = "/etc/trx-rs/tokens" to keep them out of this file. [trx-server.listen.auth] tokens = [] +# Opus audio stream for trx-client. [trx-server.audio] enabled = true listen = "127.0.0.1" @@ -42,25 +66,52 @@ channels = 2 frame_duration_ms = 20 bitrate_bps = 256000 +# Report FT8/FT4/WSPR spots to pskreporter.info. [trx-server.pskreporter] enabled = false host = "report.pskreporter.info" port = 4739 +# Forward received APRS frames to APRS-IS. [trx-server.aprsfi] enabled = false host = "rotate.aprs.net" port = 14580 passcode = -1 +beacon = false +beacon_interval_secs = 1200 +beacon_symbol_table = "/" +beacon_symbol_code = "-" +# Write decodes to JSON Lines files. [trx-server.decode_logs] enabled = false -dir = "/path/to/log/dir" +dir = "/Users/sjg/Library/Caches/trx-rs/decoders" aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log" cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log" ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log" wspr_file = "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log" +wefax_file = "TRXRS-WEFAX-%YYYY%-%MM%-%DD%.log" +# Which decoders run. Trimming this list saves real CPU on small boxes. +# Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr. +[trx-server.decoders] +enabled = [ + "aprs", + "aprs_hf", + "ais", + "cw", + "ft2", + "ft4", + "ft8", + "lrpt", + "sstv", + "vdes", + "wefax", + "wspr", +] + +# SoapySDR pipeline; used when [rig.access] type = "sdr". [trx-server.sdr] sample_rate = 1920000 bandwidth = 1500000 @@ -69,16 +120,35 @@ center_offset_hz = 100000 channels = [] max_virtual_channels = 4 +# "auto" for hardware AGC, or "manual". [trx-server.sdr.gain] mode = "auto" value = 30.0 +# Software squelch on demodulated audio. [trx-server.sdr.squelch] enabled = false threshold_db = -65.0 hysteresis_db = 3.0 tail_ms = 180 +# Impulse-noise suppression on the IQ stream. +[trx-server.sdr.noise_blanker] +enabled = false +threshold = 10.0 + +# Timeout and buffer tuning. The defaults suit most setups. +[trx-server.timeouts] +command_exec_timeout_ms = 10000 +poll_refresh_timeout_ms = 8000 +io_timeout_ms = 10000 +request_timeout_ms = 12000 +rig_task_channel_buffer = 32 + +# Client: exposes the radio to users. +[trx-client] + +# Labels shown in the web UI. [trx-client.general] callsign = "N0CALL" website_url = "https://haxx.space" @@ -86,24 +156,49 @@ website_name = "haxx.space" ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi=" log_level = "info" +# Legacy single-remote form; prefer [[remotes]] below. [trx-client.remote] -url = "192.168.1.100:9000" -rig_id = "hf" poll_interval_ms = 750 [trx-client.remote.auth] + +[[trx-client.remotes]] +name = "home-hf" +url = "192.168.1.100:4530" +rig_id = "hf" +poll_interval_ms = 750 + +[trx-client.remotes.auth] token = "my-token" +[[trx-client.remotes]] +name = "home-vhf" +url = "192.168.1.100:4530" +rig_id = "vhf" +poll_interval_ms = 750 + +[trx-client.remotes.auth] +token = "my-token" + +# Web UI. default_rig_name and the per-rig maps are keyed by the +# [[remotes]] name, not the server-side rig id. [trx-client.frontends.http] enabled = true listen = "127.0.0.1" port = 8080 -default_rig_id = "hf" +default_rig_name = "home-hf" initial_map_zoom = 10 spectrum_coverage_margin_hz = 50000 spectrum_usable_span_ratio = 0.9200000166893005 show_sdr_gain_control = true +bandplan_enabled = true +bandplan_region = "iaru_r1" +decode_history_retention_min = 1440 +[trx-client.frontends.http.decode_history_retention_min_by_rig] + +# Passphrase login for the web UI. rx_passphrase_file and +# control_passphrase_file keep the secrets out of this file. [trx-client.frontends.http.auth] enabled = false rx_passphrase = "rx-passphrase-example" @@ -113,6 +208,7 @@ session_ttl_min = 480 cookie_secure = false cookie_same_site = "Lax" +# Hamlib-compatible TCP interface, one listener per rig. [trx-client.frontends.rigctl] enabled = false listen = "127.0.0.1" @@ -120,6 +216,7 @@ port = 4532 [trx-client.frontends.rigctl.rig_ports] +# JSON-over-TCP control interface. [trx-client.frontends.http_json] enabled = true listen = "127.0.0.1" @@ -128,12 +225,16 @@ port = 0 [trx-client.frontends.http_json.auth] tokens = [] +# Where to fetch the audio stream from. [trx-client.frontends.audio] enabled = true server_port = 4531 +[trx-client.frontends.audio.rig_urls] + [trx-client.frontends.audio.rig_ports] +# Play RX audio on a local sound device and capture TX from one. [trx-client.frontends.audio.bridge] enabled = false bitrate_bps = 192000 -- 2.55.0 From bc63ded5836376be300358d5d99f2ef311945854 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:35:52 +0200 Subject: [PATCH 10/13] [feat](trx-config): warn about deprecated configuration keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several keys quietly stopped doing what they look like they do, and nothing said so: [remote] and the flat per-rig sections are ignored outright once [[remotes]] / [[rigs]] exist, [frontends.rigctl].port and --rigctl-port have been dead since rig_ports replaced them, [frontends.audio].rig_ports is superseded by rig_urls, and default_rig_id was renamed to default_rig_name. Warn once at load, naming the replacement. Defaults are indistinguishable from explicit values after deserialization, so the loader now records which key paths the file actually set and the checks work off that — no warning for a setting the user never wrote. The single-rig flat layout is not deprecated: it is the documented simple form, and only draws a warning when [[rigs]] is silently shadowing it. 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 | 11 ++++- src/trx-config/src/client.rs | 91 ++++++++++++++++++++++++++++++++++++ src/trx-config/src/file.rs | 43 ++++++++++++++--- src/trx-config/src/server.rs | 50 ++++++++++++++++++++ src/trx-server/src/main.rs | 19 ++++---- 5 files changed, 197 insertions(+), 17 deletions(-) diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index c2266af1..d2f67717 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -20,6 +20,7 @@ use tokio::task::JoinHandle; use tracing::{error, info}; use trx_app::{init_logging, normalize_name}; +use trx_config::ConfigFile; use trx_core::audio::AudioStreamInfo; use trx_core::decode::DecodedMessage; @@ -80,7 +81,7 @@ struct Cli { /// rigctl frontend listen address #[arg(long = "rigctl-listen")] rigctl_listen: Option, - /// rigctl frontend listen port + /// Deprecated: ignored, use [frontends.rigctl].rig_ports #[arg(long = "rigctl-port")] rigctl_port: Option, /// JSON TCP frontend listen address @@ -127,6 +128,7 @@ fn check_config(loaded: &trx_config::ConfigLoad) -> DynResult<()> } let mut warnings: Vec = loaded.unknown_keys.iter().map(|k| k.to_string()).collect(); + warnings.extend(ClientConfig::deprecations(&loaded.present_keys)); let mut cfg = loaded.config.clone(); let mut errors = Vec::new(); @@ -223,6 +225,13 @@ async fn async_init() -> DynResult { info!("Loaded configuration from {}", path.display()); } loaded.report_unknown_keys(cli.strict_config)?; + loaded.report_deprecations(); + if cli.rigctl_port.is_some() { + tracing::warn!( + "--rigctl-port is ignored; give each rig its own listener via \ + [frontends.rigctl].rig_ports" + ); + } let mut cfg = loaded.config; // Secrets configured as *_file are read before validation, so everything diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index 7d2f32de..d7a34e5c 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -935,6 +935,51 @@ impl ConfigFile for ClientConfig { "trx-client" } + /// Keys that no longer do what they look like they do. + fn deprecations(present: &std::collections::BTreeSet) -> Vec { + let mut warnings = Vec::new(); + + if present.contains("remote") { + if present.contains("remotes") { + warnings.push( + "[remote] is ignored because [[remotes]] is set; delete it".to_string(), + ); + } else { + warnings.push( + "[remote] is superseded by [[remotes]], which supports several rigs; \ + it still works but will be removed in a future release" + .to_string(), + ); + } + } + + if present.contains("frontends.rigctl.port") { + warnings.push( + "[frontends.rigctl].port is ignored; give each rig its own listener via \ + [frontends.rigctl].rig_ports" + .to_string(), + ); + } + + if present.contains("frontends.audio.rig_ports") { + warnings.push( + "[frontends.audio].rig_ports is superseded by [frontends.audio].rig_urls, \ + which can also point at a different host" + .to_string(), + ); + } + + if present.contains("frontends.http.default_rig_id") { + warnings.push( + "[frontends.http].default_rig_id has been renamed to default_rig_name; \ + the old name still works but will be removed in a future release" + .to_string(), + ); + } + + warnings + } + /// Include one `[[remotes]]` entry so unknown keys nested inside a remote /// entry still get a suggestion. fn reference_value() -> toml::Value { @@ -955,6 +1000,7 @@ impl ConfigFile for ClientConfig { #[cfg(test)] mod tests { use super::*; + use crate::ConfigFile; #[test] fn test_default_config() { @@ -1404,6 +1450,51 @@ url = "remote.example.com:4530" .contains("poll_interval_ms must be > 0")); } + // --- Deprecation warnings --- + + fn present(paths: &[&str]) -> std::collections::BTreeSet { + paths.iter().map(|p| p.to_string()).collect() + } + + #[test] + fn test_deprecation_flags_shadowed_remote_section() { + let warnings = ClientConfig::deprecations(&present(&["remote", "remotes"])); + assert!( + warnings.iter().any(|w| w.contains("[remote] is ignored")), + "unexpected warnings: {warnings:?}" + ); + } + + #[test] + fn test_deprecation_nudges_lone_remote_section() { + let warnings = ClientConfig::deprecations(&present(&["remote"])); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("superseded by [[remotes]]")); + } + + #[test] + fn test_deprecation_flags_ignored_rigctl_port() { + let warnings = ClientConfig::deprecations(&present(&["frontends.rigctl.port"])); + assert!(warnings.iter().any(|w| w.contains("rig_ports"))); + } + + #[test] + fn test_deprecation_flags_renamed_default_rig_id() { + let warnings = ClientConfig::deprecations(&present(&["frontends.http.default_rig_id"])); + assert!(warnings.iter().any(|w| w.contains("default_rig_name"))); + } + + #[test] + fn test_no_deprecations_for_a_current_config() { + let warnings = ClientConfig::deprecations(&present(&[ + "remotes", + "frontends.http.port", + "frontends.rigctl.rig_ports", + "frontends.audio.rig_urls", + ])); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + } + // --- Secret indirection --- fn secret_file(content: &str) -> tempfile::NamedTempFile { diff --git a/src/trx-config/src/file.rs b/src/trx-config/src/file.rs index 7e1211a9..9b119326 100644 --- a/src/trx-config/src/file.rs +++ b/src/trx-config/src/file.rs @@ -22,7 +22,9 @@ use serde::Serialize; use std::path::{Path, PathBuf}; use thiserror::Error; -use crate::unknown::{describe, UnknownKey}; +use std::collections::BTreeSet; + +use crate::unknown::{describe, flatten_paths, UnknownKey}; /// Every section key that may appear at the root of a combined config file. pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"]; @@ -37,6 +39,18 @@ pub struct ConfigLoad { pub path: Option, /// Keys present in the file that no config field claimed. pub unknown_keys: Vec, + /// Every key path the file actually set. Defaults are indistinguishable + /// from explicit values once deserialized, so deprecation checks need this. + pub present_keys: BTreeSet, +} + +impl ConfigLoad { + /// Log a warning for every deprecated key the file sets. + pub fn report_deprecations(&self) { + for message in T::deprecations(&self.present_keys) { + tracing::warn!("{}", message); + } + } } impl ConfigLoad { @@ -97,10 +111,12 @@ fn select_section(table: &toml::Table, key: &str) -> Option { /// Returns `Ok(Some((cfg, unknown_keys)))` when the section is present and /// parses cleanly, `Ok(None)` when the section is absent, or `Err` on I/O / /// parse failure. +type LoadedSection = (T, Vec, BTreeSet); + fn load_section_from_file( path: &Path, key: &str, -) -> Result)>, ConfigError> { +) -> Result>, ConfigError> { let content = std::fs::read_to_string(path) .map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?; @@ -116,13 +132,19 @@ fn load_section_from_file( crate::secrets::expand_env_vars(&mut section) .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?; + let present_keys = flatten_paths(§ion); + // Deserialize straight from the TOML value so serde applies every default, // recording any key no field claimed. let mut ignored: Vec = Vec::new(); let cfg: T = serde_ignored::deserialize(section, |path| ignored.push(path.to_string())) .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; - Ok(Some((cfg, describe(&ignored, &T::reference_value())))) + Ok(Some(( + cfg, + describe(&ignored, &T::reference_value()), + present_keys, + ))) } /// Trait for loading configuration from a `trx-rs.toml` section. @@ -130,6 +152,12 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize { /// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`). fn section_key() -> &'static str; + /// Warnings for deprecated keys the file sets, given every key path present + /// in it. Defaults to none. + fn deprecations(_present_keys: &BTreeSet) -> Vec { + Vec::new() + } + /// A TOML rendering of a populated config, used to suggest corrections for /// unknown keys. Implementations should fill in list-valued sections such /// as `[[rigs]]` so keys nested inside them can be suggested too. @@ -145,8 +173,8 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize { /// cannot be read, is not valid TOML, or is sectioned for some other /// component only. fn load_from_file(path: &Path) -> Result, ConfigError> { - let (config, unknown_keys) = load_section_from_file::(path, Self::section_key())? - .ok_or_else(|| { + let (config, unknown_keys, present_keys) = + load_section_from_file::(path, Self::section_key())?.ok_or_else(|| { ConfigError::ParseError( path.to_path_buf(), format!("missing [{}] section", Self::section_key()), @@ -156,6 +184,7 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize { config, path: Some(path.to_path_buf()), unknown_keys, + present_keys, }) } @@ -166,13 +195,14 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize { fn load_from_default_paths() -> Result, ConfigError> { for path in config_search_paths() { if path.exists() { - if let Some((config, unknown_keys)) = + if let Some((config, unknown_keys, present_keys)) = load_section_from_file::(&path, Self::section_key())? { return Ok(ConfigLoad { config, path: Some(path), unknown_keys, + present_keys, }); } } @@ -181,6 +211,7 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize { config: Self::default(), path: None, unknown_keys: Vec::new(), + present_keys: BTreeSet::new(), }) } } diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 586f83a2..72c9c888 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -1092,6 +1092,33 @@ impl ConfigFile for ServerConfig { "trx-server" } + /// The flat per-rig sections are silently ignored once `[[rigs]]` exists, + /// which is worth saying out loud. + fn deprecations(present: &std::collections::BTreeSet) -> Vec { + if !present.contains("rigs") { + return Vec::new(); + } + [ + "rig", + "behavior", + "audio", + "sdr", + "pskreporter", + "aprsfi", + "decode_logs", + "decoders", + ] + .iter() + .filter(|section| present.contains(**section)) + .map(|section| { + format!( + "[{section}] is ignored because [[rigs]] is set; move it to the \ + matching [rigs.{section}] entry" + ) + }) + .collect() + } + /// Include one `[[rigs]]` entry so unknown keys nested inside a rig entry /// still get a suggestion. fn reference_value() -> toml::Value { @@ -1106,6 +1133,7 @@ impl ConfigFile for ServerConfig { #[cfg(test)] mod tests { use super::*; + use crate::ConfigFile; #[test] fn test_default_config() { @@ -1751,6 +1779,28 @@ port = 4531 ); } + // --- Deprecation warnings --- + + fn present(paths: &[&str]) -> std::collections::BTreeSet { + paths.iter().map(|p| p.to_string()).collect() + } + + #[test] + fn test_deprecation_flags_flat_sections_shadowed_by_rigs() { + let warnings = ServerConfig::deprecations(&present(&["rigs", "rig", "audio"])); + assert_eq!(warnings.len(), 2, "unexpected warnings: {warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("[rig] is ignored"))); + assert!(warnings.iter().any(|w| w.contains("[audio] is ignored"))); + } + + #[test] + fn test_no_deprecation_for_single_rig_layout() { + // The flat layout is the documented simple form; only warn when it is + // being silently ignored. + let warnings = ServerConfig::deprecations(&present(&["rig", "audio", "listen"])); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + } + // --- Secret indirection --- #[test] diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index b670b03e..7f7b9d93 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -30,6 +30,7 @@ use trx_core::audio::AudioStreamInfo; use trx_app::{init_logging, normalize_name}; use trx_config::shared::BoundSocket; +use trx_config::ConfigFile; use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess}; use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff}; use trx_core::rig::request::RigRequest; @@ -940,8 +941,10 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad) -> Dyn None => println!("(no config file found; checking built-in defaults)"), } - for key in &loaded.unknown_keys { - println!(" warning: {}", key); + let mut warnings: Vec = loaded.unknown_keys.iter().map(|k| k.to_string()).collect(); + warnings.extend(ServerConfig::deprecations(&loaded.present_keys)); + for warning in &warnings { + println!(" warning: {}", warning); } let mut cfg = loaded.config.clone(); @@ -966,17 +969,12 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad) -> Dyn 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()); + if !warnings.is_empty() { + println!(" {} warning(s)", warnings.len()); } Ok(()) } else { - Err(format!( - "{} error(s), {} warning(s)", - errors.len(), - loaded.unknown_keys.len() - ) - .into()) + Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into()) } } @@ -1010,6 +1008,7 @@ async fn main() -> DynResult<()> { info!("Loaded configuration from {}", path.display()); } loaded.report_unknown_keys(cli.strict_config)?; + loaded.report_deprecations(); let mut cfg = loaded.config; // Secrets configured as *_file are read before validation, so everything -- 2.55.0 From 084f629b5b2ac85905d772ee11ec2714d353ad15 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:39:09 +0200 Subject: [PATCH 11/13] [docs](trx-rs): record the trx-config crate and its commands Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- CLAUDE.md | 9 ++++++++- src/trx-config/src/client.rs | 24 +++++++++++++----------- src/trx-config/src/example.rs | 1 - 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b6da9ce2..5981235c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,12 @@ cargo test -p trx-core ./target/release/trx-server --print-config > trx-server.toml ./target/release/trx-client --print-config > trx-client.toml +# Validate a config without starting anything (reports every problem) +./target/release/trx-server --check-config --config trx-rs.toml + +# Regenerate trx-rs.toml.example after changing a config struct +cargo run -p trx-config --example generate_example + # Run server ./target/release/trx-server --config trx-server.toml # or via CLI args: @@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`: src/ trx-core/ # Core types, traits, state machine, controller (~3,500 LOC) trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC) - trx-app/ # Shared application helpers (config paths, logging init) + trx-app/ # Shared application helpers (logging init, name normalization) + trx-config/ # Client + server config structs, loader, validators (~2,500 LOC) trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC) trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC) trx-backend/ # Backend abstraction trait + factory + dummy diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index d7a34e5c..0eb1c17e 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -1523,17 +1523,19 @@ url = "remote.example.com:4530" #[test] fn test_remote_token_file_fills_token() { let f = secret_file("remote-token"); - let mut config = ClientConfig::default(); - config.remotes = vec![RemoteEntry { - name: "hf".to_string(), - url: "127.0.0.1:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig { - token: None, - token_file: Some(f.path().to_str().unwrap().to_string()), - }, - poll_interval_ms: 750, - }]; + let mut config = ClientConfig { + remotes: vec![RemoteEntry { + name: "hf".to_string(), + url: "127.0.0.1:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig { + token: None, + token_file: Some(f.path().to_str().unwrap().to_string()), + }, + poll_interval_ms: 750, + }], + ..Default::default() + }; config.resolve_secrets(None).unwrap(); assert_eq!(config.remotes[0].auth.token.as_deref(), Some("remote-token")); } diff --git a/src/trx-config/src/example.rs b/src/trx-config/src/example.rs index 9537a332..2203251c 100644 --- a/src/trx-config/src/example.rs +++ b/src/trx-config/src/example.rs @@ -196,7 +196,6 @@ fn annotate(doc: &mut DocumentMut, path: &str, comment: &str) { #[cfg(test)] mod tests { use super::*; - use crate::ConfigFile; /// The checked-in example must match what the structs produce, so a new /// config field cannot land without showing up in the example. -- 2.55.0 From 0fc977f19f6f25b0f9b51c1018c72d5589d8ca9d Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:44:17 +0200 Subject: [PATCH 12/13] [style](trx-config): apply rustfmt Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- src/trx-config/src/client.rs | 24 +++++++++++++++++------- src/trx-config/src/example.rs | 35 ++++++++++++++++++++++++++++------- src/trx-config/src/secrets.rs | 5 ++--- src/trx-config/src/server.rs | 21 ++++++++++++++------- src/trx-config/src/unknown.rs | 11 +++++++++-- src/trx-server/src/main.rs | 7 +++++-- 6 files changed, 75 insertions(+), 28 deletions(-) diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index 0eb1c17e..27fc1504 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -16,9 +16,9 @@ use std::net::IpAddr; use std::path::Path; use std::time::Duration; -use serde::{Deserialize, Serialize}; use crate::file::{ConfigError, ConfigFile, ConfigLoad}; use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens, BoundSocket}; +use serde::{Deserialize, Serialize}; /// Top-level client configuration structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -472,7 +472,12 @@ impl ClientConfig { for name in self.frontends.audio.rig_ports.keys() { check(name, "[frontends.audio].rig_ports"); } - for name in self.frontends.http.decode_history_retention_min_by_rig.keys() { + for name in self + .frontends + .http + .decode_history_retention_min_by_rig + .keys() + { check(name, "[frontends.http].decode_history_retention_min_by_rig"); } @@ -941,9 +946,8 @@ impl ConfigFile for ClientConfig { if present.contains("remote") { if present.contains("remotes") { - warnings.push( - "[remote] is ignored because [[remotes]] is set; delete it".to_string(), - ); + warnings + .push("[remote] is ignored because [[remotes]] is set; delete it".to_string()); } else { warnings.push( "[remote] is superseded by [[remotes]], which supports several rigs; \ @@ -1537,7 +1541,10 @@ url = "remote.example.com:4530" ..Default::default() }; config.resolve_secrets(None).unwrap(); - assert_eq!(config.remotes[0].auth.token.as_deref(), Some("remote-token")); + assert_eq!( + config.remotes[0].auth.token.as_deref(), + Some("remote-token") + ); } #[test] @@ -1546,7 +1553,10 @@ url = "remote.example.com:4530" let mut config = ClientConfig::default(); config.remote.auth.token = Some("inline".to_string()); config.remote.auth.token_file = Some(f.path().to_str().unwrap().to_string()); - assert!(config.resolve_secrets(None).unwrap_err().contains("not both")); + assert!(config + .resolve_secrets(None) + .unwrap_err() + .contains("not both")); } // --- Second-phase validation against the resolved remote list --- diff --git a/src/trx-config/src/example.rs b/src/trx-config/src/example.rs index 2203251c..f61c22fd 100644 --- a/src/trx-config/src/example.rs +++ b/src/trx-config/src/example.rs @@ -69,14 +69,26 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[ "trx-server.pskreporter", "Report FT8/FT4/WSPR spots to pskreporter.info.", ), - ("trx-server.aprsfi", "Forward received APRS frames to APRS-IS."), - ("trx-server.decode_logs", "Write decodes to JSON Lines files."), + ( + "trx-server.aprsfi", + "Forward received APRS frames to APRS-IS.", + ), + ( + "trx-server.decode_logs", + "Write decodes to JSON Lines files.", + ), ( "trx-server.sdr", "SoapySDR pipeline; used when [rig.access] type = \"sdr\".", ), - ("trx-server.sdr.gain", "\"auto\" for hardware AGC, or \"manual\"."), - ("trx-server.sdr.squelch", "Software squelch on demodulated audio."), + ( + "trx-server.sdr.gain", + "\"auto\" for hardware AGC, or \"manual\".", + ), + ( + "trx-server.sdr.squelch", + "Software squelch on demodulated audio.", + ), ( "trx-server.sdr.noise_blanker", "Impulse-noise suppression on the IQ stream.", @@ -105,8 +117,14 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[ "trx-client.frontends.rigctl", "Hamlib-compatible TCP interface, one listener per rig.", ), - ("trx-client.frontends.http_json", "JSON-over-TCP control interface."), - ("trx-client.frontends.audio", "Where to fetch the audio stream from."), + ( + "trx-client.frontends.http_json", + "JSON-over-TCP control interface.", + ), + ( + "trx-client.frontends.audio", + "Where to fetch the audio stream from.", + ), ( "trx-client.frontends.audio.bridge", "Play RX audio on a local sound device and capture TX from one.", @@ -247,7 +265,10 @@ mod tests { None => doc.get(segment), Some(current) => current.as_table().and_then(|t| t.get(segment)), }; - assert!(item.is_some(), "commented section [{path}] no longer exists"); + assert!( + item.is_some(), + "commented section [{path}] no longer exists" + ); } } } diff --git a/src/trx-config/src/secrets.rs b/src/trx-config/src/secrets.rs index 96500e41..ab5084e1 100644 --- a/src/trx-config/src/secrets.rs +++ b/src/trx-config/src/secrets.rs @@ -64,9 +64,8 @@ fn expand_str(input: &str) -> Result, String> { // Not a variable reference; pass it through untouched. out.push_str(&rest[start..start + 2 + end + 1]); } else { - let value = std::env::var(name).map_err(|_| { - format!("config references unset environment variable ${{{name}}}") - })?; + let value = std::env::var(name) + .map_err(|_| format!("config references unset environment variable ${{{name}}}"))?; out.push_str(&value); } rest = &after[end + 1..]; diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 72c9c888..7c6f233c 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -14,9 +14,9 @@ use std::net::IpAddr; use std::path::Path; -use serde::{Deserialize, Serialize}; use crate::file::{ConfigError, ConfigFile, ConfigLoad}; use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens, BoundSocket}; +use serde::{Deserialize, Serialize}; pub use trx_decode_log::DecodeLogsConfig; use trx_core::rig::state::RigMode; @@ -831,13 +831,17 @@ fn validate_rig_instance( errors.push(format!("{prefix}[behavior].poll_interval_ms must be > 0")); } if rig.behavior.poll_interval_tx_ms == 0 { - errors.push(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 { errors.push(format!("{prefix}[behavior].max_retries must be > 0")); } if rig.behavior.retry_base_delay_ms == 0 { - errors.push(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 { @@ -906,9 +910,10 @@ fn validate_rig_instance( { errors.push(e); } - if let Err(e) = - validate_sdr_nb_config(&format!("{prefix}[sdr.noise_blanker]"), &rig.sdr.noise_blanker) - { + if let Err(e) = validate_sdr_nb_config( + &format!("{prefix}[sdr.noise_blanker]"), + &rig.sdr.noise_blanker, + ) { errors.push(e); } @@ -1885,7 +1890,9 @@ enabled = ["cw"] enabled = ["ft8", "morse"] "#; let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let err = cfg.validate().expect_err("expected an unknown decoder error"); + let err = cfg + .validate() + .expect_err("expected an unknown decoder error"); assert!(err.contains("morse"), "unexpected error: {err}"); } diff --git a/src/trx-config/src/unknown.rs b/src/trx-config/src/unknown.rs index 6d9e22ad..0ca66c1c 100644 --- a/src/trx-config/src/unknown.rs +++ b/src/trx-config/src/unknown.rs @@ -25,7 +25,11 @@ pub struct UnknownKey { impl fmt::Display for UnknownKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.suggestion { - Some(s) => write!(f, "unknown config key '{}' (did you mean '{}'?)", self.path, s), + Some(s) => write!( + f, + "unknown config key '{}' (did you mean '{}'?)", + self.path, s + ), None => write!(f, "unknown config key '{}'", self.path), } } @@ -203,7 +207,10 @@ sample_rate = 48000 #[test] fn test_suggest_finds_close_sibling() { let known = flatten_paths(&reference()); - assert_eq!(suggest("listen.prot", &known).as_deref(), Some("listen.port")); + assert_eq!( + suggest("listen.prot", &known).as_deref(), + Some("listen.port") + ); } #[test] diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 7f7b9d93..e5a048ac 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -29,9 +29,9 @@ use tracing::{error, info, warn}; use trx_core::audio::AudioStreamInfo; use trx_app::{init_logging, normalize_name}; +use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess}; use trx_config::shared::BoundSocket; use trx_config::ConfigFile; -use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess}; use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff}; use trx_core::rig::request::RigRequest; use trx_core::rig::state::RigState; @@ -967,7 +967,10 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad) -> Dyn println!( " OK: {} rig(s) configured: {}", rigs.len(), - rigs.iter().map(|r| r.id.as_str()).collect::>().join(", ") + rigs.iter() + .map(|r| r.id.as_str()) + .collect::>() + .join(", ") ); if !warnings.is_empty() { println!(" {} warning(s)", warnings.len()); -- 2.55.0 From 46c9827e8ac8c4dde81c774f4d848ba3476ad077 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 22:08:06 +0200 Subject: [PATCH 13/13] [fix](trx-config): keep the generated example off the machine that made it The example is generated from the config defaults, and [decode_logs].dir defaults to the running user's cache directory. So the file rendered /Users/sjg/Library/Caches/trx-rs/decoders on the machine that generated it and /root/.cache/trx-rs/decoders in CI, and the up-to-date test failed for everyone but its author. Pin dir to an illustrative /var/lib/trx-rs/decoders in the example config; omitting the key still falls back to the per-user directory. A new test asserts the rendered example contains none of this machine's home, cache or config directories, so the next environment-derived default cannot slip through the same way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- src/trx-config/src/example.rs | 22 +++++++++++++++++++++- src/trx-config/src/server.rs | 9 ++++++++- trx-rs.toml.example | 3 ++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/trx-config/src/example.rs b/src/trx-config/src/example.rs index f61c22fd..9fa3eab5 100644 --- a/src/trx-config/src/example.rs +++ b/src/trx-config/src/example.rs @@ -63,7 +63,8 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[ ( "trx-server.decoders", "Which decoders run. Trimming this list saves real CPU on small boxes.\n\ - Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.", + Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.\n\ + output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).", ), ( "trx-server.pskreporter", @@ -232,6 +233,25 @@ mod tests { ); } + /// Nothing in the example may be derived from the machine that generated + /// it: [decode_logs].dir defaults to the running user's cache directory, + /// which made the generated file differ between a developer's laptop and + /// CI, and the up-to-date test fail for everyone but its author. + #[test] + fn test_example_has_no_machine_specific_paths() { + let example = combined_example(); + for dir in [dirs::home_dir(), dirs::cache_dir(), dirs::config_dir()] + .into_iter() + .flatten() + { + let dir = dir.to_string_lossy().into_owned(); + assert!( + !example.contains(&dir), + "the example contains this machine's {dir}; pin the value in example_config()" + ); + } + } + #[test] fn test_example_loads_and_validates() { let mut file = tempfile::NamedTempFile::new().unwrap(); diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 7c6f233c..10595c51 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -755,7 +755,14 @@ impl ServerConfig { audio: AudioConfig::default(), pskreporter: PskReporterConfig::default(), aprsfi: AprsFiConfig::default(), - decode_logs: DecodeLogsConfig::default(), + decode_logs: DecodeLogsConfig { + // Pinned rather than defaulted: the default is derived from the + // running user's cache directory, which would make the generated + // example differ from machine to machine. Omitting the key + // falls back to that per-user directory. + dir: "/var/lib/trx-rs/decoders".to_string(), + ..DecodeLogsConfig::default() + }, decoders: DecodersConfig::default(), sdr: SdrConfig::default(), timeouts: TimeoutsConfig::default(), diff --git a/trx-rs.toml.example b/trx-rs.toml.example index 9f552ee7..cd2c1f4d 100644 --- a/trx-rs.toml.example +++ b/trx-rs.toml.example @@ -86,7 +86,7 @@ beacon_symbol_code = "-" # Write decodes to JSON Lines files. [trx-server.decode_logs] enabled = false -dir = "/Users/sjg/Library/Caches/trx-rs/decoders" +dir = "/var/lib/trx-rs/decoders" aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log" cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log" ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log" @@ -95,6 +95,7 @@ wefax_file = "TRXRS-WEFAX-%YYYY%-%MM%-%DD%.log" # Which decoders run. Trimming this list saves real CPU on small boxes. # Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr. +# output_dir sets where sstv/wefax/lrpt write images (default: user cache dir). [trx-server.decoders] enabled = [ "aprs", -- 2.55.0