use std::env;
use std::time::Duration;
#[derive(Debug, Clone, thiserror::Error)]
pub enum ConfigError {
#[error("RECALL_TOKEN is not set; refusing to start with no auth")]
MissingToken,
#[error("RECALL_TLS_CERT and RECALL_TLS_KEY must both be set, or neither")]
PartialTlsFiles,
#[error("RECALL_TLS_ACME_DOMAINS and RECALL_TLS_ACME_EMAIL must both be set, or neither")]
PartialTlsAcme,
#[error(
"RECALL_TLS_CERT/RECALL_TLS_KEY and RECALL_TLS_ACME_DOMAINS/RECALL_TLS_ACME_EMAIL \
are two different TLS modes; set one, not both"
)]
BothTlsModes,
#[error(
"RECALL_TRUSTED_IP_HEADER must be unset or empty while TLS is on; with direct TLS there \
is no ingress, so the client IP always comes from the socket's peer address"
)]
TrustedIpHeaderWithTls,
#[error(
"RECALL_TLS_REQUIRED is set, but no TLS mode is configured; set RECALL_TLS_CERT and \
RECALL_TLS_KEY, or RECALL_TLS_ACME_DOMAINS and RECALL_TLS_ACME_EMAIL"
)]
TlsRequired,
#[error("RECALL_TLS_ACME_DOMAINS names no domain")]
NoAcmeDomains,
#[error("RECALL_TLS_ACME_DOMAINS: {0:?} is a wildcard, which TLS-ALPN-01 cannot validate")]
WildcardAcmeDomain(String),
#[error("{var}={value:?} is not a yes/no value; use true/false, 1/0 or yes/no")]
InvalidFlag {
var: &'static str,
value: String,
},
}
#[derive(Debug, Clone)]
pub struct Config {
pub addr: String,
pub token: String,
pub db_path: String,
pub git_commit: String,
pub backup_dir: String,
pub backup_interval: Duration,
pub backup_keep: usize,
pub rate_limit_window: Duration,
pub rate_limit_max: u32,
pub trusted_ip_header: String,
pub merge_enabled: bool,
pub merge_timeout: Duration,
pub claude_bin: String,
pub claude_status_interval: Duration,
pub ephemeral_device_ttl: Duration,
pub eval_interval: Option<Duration>,
pub public_url: String,
pub tls: TlsMode,
pub tls_max_connections: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TlsMode {
Off,
Files {
cert_path: String,
key_path: String,
},
Acme {
domains: Vec<String>,
email: String,
cache_dir: String,
staging: bool,
},
}
impl TlsMode {
pub fn is_enabled(&self) -> bool {
!matches!(self, TlsMode::Off)
}
}
impl Default for Config {
fn default() -> Self {
Self {
addr: "0.0.0.0:8787".to_string(),
token: String::new(),
db_path: "data/recall.db".to_string(),
git_commit: "unknown".to_string(),
backup_dir: String::new(),
backup_interval: Duration::from_secs(24 * 60 * 60),
backup_keep: 7,
rate_limit_window: Duration::from_secs(60),
rate_limit_max: 60,
trusted_ip_header: "cf-connecting-ip".to_string(),
merge_enabled: true,
merge_timeout: Duration::from_secs(45),
claude_bin: "claude".to_string(),
claude_status_interval: Duration::from_secs(30 * 60),
ephemeral_device_ttl: DEFAULT_EPHEMERAL_DEVICE_TTL,
eval_interval: None,
public_url: String::new(),
tls: TlsMode::Off,
tls_max_connections: DEFAULT_TLS_MAX_CONNECTIONS,
}
}
}
const DEFAULT_EPHEMERAL_DEVICE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const DEFAULT_TLS_MAX_CONNECTIONS: usize = 512;
fn flag<F>(lookup: &F, var: &'static str) -> Result<bool, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
let Some(raw) = lookup(var) else {
return Ok(false);
};
match raw.trim().to_ascii_lowercase().as_str() {
"" | "false" | "0" | "no" => Ok(false),
"true" | "1" | "yes" => Ok(true),
_ => Err(ConfigError::InvalidFlag { var, value: raw }),
}
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
Self::from_lookup(|key| env::var(key).ok())
}
pub fn from_lookup<F>(lookup: F) -> Result<Self, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
let get = |key: &str| lookup(key).filter(|v| !v.is_empty());
let or = |key: &str, fallback: &str| get(key).unwrap_or_else(|| fallback.to_string());
let num =
|key: &str, fallback: u64| get(key).and_then(|v| v.parse().ok()).unwrap_or(fallback);
let files_tls = match (get("RECALL_TLS_CERT"), get("RECALL_TLS_KEY")) {
(Some(cert_path), Some(key_path)) => Some(TlsMode::Files {
cert_path,
key_path,
}),
(None, None) => None,
_ => return Err(ConfigError::PartialTlsFiles),
};
let acme_tls = match (get("RECALL_TLS_ACME_DOMAINS"), get("RECALL_TLS_ACME_EMAIL")) {
(Some(domains), Some(email)) => {
let domains: Vec<String> = domains
.split(',')
.map(str::trim)
.filter(|d| !d.is_empty())
.map(str::to_string)
.collect();
if domains.is_empty() {
return Err(ConfigError::NoAcmeDomains);
}
if let Some(wildcard) = domains.iter().find(|d| d.contains('*')) {
return Err(ConfigError::WildcardAcmeDomain(wildcard.clone()));
}
Some(TlsMode::Acme {
domains,
email,
cache_dir: or("RECALL_TLS_ACME_DIR", "/data/acme"),
staging: flag(&lookup, "RECALL_TLS_ACME_STAGING")?,
})
}
(None, None) => None,
_ => return Err(ConfigError::PartialTlsAcme),
};
let tls = match (files_tls, acme_tls) {
(Some(_), Some(_)) => return Err(ConfigError::BothTlsModes),
(Some(mode), None) | (None, Some(mode)) => mode,
(None, None) => TlsMode::Off,
};
if tls.is_enabled()
&& lookup("RECALL_TRUSTED_IP_HEADER").is_some_and(|v| !v.trim().is_empty())
{
return Err(ConfigError::TrustedIpHeaderWithTls);
}
if !tls.is_enabled() && flag(&lookup, "RECALL_TLS_REQUIRED")? {
return Err(ConfigError::TlsRequired);
}
let mut cfg = Config {
addr: format!("0.0.0.0:{}", or("RECALL_PORT", "8787")),
token: get("RECALL_TOKEN").unwrap_or_default(),
db_path: or("RECALL_DB_PATH", "data/recall.db"),
git_commit: or(
"RECALL_GIT_COMMIT",
option_env!("RECALL_GIT_COMMIT").unwrap_or("unknown"),
),
backup_dir: get("RECALL_BACKUP_DIR").unwrap_or_default(),
backup_interval: Duration::from_secs(
num("RECALL_BACKUP_INTERVAL_HOURS", 24).saturating_mul(3600),
),
backup_keep: num("RECALL_BACKUP_KEEP", 7) as usize,
rate_limit_window: Duration::from_millis(num("RECALL_RATE_LIMIT_WINDOW_MS", 60_000)),
rate_limit_max: num("RECALL_RATE_LIMIT_MAX", 60) as u32,
trusted_ip_header: if tls.is_enabled() {
String::new()
} else {
lookup("RECALL_TRUSTED_IP_HEADER")
.map(|v| v.trim().to_ascii_lowercase())
.unwrap_or_else(|| "cf-connecting-ip".to_string())
},
merge_enabled: lookup("RECALL_MERGE_ENABLED").as_deref() != Some("false"),
merge_timeout: Duration::from_millis(num("RECALL_MERGE_TIMEOUT_MS", 45_000)),
claude_bin: or("RECALL_CLAUDE_BIN", "claude"),
claude_status_interval: Duration::from_millis(num(
"RECALL_CLAUDE_STATUS_INTERVAL_MS",
30 * 60_000,
)),
ephemeral_device_ttl: Duration::from_secs(
num("RECALL_EPHEMERAL_DEVICE_TTL_HOURS", 24).saturating_mul(3600),
),
eval_interval: Some(num("RECALL_EVAL_INTERVAL_HOURS", 0))
.filter(|h| *h > 0)
.map(|h| Duration::from_secs(h.saturating_mul(3600))),
public_url: get("RECALL_PUBLIC_URL")
.map(|v| v.trim().to_string())
.unwrap_or_default(),
tls,
tls_max_connections: num(
"RECALL_TLS_MAX_CONNECTIONS",
DEFAULT_TLS_MAX_CONNECTIONS as u64,
) as usize,
};
if cfg.token.is_empty() {
return Err(ConfigError::MissingToken);
}
if cfg.backup_interval.is_zero() {
cfg.backup_interval = Duration::from_secs(24 * 60 * 60);
}
if cfg.rate_limit_window.is_zero() {
cfg.rate_limit_window = Duration::from_secs(60);
}
if cfg.claude_status_interval.is_zero() {
cfg.claude_status_interval = Duration::from_secs(30 * 60);
}
if cfg.ephemeral_device_ttl.is_zero() {
cfg.ephemeral_device_ttl = DEFAULT_EPHEMERAL_DEVICE_TTL;
}
if cfg.merge_timeout.is_zero() {
cfg.merge_timeout = Duration::from_millis(45_000);
}
if cfg.tls_max_connections == 0 {
cfg.tls_max_connections = DEFAULT_TLS_MAX_CONNECTIONS;
}
Ok(cfg)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |key| {
pairs
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| (*v).to_string())
}
}
#[test]
fn defaults_match_the_node_implementation() {
let cfg = Config::default();
assert_eq!(cfg.rate_limit_max, 60);
assert_eq!(cfg.rate_limit_window, Duration::from_secs(60));
assert_eq!(cfg.merge_timeout, Duration::from_secs(45));
assert_eq!(cfg.backup_keep, 7);
assert!(cfg.merge_enabled);
assert_eq!(cfg.claude_bin, "claude");
}
#[test]
fn refuses_to_start_without_a_token() {
assert!(
matches!(
Config::from_lookup(env(&[])),
Err(ConfigError::MissingToken)
),
"a server reachable from the internet with no auth is not a degraded mode worth supporting"
);
assert!(matches!(
Config::from_lookup(env(&[("RECALL_TOKEN", "")])),
Err(ConfigError::MissingToken)
));
}
#[test]
fn reads_every_override() {
let cfg = Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_PORT", "9000"),
("RECALL_DB_PATH", "/data/x.db"),
("RECALL_GIT_COMMIT", "abc1234"),
("RECALL_BACKUP_DIR", "/backups"),
("RECALL_BACKUP_INTERVAL_HOURS", "6"),
("RECALL_BACKUP_KEEP", "3"),
("RECALL_RATE_LIMIT_WINDOW_MS", "1000"),
("RECALL_RATE_LIMIT_MAX", "5"),
("RECALL_MERGE_TIMEOUT_MS", "1234"),
("RECALL_CLAUDE_BIN", "/usr/bin/claude"),
("RECALL_CLAUDE_STATUS_INTERVAL_MS", "60000"),
("RECALL_EPHEMERAL_DEVICE_TTL_HOURS", "2"),
("RECALL_PUBLIC_URL", " https://recall.example.com "),
]))
.unwrap();
assert_eq!(cfg.addr, "0.0.0.0:9000");
assert_eq!(cfg.db_path, "/data/x.db");
assert_eq!(cfg.git_commit, "abc1234");
assert_eq!(cfg.backup_dir, "/backups");
assert_eq!(cfg.backup_interval, Duration::from_secs(6 * 3600));
assert_eq!(cfg.backup_keep, 3);
assert_eq!(cfg.rate_limit_window, Duration::from_millis(1000));
assert_eq!(cfg.rate_limit_max, 5);
assert_eq!(cfg.merge_timeout, Duration::from_millis(1234));
assert_eq!(cfg.claude_bin, "/usr/bin/claude");
assert_eq!(cfg.claude_status_interval, Duration::from_millis(60_000));
assert_eq!(cfg.ephemeral_device_ttl, Duration::from_secs(2 * 3600));
assert_eq!(cfg.public_url, "https://recall.example.com");
}
#[test]
fn merge_is_disabled_only_by_the_literal_false() {
for (value, want) in [("false", false), ("true", true), ("0", true), ("", true)] {
let cfg = Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_MERGE_ENABLED", value),
]))
.unwrap();
assert_eq!(cfg.merge_enabled, want, "RECALL_MERGE_ENABLED={value:?}");
}
}
#[test]
fn zero_and_unparseable_durations_fall_back_to_their_defaults() {
for value in ["0", "not-a-number", "-5", " 6"] {
let cfg = Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_BACKUP_INTERVAL_HOURS", value),
("RECALL_RATE_LIMIT_WINDOW_MS", value),
("RECALL_CLAUDE_STATUS_INTERVAL_MS", value),
("RECALL_MERGE_TIMEOUT_MS", value),
("RECALL_EPHEMERAL_DEVICE_TTL_HOURS", value),
]))
.unwrap();
assert_eq!(
cfg.ephemeral_device_ttl,
Duration::from_secs(24 * 3600),
"{value:?}"
);
assert_eq!(
cfg.backup_interval,
Duration::from_secs(24 * 3600),
"{value:?}"
);
assert_eq!(cfg.rate_limit_window, Duration::from_secs(60), "{value:?}");
assert_eq!(
cfg.claude_status_interval,
Duration::from_secs(30 * 60),
"{value:?}"
);
assert_eq!(
cfg.merge_timeout,
Duration::from_millis(45_000),
"{value:?}"
);
assert!(
!cfg.merge_timeout.is_zero(),
"a zero merge timeout fails every merge instantly and silently"
);
}
}
#[test]
fn tls_is_off_by_default() {
assert_eq!(Config::default().tls, TlsMode::Off);
let cfg = Config::from_lookup(env(&[("RECALL_TOKEN", "t")])).unwrap();
assert_eq!(cfg.tls, TlsMode::Off);
assert_eq!(cfg.trusted_ip_header, "cf-connecting-ip");
}
#[test]
fn tls_files_mode_needs_both_variables() {
for pairs in [
&[("RECALL_TOKEN", "t"), ("RECALL_TLS_CERT", "/c.pem")][..],
&[("RECALL_TOKEN", "t"), ("RECALL_TLS_KEY", "/k.pem")][..],
] {
assert!(
matches!(
Config::from_lookup(env(pairs)),
Err(ConfigError::PartialTlsFiles)
),
"{pairs:?}"
);
}
let cfg = Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_CERT", "/c.pem"),
("RECALL_TLS_KEY", "/k.pem"),
]))
.unwrap();
assert_eq!(
cfg.tls,
TlsMode::Files {
cert_path: "/c.pem".to_string(),
key_path: "/k.pem".to_string(),
}
);
}
#[test]
fn tls_acme_mode_needs_both_variables_and_splits_domains() {
for pairs in [
&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_ACME_DOMAINS", "example.com"),
][..],
&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
][..],
] {
assert!(
matches!(
Config::from_lookup(env(pairs)),
Err(ConfigError::PartialTlsAcme)
),
"{pairs:?}"
);
}
let cfg = Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_ACME_DOMAINS", " a.example.com, b.example.com ,"),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
]))
.unwrap();
assert_eq!(
cfg.tls,
TlsMode::Acme {
domains: vec!["a.example.com".to_string(), "b.example.com".to_string()],
email: "me@example.com".to_string(),
cache_dir: "/data/acme".to_string(),
staging: false,
}
);
}
#[test]
fn tls_acme_staging_and_cache_dir_are_overridable() {
let cfg = Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_ACME_DOMAINS", "example.com"),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
("RECALL_TLS_ACME_DIR", "/tmp/acme-cache"),
("RECALL_TLS_ACME_STAGING", "true"),
]))
.unwrap();
let TlsMode::Acme {
cache_dir, staging, ..
} = cfg.tls
else {
panic!("expected TlsMode::Acme, got {:?}", cfg.tls);
};
assert_eq!(cache_dir, "/tmp/acme-cache");
assert!(staging);
}
#[test]
fn configuring_both_tls_modes_is_refused() {
assert!(matches!(
Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_CERT", "/c.pem"),
("RECALL_TLS_KEY", "/k.pem"),
("RECALL_TLS_ACME_DOMAINS", "example.com"),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
])),
Err(ConfigError::BothTlsModes)
));
}
#[test]
fn trusted_ip_header_with_tls_refuses_to_start() {
for tls_pairs in [
&[("RECALL_TLS_CERT", "/c.pem"), ("RECALL_TLS_KEY", "/k.pem")][..],
&[
("RECALL_TLS_ACME_DOMAINS", "example.com"),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
][..],
] {
let mut pairs = vec![("RECALL_TOKEN", "t")];
pairs.extend_from_slice(tls_pairs);
for header_value in ["x-real-ip", "cf-connecting-ip", " X-Real-IP "] {
let mut pairs = pairs.clone();
pairs.push(("RECALL_TRUSTED_IP_HEADER", header_value));
assert!(
matches!(
Config::from_lookup(env(&pairs)),
Err(ConfigError::TrustedIpHeaderWithTls)
),
"{pairs:?}"
);
}
for header in [None, Some(""), Some(" ")] {
let mut pairs = pairs.clone();
if let Some(value) = header {
pairs.push(("RECALL_TRUSTED_IP_HEADER", value));
}
let cfg = Config::from_lookup(env(&pairs)).unwrap();
assert_eq!(cfg.trusted_ip_header, "", "{pairs:?}");
}
}
}
#[test]
fn tls_required_without_tls_refuses_to_start() {
for required in ["true", "TRUE", "1", "yes", "Yes"] {
for tls_pairs in [
&[][..],
&[("RECALL_TLS_CERT", ""), ("RECALL_TLS_KEY", "")][..],
&[
("RECALL_TLS_ACME_DOMAINS", ""),
("RECALL_TLS_ACME_EMAIL", ""),
][..],
] {
let mut pairs = vec![("RECALL_TOKEN", "t"), ("RECALL_TLS_REQUIRED", required)];
pairs.extend_from_slice(tls_pairs);
assert!(
matches!(
Config::from_lookup(env(&pairs)),
Err(ConfigError::TlsRequired)
),
"{pairs:?}"
);
}
}
let cfg = Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_REQUIRED", "true"),
("RECALL_TLS_CERT", "/c.pem"),
("RECALL_TLS_KEY", "/k.pem"),
]))
.unwrap();
assert!(cfg.tls.is_enabled());
for off in ["", "false", "0", "no", "NO"] {
let cfg =
Config::from_lookup(env(&[("RECALL_TOKEN", "t"), ("RECALL_TLS_REQUIRED", off)]))
.unwrap();
assert_eq!(cfg.tls, TlsMode::Off, "{off:?}");
}
assert!(matches!(
Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_REQUIRED", "ture"),
])),
Err(ConfigError::InvalidFlag {
var: "RECALL_TLS_REQUIRED",
..
})
));
}
#[test]
fn tls_acme_refuses_an_empty_domain_list_and_wildcards() {
for domains in [",", " , ,", " "] {
let result = Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_ACME_DOMAINS", domains),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
]));
assert!(
matches!(result, Err(ConfigError::NoAcmeDomains)),
"{domains:?}: {result:?}"
);
}
for domains in ["*.example.com", "example.com, *.example.com"] {
let result = Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_ACME_DOMAINS", domains),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
]));
assert!(
matches!(&result, Err(ConfigError::WildcardAcmeDomain(d)) if d == "*.example.com"),
"{domains:?}: {result:?}"
);
}
}
#[test]
fn tls_acme_staging_accepts_common_spellings_and_refuses_the_rest() {
let acme = |staging: &'static str| {
Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_ACME_DOMAINS", "example.com"),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
("RECALL_TLS_ACME_STAGING", staging),
]))
};
for (value, want) in [
("true", true),
("TRUE", true),
("1", true),
("yes", true),
("false", false),
("0", false),
("No", false),
("", false),
] {
let TlsMode::Acme { staging, .. } = acme(value).unwrap().tls else {
panic!("expected TlsMode::Acme");
};
assert_eq!(staging, want, "RECALL_TLS_ACME_STAGING={value:?}");
}
for value in ["staging", "ture", "on?"] {
assert!(
matches!(
acme(value),
Err(ConfigError::InvalidFlag {
var: "RECALL_TLS_ACME_STAGING",
..
})
),
"{value:?}"
);
}
}
#[test]
fn tls_max_connections_falls_back_to_its_default() {
let read = |value: &'static str| {
Config::from_lookup(env(&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_MAX_CONNECTIONS", value),
]))
.unwrap()
.tls_max_connections
};
assert_eq!(Config::default().tls_max_connections, 512);
assert_eq!(read("64"), 64);
for fallback in ["0", "", "lots", "-1"] {
assert_eq!(read(fallback), 512, "{fallback:?}");
}
}
}