Fix and harden client/server configuration #48

Merged
sjg merged 13 commits from feat/config-improvements into main 2026-08-06 22:22:40 +02:00
5 changed files with 197 additions and 17 deletions
Showing only changes of commit bc63ded583 - Show all commits
+10 -1
View File
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
use tracing::{error, info}; use tracing::{error, info};
use trx_app::{init_logging, normalize_name}; use trx_app::{init_logging, normalize_name};
use trx_config::ConfigFile;
use trx_core::audio::AudioStreamInfo; use trx_core::audio::AudioStreamInfo;
use trx_core::decode::DecodedMessage; use trx_core::decode::DecodedMessage;
@@ -80,7 +81,7 @@ struct Cli {
/// rigctl frontend listen address /// rigctl frontend listen address
#[arg(long = "rigctl-listen")] #[arg(long = "rigctl-listen")]
rigctl_listen: Option<IpAddr>, rigctl_listen: Option<IpAddr>,
/// rigctl frontend listen port /// Deprecated: ignored, use [frontends.rigctl].rig_ports
#[arg(long = "rigctl-port")] #[arg(long = "rigctl-port")]
rigctl_port: Option<u16>, rigctl_port: Option<u16>,
/// JSON TCP frontend listen address /// JSON TCP frontend listen address
@@ -127,6 +128,7 @@ fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()>
} }
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect(); let mut warnings: Vec<String> = 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 cfg = loaded.config.clone();
let mut errors = Vec::new(); let mut errors = Vec::new();
@@ -223,6 +225,13 @@ async fn async_init() -> DynResult<AppState> {
info!("Loaded configuration from {}", path.display()); info!("Loaded configuration from {}", path.display());
} }
loaded.report_unknown_keys(cli.strict_config)?; 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; let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything // Secrets configured as *_file are read before validation, so everything
+91
View File
@@ -935,6 +935,51 @@ impl ConfigFile for ClientConfig {
"trx-client" "trx-client"
} }
/// Keys that no longer do what they look like they do.
fn deprecations(present: &std::collections::BTreeSet<String>) -> Vec<String> {
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 /// Include one `[[remotes]]` entry so unknown keys nested inside a remote
/// entry still get a suggestion. /// entry still get a suggestion.
fn reference_value() -> toml::Value { fn reference_value() -> toml::Value {
@@ -955,6 +1000,7 @@ impl ConfigFile for ClientConfig {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::ConfigFile;
#[test] #[test]
fn test_default_config() { fn test_default_config() {
@@ -1404,6 +1450,51 @@ url = "remote.example.com:4530"
.contains("poll_interval_ms must be > 0")); .contains("poll_interval_ms must be > 0"));
} }
// --- Deprecation warnings ---
fn present(paths: &[&str]) -> std::collections::BTreeSet<String> {
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 --- // --- Secret indirection ---
fn secret_file(content: &str) -> tempfile::NamedTempFile { fn secret_file(content: &str) -> tempfile::NamedTempFile {
+37 -6
View File
@@ -22,7 +22,9 @@ use serde::Serialize;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use thiserror::Error; use thiserror::Error;
use crate::unknown::{describe, UnknownKey}; use std::collections::BTreeSet;
use crate::unknown::{describe, flatten_paths, UnknownKey};
/// Every section key that may appear at the root of a combined config file. /// Every section key that may appear at the root of a combined config file.
pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"]; pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"];
@@ -37,6 +39,18 @@ pub struct ConfigLoad<T> {
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
/// Keys present in the file that no config field claimed. /// Keys present in the file that no config field claimed.
pub unknown_keys: Vec<UnknownKey>, pub unknown_keys: Vec<UnknownKey>,
/// Every key path the file actually set. Defaults are indistinguishable
/// from explicit values once deserialized, so deprecation checks need this.
pub present_keys: BTreeSet<String>,
}
impl<T: ConfigFile> ConfigLoad<T> {
/// 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<T> ConfigLoad<T> { impl<T> ConfigLoad<T> {
@@ -97,10 +111,12 @@ fn select_section(table: &toml::Table, key: &str) -> Option<toml::Value> {
/// Returns `Ok(Some((cfg, unknown_keys)))` when the section is present and /// 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 / /// parses cleanly, `Ok(None)` when the section is absent, or `Err` on I/O /
/// parse failure. /// parse failure.
type LoadedSection<T> = (T, Vec<UnknownKey>, BTreeSet<String>);
fn load_section_from_file<T: ConfigFile>( fn load_section_from_file<T: ConfigFile>(
path: &Path, path: &Path,
key: &str, key: &str,
) -> Result<Option<(T, Vec<UnknownKey>)>, ConfigError> { ) -> Result<Option<LoadedSection<T>>, ConfigError> {
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?; .map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
@@ -116,13 +132,19 @@ fn load_section_from_file<T: ConfigFile>(
crate::secrets::expand_env_vars(&mut section) crate::secrets::expand_env_vars(&mut section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?; .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?;
let present_keys = flatten_paths(&section);
// Deserialize straight from the TOML value so serde applies every default, // Deserialize straight from the TOML value so serde applies every default,
// recording any key no field claimed. // recording any key no field claimed.
let mut ignored: Vec<String> = Vec::new(); let mut ignored: Vec<String> = Vec::new();
let cfg: T = serde_ignored::deserialize(section, |path| ignored.push(path.to_string())) 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()))?; .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some((cfg, describe(&ignored, &T::reference_value())))) Ok(Some((
cfg,
describe(&ignored, &T::reference_value()),
present_keys,
)))
} }
/// Trait for loading configuration from a `trx-rs.toml` section. /// Trait for loading configuration from a `trx-rs.toml` section.
@@ -130,6 +152,12 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`). /// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str; 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<String>) -> Vec<String> {
Vec::new()
}
/// A TOML rendering of a populated config, used to suggest corrections for /// A TOML rendering of a populated config, used to suggest corrections for
/// unknown keys. Implementations should fill in list-valued sections such /// unknown keys. Implementations should fill in list-valued sections such
/// as `[[rigs]]` so keys nested inside them can be suggested too. /// as `[[rigs]]` so keys nested inside them can be suggested too.
@@ -145,8 +173,8 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
/// cannot be read, is not valid TOML, or is sectioned for some other /// cannot be read, is not valid TOML, or is sectioned for some other
/// component only. /// component only.
fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> { fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
let (config, unknown_keys) = load_section_from_file::<Self>(path, Self::section_key())? let (config, unknown_keys, present_keys) =
.ok_or_else(|| { load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError( ConfigError::ParseError(
path.to_path_buf(), path.to_path_buf(),
format!("missing [{}] section", Self::section_key()), format!("missing [{}] section", Self::section_key()),
@@ -156,6 +184,7 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
config, config,
path: Some(path.to_path_buf()), path: Some(path.to_path_buf()),
unknown_keys, unknown_keys,
present_keys,
}) })
} }
@@ -166,13 +195,14 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
fn load_from_default_paths() -> Result<ConfigLoad<Self>, ConfigError> { fn load_from_default_paths() -> Result<ConfigLoad<Self>, ConfigError> {
for path in config_search_paths() { for path in config_search_paths() {
if path.exists() { if path.exists() {
if let Some((config, unknown_keys)) = if let Some((config, unknown_keys, present_keys)) =
load_section_from_file::<Self>(&path, Self::section_key())? load_section_from_file::<Self>(&path, Self::section_key())?
{ {
return Ok(ConfigLoad { return Ok(ConfigLoad {
config, config,
path: Some(path), path: Some(path),
unknown_keys, unknown_keys,
present_keys,
}); });
} }
} }
@@ -181,6 +211,7 @@ pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
config: Self::default(), config: Self::default(),
path: None, path: None,
unknown_keys: Vec::new(), unknown_keys: Vec::new(),
present_keys: BTreeSet::new(),
}) })
} }
} }
+50
View File
@@ -1092,6 +1092,33 @@ impl ConfigFile for ServerConfig {
"trx-server" "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<String>) -> Vec<String> {
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 /// Include one `[[rigs]]` entry so unknown keys nested inside a rig entry
/// still get a suggestion. /// still get a suggestion.
fn reference_value() -> toml::Value { fn reference_value() -> toml::Value {
@@ -1106,6 +1133,7 @@ impl ConfigFile for ServerConfig {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::ConfigFile;
#[test] #[test]
fn test_default_config() { fn test_default_config() {
@@ -1751,6 +1779,28 @@ port = 4531
); );
} }
// --- Deprecation warnings ---
fn present(paths: &[&str]) -> std::collections::BTreeSet<String> {
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 --- // --- Secret indirection ---
#[test] #[test]
+9 -10
View File
@@ -30,6 +30,7 @@ use trx_core::audio::AudioStreamInfo;
use trx_app::{init_logging, normalize_name}; use trx_app::{init_logging, normalize_name};
use trx_config::shared::BoundSocket; use trx_config::shared::BoundSocket;
use trx_config::ConfigFile;
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess}; use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff}; use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
use trx_core::rig::request::RigRequest; use trx_core::rig::request::RigRequest;
@@ -940,8 +941,10 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> Dyn
None => println!("(no config file found; checking built-in defaults)"), None => println!("(no config file found; checking built-in defaults)"),
} }
for key in &loaded.unknown_keys { let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
println!(" warning: {}", key); warnings.extend(ServerConfig::deprecations(&loaded.present_keys));
for warning in &warnings {
println!(" warning: {}", warning);
} }
let mut cfg = loaded.config.clone(); let mut cfg = loaded.config.clone();
@@ -966,17 +969,12 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> Dyn
rigs.len(), rigs.len(),
rigs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>().join(", ") rigs.iter().map(|r| r.id.as_str()).collect::<Vec<_>>().join(", ")
); );
if !loaded.unknown_keys.is_empty() { if !warnings.is_empty() {
println!(" {} warning(s)", loaded.unknown_keys.len()); println!(" {} warning(s)", warnings.len());
} }
Ok(()) Ok(())
} else { } else {
Err(format!( Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
"{} error(s), {} warning(s)",
errors.len(),
loaded.unknown_keys.len()
)
.into())
} }
} }
@@ -1010,6 +1008,7 @@ async fn main() -> DynResult<()> {
info!("Loaded configuration from {}", path.display()); info!("Loaded configuration from {}", path.display());
} }
loaded.report_unknown_keys(cli.strict_config)?; loaded.report_unknown_keys(cli.strict_config)?;
loaded.report_deprecations();
let mut cfg = loaded.config; let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything // Secrets configured as *_file are read before validation, so everything