[feat](trx-rs): add --check-config to the server and client

Validating a config meant starting the daemon and reading the first error it
died on, fixing that, and repeating.  Add --check-config, which loads the
config through the real loader, reports every problem at once and exits 0/1:

    $ trx-server --check-config --config trx-rs.toml
    trx-rs.toml
      warning: unknown config key 'listen.prot' (did you mean 'listen.port'?)
      error: [general].log_level 'verbose' is invalid (expected one of: ...)
      error: [rig.access].baud must be > 0 for serial access
      error: [audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60
      error: [listen] and rig "default" [audio] would both bind 127.0.0.1:4530

Validation grows validate_all()/validate_resolved_all() alongside the existing
first-error entry points; validate() is now the first element of validate_all().
Sockets are built by one helper shared by startup and the check, so the two
cannot disagree about what --listen overrides.

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