Fix and harden client/server configuration #48
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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): {}",
|
||||
|
||||
Reference in New Issue
Block a user