recall_server/config.rs
1//! Server settings.
2//!
3//! Environment variable names are deliberately unchanged from the
4//! shell/Node implementation this replaces, so no machine and no cloud
5//! environment needs re-provisioning to switch over.
6
7use std::env;
8use std::time::Duration;
9
10/// Why the server cannot start.
11#[derive(Debug, Clone, thiserror::Error)]
12pub enum ConfigError {
13 /// Mirrors the Node server's refusal to start without auth — a server
14 /// reachable from the internet with no token is not a degraded mode
15 /// worth supporting.
16 #[error("RECALL_TOKEN is not set; refusing to start with no auth")]
17 MissingToken,
18 /// `RECALL_TLS_CERT` and `RECALL_TLS_KEY` name one file each; half a
19 /// pair is almost always a typo in one of the two variable names.
20 #[error("RECALL_TLS_CERT and RECALL_TLS_KEY must both be set, or neither")]
21 PartialTlsFiles,
22 /// `RECALL_TLS_ACME_DOMAINS` and `RECALL_TLS_ACME_EMAIL` are the same
23 /// kind of pair, for the same reason.
24 #[error("RECALL_TLS_ACME_DOMAINS and RECALL_TLS_ACME_EMAIL must both be set, or neither")]
25 PartialTlsAcme,
26 /// The two TLS modes are mutually exclusive: each picks its own
27 /// certificate source, and a config with both is ambiguous about which
28 /// one wins rather than a config this server can just run.
29 #[error(
30 "RECALL_TLS_CERT/RECALL_TLS_KEY and RECALL_TLS_ACME_DOMAINS/RECALL_TLS_ACME_EMAIL \
31 are two different TLS modes; set one, not both"
32 )]
33 BothTlsModes,
34 /// With direct TLS there is no ingress, so the socket's own peer
35 /// address is the only client IP that is not the client's own choice.
36 /// Setting this variable anyway is either a no-op or, if it is ever
37 /// read, a way for a client to buy itself an unlimited number of token
38 /// guesses by rotating whatever header it names, so refusing to start
39 /// beats silently ignoring the setting.
40 ///
41 /// An explicitly *empty* value is the one exception: it means "trust no
42 /// header", which is exactly what TLS forces anyway, so
43 /// `deploy/docker-compose.direct.yml` sets it that way as a second
44 /// guard, in case its TLS variables ever go missing and the server
45 /// comes up as plain HTTP instead.
46 #[error(
47 "RECALL_TRUSTED_IP_HEADER must be unset or empty while TLS is on; with direct TLS there \
48 is no ingress, so the client IP always comes from the socket's peer address"
49 )]
50 TrustedIpHeaderWithTls,
51 /// `RECALL_TLS_REQUIRED` is set, but neither TLS mode is configured.
52 /// A deployment that publishes its port straight to the internet
53 /// (`deploy/docker-compose.direct.yml`) must never come up as plain
54 /// HTTP just because its certificate variables went missing or empty:
55 /// that would put the bearer token on the wire in clear text.
56 #[error(
57 "RECALL_TLS_REQUIRED is set, but no TLS mode is configured; set RECALL_TLS_CERT and \
58 RECALL_TLS_KEY, or RECALL_TLS_ACME_DOMAINS and RECALL_TLS_ACME_EMAIL"
59 )]
60 TlsRequired,
61 /// `RECALL_TLS_ACME_DOMAINS` was set but named no domain at all (only
62 /// commas or whitespace). There is nothing to ask a certificate for,
63 /// and a server with no certificate fails every handshake.
64 #[error("RECALL_TLS_ACME_DOMAINS names no domain")]
65 NoAcmeDomains,
66 /// TLS-ALPN-01, the only challenge this server answers, cannot prove
67 /// control of a wildcard name (Let's Encrypt requires DNS-01 for
68 /// those), so an order for one could only ever fail, over and over, on
69 /// the ACME directory's rate limit.
70 #[error("RECALL_TLS_ACME_DOMAINS: {0:?} is a wildcard, which TLS-ALPN-01 cannot validate")]
71 WildcardAcmeDomain(String),
72 /// A yes/no setting whose value is neither. Unlike the numeric
73 /// tunables, which fall back to a default on a typo, these two decide
74 /// whether the server may run without TLS and which certificate
75 /// authority it trusts, so guessing either way would be wrong
76 /// somewhere.
77 #[error("{var}={value:?} is not a yes/no value; use true/false, 1/0 or yes/no")]
78 InvalidFlag {
79 /// The variable's name.
80 var: &'static str,
81 /// What it was set to.
82 value: String,
83 },
84}
85
86/// What `recall-server` needs.
87///
88/// Every field has a default that is safe to run with, except [`token`],
89/// which has none — see [`ConfigError::MissingToken`].
90///
91/// | Field | Variable | Default |
92/// |---|---|---|
93/// | [`addr`] | `RECALL_PORT` (the host is always `0.0.0.0`) | `0.0.0.0:8787` |
94/// | [`token`] | `RECALL_TOKEN` | *required* |
95/// | [`db_path`] | `RECALL_DB_PATH` | `data/recall.db` |
96/// | [`git_commit`] | `RECALL_GIT_COMMIT` | the commit the binary was built from, else `unknown` |
97/// | [`backup_dir`] | `RECALL_BACKUP_DIR` | off |
98/// | [`backup_interval`] | `RECALL_BACKUP_INTERVAL_HOURS` | 24h |
99/// | [`backup_keep`] | `RECALL_BACKUP_KEEP` | 7 |
100/// | [`rate_limit_window`] | `RECALL_RATE_LIMIT_WINDOW_MS` | 60s |
101/// | [`rate_limit_max`] | `RECALL_RATE_LIMIT_MAX` | 60 |
102/// | [`trusted_ip_header`] | `RECALL_TRUSTED_IP_HEADER` | `cf-connecting-ip`, forced empty when [`tls`] is on |
103/// | [`merge_enabled`] | `RECALL_MERGE_ENABLED` | on |
104/// | [`merge_timeout`] | `RECALL_MERGE_TIMEOUT_MS` | 45s |
105/// | [`claude_bin`] | `RECALL_CLAUDE_BIN` | `claude` |
106/// | [`claude_status_interval`] | `RECALL_CLAUDE_STATUS_INTERVAL_MS` | 30m |
107/// | [`ephemeral_device_ttl`] | `RECALL_EPHEMERAL_DEVICE_TTL_HOURS` | 24h |
108/// | [`eval_interval`] | `RECALL_EVAL_INTERVAL_HOURS` | off |
109/// | [`public_url`] | `RECALL_PUBLIC_URL` | unset: passkey sign-in off |
110/// | [`tls`] | `RECALL_TLS_CERT`/`RECALL_TLS_KEY`, or `RECALL_TLS_ACME_DOMAINS`/`RECALL_TLS_ACME_EMAIL`/`RECALL_TLS_ACME_DIR`/`RECALL_TLS_ACME_STAGING` | off |
111/// | [`tls_max_connections`] | `RECALL_TLS_MAX_CONNECTIONS` | 512 |
112///
113/// `RECALL_TLS_REQUIRED` is read but not stored: when it is true, a
114/// config with [`tls`] off refuses to start ([`ConfigError::TlsRequired`]).
115///
116/// [`addr`]: Config::addr
117/// [`token`]: Config::token
118/// [`db_path`]: Config::db_path
119/// [`git_commit`]: Config::git_commit
120/// [`backup_dir`]: Config::backup_dir
121/// [`backup_interval`]: Config::backup_interval
122/// [`backup_keep`]: Config::backup_keep
123/// [`rate_limit_window`]: Config::rate_limit_window
124/// [`rate_limit_max`]: Config::rate_limit_max
125/// [`trusted_ip_header`]: Config::trusted_ip_header
126/// [`merge_enabled`]: Config::merge_enabled
127/// [`merge_timeout`]: Config::merge_timeout
128/// [`claude_bin`]: Config::claude_bin
129/// [`claude_status_interval`]: Config::claude_status_interval
130/// [`ephemeral_device_ttl`]: Config::ephemeral_device_ttl
131/// [`eval_interval`]: Config::eval_interval
132/// [`public_url`]: Config::public_url
133/// [`tls`]: Config::tls
134/// [`tls_max_connections`]: Config::tls_max_connections
135#[derive(Debug, Clone)]
136pub struct Config {
137 /// The socket to bind: every interface, on `RECALL_PORT`. What can
138 /// reach it is decided by the compose file, not by the address.
139 pub addr: String,
140 /// The single bearer token. There is no second one, by design.
141 pub token: String,
142 /// The SQLite file. Opened, never created from a schema migration — it
143 /// is the same file the Node server wrote. Kept in WAL mode, with its
144 /// `-wal` and `-shm` files beside it, so it must be on a local
145 /// filesystem (see [`Store::open`](crate::Store::open)).
146 pub db_path: String,
147 /// Reported by `GET /health` so a deploy can be confirmed from outside.
148 /// A release binary knows its own commit, stamped at build time; the
149 /// variable overrides it, for an image built from a checkout.
150 pub git_commit: String,
151
152 /// Where periodic database snapshots go. Empty disables backups.
153 pub backup_dir: String,
154 /// How often to take one.
155 pub backup_interval: Duration,
156 /// How many to keep before deleting the oldest.
157 pub backup_keep: usize,
158
159 /// The window rate limiting counts requests over.
160 pub rate_limit_window: Duration,
161 /// How many requests one client may make in that window.
162 pub rate_limit_max: u32,
163 /// The one request header whose value is taken as the client's address,
164 /// or empty to trust none and use the socket's peer address.
165 ///
166 /// Rate limiting keys off this, and rate limiting runs *before* auth
167 /// precisely so a flood of invalid tokens is limited too — so a client
168 /// that can choose its own value here can rotate it and get unlimited
169 /// attempts at guessing the token.
170 ///
171 /// That makes this a statement about the deployment, not a preference:
172 /// it names the header the *ingress* sets, and it is only safe when
173 /// nothing can reach this server except through that ingress. Exactly
174 /// one header is read, so a value the client supplies under any other
175 /// name is ignored.
176 ///
177 /// | Ingress | Set this to |
178 /// |---|---|
179 /// | Cloudflare Tunnel | `cf-connecting-ip` (the default) |
180 /// | Traefik, nginx, Caddy | `x-real-ip` |
181 /// | None — reached directly | empty |
182 ///
183 /// Deliberately *not* `x-forwarded-for`: a proxy appends to it, so the
184 /// first entry is whatever the client sent. Reading it as one value is
185 /// the classic way to make this setting useless.
186 pub trusted_ip_header: String,
187
188 /// Whether to attempt semantic merge at all. Off means last-write-wins.
189 pub merge_enabled: bool,
190 /// How long a merge may take before it is abandoned — and, like every
191 /// other merge failure, degraded to last-write-wins.
192 pub merge_timeout: Duration,
193 /// The `claude` binary to shell out to. Never the Anthropic API.
194 pub claude_bin: String,
195 /// How often to re-check that the binary is present and logged in.
196 pub claude_status_interval: Duration,
197
198 /// How long an ephemeral device, one a cloud session enrolled with an
199 /// ephemeral authkey, may go without a signed request before it
200 /// is removed.
201 ///
202 /// A day by default. A cloud session left open over lunch, a meeting or
203 /// a night keeps its device; a day's worth of finished sessions does
204 /// not pile up in the device list; and the key a finished session left
205 /// in its container stops working within a day of its last use.
206 pub ephemeral_device_ttl: Duration,
207
208 /// How often to queue an evaluation of every project by itself, for
209 /// the worker to make; [`None`], the default, never does.
210 ///
211 /// A scheduled run never includes the contradiction check, which asks
212 /// `claude` once per project and so spends the owner's Claude usage:
213 /// that runs only when someone asks for it, with
214 /// `recall eval run --contradictions`. The other checks cost nothing.
215 pub eval_interval: Option<Duration>,
216
217 /// The address people reach this server at, such as
218 /// `https://recall.example.com`: an origin, with no path.
219 ///
220 /// Passkeys are bound to a site, and the server cannot learn which one
221 /// from a request: behind Traefik it sees plain HTTP, and a `Host`
222 /// header is whatever the client sent. So the site is configured. The
223 /// WebAuthn relying party id is its host and the origin a passkey must
224 /// be used from is the whole of it. Empty leaves passkey sign-in on
225 /// `/admin` off, and the page says so; nothing else depends on it.
226 pub public_url: String,
227 /// Whether this server terminates TLS itself. Off by default: the two
228 /// existing deployments (`deploy/docker-compose.yml`,
229 /// `docker-compose.traefik.yml`) put an ingress in front instead, and
230 /// that stays the default. See [`TlsMode`].
231 pub tls: TlsMode,
232 /// How many connections the direct-TLS listener holds open at once,
233 /// counting ones still in their TLS handshake. Ignored with TLS off,
234 /// where the ingress in front owns this problem.
235 ///
236 /// With no ingress, every idle or half-open socket an attacker opens
237 /// costs this process a file descriptor and a task; this bound turns
238 /// "exhaust the process's descriptors" into "fill these slots until
239 /// the timeouts in `server/tls.rs` close them". A single owner's
240 /// machines need a handful; the default leaves generous room below the
241 /// `nofile` limit `docker-compose.direct.yml` sets.
242 pub tls_max_connections: usize,
243}
244
245/// Whether, and how, `recall-server` terminates TLS itself rather than
246/// leaving it to an ingress.
247///
248/// The two modes are mutually exclusive and each requires its own pair of
249/// variables in full; see [`ConfigError::PartialTlsFiles`],
250/// [`ConfigError::PartialTlsAcme`] and [`ConfigError::BothTlsModes`].
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum TlsMode {
253 /// Plain HTTP on [`Config::addr`]. An ingress is expected to terminate
254 /// TLS in front of this, per `deploy/README.md`.
255 Off,
256 /// `RECALL_TLS_CERT` + `RECALL_TLS_KEY`: serve HTTPS on
257 /// [`Config::addr`] from a certificate and key already on disk, such as
258 /// one a separate ACME client keeps renewed.
259 Files {
260 /// PEM certificate chain path (`RECALL_TLS_CERT`).
261 cert_path: String,
262 /// PEM private key path (`RECALL_TLS_KEY`).
263 key_path: String,
264 },
265 /// `RECALL_TLS_ACME_DOMAINS` + `RECALL_TLS_ACME_EMAIL`: the server gets
266 /// and renews its own certificate from an ACME directory (Let's
267 /// Encrypt, by default) over TLS-ALPN-01, which needs only the one port
268 /// it is already serving on, port 80 is never touched.
269 Acme {
270 /// The domain names to request a certificate for.
271 domains: Vec<String>,
272 /// The contact address the ACME directory may use for expiry
273 /// notices.
274 email: String,
275 /// Where the issued certificate and account key are cached, so a
276 /// restart does not re-issue one (`RECALL_TLS_ACME_DIR`).
277 cache_dir: String,
278 /// Let's Encrypt's staging directory instead of production
279 /// (`RECALL_TLS_ACME_STAGING`): much higher rate limits while
280 /// testing, at the cost of a certificate no client will trust.
281 staging: bool,
282 },
283}
284
285impl TlsMode {
286 /// Whether the server terminates TLS at all, in either mode.
287 pub fn is_enabled(&self) -> bool {
288 !matches!(self, TlsMode::Off)
289 }
290}
291
292impl Default for Config {
293 fn default() -> Self {
294 Self {
295 addr: "0.0.0.0:8787".to_string(),
296 token: String::new(),
297 db_path: "data/recall.db".to_string(),
298 git_commit: "unknown".to_string(),
299 backup_dir: String::new(),
300 backup_interval: Duration::from_secs(24 * 60 * 60),
301 backup_keep: 7,
302 rate_limit_window: Duration::from_secs(60),
303 rate_limit_max: 60,
304 trusted_ip_header: "cf-connecting-ip".to_string(),
305 merge_enabled: true,
306 merge_timeout: Duration::from_secs(45),
307 claude_bin: "claude".to_string(),
308 claude_status_interval: Duration::from_secs(30 * 60),
309 ephemeral_device_ttl: DEFAULT_EPHEMERAL_DEVICE_TTL,
310 eval_interval: None,
311 public_url: String::new(),
312 tls: TlsMode::Off,
313 tls_max_connections: DEFAULT_TLS_MAX_CONNECTIONS,
314 }
315 }
316}
317
318const DEFAULT_EPHEMERAL_DEVICE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
319
320/// See [`Config::tls_max_connections`].
321const DEFAULT_TLS_MAX_CONNECTIONS: usize = 512;
322
323/// Reads a yes/no variable: unset or empty is `false`; `true`/`1`/`yes`
324/// and `false`/`0`/`no` in any case are what they say; anything else is
325/// refused rather than guessed (see [`ConfigError::InvalidFlag`]).
326fn flag<F>(lookup: &F, var: &'static str) -> Result<bool, ConfigError>
327where
328 F: Fn(&str) -> Option<String>,
329{
330 let Some(raw) = lookup(var) else {
331 return Ok(false);
332 };
333 match raw.trim().to_ascii_lowercase().as_str() {
334 "" | "false" | "0" | "no" => Ok(false),
335 "true" | "1" | "yes" => Ok(true),
336 _ => Err(ConfigError::InvalidFlag { var, value: raw }),
337 }
338}
339
340impl Config {
341 /// Reads configuration from the real process environment.
342 pub fn from_env() -> Result<Self, ConfigError> {
343 Self::from_lookup(|key| env::var(key).ok())
344 }
345
346 /// Reads configuration through a caller-supplied lookup, applying the
347 /// same defaults the Node implementation used.
348 ///
349 /// The lookup is injected rather than read from `std::env` inside so the
350 /// clamping below is testable: `set_var` is process-global, and Rust runs
351 /// tests in parallel threads, so an env-reading test races every other
352 /// test in the binary.
353 ///
354 /// An unparseable value falls back rather than failing the boot, matching
355 /// the Node and Go implementations — a typo in one tunable should not be
356 /// the reason a server won't start.
357 pub fn from_lookup<F>(lookup: F) -> Result<Self, ConfigError>
358 where
359 F: Fn(&str) -> Option<String>,
360 {
361 let get = |key: &str| lookup(key).filter(|v| !v.is_empty());
362 let or = |key: &str, fallback: &str| get(key).unwrap_or_else(|| fallback.to_string());
363 let num =
364 |key: &str, fallback: u64| get(key).and_then(|v| v.parse().ok()).unwrap_or(fallback);
365
366 let files_tls = match (get("RECALL_TLS_CERT"), get("RECALL_TLS_KEY")) {
367 (Some(cert_path), Some(key_path)) => Some(TlsMode::Files {
368 cert_path,
369 key_path,
370 }),
371 (None, None) => None,
372 _ => return Err(ConfigError::PartialTlsFiles),
373 };
374 let acme_tls = match (get("RECALL_TLS_ACME_DOMAINS"), get("RECALL_TLS_ACME_EMAIL")) {
375 (Some(domains), Some(email)) => {
376 let domains: Vec<String> = domains
377 .split(',')
378 .map(str::trim)
379 .filter(|d| !d.is_empty())
380 .map(str::to_string)
381 .collect();
382 if domains.is_empty() {
383 return Err(ConfigError::NoAcmeDomains);
384 }
385 if let Some(wildcard) = domains.iter().find(|d| d.contains('*')) {
386 return Err(ConfigError::WildcardAcmeDomain(wildcard.clone()));
387 }
388 Some(TlsMode::Acme {
389 domains,
390 email,
391 cache_dir: or("RECALL_TLS_ACME_DIR", "/data/acme"),
392 // A typo here used to mean production, silently; now
393 // it refuses to start instead, since "I asked for
394 // staging and burned the production rate limit" and
395 // "I asked for production and got an untrusted
396 // certificate" are both worse than a clear error.
397 staging: flag(&lookup, "RECALL_TLS_ACME_STAGING")?,
398 })
399 }
400 (None, None) => None,
401 _ => return Err(ConfigError::PartialTlsAcme),
402 };
403 let tls = match (files_tls, acme_tls) {
404 (Some(_), Some(_)) => return Err(ConfigError::BothTlsModes),
405 (Some(mode), None) | (None, Some(mode)) => mode,
406 (None, None) => TlsMode::Off,
407 };
408 // With direct TLS there is no ingress, so the setting that protects
409 // the rate limiter behind one is not just unnecessary but actively
410 // dangerous here: reading it at all would let a direct client pick
411 // its own rate-limit bucket by supplying whatever header it names.
412 // Refusing to start beats silently ignoring a value left over from
413 // moving a deployment from behind an ingress to direct TLS.
414 // An explicitly empty value is allowed: it says "trust no header",
415 // which is what TLS forces anyway, and it is what lets the direct
416 // compose file pin the header off even if it ever came up without
417 // TLS.
418 if tls.is_enabled()
419 && lookup("RECALL_TRUSTED_IP_HEADER").is_some_and(|v| !v.trim().is_empty())
420 {
421 return Err(ConfigError::TrustedIpHeaderWithTls);
422 }
423 if !tls.is_enabled() && flag(&lookup, "RECALL_TLS_REQUIRED")? {
424 return Err(ConfigError::TlsRequired);
425 }
426
427 let mut cfg = Config {
428 addr: format!("0.0.0.0:{}", or("RECALL_PORT", "8787")),
429 token: get("RECALL_TOKEN").unwrap_or_default(),
430 db_path: or("RECALL_DB_PATH", "data/recall.db"),
431 git_commit: or(
432 "RECALL_GIT_COMMIT",
433 option_env!("RECALL_GIT_COMMIT").unwrap_or("unknown"),
434 ),
435 backup_dir: get("RECALL_BACKUP_DIR").unwrap_or_default(),
436 backup_interval: Duration::from_secs(
437 num("RECALL_BACKUP_INTERVAL_HOURS", 24).saturating_mul(3600),
438 ),
439 backup_keep: num("RECALL_BACKUP_KEEP", 7) as usize,
440 rate_limit_window: Duration::from_millis(num("RECALL_RATE_LIMIT_WINDOW_MS", 60_000)),
441 rate_limit_max: num("RECALL_RATE_LIMIT_MAX", 60) as u32,
442 // Lowercased because HeaderMap lookups are case-insensitive but
443 // this is compared as a plain string. Forced empty under TLS
444 // regardless of this default: the check above already refused
445 // to start if the variable named a header, and with no ingress
446 // in front, no header is safe to trust at all.
447 trusted_ip_header: if tls.is_enabled() {
448 String::new()
449 } else {
450 lookup("RECALL_TRUSTED_IP_HEADER")
451 .map(|v| v.trim().to_ascii_lowercase())
452 .unwrap_or_else(|| "cf-connecting-ip".to_string())
453 },
454 // Opt-out, not opt-in: only the literal "false" disables it, so a
455 // typo leaves merge on rather than silently off.
456 merge_enabled: lookup("RECALL_MERGE_ENABLED").as_deref() != Some("false"),
457 merge_timeout: Duration::from_millis(num("RECALL_MERGE_TIMEOUT_MS", 45_000)),
458 claude_bin: or("RECALL_CLAUDE_BIN", "claude"),
459 claude_status_interval: Duration::from_millis(num(
460 "RECALL_CLAUDE_STATUS_INTERVAL_MS",
461 30 * 60_000,
462 )),
463 ephemeral_device_ttl: Duration::from_secs(
464 num("RECALL_EPHEMERAL_DEVICE_TTL_HOURS", 24).saturating_mul(3600),
465 ),
466 // Unset, empty, 0 or not a number: off.
467 eval_interval: Some(num("RECALL_EVAL_INTERVAL_HOURS", 0))
468 .filter(|h| *h > 0)
469 .map(|h| Duration::from_secs(h.saturating_mul(3600))),
470 // Checked when the server starts, not here: a bad value turns
471 // passkey sign-in off with the reason on the page, rather than
472 // keeping a server that syncs fine from starting.
473 public_url: get("RECALL_PUBLIC_URL")
474 .map(|v| v.trim().to_string())
475 .unwrap_or_default(),
476 tls,
477 tls_max_connections: num(
478 "RECALL_TLS_MAX_CONNECTIONS",
479 DEFAULT_TLS_MAX_CONNECTIONS as u64,
480 ) as usize,
481 };
482 if cfg.token.is_empty() {
483 return Err(ConfigError::MissingToken);
484 }
485 // A zero interval would spin a background loop as fast as the
486 // scheduler allows.
487 if cfg.backup_interval.is_zero() {
488 cfg.backup_interval = Duration::from_secs(24 * 60 * 60);
489 }
490 if cfg.rate_limit_window.is_zero() {
491 cfg.rate_limit_window = Duration::from_secs(60);
492 }
493 if cfg.claude_status_interval.is_zero() {
494 cfg.claude_status_interval = Duration::from_secs(30 * 60);
495 }
496 // Zero would remove every ephemeral device at the next sweep,
497 // including the one whose session is running now.
498 if cfg.ephemeral_device_ttl.is_zero() {
499 cfg.ephemeral_device_ttl = DEFAULT_EPHEMERAL_DEVICE_TTL;
500 }
501 // A zero timeout is worse than a spinning loop: every merge would
502 // hit an already-expired deadline and fail instantly, silently
503 // degrading to last-write-wins with nothing in the logs that points
504 // at the typo responsible.
505 if cfg.merge_timeout.is_zero() {
506 cfg.merge_timeout = Duration::from_millis(45_000);
507 }
508 // Zero connections would refuse every client, the TLS-mode
509 // equivalent of an expired merge timeout.
510 if cfg.tls_max_connections == 0 {
511 cfg.tls_max_connections = DEFAULT_TLS_MAX_CONNECTIONS;
512 }
513 Ok(cfg)
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
522 move |key| {
523 pairs
524 .iter()
525 .find(|(k, _)| *k == key)
526 .map(|(_, v)| (*v).to_string())
527 }
528 }
529
530 #[test]
531 fn defaults_match_the_node_implementation() {
532 let cfg = Config::default();
533 assert_eq!(cfg.rate_limit_max, 60);
534 assert_eq!(cfg.rate_limit_window, Duration::from_secs(60));
535 assert_eq!(cfg.merge_timeout, Duration::from_secs(45));
536 assert_eq!(cfg.backup_keep, 7);
537 assert!(cfg.merge_enabled);
538 assert_eq!(cfg.claude_bin, "claude");
539 }
540
541 #[test]
542 fn refuses_to_start_without_a_token() {
543 assert!(
544 matches!(
545 Config::from_lookup(env(&[])),
546 Err(ConfigError::MissingToken)
547 ),
548 "a server reachable from the internet with no auth is not a degraded mode worth supporting"
549 );
550 // Set-but-empty is not set. Go's os.Getenv couldn't tell the two
551 // apart; here it would otherwise boot with a token of "".
552 assert!(matches!(
553 Config::from_lookup(env(&[("RECALL_TOKEN", "")])),
554 Err(ConfigError::MissingToken)
555 ));
556 }
557
558 #[test]
559 fn reads_every_override() {
560 let cfg = Config::from_lookup(env(&[
561 ("RECALL_TOKEN", "t"),
562 ("RECALL_PORT", "9000"),
563 ("RECALL_DB_PATH", "/data/x.db"),
564 ("RECALL_GIT_COMMIT", "abc1234"),
565 ("RECALL_BACKUP_DIR", "/backups"),
566 ("RECALL_BACKUP_INTERVAL_HOURS", "6"),
567 ("RECALL_BACKUP_KEEP", "3"),
568 ("RECALL_RATE_LIMIT_WINDOW_MS", "1000"),
569 ("RECALL_RATE_LIMIT_MAX", "5"),
570 ("RECALL_MERGE_TIMEOUT_MS", "1234"),
571 ("RECALL_CLAUDE_BIN", "/usr/bin/claude"),
572 ("RECALL_CLAUDE_STATUS_INTERVAL_MS", "60000"),
573 ("RECALL_EPHEMERAL_DEVICE_TTL_HOURS", "2"),
574 ("RECALL_PUBLIC_URL", " https://recall.example.com "),
575 ]))
576 .unwrap();
577
578 assert_eq!(cfg.addr, "0.0.0.0:9000");
579 assert_eq!(cfg.db_path, "/data/x.db");
580 assert_eq!(cfg.git_commit, "abc1234");
581 assert_eq!(cfg.backup_dir, "/backups");
582 assert_eq!(cfg.backup_interval, Duration::from_secs(6 * 3600));
583 assert_eq!(cfg.backup_keep, 3);
584 assert_eq!(cfg.rate_limit_window, Duration::from_millis(1000));
585 assert_eq!(cfg.rate_limit_max, 5);
586 assert_eq!(cfg.merge_timeout, Duration::from_millis(1234));
587 assert_eq!(cfg.claude_bin, "/usr/bin/claude");
588 assert_eq!(cfg.claude_status_interval, Duration::from_millis(60_000));
589 assert_eq!(cfg.ephemeral_device_ttl, Duration::from_secs(2 * 3600));
590 assert_eq!(cfg.public_url, "https://recall.example.com");
591 }
592
593 #[test]
594 fn merge_is_disabled_only_by_the_literal_false() {
595 for (value, want) in [("false", false), ("true", true), ("0", true), ("", true)] {
596 let cfg = Config::from_lookup(env(&[
597 ("RECALL_TOKEN", "t"),
598 ("RECALL_MERGE_ENABLED", value),
599 ]))
600 .unwrap();
601 assert_eq!(cfg.merge_enabled, want, "RECALL_MERGE_ENABLED={value:?}");
602 }
603 }
604
605 /// Every duration is clamped, not just the ones whose failure is loud.
606 ///
607 /// The Go implementation clamped only two of the four. A zero
608 /// `CLAUDE_STATUS_INTERVAL` reached `time.NewTicker`, which panics on a
609 /// non-positive duration — one config typo crashing the server at
610 /// startup. A zero `MERGE_TIMEOUT` is quieter and worse: every merge
611 /// hits an already-expired deadline and fails instantly, silently
612 /// degrading to last-write-wins with nothing pointing at the cause.
613 #[test]
614 fn zero_and_unparseable_durations_fall_back_to_their_defaults() {
615 for value in ["0", "not-a-number", "-5", " 6"] {
616 let cfg = Config::from_lookup(env(&[
617 ("RECALL_TOKEN", "t"),
618 ("RECALL_BACKUP_INTERVAL_HOURS", value),
619 ("RECALL_RATE_LIMIT_WINDOW_MS", value),
620 ("RECALL_CLAUDE_STATUS_INTERVAL_MS", value),
621 ("RECALL_MERGE_TIMEOUT_MS", value),
622 ("RECALL_EPHEMERAL_DEVICE_TTL_HOURS", value),
623 ]))
624 .unwrap();
625
626 assert_eq!(
627 cfg.ephemeral_device_ttl,
628 Duration::from_secs(24 * 3600),
629 "{value:?}"
630 );
631
632 assert_eq!(
633 cfg.backup_interval,
634 Duration::from_secs(24 * 3600),
635 "{value:?}"
636 );
637 assert_eq!(cfg.rate_limit_window, Duration::from_secs(60), "{value:?}");
638 assert_eq!(
639 cfg.claude_status_interval,
640 Duration::from_secs(30 * 60),
641 "{value:?}"
642 );
643 assert_eq!(
644 cfg.merge_timeout,
645 Duration::from_millis(45_000),
646 "{value:?}"
647 );
648 assert!(
649 !cfg.merge_timeout.is_zero(),
650 "a zero merge timeout fails every merge instantly and silently"
651 );
652 }
653 }
654
655 #[test]
656 fn tls_is_off_by_default() {
657 assert_eq!(Config::default().tls, TlsMode::Off);
658 let cfg = Config::from_lookup(env(&[("RECALL_TOKEN", "t")])).unwrap();
659 assert_eq!(cfg.tls, TlsMode::Off);
660 assert_eq!(cfg.trusted_ip_header, "cf-connecting-ip");
661 }
662
663 #[test]
664 fn tls_files_mode_needs_both_variables() {
665 for pairs in [
666 &[("RECALL_TOKEN", "t"), ("RECALL_TLS_CERT", "/c.pem")][..],
667 &[("RECALL_TOKEN", "t"), ("RECALL_TLS_KEY", "/k.pem")][..],
668 ] {
669 assert!(
670 matches!(
671 Config::from_lookup(env(pairs)),
672 Err(ConfigError::PartialTlsFiles)
673 ),
674 "{pairs:?}"
675 );
676 }
677
678 let cfg = Config::from_lookup(env(&[
679 ("RECALL_TOKEN", "t"),
680 ("RECALL_TLS_CERT", "/c.pem"),
681 ("RECALL_TLS_KEY", "/k.pem"),
682 ]))
683 .unwrap();
684 assert_eq!(
685 cfg.tls,
686 TlsMode::Files {
687 cert_path: "/c.pem".to_string(),
688 key_path: "/k.pem".to_string(),
689 }
690 );
691 }
692
693 #[test]
694 fn tls_acme_mode_needs_both_variables_and_splits_domains() {
695 for pairs in [
696 &[
697 ("RECALL_TOKEN", "t"),
698 ("RECALL_TLS_ACME_DOMAINS", "example.com"),
699 ][..],
700 &[
701 ("RECALL_TOKEN", "t"),
702 ("RECALL_TLS_ACME_EMAIL", "me@example.com"),
703 ][..],
704 ] {
705 assert!(
706 matches!(
707 Config::from_lookup(env(pairs)),
708 Err(ConfigError::PartialTlsAcme)
709 ),
710 "{pairs:?}"
711 );
712 }
713
714 let cfg = Config::from_lookup(env(&[
715 ("RECALL_TOKEN", "t"),
716 ("RECALL_TLS_ACME_DOMAINS", " a.example.com, b.example.com ,"),
717 ("RECALL_TLS_ACME_EMAIL", "me@example.com"),
718 ]))
719 .unwrap();
720 assert_eq!(
721 cfg.tls,
722 TlsMode::Acme {
723 domains: vec!["a.example.com".to_string(), "b.example.com".to_string()],
724 email: "me@example.com".to_string(),
725 cache_dir: "/data/acme".to_string(),
726 staging: false,
727 }
728 );
729 }
730
731 #[test]
732 fn tls_acme_staging_and_cache_dir_are_overridable() {
733 let cfg = Config::from_lookup(env(&[
734 ("RECALL_TOKEN", "t"),
735 ("RECALL_TLS_ACME_DOMAINS", "example.com"),
736 ("RECALL_TLS_ACME_EMAIL", "me@example.com"),
737 ("RECALL_TLS_ACME_DIR", "/tmp/acme-cache"),
738 ("RECALL_TLS_ACME_STAGING", "true"),
739 ]))
740 .unwrap();
741 let TlsMode::Acme {
742 cache_dir, staging, ..
743 } = cfg.tls
744 else {
745 panic!("expected TlsMode::Acme, got {:?}", cfg.tls);
746 };
747 assert_eq!(cache_dir, "/tmp/acme-cache");
748 assert!(staging);
749 }
750
751 #[test]
752 fn configuring_both_tls_modes_is_refused() {
753 assert!(matches!(
754 Config::from_lookup(env(&[
755 ("RECALL_TOKEN", "t"),
756 ("RECALL_TLS_CERT", "/c.pem"),
757 ("RECALL_TLS_KEY", "/k.pem"),
758 ("RECALL_TLS_ACME_DOMAINS", "example.com"),
759 ("RECALL_TLS_ACME_EMAIL", "me@example.com"),
760 ])),
761 Err(ConfigError::BothTlsModes)
762 ));
763 }
764
765 /// The mandatory security rule: behind an ingress the trusted header is
766 /// how the rate limiter learns the real client address, but direct TLS
767 /// has no ingress to set it, so a client that could still choose the
768 /// value would buy itself unlimited token guesses. Naming a header
769 /// alongside TLS is refused outright, matching
770 /// `scripts/tls-trusted-ip-check.sh`'s socket-level proof of the same
771 /// rule.
772 #[test]
773 fn trusted_ip_header_with_tls_refuses_to_start() {
774 for tls_pairs in [
775 &[("RECALL_TLS_CERT", "/c.pem"), ("RECALL_TLS_KEY", "/k.pem")][..],
776 &[
777 ("RECALL_TLS_ACME_DOMAINS", "example.com"),
778 ("RECALL_TLS_ACME_EMAIL", "me@example.com"),
779 ][..],
780 ] {
781 let mut pairs = vec![("RECALL_TOKEN", "t")];
782 pairs.extend_from_slice(tls_pairs);
783
784 for header_value in ["x-real-ip", "cf-connecting-ip", " X-Real-IP "] {
785 let mut pairs = pairs.clone();
786 pairs.push(("RECALL_TRUSTED_IP_HEADER", header_value));
787 assert!(
788 matches!(
789 Config::from_lookup(env(&pairs)),
790 Err(ConfigError::TrustedIpHeaderWithTls)
791 ),
792 "{pairs:?}"
793 );
794 }
795
796 // Unset, or explicitly empty (what docker-compose.direct.yml
797 // sets, as a second guard should TLS ever be missing), are the
798 // only values allowed, and both force the empty string, not the
799 // plain-HTTP default of cf-connecting-ip.
800 for header in [None, Some(""), Some(" ")] {
801 let mut pairs = pairs.clone();
802 if let Some(value) = header {
803 pairs.push(("RECALL_TRUSTED_IP_HEADER", value));
804 }
805 let cfg = Config::from_lookup(env(&pairs)).unwrap();
806 assert_eq!(cfg.trusted_ip_header, "", "{pairs:?}");
807 }
808 }
809 }
810
811 /// The direct compose file's fail-closed switch: with
812 /// `RECALL_TLS_REQUIRED` on, a config whose TLS variables are missing
813 /// or empty refuses to start instead of coming up as plain HTTP with
814 /// the port published to the internet.
815 #[test]
816 fn tls_required_without_tls_refuses_to_start() {
817 for required in ["true", "TRUE", "1", "yes", "Yes"] {
818 for tls_pairs in [
819 &[][..],
820 // Empty is unset, so this is still no TLS at all.
821 &[("RECALL_TLS_CERT", ""), ("RECALL_TLS_KEY", "")][..],
822 &[
823 ("RECALL_TLS_ACME_DOMAINS", ""),
824 ("RECALL_TLS_ACME_EMAIL", ""),
825 ][..],
826 ] {
827 let mut pairs = vec![("RECALL_TOKEN", "t"), ("RECALL_TLS_REQUIRED", required)];
828 pairs.extend_from_slice(tls_pairs);
829 assert!(
830 matches!(
831 Config::from_lookup(env(&pairs)),
832 Err(ConfigError::TlsRequired)
833 ),
834 "{pairs:?}"
835 );
836 }
837 }
838
839 // With TLS configured it starts, and off or unset it changes
840 // nothing about plain HTTP.
841 let cfg = Config::from_lookup(env(&[
842 ("RECALL_TOKEN", "t"),
843 ("RECALL_TLS_REQUIRED", "true"),
844 ("RECALL_TLS_CERT", "/c.pem"),
845 ("RECALL_TLS_KEY", "/k.pem"),
846 ]))
847 .unwrap();
848 assert!(cfg.tls.is_enabled());
849 for off in ["", "false", "0", "no", "NO"] {
850 let cfg =
851 Config::from_lookup(env(&[("RECALL_TOKEN", "t"), ("RECALL_TLS_REQUIRED", off)]))
852 .unwrap();
853 assert_eq!(cfg.tls, TlsMode::Off, "{off:?}");
854 }
855
856 // A typo is refused rather than read as "not required".
857 assert!(matches!(
858 Config::from_lookup(env(&[
859 ("RECALL_TOKEN", "t"),
860 ("RECALL_TLS_REQUIRED", "ture"),
861 ])),
862 Err(ConfigError::InvalidFlag {
863 var: "RECALL_TLS_REQUIRED",
864 ..
865 })
866 ));
867 }
868
869 #[test]
870 fn tls_acme_refuses_an_empty_domain_list_and_wildcards() {
871 for domains in [",", " , ,", " "] {
872 let result = Config::from_lookup(env(&[
873 ("RECALL_TOKEN", "t"),
874 ("RECALL_TLS_ACME_DOMAINS", domains),
875 ("RECALL_TLS_ACME_EMAIL", "me@example.com"),
876 ]));
877 assert!(
878 matches!(result, Err(ConfigError::NoAcmeDomains)),
879 "{domains:?}: {result:?}"
880 );
881 }
882 for domains in ["*.example.com", "example.com, *.example.com"] {
883 let result = Config::from_lookup(env(&[
884 ("RECALL_TOKEN", "t"),
885 ("RECALL_TLS_ACME_DOMAINS", domains),
886 ("RECALL_TLS_ACME_EMAIL", "me@example.com"),
887 ]));
888 assert!(
889 matches!(&result, Err(ConfigError::WildcardAcmeDomain(d)) if d == "*.example.com"),
890 "{domains:?}: {result:?}"
891 );
892 }
893 }
894
895 /// Staging used to be on only for the literal `true`, so `TRUE` or
896 /// `1` silently meant production. Now every common spelling of yes
897 /// works, and anything unrecognised refuses to start.
898 #[test]
899 fn tls_acme_staging_accepts_common_spellings_and_refuses_the_rest() {
900 let acme = |staging: &'static str| {
901 Config::from_lookup(env(&[
902 ("RECALL_TOKEN", "t"),
903 ("RECALL_TLS_ACME_DOMAINS", "example.com"),
904 ("RECALL_TLS_ACME_EMAIL", "me@example.com"),
905 ("RECALL_TLS_ACME_STAGING", staging),
906 ]))
907 };
908 for (value, want) in [
909 ("true", true),
910 ("TRUE", true),
911 ("1", true),
912 ("yes", true),
913 ("false", false),
914 ("0", false),
915 ("No", false),
916 ("", false),
917 ] {
918 let TlsMode::Acme { staging, .. } = acme(value).unwrap().tls else {
919 panic!("expected TlsMode::Acme");
920 };
921 assert_eq!(staging, want, "RECALL_TLS_ACME_STAGING={value:?}");
922 }
923 for value in ["staging", "ture", "on?"] {
924 assert!(
925 matches!(
926 acme(value),
927 Err(ConfigError::InvalidFlag {
928 var: "RECALL_TLS_ACME_STAGING",
929 ..
930 })
931 ),
932 "{value:?}"
933 );
934 }
935 }
936
937 #[test]
938 fn tls_max_connections_falls_back_to_its_default() {
939 let read = |value: &'static str| {
940 Config::from_lookup(env(&[
941 ("RECALL_TOKEN", "t"),
942 ("RECALL_TLS_MAX_CONNECTIONS", value),
943 ]))
944 .unwrap()
945 .tls_max_connections
946 };
947 assert_eq!(Config::default().tls_max_connections, 512);
948 assert_eq!(read("64"), 64);
949 for fallback in ["0", "", "lots", "-1"] {
950 assert_eq!(read(fallback), 512, "{fallback:?}");
951 }
952 }
953}