From c230feade454a07b20b1fae9a8add0d8dc95b5ed Mon Sep 17 00:00:00 2001 From: Shon Thomas Date: Sat, 9 May 2026 04:54:26 -0800 Subject: [PATCH] feat(secrets): env-var resolution for *_file paths and string fields Operators driving rota.yaml from a secret manager (Doppler, Vault agent, systemd LoadCredentialEncrypted=, plain compose env_file) no longer have to pre-stage plaintext files on disk. Two patterns: * Every `*_file:` field accepts the path-prefix sentinel `env:NAME`. When set, rota reads the named env var as the secret value instead of opening a file. Existing real paths keep working unchanged. Applied to namecheap.api_key_file, cloudflare.api_token_file, email password_file, webhook bearer_token_file, ACME hmac_key_file, surrealdb audit password_file. Skipped for ACME account_credentials_file because that is a read-write persistent state file, not a secret reference. * Every operator-set String field gets `${VAR}` interpolation against the process environment at config-load time. Multiple refs per string are supported. Applied via RotaConfig::resolve_env to namecheap.username, namecheap.api_user, namecheap.client_ip, acme.directory_url, acme.contact_email, surrealdb audit endpoint/namespace/database/username, alert email smtp_host/username/from/to[], alert webhook url. An unset referenced variable returns Error::ConfigInvalid at startup rather than silently letting a downstream API call go out with an empty value. Implementation lives alongside existing redaction in rota_core::secrets: read_secret(path) and expand_env(s). RotaConfig::load calls resolve_env after serde parse so consumers see fully-resolved values. Tests: 8 new unit tests in rota-core covering file read with trim, env-prefix dispatch, missing-env error, ${} interpolation single + multiple, passthrough, unterminated-brace error. Docs: new "Secrets and environment variables" section in book/src/configuration.md showing the Doppler, systemd, Vault, and plain compose patterns. rota.example.yaml header gains a usage block plus an alternate namecheap example. --- book/src/configuration.md | 20 +++ crates/rota-core/src/config.rs | 69 +++++++++- crates/rota-core/src/secrets.rs | 152 ++++++++++++++++++++-- crates/rota-daemon/src/audit/surrealdb.rs | 6 +- crates/rota-daemon/src/backends/acme.rs | 11 +- crates/rota-daemon/src/backends/mod.rs | 44 +------ rota.example.yaml | 22 ++++ 7 files changed, 257 insertions(+), 67 deletions(-) diff --git a/book/src/configuration.md b/book/src/configuration.md index bc655eb..d6262d1 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -50,6 +50,26 @@ audit: `endpoint` accepts `mem://`, `file://path`, `ws://`, `wss://`, `http://`, `https://`. Embedded engines (`mem://`, `file://`) skip auth; remote engines need `username` and `password_file`. +## Secrets and environment variables + +Every `*_file:` field in this config can read from an environment variable instead of a file by setting the path to `env:VAR_NAME`. Every operator-set `String` field accepts `${VAR}` interpolation against the process environment at config-load time. An unset referenced variable is a fatal startup error. + +```yaml +namecheap: + api_key_file: env:NAMECHEAP_API_KEY # secret comes from env, not a file + username: ${NAMECHEAP_USERNAME} # inline interpolation + client_ip: ${NAMECHEAP_CLIENT_IP} +``` + +This pairs with any secret-injection mechanism that exports env vars to the daemon process. Common shapes: + +* **Doppler**: run rotad as `doppler run --project rota --config prd -- rotad --config /etc/rota/rota.yaml`. Doppler injects `NAMECHEAP_API_KEY`, etc. into the child process. +* **systemd LoadCredentialEncrypted=**: drops decrypted material into `$CREDENTIALS_DIRECTORY/`. Reference via `env:` if you also export the value through `Environment=`, or point `*_file:` directly at the credentials path. +* **HashiCorp Vault Agent**: writes a templated env file the systemd unit reads via `EnvironmentFile=`. +* **Plain compose `env_file:`** for hosts where Doppler is overkill. + +Refusing to start when a referenced variable is unset means a misconfigured deploy fails loud at boot rather than silently calling a vendor API with an empty key. + ## CA accounts ### `namecheap` diff --git a/crates/rota-core/src/config.rs b/crates/rota-core/src/config.rs index dcf73d4..d369d48 100644 --- a/crates/rota-core/src/config.rs +++ b/crates/rota-core/src/config.rs @@ -52,6 +52,9 @@ pub struct RotaConfig { impl RotaConfig { /// Load a config file, returning a typed parse error on failure. + /// After parse, walks every operator-set string and resolves + /// `${VAR}` references against the process environment so the + /// daemon sees fully-resolved values. pub fn load(path: &Path) -> Result { if !path.exists() { return Err(Error::ConfigNotFound { @@ -59,10 +62,72 @@ impl RotaConfig { }); } let raw = std::fs::read_to_string(path)?; - serde_yaml::from_str(&raw).map_err(|err| Error::ConfigParse { + let mut cfg: Self = serde_yaml::from_str(&raw).map_err(|err| Error::ConfigParse { path: path.to_owned(), message: err.to_string(), - }) + })?; + cfg.resolve_env()?; + Ok(cfg) + } + + /// Walk operator-set string fields and expand `${VAR}` references. + /// Idempotent: calling on an already-resolved config is a no-op. + /// `*_file:` paths use the `env:NAME` sentinel handled in + /// [`crate::secrets::read_secret`] instead, so they are not touched + /// here. + pub fn resolve_env(&mut self) -> Result<()> { + use crate::secrets::expand_env; + + if let Some(nc) = &mut self.namecheap { + nc.username = expand_env(&nc.username)?; + if let Some(au) = nc.api_user.as_mut() { + *au = expand_env(au)?; + } + nc.client_ip = expand_env(&nc.client_ip)?; + } + if let Some(acme) = &mut self.acme { + acme.directory_url = expand_env(&acme.directory_url)?; + if let Some(email) = acme.contact_email.as_mut() { + *email = expand_env(email)?; + } + } + if let Some(AuditSpec::Surrealdb { + endpoint, + namespace, + database, + username, + .. + }) = self.audit.as_mut() + { + *endpoint = expand_env(endpoint)?; + *namespace = expand_env(namespace)?; + *database = expand_env(database)?; + if let Some(u) = username.as_mut() { + *u = expand_env(u)?; + } + } + for alert in &mut self.alerts { + match alert { + AlertSpec::Email { + smtp_host, + username, + from, + to, + .. + } => { + *smtp_host = expand_env(smtp_host)?; + *username = expand_env(username)?; + *from = expand_env(from)?; + for addr in to.iter_mut() { + *addr = expand_env(addr)?; + } + } + AlertSpec::Webhook { url, .. } => { + *url = expand_env(url)?; + } + } + } + Ok(()) } /// Look up a cert by id. diff --git a/crates/rota-core/src/secrets.rs b/crates/rota-core/src/secrets.rs index 5b3e7a7..26d4aca 100644 --- a/crates/rota-core/src/secrets.rs +++ b/crates/rota-core/src/secrets.rs @@ -1,15 +1,73 @@ -//! Best-effort redaction of secrets that show up in error strings -//! and log messages. +//! Two responsibilities, both about secrets in config + logs: //! -//! Use this any time a string that may have come from an HTTP error, -//! a vendor SDK, or a wrapped error chain is about to land in a log -//! line, an audit row, or a user-facing error message. The Namecheap -//! API in particular carries auth in URL query params (`ApiKey=...`), -//! and `reqwest::Error`'s Debug impl embeds the request URL, so any -//! error originating from a network call may surface the key -//! verbatim. The function is best-effort: it strips the patterns we -//! know about, and we add patterns as we find them. Non-matching -//! input passes through unchanged. +//! 1. [`redact`]: best-effort scrubbing of secret-shaped substrings +//! in error strings and log messages. +//! 2. [`read_secret`] + [`expand_env`]: env-var resolution at +//! config-load time, so operators can drive rota.yaml from a +//! secret manager (Doppler, Vault agent, etc.) without writing +//! plaintext files to disk. `*_file:` paths accept an `env:NAME` +//! sentinel and `String` fields accept `${VAR}` interpolation. + +use std::path::Path; + +use crate::{Error, Result}; + +/// Path-prefix sentinel for env-var-driven secrets. A `*_file:` field +/// set to e.g. `env:NAMECHEAP_API_KEY` reads the named environment +/// variable instead of opening a file. +const ENV_PREFIX: &str = "env:"; + +/// Read a secret. If `path` is `env:NAME`, returns the value of the +/// named env var; otherwise reads the file at `path`. Trims trailing +/// whitespace so a key file with a stray newline still produces the +/// raw secret. Errors classify as [`Error::ConfigInvalid`] either way. +pub fn read_secret(path: &Path) -> Result { + if let Some(name) = env_ref(path) { + return read_env(&name); + } + let raw = std::fs::read_to_string(path) + .map_err(|e| Error::ConfigInvalid(format!("secret file {}: {e}", path.display())))?; + Ok(raw.trim().to_owned()) +} + +/// Expand `${VAR}` references in `s` against the process environment. +/// Multiple references in one string are supported; literal `$` outside +/// `${...}` passes through unchanged. An unset variable or an +/// unterminated `${...}` returns [`Error::ConfigInvalid`]. +pub fn expand_env(s: &str) -> Result { + if !s.contains("${") { + return Ok(s.to_owned()); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find("${") { + out.push_str(&rest[..i]); + let after = &rest[i + 2..]; + let end = after.find('}').ok_or_else(|| { + Error::ConfigInvalid(format!("unterminated ${{...}} in config string {s:?}")) + })?; + let var = &after[..end]; + out.push_str(&read_env(var)?); + rest = &after[end + 1..]; + } + out.push_str(rest); + Ok(out) +} + +fn env_ref(path: &Path) -> Option { + path + .to_str() + .and_then(|s| s.strip_prefix(ENV_PREFIX)) + .map(|s| s.trim().to_owned()) +} + +fn read_env(name: &str) -> Result { + std::env::var(name).map_err(|_| { + Error::ConfigInvalid(format!( + "env var {name} is referenced in config but not set in the process environment" + )) + }) +} const PATTERNS: &[&str] = &[ // Namecheap API auth. @@ -130,4 +188,76 @@ mod tests { fn empty_string_is_empty() { assert_eq!(redact(""), ""); } + + // env-var resolution + + use std::path::PathBuf; + + #[test] + fn read_secret_from_file_trims_whitespace() { + let mut path = std::env::temp_dir(); + path.push(format!("rota-secret-{}.txt", std::process::id())); + std::fs::write(&path, "abc123\n").unwrap(); + assert_eq!(read_secret(&path).unwrap(), "abc123"); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn read_secret_resolves_env_prefix() { + std::env::set_var("ROTA_TEST_SECRET_RESOLVE", "from-env-resolved"); + let path = PathBuf::from("env:ROTA_TEST_SECRET_RESOLVE"); + assert_eq!(read_secret(&path).unwrap(), "from-env-resolved"); + std::env::remove_var("ROTA_TEST_SECRET_RESOLVE"); + } + + #[test] + fn read_secret_errors_when_env_var_unset() { + let path = PathBuf::from("env:ROTA_TEST_SECRET_DEFINITELY_UNSET"); + let err = read_secret(&path).unwrap_err(); + assert!(err + .to_string() + .contains("ROTA_TEST_SECRET_DEFINITELY_UNSET")); + } + + #[test] + fn expand_env_passthrough_when_no_braces() { + assert_eq!(expand_env("plain string").unwrap(), "plain string"); + assert_eq!(expand_env("").unwrap(), ""); + } + + #[test] + fn expand_env_substitutes_single_var() { + std::env::set_var("ROTA_TEST_EXPAND_USER", "alice"); + assert_eq!(expand_env("${ROTA_TEST_EXPAND_USER}").unwrap(), "alice"); + assert_eq!( + expand_env("hi-${ROTA_TEST_EXPAND_USER}!").unwrap(), + "hi-alice!" + ); + std::env::remove_var("ROTA_TEST_EXPAND_USER"); + } + + #[test] + fn expand_env_substitutes_multiple_vars() { + std::env::set_var("ROTA_TEST_EXPAND_A", "1"); + std::env::set_var("ROTA_TEST_EXPAND_B", "2"); + assert_eq!( + expand_env("${ROTA_TEST_EXPAND_A}-${ROTA_TEST_EXPAND_B}").unwrap(), + "1-2" + ); + std::env::remove_var("ROTA_TEST_EXPAND_A"); + std::env::remove_var("ROTA_TEST_EXPAND_B"); + } + + #[test] + fn expand_env_errors_on_unset_var() { + let err = expand_env("${ROTA_TEST_EXPAND_DEFINITELY_UNSET}").unwrap_err(); + assert!(err + .to_string() + .contains("ROTA_TEST_EXPAND_DEFINITELY_UNSET")); + } + + #[test] + fn expand_env_errors_on_unterminated_brace() { + assert!(expand_env("${ROTA_TEST_EXPAND_UNTERMINATED").is_err()); + } } diff --git a/crates/rota-daemon/src/audit/surrealdb.rs b/crates/rota-daemon/src/audit/surrealdb.rs index a3b54cb..0e7d408 100644 --- a/crates/rota-daemon/src/audit/surrealdb.rs +++ b/crates/rota-daemon/src/audit/surrealdb.rs @@ -101,11 +101,7 @@ pub async fn open_client(spec: &AuditSpec) -> Result { client.connect().await.map_err(map_err)?; if let (Some(user), Some(file)) = (username, password_file) { - let pwd = tokio::fs::read_to_string(file) - .await - .map_err(|e| Error::ConfigInvalid(format!("read {}: {e}", file.display())))? - .trim() - .to_owned(); + let pwd = rota_core::secrets::read_secret(file)?; client .signin(&RootCredentials::new(user.clone(), pwd)) .await diff --git a/crates/rota-daemon/src/backends/acme.rs b/crates/rota-daemon/src/backends/acme.rs index cb70f6d..ca74aea 100644 --- a/crates/rota-daemon/src/backends/acme.rs +++ b/crates/rota-daemon/src/backends/acme.rs @@ -127,16 +127,7 @@ async fn create_account(spec: &AcmeAccount) -> Result { } async fn load_eab(eab: &EabConfig) -> Result { - let hmac = tokio::fs::read_to_string(&eab.hmac_key_file) - .await - .map_err(|e| { - Error::Ca(format!( - "read eab hmac {}: {e}", - eab.hmac_key_file.display() - )) - })? - .trim() - .to_owned(); + let hmac = rota_core::secrets::read_secret(&eab.hmac_key_file)?; Ok(ExternalAccountKey::new(eab.kid.clone(), hmac.as_bytes())) } diff --git a/crates/rota-daemon/src/backends/mod.rs b/crates/rota-daemon/src/backends/mod.rs index 3748a1d..0207717 100644 --- a/crates/rota-daemon/src/backends/mod.rs +++ b/crates/rota-daemon/src/backends/mod.rs @@ -26,6 +26,7 @@ use rota_core::config::{ AlertSpec, CaSpec, CertConfig, CloudflareAccount, DcvSpec, InstallSpec, NamecheapAccount, RotaConfig, }; +use rota_core::secrets::read_secret; use rota_core::{Error, Result}; use acme::AcmeCa; @@ -89,15 +90,7 @@ pub async fn build_from_config(config: &RotaConfig) -> Result> } fn build_namecheap_client(account: &NamecheapAccount) -> Result> { - let api_key = std::fs::read_to_string(&account.api_key_file) - .map_err(|e| { - Error::ConfigInvalid(format!( - "namecheap api_key_file {}: {e}", - account.api_key_file.display() - )) - })? - .trim() - .to_owned(); + let api_key = read_secret(&account.api_key_file)?; let creds = NamecheapCreds { api_user: account .api_user @@ -111,15 +104,7 @@ fn build_namecheap_client(account: &NamecheapAccount) -> Result Result> { - let token = std::fs::read_to_string(&account.api_token_file) - .map_err(|e| { - Error::ConfigInvalid(format!( - "cloudflare api_token_file {}: {e}", - account.api_token_file.display() - )) - })? - .trim() - .to_owned(); + let token = read_secret(&account.api_token_file)?; Ok(CloudflareClient::new(token).into_arc()) } @@ -239,15 +224,7 @@ fn build_alert(spec: &AlertSpec) -> Result> { from, to, } => { - let password = std::fs::read_to_string(password_file) - .map_err(|e| { - Error::ConfigInvalid(format!( - "email alert password_file {}: {e}", - password_file.display() - )) - })? - .trim() - .to_owned(); + let password = read_secret(password_file)?; let alert = EmailAlert::new(EmailAlertParams { smtp_host: smtp_host.as_str(), smtp_port: *smtp_port, @@ -266,18 +243,7 @@ fn build_alert(spec: &AlertSpec) -> Result> { } => { let bearer_token = match bearer_token_file { None => None, - Some(path) => { - let token = std::fs::read_to_string(path) - .map_err(|e| { - Error::ConfigInvalid(format!( - "webhook alert bearer_token_file {}: {e}", - path.display() - )) - })? - .trim() - .to_owned(); - Some(token) - } + Some(path) => Some(read_secret(path)?), }; let timeout = timeout_seconds.map(std::time::Duration::from_secs); let alert = WebhookAlert::new(WebhookAlertParams { diff --git a/rota.example.yaml b/rota.example.yaml index 3525760..94dfeb1 100644 --- a/rota.example.yaml +++ b/rota.example.yaml @@ -4,6 +4,22 @@ # `rotad --config` points) and fill in your own values. Mode 600 if # any secrets are inlined; rota prefers to read them from referenced # files so they don't sit in this tree. +# +# Two ways to feed credentials without writing plaintext to disk: +# +# `*_file:` paths accept the sentinel `env:VAR_NAME` to mean +# "read VAR_NAME from the process environment as the secret value": +# api_key_file: env:NAMECHEAP_API_KEY +# +# `String` fields accept `${VAR}` interpolation against the process +# environment at config-load time: +# username: ${NAMECHEAP_USERNAME} +# client_ip: ${NAMECHEAP_CLIENT_IP} +# +# Pair either form with `doppler run -- rotad ...`, a Vault Agent +# template, systemd LoadCredentialEncrypted=, or any other env-var +# secret manager. An unset referenced variable is a fatal config +# error so misconfigurations fail loud at startup, not at first use. daemon: database_path: /var/lib/rota/rota.db @@ -19,6 +35,12 @@ namecheap: api_key_file: /etc/rota/secrets/namecheap-api.key username: your-namecheap-username client_ip: 192.0.2.1 +# Or, if a secret manager (Doppler, Vault, etc.) is exporting these +# into the daemon's environment: +# namecheap: +# api_key_file: env:NAMECHEAP_API_KEY +# username: ${NAMECHEAP_USERNAME} +# client_ip: ${NAMECHEAP_CLIENT_IP} # Cluster federation. Multiple rotad instances pointing at the same # SurrealDB audit store elect a single leader to run the renewal