Skip to main content

faucet_cli/serve/
config.rs

1//! `ServeConfig` — the validated, runtime-ready server configuration built from
2//! `ServeArgs`. The no-auth gate lives here so an unauthenticated server can
3//! never start silently.
4
5use crate::cli::ServeArgs;
6use crate::error::{CliError, CliResult};
7use std::net::SocketAddr;
8use std::path::PathBuf;
9use std::time::Duration;
10
11/// How `/v1/*` requests are authenticated.
12#[derive(Clone)]
13pub enum AuthMode {
14    /// Require `Authorization: Bearer <token>`.
15    Token(String),
16    /// Authentication explicitly disabled via `--no-auth`.
17    None,
18}
19
20// Hand-written so `{:?}` of an `AuthMode` (or the `ServeConfig` that embeds one)
21// never prints the bearer token in clear. The token is also registered for
22// redaction in `ServeConfig::from_args`, but masking here closes the Debug path
23// directly.
24impl std::fmt::Debug for AuthMode {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            AuthMode::Token(_) => f.debug_tuple("Token").field(&"***").finish(),
28            AuthMode::None => f.write_str("None"),
29        }
30    }
31}
32
33/// Run-history storage backend selection (parsed from `--history`).
34#[derive(Debug, Clone)]
35pub enum HistoryBackendSpec {
36    /// In-process `DashMap`; lost on restart (default).
37    Memory,
38    /// Postgres connection URL (stored verbatim, e.g. `postgres://host/db`).
39    Postgres(String),
40    /// SQLite connection URL, stored verbatim including the `sqlite:` scheme
41    /// (e.g. `sqlite:runs.db`, `sqlite::memory:`) so the backend can hand it
42    /// straight to `sqlx` without re-deriving the form.
43    Sqlite(String),
44}
45
46/// Validated server configuration.
47#[derive(Debug, Clone)]
48pub struct ServeConfig {
49    pub listen: SocketAddr,
50    pub auth: AuthMode,
51    pub max_concurrent_runs: usize,
52    pub max_queued_runs: usize,
53    pub default_config_path: Option<PathBuf>,
54    pub history: HistoryBackendSpec,
55    pub cors_origins: Vec<String>,
56    pub body_limit_bytes: usize,
57    pub shutdown_grace: Duration,
58    pub retain_terminal_runs: Duration,
59    pub idempotency_retention: Duration,
60    /// Run-ownership lease TTL for multi-instance orphan fencing (#146 H7).
61    pub lease_ttl: Duration,
62    pub probe_timeout: Duration,
63    pub env_file: Option<PathBuf>,
64    pub no_env_file: bool,
65    /// Tracing filter directive for serve's own subscriber. Set from the
66    /// clap-resolved `--log-level` / `FAUCET_LOG`; defaults to `"info"`.
67    pub log_level: String,
68}
69
70fn default_max_concurrent() -> usize {
71    std::thread::available_parallelism()
72        .map(|n| n.get())
73        .unwrap_or(4)
74        .clamp(1, 16)
75}
76
77impl ServeConfig {
78    /// Build + validate a `ServeConfig` from parsed CLI args. Enforces the
79    /// no-auth gate: a server with neither a token nor `--no-auth` refuses to start.
80    pub fn from_args(args: ServeArgs) -> CliResult<Self> {
81        let auth = match (args.auth_token, args.no_auth) {
82            (Some(t), _) if t.is_empty() => {
83                return Err(CliError::Serve(
84                    "--auth-token / FAUCET_SERVE_AUTH_TOKEN must not be empty \
85                     (use --no-auth to explicitly disable authentication)"
86                        .into(),
87                ));
88            }
89            (Some(t), _) => {
90                // Register so the RedactingWriter scrubs the token from any
91                // tracing/log/error output for the lifetime of the process.
92                crate::secrets::registry::register(&t);
93                AuthMode::Token(t)
94            }
95            (None, true) => AuthMode::None,
96            (None, false) => {
97                return Err(CliError::Serve(
98                    "refusing to start without authentication: pass --auth-token \
99                     (or FAUCET_SERVE_AUTH_TOKEN), or --no-auth to explicitly disable it"
100                        .into(),
101                ));
102            }
103        };
104
105        let listen: SocketAddr = args
106            .listen
107            .parse()
108            .map_err(|e| CliError::Serve(format!("invalid --listen '{}': {e}", args.listen)))?;
109
110        let history = match args.history {
111            None => HistoryBackendSpec::Memory,
112            Some(u) if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
113                HistoryBackendSpec::Postgres(u)
114            }
115            Some(u) if u.starts_with("sqlite:") => HistoryBackendSpec::Sqlite(u),
116            Some(u) => {
117                return Err(CliError::Serve(format!(
118                    "unrecognised --history '{u}': use a postgres:// URL or sqlite:<path>"
119                )));
120            }
121        };
122
123        if args.lease_ttl_secs == 0 {
124            return Err(CliError::Serve(
125                "--lease-ttl-secs must be > 0 (it gates multi-instance orphan recovery; \
126                 0 would immediately expire every run's lease and let a maintenance tick \
127                 fail in-flight runs)"
128                    .into(),
129            ));
130        }
131
132        // A zero drain window makes graceful shutdown `timeout(Duration::ZERO,
133        // wait_drained())` time out instantly and cancel in-flight runs —
134        // defeating the drain. Reject it (mirroring the lease-ttl gate).
135        // `--idempotency-retention-secs == 0` is NOT rejected: it is an explicit
136        // "dedup disabled" sentinel (every prior claim is immediately expired).
137        if args.shutdown_grace_secs == 0 {
138            return Err(CliError::Serve(
139                "--shutdown-grace-secs must be > 0 (0 makes graceful shutdown cancel \
140                 in-flight runs immediately instead of draining them; use a small \
141                 value like 1)"
142                    .into(),
143            ));
144        }
145
146        let max_concurrent_runs = args
147            .max_concurrent_runs
148            .unwrap_or_else(default_max_concurrent)
149            .max(1);
150        let max_queued_runs = args
151            .max_queued_runs
152            .unwrap_or_else(|| max_concurrent_runs.saturating_mul(8))
153            .max(1);
154
155        Ok(Self {
156            listen,
157            auth,
158            max_concurrent_runs,
159            max_queued_runs,
160            default_config_path: args.default_config,
161            history,
162            cors_origins: args.cors_origin,
163            body_limit_bytes: args.body_limit_bytes,
164            shutdown_grace: Duration::from_secs(args.shutdown_grace_secs),
165            retain_terminal_runs: Duration::from_secs(args.retain_terminal_runs_secs),
166            idempotency_retention: Duration::from_secs(args.idempotency_retention_secs),
167            lease_ttl: Duration::from_secs(args.lease_ttl_secs),
168            probe_timeout: Duration::from_secs(args.probe_timeout_secs),
169            env_file: args.env_file,
170            no_env_file: args.no_env_file,
171            log_level: "info".to_string(),
172        })
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::net::SocketAddr;
180
181    fn base_args() -> crate::cli::ServeArgs {
182        crate::cli::ServeArgs {
183            listen: "127.0.0.1:8080".into(),
184            auth_token: None,
185            no_auth: false,
186            max_concurrent_runs: None,
187            max_queued_runs: None,
188            default_config: None,
189            history: None,
190            cors_origin: vec![],
191            body_limit_bytes: 1_048_576,
192            shutdown_grace_secs: 60,
193            retain_terminal_runs_secs: 604_800,
194            idempotency_retention_secs: 86_400,
195            lease_ttl_secs: 30,
196            probe_timeout_secs: 10,
197            env_file: None,
198            no_env_file: false,
199        }
200    }
201
202    #[test]
203    fn no_auth_gate_rejects_silent_unauthenticated_start() {
204        let err = ServeConfig::from_args(base_args()).unwrap_err();
205        assert!(err.to_string().contains("--no-auth"), "{err}");
206    }
207
208    #[test]
209    fn explicit_no_auth_is_allowed() {
210        let mut a = base_args();
211        a.no_auth = true;
212        let cfg = ServeConfig::from_args(a).unwrap();
213        assert!(matches!(cfg.auth, AuthMode::None));
214    }
215
216    #[test]
217    fn token_sets_token_auth() {
218        let mut a = base_args();
219        a.auth_token = Some("hunter2".into());
220        let cfg = ServeConfig::from_args(a).unwrap();
221        assert!(matches!(cfg.auth, AuthMode::Token(t) if t == "hunter2"));
222    }
223
224    #[test]
225    fn auth_mode_debug_masks_token() {
226        // ServeConfig derives Debug and embeds AuthMode; a {:?} must not print
227        // the bearer token in clear.
228        let s = format!("{:?}", AuthMode::Token("supersecrettoken".into()));
229        assert!(
230            !s.contains("supersecrettoken"),
231            "serve token leaked via Debug: {s}"
232        );
233        assert!(s.contains("***"), "token not masked: {s}");
234    }
235
236    #[test]
237    fn token_is_registered_for_redaction() {
238        let mut a = base_args();
239        a.auth_token = Some("uniqueserveauthsecret987".into());
240        let _cfg = ServeConfig::from_args(a).unwrap();
241        // The token must be registered so the RedactingWriter scrubs it from logs.
242        let scrubbed =
243            crate::secrets::registry::redact("hdr=uniqueserveauthsecret987 end").into_owned();
244        assert!(
245            !scrubbed.contains("uniqueserveauthsecret987"),
246            "serve token not registered for redaction: {scrubbed}"
247        );
248    }
249
250    #[test]
251    fn listen_parses_to_socket_addr() {
252        let mut a = base_args();
253        a.no_auth = true;
254        a.listen = "0.0.0.0:9999".into();
255        let cfg = ServeConfig::from_args(a).unwrap();
256        assert_eq!(cfg.listen, "0.0.0.0:9999".parse::<SocketAddr>().unwrap());
257    }
258
259    #[test]
260    fn history_url_selects_backend() {
261        let mut a = base_args();
262        a.no_auth = true;
263        a.history = Some("postgres://localhost/db".into());
264        assert!(matches!(
265            ServeConfig::from_args(a.clone()).unwrap().history,
266            HistoryBackendSpec::Postgres(_)
267        ));
268        // The `postgresql://` alias maps to the same backend.
269        a.history = Some("postgresql://localhost/db".into());
270        assert!(matches!(
271            ServeConfig::from_args(a.clone()).unwrap().history,
272            HistoryBackendSpec::Postgres(_)
273        ));
274        // SQLite is stored verbatim, scheme included (for direct sqlx use).
275        a.history = Some("sqlite:runs.db".into());
276        match ServeConfig::from_args(a).unwrap().history {
277            HistoryBackendSpec::Sqlite(url) => assert_eq!(url, "sqlite:runs.db"),
278            other => panic!("expected Sqlite, got {other:?}"),
279        }
280    }
281
282    #[test]
283    fn empty_token_is_rejected() {
284        let mut a = base_args();
285        a.auth_token = Some(String::new());
286        let err = ServeConfig::from_args(a).unwrap_err();
287        assert!(err.to_string().contains("must not be empty"), "{err}");
288    }
289
290    #[test]
291    fn invalid_listen_returns_error() {
292        let mut a = base_args();
293        a.no_auth = true;
294        a.listen = "not-a-socket".into();
295        let err = ServeConfig::from_args(a).unwrap_err();
296        assert!(err.to_string().contains("invalid --listen"), "{err}");
297    }
298
299    #[test]
300    fn unrecognised_history_scheme_returns_error() {
301        let mut a = base_args();
302        a.no_auth = true;
303        a.history = Some("mysql://localhost/db".into());
304        let err = ServeConfig::from_args(a).unwrap_err();
305        assert!(err.to_string().contains("unrecognised --history"), "{err}");
306    }
307
308    #[test]
309    fn zero_lease_ttl_is_rejected() {
310        let mut a = base_args();
311        a.no_auth = true;
312        a.lease_ttl_secs = 0;
313        let err = ServeConfig::from_args(a).unwrap_err();
314        assert!(err.to_string().contains("--lease-ttl-secs"), "{err}");
315    }
316
317    #[test]
318    fn lease_ttl_maps_to_duration() {
319        let mut a = base_args();
320        a.no_auth = true;
321        a.lease_ttl_secs = 45;
322        let cfg = ServeConfig::from_args(a).unwrap();
323        assert_eq!(cfg.lease_ttl, Duration::from_secs(45));
324    }
325
326    #[test]
327    fn zero_shutdown_grace_is_rejected() {
328        // A zero drain window makes graceful shutdown `timeout(Duration::ZERO,
329        // wait_drained())` cancel in-flight runs instantly — defeating the
330        // drain. Reject it the same way the lease-ttl gate does.
331        let mut a = base_args();
332        a.no_auth = true;
333        a.shutdown_grace_secs = 0;
334        let err = ServeConfig::from_args(a).unwrap_err();
335        assert!(err.to_string().contains("--shutdown-grace-secs"), "{err}");
336    }
337
338    #[test]
339    fn nonzero_shutdown_grace_is_accepted() {
340        let mut a = base_args();
341        a.no_auth = true;
342        a.shutdown_grace_secs = 5;
343        let cfg = ServeConfig::from_args(a).unwrap();
344        assert_eq!(cfg.shutdown_grace, Duration::from_secs(5));
345    }
346
347    #[test]
348    fn zero_idempotency_retention_is_accepted_as_dedup_disabled_sentinel() {
349        // `idempotency_retention_secs == 0` is an explicit "dedup disabled"
350        // sentinel (every prior claim is immediately expired), NOT an error.
351        let mut a = base_args();
352        a.no_auth = true;
353        a.idempotency_retention_secs = 0;
354        let cfg = ServeConfig::from_args(a).unwrap();
355        assert_eq!(cfg.idempotency_retention, Duration::ZERO);
356    }
357}