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}
19
20/// What `recall serve` needs.
21///
22/// Every field has a default that is safe to run with, except [`token`],
23/// which has none — see [`ConfigError::MissingToken`].
24///
25/// | Field | Variable | Default |
26/// |---|---|---|
27/// | [`addr`] | `RECALL_HOST`, `RECALL_PORT` | `0.0.0.0:8787` |
28/// | [`token`] | `RECALL_TOKEN` | *required* |
29/// | [`db_path`] | `RECALL_DB_PATH` | `data/recall.db` |
30/// | [`git_commit`] | `RECALL_GIT_COMMIT` | `unknown` |
31/// | [`backup_dir`] | `RECALL_BACKUP_DIR` | off |
32/// | [`backup_interval`] | `RECALL_BACKUP_INTERVAL_MS` | 24h |
33/// | [`backup_keep`] | `RECALL_BACKUP_KEEP` | 7 |
34/// | [`rate_limit_window`] | `RECALL_RATE_LIMIT_WINDOW_MS` | 60s |
35/// | [`rate_limit_max`] | `RECALL_RATE_LIMIT_MAX` | 60 |
36/// | [`trusted_ip_header`] | `RECALL_TRUSTED_IP_HEADER` | `cf-connecting-ip` |
37/// | [`merge_enabled`] | `RECALL_MERGE_ENABLED` | on |
38/// | [`merge_timeout`] | `RECALL_MERGE_TIMEOUT_MS` | 45s |
39/// | [`claude_bin`] | `RECALL_CLAUDE_BIN` | `claude` |
40/// | [`claude_status_interval`] | `RECALL_CLAUDE_STATUS_INTERVAL_MS` | 30m |
41///
42/// [`addr`]: Config::addr
43/// [`token`]: Config::token
44/// [`db_path`]: Config::db_path
45/// [`git_commit`]: Config::git_commit
46/// [`backup_dir`]: Config::backup_dir
47/// [`backup_interval`]: Config::backup_interval
48/// [`backup_keep`]: Config::backup_keep
49/// [`rate_limit_window`]: Config::rate_limit_window
50/// [`rate_limit_max`]: Config::rate_limit_max
51/// [`trusted_ip_header`]: Config::trusted_ip_header
52/// [`merge_enabled`]: Config::merge_enabled
53/// [`merge_timeout`]: Config::merge_timeout
54/// [`claude_bin`]: Config::claude_bin
55/// [`claude_status_interval`]: Config::claude_status_interval
56#[derive(Debug, Clone)]
57pub struct Config {
58    /// The socket to bind, assembled from host and port.
59    pub addr: String,
60    /// The single bearer token. There is no second one, by design.
61    pub token: String,
62    /// The SQLite file. Opened, never created from a schema migration — it
63    /// is the same file the Node server wrote.
64    pub db_path: String,
65    /// Reported by `GET /health` so a deploy can be confirmed from outside.
66    pub git_commit: String,
67
68    /// Where periodic database snapshots go. Empty disables backups.
69    pub backup_dir: String,
70    /// How often to take one.
71    pub backup_interval: Duration,
72    /// How many to keep before deleting the oldest.
73    pub backup_keep: usize,
74
75    /// The window rate limiting counts requests over.
76    pub rate_limit_window: Duration,
77    /// How many requests one client may make in that window.
78    pub rate_limit_max: u32,
79    /// The one request header whose value is taken as the client's address,
80    /// or empty to trust none and use the socket's peer address.
81    ///
82    /// Rate limiting keys off this, and rate limiting runs *before* auth
83    /// precisely so a flood of invalid tokens is limited too — so a client
84    /// that can choose its own value here can rotate it and get unlimited
85    /// attempts at guessing the token.
86    ///
87    /// That makes this a statement about the deployment, not a preference:
88    /// it names the header the *ingress* sets, and it is only safe when
89    /// nothing can reach this server except through that ingress. Exactly
90    /// one header is read, so a value the client supplies under any other
91    /// name is ignored.
92    ///
93    /// | Ingress | Set this to |
94    /// |---|---|
95    /// | Cloudflare Tunnel | `cf-connecting-ip` (the default) |
96    /// | Traefik, nginx, Caddy | `x-real-ip` |
97    /// | None — reached directly | empty |
98    ///
99    /// Deliberately *not* `x-forwarded-for`: a proxy appends to it, so the
100    /// first entry is whatever the client sent. Reading it as one value is
101    /// the classic way to make this setting useless.
102    pub trusted_ip_header: String,
103
104    /// Whether to attempt semantic merge at all. Off means last-write-wins.
105    pub merge_enabled: bool,
106    /// How long a merge may take before it is abandoned — and, like every
107    /// other merge failure, degraded to last-write-wins.
108    pub merge_timeout: Duration,
109    /// The `claude` binary to shell out to. Never the Anthropic API.
110    pub claude_bin: String,
111    /// How often to re-check that the binary is present and logged in.
112    pub claude_status_interval: Duration,
113}
114
115impl Default for Config {
116    fn default() -> Self {
117        Self {
118            addr: "0.0.0.0:8787".to_string(),
119            token: String::new(),
120            db_path: "data/recall.db".to_string(),
121            git_commit: "unknown".to_string(),
122            backup_dir: String::new(),
123            backup_interval: Duration::from_secs(24 * 60 * 60),
124            backup_keep: 7,
125            rate_limit_window: Duration::from_secs(60),
126            rate_limit_max: 60,
127            trusted_ip_header: "cf-connecting-ip".to_string(),
128            merge_enabled: true,
129            merge_timeout: Duration::from_secs(45),
130            claude_bin: "claude".to_string(),
131            claude_status_interval: Duration::from_secs(30 * 60),
132        }
133    }
134}
135
136impl Config {
137    /// Reads configuration from the real process environment.
138    pub fn from_env() -> Result<Self, ConfigError> {
139        Self::from_lookup(|key| env::var(key).ok())
140    }
141
142    /// Reads configuration through a caller-supplied lookup, applying the
143    /// same defaults the Node implementation used.
144    ///
145    /// The lookup is injected rather than read from `std::env` inside so the
146    /// clamping below is testable: `set_var` is process-global, and Rust runs
147    /// tests in parallel threads, so an env-reading test races every other
148    /// test in the binary.
149    ///
150    /// An unparseable value falls back rather than failing the boot, matching
151    /// the Node and Go implementations — a typo in one tunable should not be
152    /// the reason a server won't start.
153    pub fn from_lookup<F>(lookup: F) -> Result<Self, ConfigError>
154    where
155        F: Fn(&str) -> Option<String>,
156    {
157        let get = |key: &str| lookup(key).filter(|v| !v.is_empty());
158        let or = |key: &str, fallback: &str| get(key).unwrap_or_else(|| fallback.to_string());
159        let num =
160            |key: &str, fallback: u64| get(key).and_then(|v| v.parse().ok()).unwrap_or(fallback);
161
162        let mut cfg = Config {
163            addr: format!("0.0.0.0:{}", or("RECALL_PORT", "8787")),
164            token: get("RECALL_TOKEN").unwrap_or_default(),
165            db_path: or("RECALL_DB_PATH", "data/recall.db"),
166            git_commit: or("RECALL_GIT_COMMIT", "unknown"),
167            backup_dir: get("RECALL_BACKUP_DIR").unwrap_or_default(),
168            backup_interval: Duration::from_secs(
169                num("RECALL_BACKUP_INTERVAL_HOURS", 24).saturating_mul(3600),
170            ),
171            backup_keep: num("RECALL_BACKUP_KEEP", 7) as usize,
172            rate_limit_window: Duration::from_millis(num("RECALL_RATE_LIMIT_WINDOW_MS", 60_000)),
173            rate_limit_max: num("RECALL_RATE_LIMIT_MAX", 60) as u32,
174            // Lowercased because HeaderMap lookups are case-insensitive but
175            // this is compared as a plain string.
176            trusted_ip_header: lookup("RECALL_TRUSTED_IP_HEADER")
177                .map(|v| v.trim().to_ascii_lowercase())
178                .unwrap_or_else(|| "cf-connecting-ip".to_string()),
179            // Opt-out, not opt-in: only the literal "false" disables it, so a
180            // typo leaves merge on rather than silently off.
181            merge_enabled: lookup("RECALL_MERGE_ENABLED").as_deref() != Some("false"),
182            merge_timeout: Duration::from_millis(num("RECALL_MERGE_TIMEOUT_MS", 45_000)),
183            claude_bin: or("RECALL_CLAUDE_BIN", "claude"),
184            claude_status_interval: Duration::from_millis(num(
185                "RECALL_CLAUDE_STATUS_INTERVAL_MS",
186                30 * 60_000,
187            )),
188        };
189        if cfg.token.is_empty() {
190            return Err(ConfigError::MissingToken);
191        }
192        // A zero interval would spin a background loop as fast as the
193        // scheduler allows.
194        if cfg.backup_interval.is_zero() {
195            cfg.backup_interval = Duration::from_secs(24 * 60 * 60);
196        }
197        if cfg.rate_limit_window.is_zero() {
198            cfg.rate_limit_window = Duration::from_secs(60);
199        }
200        if cfg.claude_status_interval.is_zero() {
201            cfg.claude_status_interval = Duration::from_secs(30 * 60);
202        }
203        // A zero timeout is worse than a spinning loop: every merge would
204        // hit an already-expired deadline and fail instantly, silently
205        // degrading to last-write-wins with nothing in the logs that points
206        // at the typo responsible.
207        if cfg.merge_timeout.is_zero() {
208            cfg.merge_timeout = Duration::from_millis(45_000);
209        }
210        Ok(cfg)
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
219        move |key| {
220            pairs
221                .iter()
222                .find(|(k, _)| *k == key)
223                .map(|(_, v)| (*v).to_string())
224        }
225    }
226
227    #[test]
228    fn defaults_match_the_node_implementation() {
229        let cfg = Config::default();
230        assert_eq!(cfg.rate_limit_max, 60);
231        assert_eq!(cfg.rate_limit_window, Duration::from_secs(60));
232        assert_eq!(cfg.merge_timeout, Duration::from_secs(45));
233        assert_eq!(cfg.backup_keep, 7);
234        assert!(cfg.merge_enabled);
235        assert_eq!(cfg.claude_bin, "claude");
236    }
237
238    #[test]
239    fn refuses_to_start_without_a_token() {
240        assert!(
241            matches!(
242                Config::from_lookup(env(&[])),
243                Err(ConfigError::MissingToken)
244            ),
245            "a server reachable from the internet with no auth is not a degraded mode worth supporting"
246        );
247        // Set-but-empty is not set. Go's os.Getenv couldn't tell the two
248        // apart; here it would otherwise boot with a token of "".
249        assert!(matches!(
250            Config::from_lookup(env(&[("RECALL_TOKEN", "")])),
251            Err(ConfigError::MissingToken)
252        ));
253    }
254
255    #[test]
256    fn reads_every_override() {
257        let cfg = Config::from_lookup(env(&[
258            ("RECALL_TOKEN", "t"),
259            ("RECALL_PORT", "9000"),
260            ("RECALL_DB_PATH", "/data/x.db"),
261            ("RECALL_GIT_COMMIT", "abc1234"),
262            ("RECALL_BACKUP_DIR", "/backups"),
263            ("RECALL_BACKUP_INTERVAL_HOURS", "6"),
264            ("RECALL_BACKUP_KEEP", "3"),
265            ("RECALL_RATE_LIMIT_WINDOW_MS", "1000"),
266            ("RECALL_RATE_LIMIT_MAX", "5"),
267            ("RECALL_MERGE_TIMEOUT_MS", "1234"),
268            ("RECALL_CLAUDE_BIN", "/usr/bin/claude"),
269            ("RECALL_CLAUDE_STATUS_INTERVAL_MS", "60000"),
270        ]))
271        .unwrap();
272
273        assert_eq!(cfg.addr, "0.0.0.0:9000");
274        assert_eq!(cfg.db_path, "/data/x.db");
275        assert_eq!(cfg.git_commit, "abc1234");
276        assert_eq!(cfg.backup_dir, "/backups");
277        assert_eq!(cfg.backup_interval, Duration::from_secs(6 * 3600));
278        assert_eq!(cfg.backup_keep, 3);
279        assert_eq!(cfg.rate_limit_window, Duration::from_millis(1000));
280        assert_eq!(cfg.rate_limit_max, 5);
281        assert_eq!(cfg.merge_timeout, Duration::from_millis(1234));
282        assert_eq!(cfg.claude_bin, "/usr/bin/claude");
283        assert_eq!(cfg.claude_status_interval, Duration::from_millis(60_000));
284    }
285
286    #[test]
287    fn merge_is_disabled_only_by_the_literal_false() {
288        for (value, want) in [("false", false), ("true", true), ("0", true), ("", true)] {
289            let cfg = Config::from_lookup(env(&[
290                ("RECALL_TOKEN", "t"),
291                ("RECALL_MERGE_ENABLED", value),
292            ]))
293            .unwrap();
294            assert_eq!(cfg.merge_enabled, want, "RECALL_MERGE_ENABLED={value:?}");
295        }
296    }
297
298    /// Every duration is clamped, not just the ones whose failure is loud.
299    ///
300    /// The Go implementation clamped only two of the four. A zero
301    /// `CLAUDE_STATUS_INTERVAL` reached `time.NewTicker`, which panics on a
302    /// non-positive duration — one config typo crashing the server at
303    /// startup. A zero `MERGE_TIMEOUT` is quieter and worse: every merge
304    /// hits an already-expired deadline and fails instantly, silently
305    /// degrading to last-write-wins with nothing pointing at the cause.
306    #[test]
307    fn zero_and_unparseable_durations_fall_back_to_their_defaults() {
308        for value in ["0", "not-a-number", "-5", " 6"] {
309            let cfg = Config::from_lookup(env(&[
310                ("RECALL_TOKEN", "t"),
311                ("RECALL_BACKUP_INTERVAL_HOURS", value),
312                ("RECALL_RATE_LIMIT_WINDOW_MS", value),
313                ("RECALL_CLAUDE_STATUS_INTERVAL_MS", value),
314                ("RECALL_MERGE_TIMEOUT_MS", value),
315            ]))
316            .unwrap();
317
318            assert_eq!(
319                cfg.backup_interval,
320                Duration::from_secs(24 * 3600),
321                "{value:?}"
322            );
323            assert_eq!(cfg.rate_limit_window, Duration::from_secs(60), "{value:?}");
324            assert_eq!(
325                cfg.claude_status_interval,
326                Duration::from_secs(30 * 60),
327                "{value:?}"
328            );
329            assert_eq!(
330                cfg.merge_timeout,
331                Duration::from_millis(45_000),
332                "{value:?}"
333            );
334            assert!(
335                !cfg.merge_timeout.is_zero(),
336                "a zero merge timeout fails every merge instantly and silently"
337            );
338        }
339    }
340}