Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions book/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`. 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`
Expand Down
69 changes: 67 additions & 2 deletions crates/rota-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,82 @@ 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<Self> {
if !path.exists() {
return Err(Error::ConfigNotFound {
path: path.to_owned(),
});
}
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.
Expand Down
152 changes: 141 additions & 11 deletions crates/rota-core/src/secrets.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
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<String> {
path
.to_str()
.and_then(|s| s.strip_prefix(ENV_PREFIX))
.map(|s| s.trim().to_owned())
}

fn read_env(name: &str) -> Result<String> {
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.
Expand Down Expand Up @@ -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());
}
}
6 changes: 1 addition & 5 deletions crates/rota-daemon/src/audit/surrealdb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,7 @@ pub async fn open_client(spec: &AuditSpec) -> Result<DatabaseClient> {
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
Expand Down
11 changes: 1 addition & 10 deletions crates/rota-daemon/src/backends/acme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,7 @@ async fn create_account(spec: &AcmeAccount) -> Result<Account> {
}

async fn load_eab(eab: &EabConfig) -> Result<ExternalAccountKey> {
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()))
}

Expand Down
44 changes: 5 additions & 39 deletions crates/rota-daemon/src/backends/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,15 +90,7 @@ pub async fn build_from_config(config: &RotaConfig) -> Result<Vec<CertBackends>>
}

fn build_namecheap_client(account: &NamecheapAccount) -> Result<Arc<NamecheapClient>> {
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
Expand All @@ -111,15 +104,7 @@ fn build_namecheap_client(account: &NamecheapAccount) -> Result<Arc<NamecheapCli
}

fn build_cloudflare_client(account: &CloudflareAccount) -> Result<Arc<CloudflareClient>> {
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())
}

Expand Down Expand Up @@ -239,15 +224,7 @@ fn build_alert(spec: &AlertSpec) -> Result<Arc<dyn AlertBackend>> {
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,
Expand All @@ -266,18 +243,7 @@ fn build_alert(spec: &AlertSpec) -> Result<Arc<dyn AlertBackend>> {
} => {
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 {
Expand Down
Loading
Loading