Some things can only be checked once CLI overrides have been folded in and the rig/remote lists are final, so nothing checked them at all: - The client's per-rig maps (rigctl.rig_ports, audio.rig_urls, audio.rig_ports, decode_history_retention_min_by_rig, http.default_rig_name) are keyed by a remote's short name. A typo used to spawn a rigctl listener that injected a rig_id no remote answered to, without a word in the log. - Nothing noticed two listeners claiming one socket. [listen].port and a rig's [audio].port could both be 4530; on the client, http, http_json and each rigctl rig port could collide freely. Add validate_resolved() to both configs, run after argument parsing, plus a shared socket-conflict check that treats a wildcard address as conflicting with any address on the same port and ignores port 0. 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>
183 lines
5.4 KiB
Rust
183 lines
5.4 KiB
Rust
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
//! Shared configuration validation helpers used by both `trx-server` and
|
|
//! `trx-client`.
|
|
//!
|
|
//! # Non-shared structs
|
|
//!
|
|
//! `GeneralConfig` is defined separately in each binary because the fields
|
|
//! differ:
|
|
//!
|
|
//! - **Server** `GeneralConfig`: `callsign`, `log_level`, `latitude`,
|
|
//! `longitude`
|
|
//! - **Client** `GeneralConfig`: `callsign`, `log_level`, `website_url`,
|
|
//! `website_name`, `ais_vessel_url_base`
|
|
//!
|
|
//! Only `callsign` and `log_level` overlap. Merging into a single struct
|
|
//! would either bloat both binaries with unused fields or require a trait
|
|
//! abstraction that adds complexity without clear benefit.
|
|
|
|
use std::net::IpAddr;
|
|
|
|
/// A socket a component intends to bind, and what it is for.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct BoundSocket {
|
|
pub addr: IpAddr,
|
|
pub port: u16,
|
|
/// Human-readable owner, e.g. `[listen]` or `[frontends.http]`.
|
|
pub label: String,
|
|
}
|
|
|
|
impl BoundSocket {
|
|
pub fn new(addr: IpAddr, port: u16, label: impl Into<String>) -> Self {
|
|
Self {
|
|
addr,
|
|
port,
|
|
label: label.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reject two components trying to bind the same socket.
|
|
///
|
|
/// A wildcard address (`0.0.0.0` / `::`) conflicts with any other address on
|
|
/// the same port, since binding it claims every interface. Port 0 means "pick
|
|
/// an ephemeral port" and never conflicts.
|
|
pub fn check_socket_conflicts(sockets: &[BoundSocket]) -> Result<(), String> {
|
|
for (i, a) in sockets.iter().enumerate() {
|
|
if a.port == 0 {
|
|
continue;
|
|
}
|
|
for b in &sockets[i + 1..] {
|
|
if b.port != a.port {
|
|
continue;
|
|
}
|
|
if a.addr == b.addr || a.addr.is_unspecified() || b.addr.is_unspecified() {
|
|
return Err(format!(
|
|
"{} and {} would both bind {}:{}",
|
|
a.label, b.label, a.addr, a.port
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate that a log level string is one of the accepted values.
|
|
///
|
|
/// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere)
|
|
/// or a recognised level name.
|
|
pub fn validate_log_level(level: Option<&str>) -> Result<(), String> {
|
|
if let Some(level) = level {
|
|
match level {
|
|
"trace" | "debug" | "info" | "warn" | "error" => {}
|
|
_ => {
|
|
return Err(format!(
|
|
"[general].log_level '{}' is invalid (expected one of: trace, debug, info, warn, error)",
|
|
level
|
|
))
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate that a list of authentication tokens contains no empty entries.
|
|
///
|
|
/// `path` is a human-readable config path prefix used in the error message
|
|
/// (e.g. `"[listen.auth].tokens"`).
|
|
pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
|
|
if tokens.iter().any(|t| t.trim().is_empty()) {
|
|
return Err(format!("{path} must not contain empty tokens"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sock(addr: &str, port: u16, label: &str) -> BoundSocket {
|
|
BoundSocket::new(addr.parse().unwrap(), port, label)
|
|
}
|
|
|
|
#[test]
|
|
fn test_socket_conflicts_detects_exact_duplicate() {
|
|
let err = check_socket_conflicts(&[
|
|
sock("127.0.0.1", 8080, "[frontends.http]"),
|
|
sock("127.0.0.1", 8080, "[frontends.http_json]"),
|
|
])
|
|
.unwrap_err();
|
|
assert!(err.contains("127.0.0.1:8080"), "unexpected error: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_socket_conflicts_detects_wildcard_overlap() {
|
|
assert!(check_socket_conflicts(&[
|
|
sock("0.0.0.0", 4530, "[listen]"),
|
|
sock("127.0.0.1", 4530, "[audio]"),
|
|
])
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_socket_conflicts_allows_distinct_addresses() {
|
|
assert!(check_socket_conflicts(&[
|
|
sock("127.0.0.1", 4530, "[listen]"),
|
|
sock("192.168.1.5", 4530, "[audio]"),
|
|
])
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_socket_conflicts_ignores_ephemeral_ports() {
|
|
assert!(check_socket_conflicts(&[
|
|
sock("127.0.0.1", 0, "[frontends.http_json]"),
|
|
sock("127.0.0.1", 0, "[frontends.http]"),
|
|
])
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_log_level_none() {
|
|
assert!(validate_log_level(None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_log_level_valid() {
|
|
for level in &["trace", "debug", "info", "warn", "error"] {
|
|
assert!(validate_log_level(Some(level)).is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_log_level_invalid() {
|
|
assert!(validate_log_level(Some("verbose")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_tokens_empty_list() {
|
|
assert!(validate_tokens("[auth].tokens", &[]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_tokens_valid() {
|
|
let tokens = vec!["abc".to_string(), "def".to_string()];
|
|
assert!(validate_tokens("[auth].tokens", &tokens).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_tokens_rejects_empty() {
|
|
let tokens = vec!["abc".to_string(), "".to_string()];
|
|
assert!(validate_tokens("[auth].tokens", &tokens).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_tokens_rejects_whitespace_only() {
|
|
let tokens = vec![" ".to_string()];
|
|
assert!(validate_tokens("[auth].tokens", &tokens).is_err());
|
|
}
|
|
}
|