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
6 changed files with 75 additions and 28 deletions
Showing only changes of commit 0fc977f19f - Show all commits
+17 -7
View File
@@ -16,9 +16,9 @@ use std::net::IpAddr;
use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::file::{ConfigError, ConfigFile, ConfigLoad};
use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens, BoundSocket};
use serde::{Deserialize, Serialize};
/// Top-level client configuration structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -472,7 +472,12 @@ impl ClientConfig {
for name in self.frontends.audio.rig_ports.keys() {
check(name, "[frontends.audio].rig_ports");
}
for name in self.frontends.http.decode_history_retention_min_by_rig.keys() {
for name in self
.frontends
.http
.decode_history_retention_min_by_rig
.keys()
{
check(name, "[frontends.http].decode_history_retention_min_by_rig");
}
@@ -941,9 +946,8 @@ impl ConfigFile for ClientConfig {
if present.contains("remote") {
if present.contains("remotes") {
warnings.push(
"[remote] is ignored because [[remotes]] is set; delete it".to_string(),
);
warnings
.push("[remote] is ignored because [[remotes]] is set; delete it".to_string());
} else {
warnings.push(
"[remote] is superseded by [[remotes]], which supports several rigs; \
@@ -1537,7 +1541,10 @@ url = "remote.example.com:4530"
..Default::default()
};
config.resolve_secrets(None).unwrap();
assert_eq!(config.remotes[0].auth.token.as_deref(), Some("remote-token"));
assert_eq!(
config.remotes[0].auth.token.as_deref(),
Some("remote-token")
);
}
#[test]
@@ -1546,7 +1553,10 @@ url = "remote.example.com:4530"
let mut config = ClientConfig::default();
config.remote.auth.token = Some("inline".to_string());
config.remote.auth.token_file = Some(f.path().to_str().unwrap().to_string());
assert!(config.resolve_secrets(None).unwrap_err().contains("not both"));
assert!(config
.resolve_secrets(None)
.unwrap_err()
.contains("not both"));
}
// --- Second-phase validation against the resolved remote list ---
+28 -7
View File
@@ -69,14 +69,26 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
"trx-server.pskreporter",
"Report FT8/FT4/WSPR spots to pskreporter.info.",
),
("trx-server.aprsfi", "Forward received APRS frames to APRS-IS."),
("trx-server.decode_logs", "Write decodes to JSON Lines files."),
(
"trx-server.aprsfi",
"Forward received APRS frames to APRS-IS.",
),
(
"trx-server.decode_logs",
"Write decodes to JSON Lines files.",
),
(
"trx-server.sdr",
"SoapySDR pipeline; used when [rig.access] type = \"sdr\".",
),
("trx-server.sdr.gain", "\"auto\" for hardware AGC, or \"manual\"."),
("trx-server.sdr.squelch", "Software squelch on demodulated audio."),
(
"trx-server.sdr.gain",
"\"auto\" for hardware AGC, or \"manual\".",
),
(
"trx-server.sdr.squelch",
"Software squelch on demodulated audio.",
),
(
"trx-server.sdr.noise_blanker",
"Impulse-noise suppression on the IQ stream.",
@@ -105,8 +117,14 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
"trx-client.frontends.rigctl",
"Hamlib-compatible TCP interface, one listener per rig.",
),
("trx-client.frontends.http_json", "JSON-over-TCP control interface."),
("trx-client.frontends.audio", "Where to fetch the audio stream from."),
(
"trx-client.frontends.http_json",
"JSON-over-TCP control interface.",
),
(
"trx-client.frontends.audio",
"Where to fetch the audio stream from.",
),
(
"trx-client.frontends.audio.bridge",
"Play RX audio on a local sound device and capture TX from one.",
@@ -247,7 +265,10 @@ mod tests {
None => doc.get(segment),
Some(current) => current.as_table().and_then(|t| t.get(segment)),
};
assert!(item.is_some(), "commented section [{path}] no longer exists");
assert!(
item.is_some(),
"commented section [{path}] no longer exists"
);
}
}
}
+2 -3
View File
@@ -64,9 +64,8 @@ fn expand_str(input: &str) -> Result<Option<String>, String> {
// Not a variable reference; pass it through untouched.
out.push_str(&rest[start..start + 2 + end + 1]);
} else {
let value = std::env::var(name).map_err(|_| {
format!("config references unset environment variable ${{{name}}}")
})?;
let value = std::env::var(name)
.map_err(|_| format!("config references unset environment variable ${{{name}}}"))?;
out.push_str(&value);
}
rest = &after[end + 1..];
+14 -7
View File
@@ -14,9 +14,9 @@
use std::net::IpAddr;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::file::{ConfigError, ConfigFile, ConfigLoad};
use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens, BoundSocket};
use serde::{Deserialize, Serialize};
pub use trx_decode_log::DecodeLogsConfig;
use trx_core::rig::state::RigMode;
@@ -831,13 +831,17 @@ fn validate_rig_instance(
errors.push(format!("{prefix}[behavior].poll_interval_ms must be > 0"));
}
if rig.behavior.poll_interval_tx_ms == 0 {
errors.push(format!("{prefix}[behavior].poll_interval_tx_ms must be > 0"));
errors.push(format!(
"{prefix}[behavior].poll_interval_tx_ms must be > 0"
));
}
if rig.behavior.max_retries == 0 {
errors.push(format!("{prefix}[behavior].max_retries must be > 0"));
}
if rig.behavior.retry_base_delay_ms == 0 {
errors.push(format!("{prefix}[behavior].retry_base_delay_ms must be > 0"));
errors.push(format!(
"{prefix}[behavior].retry_base_delay_ms must be > 0"
));
}
if rig.audio.enabled {
@@ -906,9 +910,10 @@ fn validate_rig_instance(
{
errors.push(e);
}
if let Err(e) =
validate_sdr_nb_config(&format!("{prefix}[sdr.noise_blanker]"), &rig.sdr.noise_blanker)
{
if let Err(e) = validate_sdr_nb_config(
&format!("{prefix}[sdr.noise_blanker]"),
&rig.sdr.noise_blanker,
) {
errors.push(e);
}
@@ -1885,7 +1890,9 @@ enabled = ["cw"]
enabled = ["ft8", "morse"]
"#;
let cfg: ServerConfig = toml::from_str(toml_str).unwrap();
let err = cfg.validate().expect_err("expected an unknown decoder error");
let err = cfg
.validate()
.expect_err("expected an unknown decoder error");
assert!(err.contains("morse"), "unexpected error: {err}");
}
+9 -2
View File
@@ -25,7 +25,11 @@ pub struct UnknownKey {
impl fmt::Display for UnknownKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.suggestion {
Some(s) => write!(f, "unknown config key '{}' (did you mean '{}'?)", self.path, s),
Some(s) => write!(
f,
"unknown config key '{}' (did you mean '{}'?)",
self.path, s
),
None => write!(f, "unknown config key '{}'", self.path),
}
}
@@ -203,7 +207,10 @@ sample_rate = 48000
#[test]
fn test_suggest_finds_close_sibling() {
let known = flatten_paths(&reference());
assert_eq!(suggest("listen.prot", &known).as_deref(), Some("listen.port"));
assert_eq!(
suggest("listen.prot", &known).as_deref(),
Some("listen.port")
);
}
#[test]
+5 -2
View File
@@ -29,9 +29,9 @@ use tracing::{error, info, warn};
use trx_core::audio::AudioStreamInfo;
use trx_app::{init_logging, normalize_name};
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
use trx_config::shared::BoundSocket;
use trx_config::ConfigFile;
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
@@ -967,7 +967,10 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> Dyn
println!(
" OK: {} rig(s) configured: {}",
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 !warnings.is_empty() {
println!(" {} warning(s)", warnings.len());