[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:
Generated
+1
@@ -3138,6 +3138,7 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_ignored",
|
"serde_ignored",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -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 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();
|
let remotes = cfg.resolved_remotes();
|
||||||
if remotes.is_empty() {
|
if remotes.is_empty() {
|
||||||
warnings.push(
|
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() {
|
if !remotes.is_empty() {
|
||||||
errors.extend(cfg.validate_resolved_all(&remotes));
|
errors.extend(cfg.validate_resolved_all(&remotes));
|
||||||
}
|
}
|
||||||
@@ -220,6 +225,9 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
loaded.report_unknown_keys(cli.strict_config)?;
|
loaded.report_unknown_keys(cli.strict_config)?;
|
||||||
|
|
||||||
let mut 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()
|
cfg.validate()
|
||||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||||
|
|
||||||
@@ -274,7 +282,10 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
name,
|
name,
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
rig_id,
|
rig_id,
|
||||||
auth: config::RemoteAuthConfig { token },
|
auth: config::RemoteAuthConfig {
|
||||||
|
token,
|
||||||
|
token_file: None,
|
||||||
|
},
|
||||||
poll_interval_ms,
|
poll_interval_ms,
|
||||||
}]
|
}]
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -18,3 +18,6 @@ trx-core = { path = "../trx-core" }
|
|||||||
trx-decode-log = { path = "../decoders/trx-decode-log" }
|
trx-decode-log = { path = "../decoders/trx-decode-log" }
|
||||||
trx-reporting = { path = "../trx-reporting" }
|
trx-reporting = { path = "../trx-reporting" }
|
||||||
serde_ignored = "0.1"
|
serde_ignored = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ impl Default for RemoteConfig {
|
|||||||
pub struct RemoteAuthConfig {
|
pub struct RemoteAuthConfig {
|
||||||
/// Bearer token to send with JSON commands.
|
/// Bearer token to send with JSON commands.
|
||||||
pub token: Option<String>,
|
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.
|
/// A named remote connection entry.
|
||||||
@@ -236,8 +239,14 @@ pub struct HttpAuthConfig {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Passphrase for read-only access (rx role)
|
/// Passphrase for read-only access (rx role)
|
||||||
pub rx_passphrase: Option<String>,
|
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)
|
/// Passphrase for full control access (control role)
|
||||||
pub control_passphrase: Option<String>,
|
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)
|
/// Enforce TX/PTT access control (hide from unauthenticated/rx users)
|
||||||
pub tx_access_control_enabled: bool,
|
pub tx_access_control_enabled: bool,
|
||||||
/// Session time-to-live in minutes
|
/// Session time-to-live in minutes
|
||||||
@@ -253,7 +262,9 @@ impl Default for HttpAuthConfig {
|
|||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
rx_passphrase: None,
|
rx_passphrase: None,
|
||||||
|
rx_passphrase_file: None,
|
||||||
control_passphrase: None,
|
control_passphrase: None,
|
||||||
|
control_passphrase_file: None,
|
||||||
tx_access_control_enabled: true,
|
tx_access_control_enabled: true,
|
||||||
session_ttl_min: 480,
|
session_ttl_min: 480,
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
@@ -380,6 +391,10 @@ impl Default for HttpJsonFrontendConfig {
|
|||||||
pub struct HttpJsonAuthConfig {
|
pub struct HttpJsonAuthConfig {
|
||||||
/// Accepted bearer tokens.
|
/// Accepted bearer tokens.
|
||||||
pub tokens: Vec<String>,
|
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 {
|
impl ClientConfig {
|
||||||
@@ -720,6 +735,65 @@ impl ClientConfig {
|
|||||||
Ok(())
|
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.
|
/// Load configuration from a specific file path.
|
||||||
pub fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
|
pub fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
|
||||||
<Self as ConfigFile>::load_from_file(path)
|
<Self as ConfigFile>::load_from_file(path)
|
||||||
@@ -755,6 +829,7 @@ impl ClientConfig {
|
|||||||
rig_id: Some("hf".to_string()),
|
rig_id: Some("hf".to_string()),
|
||||||
auth: RemoteAuthConfig {
|
auth: RemoteAuthConfig {
|
||||||
token: Some("my-token".to_string()),
|
token: Some("my-token".to_string()),
|
||||||
|
token_file: None,
|
||||||
},
|
},
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
},
|
},
|
||||||
@@ -764,6 +839,7 @@ impl ClientConfig {
|
|||||||
rig_id: Some("vhf".to_string()),
|
rig_id: Some("vhf".to_string()),
|
||||||
auth: RemoteAuthConfig {
|
auth: RemoteAuthConfig {
|
||||||
token: Some("my-token".to_string()),
|
token: Some("my-token".to_string()),
|
||||||
|
token_file: None,
|
||||||
},
|
},
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
},
|
},
|
||||||
@@ -785,7 +861,9 @@ impl ClientConfig {
|
|||||||
auth: HttpAuthConfig {
|
auth: HttpAuthConfig {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
rx_passphrase: Some("rx-passphrase-example".to_string()),
|
rx_passphrase: Some("rx-passphrase-example".to_string()),
|
||||||
|
rx_passphrase_file: None,
|
||||||
control_passphrase: Some("control-passphrase-example".to_string()),
|
control_passphrase: Some("control-passphrase-example".to_string()),
|
||||||
|
control_passphrase_file: None,
|
||||||
tx_access_control_enabled: true,
|
tx_access_control_enabled: true,
|
||||||
session_ttl_min: 480,
|
session_ttl_min: 480,
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
@@ -1189,6 +1267,7 @@ url = "remote.example.com:4530"
|
|||||||
rig_id: Some("hf".to_string()),
|
rig_id: Some("hf".to_string()),
|
||||||
auth: RemoteAuthConfig {
|
auth: RemoteAuthConfig {
|
||||||
token: Some("tok".to_string()),
|
token: Some("tok".to_string()),
|
||||||
|
token_file: None,
|
||||||
},
|
},
|
||||||
poll_interval_ms: 750,
|
poll_interval_ms: 750,
|
||||||
},
|
},
|
||||||
@@ -1315,6 +1394,58 @@ url = "remote.example.com:4530"
|
|||||||
.contains("poll_interval_ms must be > 0"));
|
.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 ---
|
// --- Second-phase validation against the resolved remote list ---
|
||||||
|
|
||||||
fn remotes(names: &[&str]) -> Vec<RemoteEntry> {
|
fn remotes(names: &[&str]) -> Vec<RemoteEntry> {
|
||||||
|
|||||||
@@ -107,10 +107,15 @@ fn load_section_from_file<T: ConfigFile>(
|
|||||||
let table: toml::Table = toml::from_str(&content)
|
let table: toml::Table = toml::from_str(&content)
|
||||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
.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);
|
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,
|
// Deserialize straight from the TOML value so serde applies every default,
|
||||||
// recording any key no field claimed.
|
// recording any key no field claimed.
|
||||||
let mut ignored: Vec<String> = Vec::new();
|
let mut ignored: Vec<String> = Vec::new();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
|
pub mod secrets;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod unknown;
|
pub mod unknown;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -324,6 +324,10 @@ impl Default for ListenConfig {
|
|||||||
pub struct AuthConfig {
|
pub struct AuthConfig {
|
||||||
/// Valid authentication tokens (empty = no auth required)
|
/// Valid authentication tokens (empty = no auth required)
|
||||||
pub tokens: Vec<String>,
|
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.
|
/// Audio streaming configuration.
|
||||||
@@ -649,6 +653,26 @@ impl ServerConfig {
|
|||||||
.collect()
|
.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.
|
/// Load configuration from a specific file path.
|
||||||
pub fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
|
pub fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
|
||||||
<Self as ConfigFile>::load_from_file(path)
|
<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 ---
|
// --- Decoder selection ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -944,10 +944,15 @@ fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> Dyn
|
|||||||
println!(" warning: {}", key);
|
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 rigs = cfg.resolved_rigs();
|
||||||
|
|
||||||
let mut errors = cfg.validate_all();
|
errors.extend(cfg.validate_all());
|
||||||
errors.extend(cfg.validate_sdr());
|
errors.extend(cfg.validate_sdr());
|
||||||
errors.extend(cfg.validate_resolved_all(&rigs, &bound_sockets(cli, cfg, &rigs)));
|
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)?;
|
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()
|
cfg.validate()
|
||||||
.map_err(|e| format!("Invalid server configuration: {}", e))?;
|
.map_err(|e| format!("Invalid server configuration: {}", e))?;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user