[feat](trx-config): let secrets live outside the config file

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-06 21:27:18 +02:00
co-authored by Claude Opus 5
parent 88ed3da6cc
commit 76bcce8c54
9 changed files with 516 additions and 7 deletions
Generated
+1
View File
@@ -3138,6 +3138,7 @@ dependencies = [
"dirs",
"serde",
"serde_ignored",
"tempfile",
"thiserror 2.0.18",
"toml",
"tracing",
+14 -3
View File
@@ -128,7 +128,12 @@ fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()>
let mut warnings: Vec<String> = 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<ClientConfig>) -> 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<AppState> {
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<AppState> {
name,
url: url.clone(),
rig_id,
auth: config::RemoteAuthConfig { token },
auth: config::RemoteAuthConfig {
token,
token_file: None,
},
poll_interval_ms,
}]
} else {
+3
View File
@@ -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"
+131
View File
@@ -95,6 +95,9 @@ impl Default for RemoteConfig {
pub struct RemoteAuthConfig {
/// Bearer token to send with JSON commands.
pub token: Option<String>,
/// Read the token from this file instead. Mutually exclusive with `token`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_file: Option<String>,
}
/// 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<String>,
/// Read the rx passphrase from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rx_passphrase_file: Option<String>,
/// Passphrase for full control access (control role)
pub control_passphrase: Option<String>,
/// Read the control passphrase from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_passphrase_file: Option<String>,
/// 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<String>,
/// 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<String>,
}
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<ConfigLoad<Self>, ConfigError> {
<Self as ConfigFile>::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<RemoteEntry> {
+6 -1
View File
@@ -107,10 +107,15 @@ fn load_section_from_file<T: ConfigFile>(
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<String> = Vec::new();
+1
View File
@@ -11,6 +11,7 @@
pub mod client;
pub mod file;
pub mod secrets;
pub mod server;
pub mod shared;
pub mod unknown;
+298
View File
@@ -0,0 +1,298 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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<Option<String>, 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<String, String> {
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<Vec<String>, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secrets: Vec<String> = 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<String>,
file: &Option<String>,
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<String>,
file: &Option<String>,
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()
}
}
+51
View File
@@ -324,6 +324,10 @@ impl Default for ListenConfig {
pub struct AuthConfig {
/// Valid authentication tokens (empty = no auth required)
pub tokens: Vec<String>,
/// 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<String>,
}
/// 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<ConfigLoad<Self>, ConfigError> {
<Self as ConfigFile>::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]
+11 -3
View File
@@ -944,10 +944,15 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> 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))?;