[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
+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 {