Skip to main content

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