From 76bcce8c54d7658fe46fd87d6b88146aec6d1de0 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 21:27:18 +0200 Subject: [PATCH] [feat](trx-config): let secrets live outside the config file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens and passphrases had exactly one representation: plain text in trx-rs.toml. That is awkward for config-management tools, for a config kept in a private repo, and for anything shared between machines. Two alternatives: - ${VAR} anywhere in a config string, expanded from the environment at load. An unset variable is an error rather than an empty string — a silently blank passphrase is how authentication gets disabled by accident. - A *_file sibling for every credential: [listen.auth].tokens_file, [[remotes]].auth.token_file, [frontends.http.auth].rx_passphrase_file and .control_passphrase_file, [frontends.http_json.auth].tokens_file. Setting both forms is an error rather than a guess about which wins. Plus a nudge: a config file that holds credentials inline and is readable by group or others gets a warning naming the chmod that fixes it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- Cargo.lock | 1 + src/trx-client/src/main.rs | 17 +- src/trx-config/Cargo.toml | 3 + src/trx-config/src/client.rs | 131 +++++++++++++++ src/trx-config/src/file.rs | 7 +- src/trx-config/src/lib.rs | 1 + src/trx-config/src/secrets.rs | 298 ++++++++++++++++++++++++++++++++++ src/trx-config/src/server.rs | 51 ++++++ src/trx-server/src/main.rs | 14 +- 9 files changed, 516 insertions(+), 7 deletions(-) create mode 100644 src/trx-config/src/secrets.rs diff --git a/Cargo.lock b/Cargo.lock index 2c6caee6..5fed8d51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3138,6 +3138,7 @@ dependencies = [ "dirs", "serde", "serde_ignored", + "tempfile", "thiserror 2.0.18", "toml", "tracing", diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 9f4f5e3b..c2266af1 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -128,7 +128,12 @@ fn check_config(loaded: &trx_config::ConfigLoad) -> DynResult<()> let mut warnings: Vec = loaded.unknown_keys.iter().map(|k| k.to_string()).collect(); - let cfg = &loaded.config; + let mut cfg = loaded.config.clone(); + let mut errors = Vec::new(); + if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) { + errors.push(e); + } + let cfg = &cfg; let remotes = cfg.resolved_remotes(); if remotes.is_empty() { warnings.push( @@ -137,7 +142,7 @@ fn check_config(loaded: &trx_config::ConfigLoad) -> DynResult<()> ); } - let mut errors = cfg.validate_all(); + errors.extend(cfg.validate_all()); if !remotes.is_empty() { errors.extend(cfg.validate_resolved_all(&remotes)); } @@ -220,6 +225,9 @@ async fn async_init() -> DynResult { loaded.report_unknown_keys(cli.strict_config)?; let mut cfg = loaded.config; + // Secrets configured as *_file are read before validation, so everything + // downstream sees resolved values. + cfg.resolve_secrets(config_path.as_deref())?; cfg.validate() .map_err(|e| format!("Invalid client configuration: {}", e))?; @@ -274,7 +282,10 @@ async fn async_init() -> DynResult { name, url: url.clone(), rig_id, - auth: config::RemoteAuthConfig { token }, + auth: config::RemoteAuthConfig { + token, + token_file: None, + }, poll_interval_ms, }] } else { diff --git a/src/trx-config/Cargo.toml b/src/trx-config/Cargo.toml index 77f5f466..733ac0c6 100644 --- a/src/trx-config/Cargo.toml +++ b/src/trx-config/Cargo.toml @@ -18,3 +18,6 @@ trx-core = { path = "../trx-core" } trx-decode-log = { path = "../decoders/trx-decode-log" } trx-reporting = { path = "../trx-reporting" } serde_ignored = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs index d6c35127..4cd0df93 100644 --- a/src/trx-config/src/client.rs +++ b/src/trx-config/src/client.rs @@ -95,6 +95,9 @@ impl Default for RemoteConfig { pub struct RemoteAuthConfig { /// Bearer token to send with JSON commands. pub token: Option, + /// Read the token from this file instead. Mutually exclusive with `token`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_file: Option, } /// A named remote connection entry. @@ -236,8 +239,14 @@ pub struct HttpAuthConfig { pub enabled: bool, /// Passphrase for read-only access (rx role) pub rx_passphrase: Option, + /// Read the rx passphrase from this file instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rx_passphrase_file: Option, /// Passphrase for full control access (control role) pub control_passphrase: Option, + /// Read the control passphrase from this file instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_passphrase_file: Option, /// Enforce TX/PTT access control (hide from unauthenticated/rx users) pub tx_access_control_enabled: bool, /// Session time-to-live in minutes @@ -253,7 +262,9 @@ impl Default for HttpAuthConfig { Self { enabled: false, rx_passphrase: None, + rx_passphrase_file: None, control_passphrase: None, + control_passphrase_file: None, tx_access_control_enabled: true, session_ttl_min: 480, cookie_secure: false, @@ -380,6 +391,10 @@ impl Default for HttpJsonFrontendConfig { pub struct HttpJsonAuthConfig { /// Accepted bearer tokens. pub tokens: Vec, + /// Read the tokens from this file instead, one per line. Blank lines and + /// `#` comments are ignored. Mutually exclusive with `tokens`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens_file: Option, } impl ClientConfig { @@ -720,6 +735,65 @@ impl ClientConfig { Ok(()) } + /// Read any secret that was configured as a `*_file` and warn about + /// over-permissive file modes. + /// + /// Called after loading and before validation, so the rest of the code only + /// ever sees resolved values. + pub fn resolve_secrets(&mut self, config_path: Option<&Path>) -> Result<(), String> { + use crate::secrets::{resolve_secret, resolve_secret_list}; + + resolve_secret( + &mut self.remote.auth.token, + &self.remote.auth.token_file, + "[remote.auth].token", + )?; + for (i, entry) in self.remotes.iter_mut().enumerate() { + resolve_secret( + &mut entry.auth.token, + &entry.auth.token_file, + &format!("[[remotes]][{i}].auth.token"), + )?; + } + resolve_secret( + &mut self.frontends.http.auth.rx_passphrase, + &self.frontends.http.auth.rx_passphrase_file, + "[frontends.http.auth].rx_passphrase", + )?; + resolve_secret( + &mut self.frontends.http.auth.control_passphrase, + &self.frontends.http.auth.control_passphrase_file, + "[frontends.http.auth].control_passphrase", + )?; + resolve_secret_list( + &mut self.frontends.http_json.auth.tokens, + &self.frontends.http_json.auth.tokens_file, + "[frontends.http_json.auth].tokens", + )?; + + if let Some(path) = config_path { + if self.has_inline_secrets() { + crate::secrets::warn_if_group_readable(path, "tokens/passphrases"); + } + } + Ok(()) + } + + /// Whether any credential is written in the config file itself. + fn has_inline_secrets(&self) -> bool { + self.remote.auth.token_file.is_none() && self.remote.auth.token.is_some() + || self + .remotes + .iter() + .any(|r| r.auth.token_file.is_none() && r.auth.token.is_some()) + || self.frontends.http.auth.rx_passphrase_file.is_none() + && self.frontends.http.auth.rx_passphrase.is_some() + || self.frontends.http.auth.control_passphrase_file.is_none() + && self.frontends.http.auth.control_passphrase.is_some() + || self.frontends.http_json.auth.tokens_file.is_none() + && !self.frontends.http_json.auth.tokens.is_empty() + } + /// Load configuration from a specific file path. pub fn load_from_file(path: &Path) -> Result, ConfigError> { ::load_from_file(path) @@ -755,6 +829,7 @@ impl ClientConfig { rig_id: Some("hf".to_string()), auth: RemoteAuthConfig { token: Some("my-token".to_string()), + token_file: None, }, poll_interval_ms: 750, }, @@ -764,6 +839,7 @@ impl ClientConfig { rig_id: Some("vhf".to_string()), auth: RemoteAuthConfig { token: Some("my-token".to_string()), + token_file: None, }, poll_interval_ms: 750, }, @@ -785,7 +861,9 @@ impl ClientConfig { auth: HttpAuthConfig { enabled: false, rx_passphrase: Some("rx-passphrase-example".to_string()), + rx_passphrase_file: None, control_passphrase: Some("control-passphrase-example".to_string()), + control_passphrase_file: None, tx_access_control_enabled: true, session_ttl_min: 480, cookie_secure: false, @@ -1189,6 +1267,7 @@ url = "remote.example.com:4530" rig_id: Some("hf".to_string()), auth: RemoteAuthConfig { token: Some("tok".to_string()), + token_file: None, }, poll_interval_ms: 750, }, @@ -1315,6 +1394,58 @@ url = "remote.example.com:4530" .contains("poll_interval_ms must be > 0")); } + // --- Secret indirection --- + + fn secret_file(content: &str) -> tempfile::NamedTempFile { + use std::io::Write; + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn test_passphrase_file_fills_passphrase() { + let f = secret_file("hunter2\n"); + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.control_passphrase_file = + Some(f.path().to_str().unwrap().to_string()); + config.resolve_secrets(None).unwrap(); + assert_eq!( + config.frontends.http.auth.control_passphrase.as_deref(), + Some("hunter2") + ); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_remote_token_file_fills_token() { + let f = secret_file("remote-token"); + let mut config = ClientConfig::default(); + config.remotes = vec![RemoteEntry { + name: "hf".to_string(), + url: "127.0.0.1:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig { + token: None, + token_file: Some(f.path().to_str().unwrap().to_string()), + }, + poll_interval_ms: 750, + }]; + config.resolve_secrets(None).unwrap(); + assert_eq!(config.remotes[0].auth.token.as_deref(), Some("remote-token")); + } + + #[test] + fn test_token_and_token_file_together_is_an_error() { + let f = secret_file("from-file"); + let mut config = ClientConfig::default(); + config.remote.auth.token = Some("inline".to_string()); + config.remote.auth.token_file = Some(f.path().to_str().unwrap().to_string()); + assert!(config.resolve_secrets(None).unwrap_err().contains("not both")); + } + // --- Second-phase validation against the resolved remote list --- fn remotes(names: &[&str]) -> Vec { diff --git a/src/trx-config/src/file.rs b/src/trx-config/src/file.rs index 3e6feddd..7e1211a9 100644 --- a/src/trx-config/src/file.rs +++ b/src/trx-config/src/file.rs @@ -107,10 +107,15 @@ fn load_section_from_file( let table: toml::Table = toml::from_str(&content) .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; - let Some(section) = select_section(&table, key) else { + let Some(mut section) = select_section(&table, key) else { return Ok(None); }; + // ${VAR} references are expanded before deserializing, so any string in the + // file can come from the environment. + crate::secrets::expand_env_vars(&mut section) + .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?; + // Deserialize straight from the TOML value so serde applies every default, // recording any key no field claimed. let mut ignored: Vec = Vec::new(); diff --git a/src/trx-config/src/lib.rs b/src/trx-config/src/lib.rs index 4f0508b5..e9cdf81e 100644 --- a/src/trx-config/src/lib.rs +++ b/src/trx-config/src/lib.rs @@ -11,6 +11,7 @@ pub mod client; pub mod file; +pub mod secrets; pub mod server; pub mod shared; pub mod unknown; diff --git a/src/trx-config/src/secrets.rs b/src/trx-config/src/secrets.rs new file mode 100644 index 00000000..96500e41 --- /dev/null +++ b/src/trx-config/src/secrets.rs @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Keeping credentials out of the config file. +//! +//! Tokens and passphrases used to have exactly one representation: written in +//! plain text in `trx-rs.toml`, which is awkward when the config is deployed by +//! a config-management tool, committed to a private repo, or shared between +//! machines. Two alternatives are offered: +//! +//! - `${VAR}` anywhere in a config string, expanded from the environment. +//! - A `*_file` sibling of any secret key, read from disk at startup. +//! +//! Plus a nudge: a config that holds secrets and is readable by group or others +//! gets a warning. + +use std::path::Path; + +/// Expand `${VAR}` references in every string in a TOML value. +/// +/// An unset variable is an error rather than an empty string — a silently blank +/// passphrase is the kind of thing that disables authentication by accident. +pub fn expand_env_vars(value: &mut toml::Value) -> Result<(), String> { + match value { + toml::Value::String(s) => { + if let Some(expanded) = expand_str(s)? { + *s = expanded; + } + } + toml::Value::Table(table) => { + for (_, child) in table.iter_mut() { + expand_env_vars(child)?; + } + } + toml::Value::Array(items) => { + for item in items.iter_mut() { + expand_env_vars(item)?; + } + } + _ => {} + } + Ok(()) +} + +/// Expand `${VAR}` in one string; `None` when there was nothing to expand. +fn expand_str(input: &str) -> Result, String> { + if !input.contains("${") { + return Ok(None); + } + + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find('}') else { + // Unterminated: leave the rest exactly as written. + out.push_str(&rest[start..]); + return Ok(Some(out)); + }; + let name = &after[..end]; + if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + // Not a variable reference; pass it through untouched. + out.push_str(&rest[start..start + 2 + end + 1]); + } else { + let value = std::env::var(name).map_err(|_| { + format!("config references unset environment variable ${{{name}}}") + })?; + out.push_str(&value); + } + rest = &after[end + 1..]; + } + out.push_str(rest); + Ok(Some(out)) +} + +/// Read a single secret from a file: the whole file, trimmed. +pub fn read_secret_file(path: &str, what: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?; + let secret = content.trim().to_string(); + if secret.is_empty() { + return Err(format!("{what}: secret file {path} is empty")); + } + warn_if_group_readable(Path::new(path), what); + Ok(secret) +} + +/// Read a list of secrets, one per line. Blank lines and `#` comments are +/// skipped. +pub fn read_secret_list_file(path: &str, what: &str) -> Result, String> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?; + let secrets: Vec = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_string) + .collect(); + if secrets.is_empty() { + return Err(format!("{what}: secret file {path} contains no entries")); + } + warn_if_group_readable(Path::new(path), what); + Ok(secrets) +} + +/// Fill `inline` from `file` when the config used the `*_file` form. +/// +/// Setting both is an error: which one wins would be a guess. +pub fn resolve_secret( + inline: &mut Option, + file: &Option, + what: &str, +) -> Result<(), String> { + let Some(path) = file else { + return Ok(()); + }; + if inline.is_some() { + return Err(format!( + "{what}: set either the value or its _file form, not both" + )); + } + *inline = Some(read_secret_file(path, what)?); + Ok(()) +} + +/// Fill a token list from `file` when the config used the `*_file` form. +pub fn resolve_secret_list( + inline: &mut Vec, + file: &Option, + what: &str, +) -> Result<(), String> { + let Some(path) = file else { + return Ok(()); + }; + if !inline.is_empty() { + return Err(format!( + "{what}: set either the value or its _file form, not both" + )); + } + *inline = read_secret_list_file(path, what)?; + Ok(()) +} + +/// Warn when a file holding secrets is readable beyond its owner. +/// +/// Advisory only: plenty of valid setups (a dedicated service user, an +/// immutable image) are fine, so this never fails the load. +pub fn warn_if_group_readable(path: &Path, what: &str) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let Ok(meta) = std::fs::metadata(path) else { + return; + }; + let mode = meta.permissions().mode() & 0o077; + if mode != 0 { + tracing::warn!( + "{} is readable by group or others (mode {:o}); it holds secrets ({}). \ + Consider: chmod 600 {}", + path.display(), + meta.permissions().mode() & 0o777, + what, + path.display() + ); + } + } + #[cfg(not(unix))] + { + let _ = (path, what); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_expand_leaves_plain_strings_alone() { + assert_eq!(expand_str("plain").unwrap(), None); + assert_eq!(expand_str("TRXRS-%YYYY%.log").unwrap(), None); + } + + #[test] + fn test_expand_substitutes_variable() { + std::env::set_var("TRX_TEST_TOKEN", "s3cret"); + assert_eq!( + expand_str("Bearer ${TRX_TEST_TOKEN}!").unwrap().as_deref(), + Some("Bearer s3cret!") + ); + } + + #[test] + fn test_expand_errors_on_unset_variable() { + let err = expand_str("${TRX_DEFINITELY_NOT_SET_12345}").unwrap_err(); + assert!(err.contains("unset environment variable"), "{err}"); + } + + #[test] + fn test_expand_passes_through_non_variables() { + assert_eq!( + expand_str("${not a var}").unwrap().as_deref(), + Some("${not a var}") + ); + assert_eq!( + expand_str("unterminated ${VAR").unwrap().as_deref(), + Some("unterminated ${VAR") + ); + } + + #[test] + fn test_expand_walks_nested_tables_and_arrays() { + std::env::set_var("TRX_TEST_HOST", "radio.example.com"); + let mut value: toml::Value = toml::from_str( + r#" +[remote] +url = "${TRX_TEST_HOST}:4530" +hosts = ["${TRX_TEST_HOST}"] +"#, + ) + .unwrap(); + expand_env_vars(&mut value).unwrap(); + assert_eq!( + value["remote"]["url"].as_str(), + Some("radio.example.com:4530") + ); + assert_eq!( + value["remote"]["hosts"][0].as_str(), + Some("radio.example.com") + ); + } + + fn temp_file(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn test_read_secret_file_trims() { + let f = temp_file(" hunter2\n"); + assert_eq!(read_secret_file(path_of(&f), "test").unwrap(), "hunter2"); + } + + #[test] + fn test_read_secret_file_rejects_empty() { + let f = temp_file(" \n"); + assert!(read_secret_file(path_of(&f), "test").is_err()); + } + + #[test] + fn test_read_secret_list_skips_blanks_and_comments() { + let f = temp_file("# tokens\nalpha\n\n beta \n"); + assert_eq!( + read_secret_list_file(path_of(&f), "test").unwrap(), + vec!["alpha".to_string(), "beta".to_string()] + ); + } + + #[test] + fn test_resolve_secret_fills_from_file() { + let f = temp_file("from-file"); + let mut inline = None; + resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap(); + assert_eq!(inline.as_deref(), Some("from-file")); + } + + #[test] + fn test_resolve_secret_rejects_both_forms() { + let f = temp_file("from-file"); + let mut inline = Some("inline".to_string()); + let err = resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err(); + assert!(err.contains("not both"), "{err}"); + } + + #[test] + fn test_resolve_secret_is_a_no_op_without_file() { + let mut inline = Some("inline".to_string()); + resolve_secret(&mut inline, &None, "test").unwrap(); + assert_eq!(inline.as_deref(), Some("inline")); + } + + #[test] + fn test_resolve_secret_list_rejects_both_forms() { + let f = temp_file("alpha"); + let mut inline = vec!["inline".to_string()]; + let err = + resolve_secret_list(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err(); + assert!(err.contains("not both"), "{err}"); + } + + fn path_of(f: &tempfile::NamedTempFile) -> &str { + f.path().to_str().unwrap() + } +} diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 042718eb..109e0e2e 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -324,6 +324,10 @@ impl Default for ListenConfig { pub struct AuthConfig { /// Valid authentication tokens (empty = no auth required) pub tokens: Vec, + /// Read the tokens from this file instead, one per line. Blank lines and + /// `#` comments are ignored. Mutually exclusive with `tokens`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens_file: Option, } /// Audio streaming configuration. @@ -649,6 +653,26 @@ impl ServerConfig { .collect() } + /// Read any secret that was configured as a `*_file` and warn about + /// over-permissive file modes. + /// + /// Called after loading and before validation, so the rest of the code only + /// ever sees resolved values. + pub fn resolve_secrets(&mut self, config_path: Option<&Path>) -> Result<(), String> { + crate::secrets::resolve_secret_list( + &mut self.listen.auth.tokens, + &self.listen.auth.tokens_file, + "[listen.auth].tokens", + )?; + + if let Some(path) = config_path { + if !self.listen.auth.tokens.is_empty() && self.listen.auth.tokens_file.is_none() { + crate::secrets::warn_if_group_readable(path, "[listen.auth].tokens"); + } + } + Ok(()) + } + /// Load configuration from a specific file path. pub fn load_from_file(path: &Path) -> Result, ConfigError> { ::load_from_file(path) @@ -1717,6 +1741,33 @@ port = 4531 ); } + // --- Secret indirection --- + + #[test] + fn test_tokens_file_fills_tokens() { + use std::io::Write; + let mut f = tempfile::NamedTempFile::new().unwrap(); + writeln!(f, "# tokens\nalpha\nbeta").unwrap(); + f.flush().unwrap(); + + let mut cfg = ServerConfig::default(); + cfg.listen.auth.tokens_file = Some(f.path().to_str().unwrap().to_string()); + cfg.resolve_secrets(None).unwrap(); + assert_eq!( + cfg.listen.auth.tokens, + vec!["alpha".to_string(), "beta".to_string()] + ); + } + + #[test] + fn test_tokens_and_tokens_file_together_is_an_error() { + let mut cfg = ServerConfig::default(); + cfg.listen.auth.tokens = vec!["inline".to_string()]; + cfg.listen.auth.tokens_file = Some("/nonexistent".to_string()); + let err = cfg.resolve_secrets(None).unwrap_err(); + assert!(err.contains("not both"), "unexpected error: {err}"); + } + // --- Decoder selection --- #[test] diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 0621689a..b670b03e 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -944,10 +944,15 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad) -> Dyn println!(" warning: {}", key); } - let cfg = &loaded.config; + let mut cfg = loaded.config.clone(); + let mut errors = Vec::new(); + if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) { + errors.push(e); + } + let cfg = &cfg; let rigs = cfg.resolved_rigs(); - let mut errors = cfg.validate_all(); + errors.extend(cfg.validate_all()); errors.extend(cfg.validate_sdr()); errors.extend(cfg.validate_resolved_all(&rigs, &bound_sockets(cli, cfg, &rigs))); @@ -1006,7 +1011,10 @@ async fn main() -> DynResult<()> { } loaded.report_unknown_keys(cli.strict_config)?; - let cfg = loaded.config; + let mut cfg = loaded.config; + // Secrets configured as *_file are read before validation, so everything + // downstream sees resolved values. + cfg.resolve_secrets(config_path.as_deref())?; cfg.validate() .map_err(|e| format!("Invalid server configuration: {}", e))?;