[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:
@@ -53,6 +53,9 @@ struct Cli {
|
|||||||
/// Treat unknown configuration keys as a fatal error
|
/// Treat unknown configuration keys as a fatal error
|
||||||
#[arg(long = "strict-config")]
|
#[arg(long = "strict-config")]
|
||||||
strict_config: bool,
|
strict_config: bool,
|
||||||
|
/// Validate the configuration and exit without starting anything
|
||||||
|
#[arg(long = "check-config")]
|
||||||
|
check_config: bool,
|
||||||
/// Remote server URL (host:port)
|
/// Remote server URL (host:port)
|
||||||
#[arg(short = 'u', long = "url")]
|
#[arg(short = 'u', long = "url")]
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
@@ -112,6 +115,59 @@ async fn main() -> DynResult<()> {
|
|||||||
Ok(())
|
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.
|
/// Holds the state needed after async initialization completes.
|
||||||
struct AppState {
|
struct AppState {
|
||||||
shutdown_tx: watch::Sender<bool>,
|
shutdown_tx: watch::Sender<bool>,
|
||||||
@@ -145,6 +201,16 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
};
|
};
|
||||||
let config_path = loaded.path.clone();
|
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.
|
// Logging comes up before any config complaint so the warnings are visible.
|
||||||
init_logging(loaded.config.general.log_level.as_deref());
|
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
|
/// keyed by a remote's short name; a typo used to spawn a listener routing
|
||||||
/// to a rig that does not exist, in silence.
|
/// to a rig that does not exist, in silence.
|
||||||
pub fn validate_resolved(&self, remotes: &[RemoteEntry]) -> Result<(), String> {
|
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> =
|
let names: std::collections::HashSet<&str> =
|
||||||
remotes.iter().map(|r| r.name.as_str()).collect();
|
remotes.iter().map(|r| r.name.as_str()).collect();
|
||||||
let known = || {
|
let known = || {
|
||||||
@@ -426,33 +436,35 @@ impl ClientConfig {
|
|||||||
names.sort_unstable();
|
names.sort_unstable();
|
||||||
names.join(", ")
|
names.join(", ")
|
||||||
};
|
};
|
||||||
let check = |value: &str, path: &str| -> Result<(), String> {
|
let mut check = |value: &str, path: &str| {
|
||||||
if names.contains(value) {
|
if !names.contains(value) {
|
||||||
return Ok(());
|
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 {
|
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() {
|
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() {
|
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() {
|
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() {
|
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.
|
/// Sockets the enabled frontends will bind.
|
||||||
@@ -484,51 +496,96 @@ impl ClientConfig {
|
|||||||
sockets
|
sockets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate the configuration, reporting the first problem found.
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
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
|
/// Validate the configuration, reporting every section that has a problem.
|
||||||
{
|
///
|
||||||
let mut seen_names = std::collections::HashSet::new();
|
/// Each section stops at its own first error, so one mistake does not hide
|
||||||
for (i, entry) in self.remotes.iter().enumerate() {
|
/// the rest of the file.
|
||||||
if entry.name.trim().is_empty() {
|
pub fn validate_all(&self) -> Vec<String> {
|
||||||
return Err(format!("[[remotes]][{}].name must not be empty", i));
|
let mut errors = Vec::new();
|
||||||
}
|
for result in [
|
||||||
if !seen_names.insert(&entry.name) {
|
validate_log_level(self.general.log_level.as_deref()),
|
||||||
return Err(format!("[[remotes]] duplicate name \"{}\"", entry.name));
|
self.validate_general(),
|
||||||
}
|
self.validate_remotes(),
|
||||||
if entry.url.trim().is_empty() {
|
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!(
|
return Err(format!(
|
||||||
"[[remotes]][{}].url must not be empty (name \"{}\")",
|
"[[remotes]][{}].rig_id must not be empty when set (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
|
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 {
|
if self.remote.poll_interval_ms == 0 {
|
||||||
return Err("[remote].poll_interval_ms must be > 0".to_string());
|
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());
|
return Err("[remote.auth].token must not be empty when set".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(url) = &self.general.website_url {
|
Ok(())
|
||||||
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 {
|
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());
|
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() {
|
if rig_id.trim().is_empty() {
|
||||||
return Err(
|
return Err(
|
||||||
"[frontends.http].default_rig_name must not be empty when set".to_string(),
|
"[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());
|
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());
|
return Err("[frontends.http].spectrum_coverage_margin_hz must be > 0".to_string());
|
||||||
}
|
}
|
||||||
if !(self.frontends.http.spectrum_usable_span_ratio > 0.0
|
if !(http.spectrum_usable_span_ratio > 0.0 && http.spectrum_usable_span_ratio <= 1.0) {
|
||||||
&& self.frontends.http.spectrum_usable_span_ratio <= 1.0)
|
|
||||||
{
|
|
||||||
return Err(
|
return Err(
|
||||||
"[frontends.http].spectrum_usable_span_ratio must be > 0.0 and <= 1.0".to_string(),
|
"[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" => {}
|
"iaru_r1" | "iaru_r2" | "iaru_r3" => {}
|
||||||
other => {
|
other => {
|
||||||
return Err(format!(
|
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());
|
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() {
|
if rig_id.trim().is_empty() {
|
||||||
return Err(
|
return Err(
|
||||||
"[frontends.http].decode_history_retention_min_by_rig keys must not be empty"
|
"[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(
|
return Err(
|
||||||
"[frontends.rigctl].rig_ports must contain at least one rig when enabled"
|
"[frontends.rigctl].rig_ports must contain at least one rig when enabled"
|
||||||
.to_string(),
|
.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() {
|
if rig_id.trim().is_empty() {
|
||||||
return Err("[frontends.rigctl].rig_ports keys must not be empty".to_string());
|
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)
|
crate::url::parse_audio_url(url)
|
||||||
.map_err(|e| format!("[frontends.audio].server_url {e}"))?;
|
.map_err(|e| format!("[frontends.audio].server_url {e}"))?;
|
||||||
}
|
}
|
||||||
if self.frontends.audio.enabled
|
if audio.enabled && audio.server_url.is_none() && audio.server_port == 0 {
|
||||||
&& 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());
|
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() {
|
if rig_id.trim().is_empty() {
|
||||||
return Err("[frontends.audio].rig_urls keys must not be empty".to_string());
|
return Err("[frontends.audio].rig_urls keys must not be empty".to_string());
|
||||||
}
|
}
|
||||||
crate::url::parse_audio_url(url)
|
crate::url::parse_audio_url(url)
|
||||||
.map_err(|e| format!("[frontends.audio].rig_urls[\"{rig_id}\"] {e}"))?;
|
.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() {
|
if rig_id.trim().is_empty() {
|
||||||
return Err("[frontends.audio].rig_ports keys must not be empty".to_string());
|
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()
|
if !audio.bridge.rx_gain.is_finite() || audio.bridge.rx_gain < 0.0 {
|
||||||
|| self.frontends.audio.bridge.rx_gain < 0.0
|
|
||||||
{
|
|
||||||
return Err("[frontends.audio.bridge].rx_gain must be finite and >= 0".to_string());
|
return Err("[frontends.audio.bridge].rx_gain must be finite and >= 0".to_string());
|
||||||
}
|
}
|
||||||
if !self.frontends.audio.bridge.tx_gain.is_finite()
|
if !audio.bridge.tx_gain.is_finite() || audio.bridge.tx_gain < 0.0 {
|
||||||
|| self.frontends.audio.bridge.tx_gain < 0.0
|
|
||||||
{
|
|
||||||
return Err("[frontends.audio.bridge].tx_gain must be finite and >= 0".to_string());
|
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());
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -471,27 +471,43 @@ impl Default for SdrChannelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ServerConfig {
|
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()`,
|
/// Everything that belongs to a rig is checked through `resolved_rigs()`,
|
||||||
/// so the legacy flat `[rig]` / `[audio]` / … layout and `[[rigs]]` entries
|
/// so the legacy flat `[rig]` / `[audio]` / … layout and `[[rigs]]` entries
|
||||||
/// are held to exactly the same rules.
|
/// are held to exactly the same rules.
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate_all(&self) -> Vec<String> {
|
||||||
validate_log_level(self.general.log_level.as_deref())?;
|
let mut errors = Vec::new();
|
||||||
validate_coordinates(self.general.latitude, self.general.longitude)?;
|
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 {
|
if self.listen.enabled && self.listen.port == 0 {
|
||||||
return Err("[listen].port must be > 0 when listener is enabled".to_string());
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,16 +521,34 @@ impl ServerConfig {
|
|||||||
rigs: &[RigInstanceConfig],
|
rigs: &[RigInstanceConfig],
|
||||||
sockets: &[BoundSocket],
|
sockets: &[BoundSocket],
|
||||||
) -> Result<(), String> {
|
) -> 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
|
// Auto-generated ids can collide with explicitly configured ones, which
|
||||||
// only shows up after resolution.
|
// only shows up after resolution.
|
||||||
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||||
for rig in rigs {
|
for rig in rigs {
|
||||||
if !seen.insert(rig.id.as_str()) {
|
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.
|
/// 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.
|
/// Validate everything that belongs to a single rig.
|
||||||
///
|
///
|
||||||
/// Called once per entry of `resolved_rigs()`, which is what makes the flat
|
/// 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(
|
fn validate_rig_instance(
|
||||||
prefix: &str,
|
prefix: &str,
|
||||||
rig: &RigInstanceConfig,
|
rig: &RigInstanceConfig,
|
||||||
general: &GeneralConfig,
|
general: &GeneralConfig,
|
||||||
) -> Result<(), String> {
|
errors: &mut Vec<String>,
|
||||||
|
) {
|
||||||
if rig.rig.initial_freq_hz == 0 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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.enabled {
|
||||||
if rig.audio.port == 0 {
|
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 {
|
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"
|
"{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 {
|
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"
|
"{prefix}[audio].sample_rate must be in range 8000..=192000"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !(1..=2).contains(&rig.audio.channels) {
|
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 {
|
if !matches!(rig.audio.frame_duration_ms, 3 | 5 | 10 | 20 | 40 | 60) {
|
||||||
3 | 5 | 10 | 20 | 40 | 60 => {}
|
errors.push(format!(
|
||||||
_ => {
|
"{prefix}[audio].frame_duration_ms must be one of: 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 rig.audio.bitrate_bps == 0 {
|
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.enabled {
|
||||||
if rig.pskreporter.host.trim().is_empty() {
|
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 {
|
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()
|
if rig.pskreporter.receiver_locator.is_none()
|
||||||
&& (general.latitude.is_none() || general.longitude.is_none())
|
&& (general.latitude.is_none() || general.longitude.is_none())
|
||||||
{
|
{
|
||||||
return Err(format!(
|
errors.push(format!(
|
||||||
"{prefix}[pskreporter] enabled requires either [pskreporter].receiver_locator \
|
"{prefix}[pskreporter] enabled requires either [pskreporter].receiver_locator \
|
||||||
or [general].latitude and [general].longitude"
|
or [general].latitude and [general].longitude"
|
||||||
));
|
));
|
||||||
@@ -760,27 +797,33 @@ fn validate_rig_instance(
|
|||||||
|
|
||||||
if rig.aprsfi.enabled {
|
if rig.aprsfi.enabled {
|
||||||
if rig.aprsfi.host.trim().is_empty() {
|
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 {
|
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 let Some(max_gain) = rig.sdr.gain.max_value {
|
||||||
if !max_gain.is_finite() {
|
if !max_gain.is_finite() {
|
||||||
return Err(format!("{prefix}[sdr.gain].max_value must be finite"));
|
errors.push(format!("{prefix}[sdr.gain].max_value must be finite"));
|
||||||
}
|
} else if max_gain < 0.0 {
|
||||||
if max_gain < 0.0 {
|
errors.push(format!("{prefix}[sdr.gain].max_value must be >= 0"));
|
||||||
return Err(format!("{prefix}[sdr.gain].max_value must be >= 0"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
validate_sdr_squelch_config(&format!("{prefix}[sdr.squelch]"), &rig.sdr.squelch)?;
|
if let Err(e) = validate_sdr_squelch_config(&format!("{prefix}[sdr.squelch]"), &rig.sdr.squelch)
|
||||||
validate_sdr_nb_config(&format!("{prefix}[sdr.noise_blanker]"), &rig.sdr.noise_blanker)?;
|
{
|
||||||
|
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.enabled {
|
||||||
if rig.decode_logs.dir.trim().is_empty() {
|
if rig.decode_logs.dir.trim().is_empty() {
|
||||||
return Err(format!(
|
errors.push(format!(
|
||||||
"{prefix}[decode_logs].dir must not be empty when enabled"
|
"{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.ft8_file.trim().is_empty()
|
||||||
|| rig.decode_logs.wspr_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"
|
"{prefix}[decode_logs] file names must not be empty when enabled"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the SDR pipeline of a single rig (see SDR.md §11).
|
/// 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
|
/// Treat unknown configuration keys as a fatal error
|
||||||
#[arg(long = "strict-config")]
|
#[arg(long = "strict-config")]
|
||||||
strict_config: bool,
|
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)
|
/// Rig backend to use (e.g. ft817, ft450d)
|
||||||
#[arg(short = 'r', long = "rig")]
|
#[arg(short = 'r', long = "rig")]
|
||||||
rig: Option<String>,
|
rig: Option<String>,
|
||||||
@@ -886,6 +889,77 @@ fn spawn_rig_audio_stack(
|
|||||||
handles
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> DynResult<()> {
|
async fn main() -> DynResult<()> {
|
||||||
let mut bootstrap_ctx = RegistrationContext::new();
|
let mut bootstrap_ctx = RegistrationContext::new();
|
||||||
@@ -905,6 +979,10 @@ async fn main() -> DynResult<()> {
|
|||||||
};
|
};
|
||||||
let config_path = loaded.path.clone();
|
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.
|
// Logging comes up before any config complaint so the warnings are visible.
|
||||||
init_logging(loaded.config.general.log_level.as_deref());
|
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
|
// 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
|
// the things that need the final rig list — chiefly that no two listeners
|
||||||
// claim the same socket.
|
// claim the same socket.
|
||||||
{
|
cfg.validate_resolved(&resolved_rigs, &bound_sockets(&cli, &cfg, &resolved_rigs))
|
||||||
let mut sockets = Vec::new();
|
.map_err(|e| format!("Invalid server configuration: {}", e))?;
|
||||||
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))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Starting trx-server with {} rig(s): {}",
|
"Starting trx-server with {} rig(s): {}",
|
||||||
|
|||||||
Reference in New Issue
Block a user