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/Cargo.lock b/Cargo.lock index 1d7d099b..449ec57d 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" @@ -3031,10 +3041,6 @@ dependencies = [ name = "trx-app" version = "0.1.0" dependencies = [ - "dirs", - "serde", - "thiserror 2.0.18", - "toml", "tracing", "tracing-subscriber", ] @@ -3115,6 +3121,7 @@ dependencies = [ "toml", "tracing", "trx-app", + "trx-config", "trx-core", "trx-frontend", "trx-frontend-http", @@ -3124,6 +3131,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "trx-config" +version = "0.1.0" +dependencies = [ + "dirs", + "serde", + "serde_ignored", + "tempfile", + "thiserror 2.0.18", + "toml", + "toml_edit 0.22.27", + "tracing", + "trx-core", + "trx-decode-log", + "trx-reporting", +] + [[package]] name = "trx-configurator" version = "0.1.0" @@ -3132,7 +3156,9 @@ dependencies = [ "dialoguer", "tempfile", "tokio-serial", + "toml", "toml_edit 0.22.27", + "trx-config", ] [[package]] @@ -3294,6 +3320,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/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-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/config.rs b/src/trx-app/src/config.rs deleted file mode 100644 index d0b1ea7a..00000000 --- a/src/trx-app/src/config.rs +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// SPDX-License-Identifier: GPL-2.0-or-later - -use serde::de::DeserializeOwned; -use std::path::{Path, PathBuf}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum ConfigError { - #[error("Failed to read config file {0}: {1}")] - ReadError(PathBuf, String), - - #[error("Failed to parse config file {0}: {1}")] - ParseError(PathBuf, String), -} - -/// Returns the default search paths for `trx-rs.toml` -/// (current directory → XDG config → /etc). -fn config_search_paths() -> Vec { - let mut paths = vec![PathBuf::from("trx-rs.toml")]; - if let Some(config_dir) = dirs::config_dir() { - paths.push(config_dir.join("trx-rs").join("trx-rs.toml")); - } - paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml")); - paths -} - -/// 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( - path: &Path, - key: &str, -) -> Result, ConfigError> { - let content = std::fs::read_to_string(path) - .map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?; - - 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 { - return Ok(None); - }; - - // Re-serialize the section then parse as T so all serde defaults apply. - let section_toml = toml::to_string(section) - .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)) -} - -/// Trait for loading configuration from a `trx-rs.toml` section. -pub trait ConfigFile: Sized + Default + DeserializeOwned { - /// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`). - fn section_key() -> &'static str; - - /// 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. - 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()), - ) - }) - } - - /// 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> { - 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))); - } - } - } - Ok((Self::default(), None)) - } -} 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/main.rs b/src/trx-client/src/main.rs index 161f929b..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; @@ -50,6 +51,12 @@ 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, + /// 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, @@ -74,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 @@ -109,6 +116,65 @@ 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(); + warnings.extend(ClientConfig::deprecations(&loaded.present_keys)); + + 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( + "no remotes configured; --url will be required at startup (add [[remotes]] entries)" + .to_string(), + ); + } + + errors.extend(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, @@ -135,20 +201,44 @@ 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()); + 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()); if let Some(ref path) = config_path { 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 + // downstream sees resolved values. + cfg.resolve_secrets(config_path.as_deref())?; + cfg.validate() + .map_err(|e| format!("Invalid client configuration: {}", e))?; frontend_runtime.http_auth.tokens = cfg .frontends @@ -201,7 +291,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 { @@ -262,6 +355,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-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..62b19c62 --- /dev/null +++ b/src/trx-config/Cargo.toml @@ -0,0 +1,24 @@ +# 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" } +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 new file mode 100644 index 00000000..27fc1504 --- /dev/null +++ b/src/trx-config/src/client.rs @@ -0,0 +1,1686 @@ +// 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; +use std::time::Duration; + +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)] +#[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, + /// 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. +/// +/// 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, + /// 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 + 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, + rx_passphrase_file: None, + control_passphrase: None, + control_passphrase_file: 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, + /// 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 { + /// 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() + } + } + + /// 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> { + 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 = || { + let mut names: Vec<&str> = remotes.iter().map(|r| r.name.as_str()).collect(); + names.sort_unstable(); + names.join(", ") + }; + let mut check = |value: &str, path: &str| { + if !names.contains(value) { + errors.push(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"); + } + + if let Err(e) = check_socket_conflicts(&self.bound_sockets()) { + errors.push(e); + } + errors + } + + /// 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 + } + + /// Validate the configuration, reporting the first problem found. + pub fn validate(&self) -> Result<(), String> { + self.validate_all().into_iter().next().map_or(Ok(()), Err) + } + + /// 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]][{}].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 + )); + } + } + + // Legacy [remote], kept for backward compatibility. + 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()); + } + } + Ok(()) + } + + 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) = &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 http.initial_map_zoom == 0 { + return Err("[frontends.http].initial_map_zoom must be > 0".to_string()); + } + if http.spectrum_coverage_margin_hz == 0 { + return Err("[frontends.http].spectrum_coverage_margin_hz must be > 0".to_string()); + } + 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 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 http.decode_history_retention_min == 0 { + return Err("[frontends.http].decode_history_retention_min must be > 0".to_string()); + } + 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" + .to_string(), + ); + } + if *minutes == 0 { + return Err(format!( + "[frontends.http].decode_history_retention_min_by_rig[\"{}\"] must be > 0", + rig_id + )); + } + } + 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 &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 + )); + } + } + 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 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 &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 &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 !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 !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 audio.bridge.bitrate_bps == 0 { + return Err("[frontends.audio.bridge].bitrate_bps must be > 0".to_string()); + } + 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) + } + + /// Load configuration from the default search paths. + /// Returns default config if no config file is found. + pub fn load_from_default_paths() -> Result, 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. + /// 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()), + 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()), + token_file: None, + }, + 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()), + token_file: None, + }, + 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()), + 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, + 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(), + }, + } + } + + /// 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() + } +} + +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" + } + + /// 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 { + 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)] +mod tests { + use super::*; + use crate::ConfigFile; + + #[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()), + token_file: None, + }, + 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")); + } + + // --- 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 { + 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 { + 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") + ); + } + + #[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 { + 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(); + 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-config/src/example.rs b/src/trx-config/src/example.rs new file mode 100644 index 00000000..9fa3eab5 --- /dev/null +++ b/src/trx-config/src/example.rs @@ -0,0 +1,295 @@ +// 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.\n\ + output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).", + ), + ( + "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::*; + + /// 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`" + ); + } + + /// 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(); + 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/file.rs b/src/trx-config/src/file.rs new file mode 100644 index 00000000..9b119326 --- /dev/null +++ b/src/trx-config/src/file.rs @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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 serde::Serialize; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +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"]; + +/// 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, + /// 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 { + /// 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}")] + ReadError(PathBuf, String), + + #[error("Failed to parse config file {0}: {1}")] + ParseError(PathBuf, String), +} + +/// Returns the default search paths for `trx-rs.toml` +/// (current directory → XDG config → /etc). +fn config_search_paths() -> Vec { + let mut paths = vec![PathBuf::from("trx-rs.toml")]; + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("trx-rs").join("trx-rs.toml")); + } + paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml")); + 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, 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> { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?; + + let table: toml::Table = toml::from_str(&content) + .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; + + 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))?; + + 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()), + present_keys, + ))) +} + +/// Trait for loading configuration from a `trx-rs.toml` section. +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. + 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, ConfigError> { + 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()), + ) + })?; + Ok(ConfigLoad { + config, + path: Some(path.to_path_buf()), + unknown_keys, + present_keys, + }) + } + + /// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load + /// the first file that contains the expected section. + /// + /// 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((config, unknown_keys, present_keys)) = + load_section_from_file::(&path, Self::section_key())? + { + return Ok(ConfigLoad { + config, + path: Some(path), + unknown_keys, + present_keys, + }); + } + } + } + Ok(ConfigLoad { + config: Self::default(), + path: None, + unknown_keys: Vec::new(), + present_keys: BTreeSet::new(), + }) + } +} + +#[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-config/src/lib.rs b/src/trx-config/src/lib.rs new file mode 100644 index 00000000..336e1fe2 --- /dev/null +++ b/src/trx-config/src/lib.rs @@ -0,0 +1,26 @@ +// 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 example; +pub mod file; +pub mod secrets; +pub mod server; +pub mod shared; +pub mod unknown; +pub mod url; + +pub use client::ClientConfig; +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/secrets.rs b/src/trx-config/src/secrets.rs new file mode 100644 index 00000000..ab5084e1 --- /dev/null +++ b/src/trx-config/src/secrets.rs @@ -0,0 +1,297 @@ +// 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 new file mode 100644 index 00000000..10595c51 --- /dev/null +++ b/src/trx-config/src/server.rs @@ -0,0 +1,2079 @@ +// 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; + +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; + +/// 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 +/// `[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, + /// Which decoders to run for this rig. + pub decoders: DecodersConfig, +} + +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(), + decoders: DecodersConfig::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, + /// 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. + 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, + /// 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. +#[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 { + /// 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_all(&self) -> Vec { + let mut errors = Vec::new(); + let multi = !self.rigs.is_empty(); + + 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()); + } + 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> { + 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()) { + errors.push(format!("duplicate rig id after resolution: \"{}\"", rig.id)); + } + } + + if let Err(e) = check_socket_conflicts(sockets) { + errors.push(e); + } + 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(()); + } + + 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; + + 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 + )); + } + } + + if enabled_count == 0 { + return Err( + "[[rigs]] has no enabled entries; set at least one [[rigs]].enable = true" + .to_string(), + ); + } + Ok(()) + } + + /// 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() + } + + /// 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) + } + + /// Load configuration from the default search paths. + /// Returns default config if no config file is found. + pub fn load_from_default_paths() -> Result, 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(), + decoders: self.decoders.clone(), + }] + } + + /// Generate an example configuration wrapped under the `[trx-server]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + /// 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()), + 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 { + // 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(), + rigs: Vec::new(), + } + } + + /// 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() + } +} + +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(), + ), + } +} + +/// 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. 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, + errors: &mut Vec, +) { + if rig.rig.initial_freq_hz == 0 { + errors.push(format!("{prefix}[rig].initial_freq_hz must be > 0")); + } + + if let Err(e) = validate_access(prefix, &rig.rig.access) { + errors.push(e); + } + + if rig.behavior.poll_interval_ms == 0 { + 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" + )); + } + 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" + )); + } + + if rig.audio.enabled { + if rig.audio.port == 0 { + errors.push(format!( + "{prefix}[audio].port must be > 0 when audio is enabled" + )); + } + if !rig.audio.rx_enabled && !rig.audio.tx_enabled { + 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 { + errors.push(format!( + "{prefix}[audio].sample_rate must be in range 8000..=192000" + )); + } + if !(1..=2).contains(&rig.audio.channels) { + errors.push(format!("{prefix}[audio].channels must be 1 or 2")); + } + 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 { + errors.push(format!("{prefix}[audio].bitrate_bps must be > 0")); + } + } + + if rig.pskreporter.enabled { + if rig.pskreporter.host.trim().is_empty() { + errors.push(format!("{prefix}[pskreporter].host must not be empty")); + } + if rig.pskreporter.port == 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()) + { + errors.push(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() { + errors.push(format!("{prefix}[aprsfi].host must not be empty")); + } + if rig.aprsfi.port == 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() { + 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")); + } + } + 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); + } + + 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!( + "{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() + { + errors.push(format!( + "{prefix}[decode_logs] file names must not be empty when enabled" + )); + } + } +} + +/// 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(); + + 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(format!( + "{prefix}[rig.access].port must be set for serial access ([rig.access].type='serial')" + )); + } + if access.baud.unwrap_or(0) == 0 { + 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(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(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_instance(). + } + other => { + return Err(format!( + "{prefix}[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" + } + + /// 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 { + 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)] +mod tests { + use super::*; + use crate::ConfigFile; + + #[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"); + } + + // --- 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#" +[[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" + ); + } + + // --- 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] + 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] + 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. --- + + #[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#" +[[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 53% rename from src/trx-app/src/shared_config.rs rename to src/trx-config/src/shared.rs index 60460b63..f1b998cb 100644 --- a/src/trx-app/src/shared_config.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-config/src/unknown.rs b/src/trx-config/src/unknown.rs new file mode 100644 index 00000000..0ca66c1c --- /dev/null +++ b/src/trx-config/src/unknown.rs @@ -0,0 +1,263 @@ +// 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-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..dc8523fe 100644 --- a/src/trx-configurator/Cargo.toml +++ b/src/trx-configurator/Cargo.toml @@ -15,7 +15,9 @@ 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" } [dev-dependencies] tempfile = "3" 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 e5837a1a..96edaf32 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,60 @@ 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") + .config; + 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") + .config; + 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") + .config; + server.validate().expect("server section must validate"); + let client = ClientConfig::load_from_file(file.path()) + .expect("client section must load") + .config; + 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")); + } +} 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/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/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::*; diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 2a42d940..e5a048ac 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -30,6 +30,8 @@ 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_core::rig::controller::{AdaptivePolling, ExponentialBackoff}; use trx_core::rig::request::RigRequest; use trx_core::rig::state::RigState; @@ -56,6 +58,12 @@ 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, + /// 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, @@ -653,38 +661,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(); @@ -699,7 +713,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(); @@ -721,55 +735,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(); @@ -786,73 +806,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 { @@ -882,6 +905,82 @@ 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)"), + } + + 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(); + 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(); + + errors.extend(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 !warnings.is_empty() { + println!(" {} warning(s)", warnings.len()); + } + Ok(()) + } else { + Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into()) + } +} + #[tokio::main] async fn main() -> DynResult<()> { let mut bootstrap_ctx = RegistrationContext::new(); @@ -894,12 +993,30 @@ 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(); + + 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()); + + if let Some(ref path) = config_path { + 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 + // downstream sees resolved values. + cfg.resolve_secrets(config_path.as_deref())?; cfg.validate() .map_err(|e| format!("Invalid server configuration: {}", e))?; @@ -912,12 +1029,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 --- @@ -979,6 +1090,12 @@ 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. + 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): {}", resolved_rigs.len(), diff --git a/trx-rs.toml.example b/trx-rs.toml.example index 1e7e65ee..cd2c1f4d 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,53 @@ 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 = "/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" 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. +# output_dir sets where sstv/wefax/lrpt write images (default: user cache dir). +[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 +121,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 +157,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 +209,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 +217,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 +226,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