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 crate::serve::cluster::ClusterConfig;
8use crate::serve::rbac::{AuthContext, RbacConfig, Role};
9use std::net::SocketAddr;
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::time::Duration;
13
14/// How `/v1/*` requests are authenticated.
15#[derive(Clone)]
16pub enum AuthMode {
17    /// Require `Authorization: Bearer <token>` — a single implicit `admin`
18    /// principal.
19    Token(String),
20    /// Multi-principal RBAC from `--auth-config`: bearer token → principal → role.
21    Rbac(Arc<RbacConfig>),
22    /// Authentication explicitly disabled via `--no-auth`.
23    None,
24}
25
26// Hand-written so `{:?}` of an `AuthMode` (or the `ServeConfig` that embeds one)
27// never prints the bearer token in clear. The token is also registered for
28// redaction in `ServeConfig::from_args`, but masking here closes the Debug path
29// directly.
30impl std::fmt::Debug for AuthMode {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            AuthMode::Token(_) => f.debug_tuple("Token").field(&"***").finish(),
34            AuthMode::Rbac(cfg) => f.debug_tuple("Rbac").field(cfg).finish(),
35            AuthMode::None => f.write_str("None"),
36        }
37    }
38}
39
40impl AuthMode {
41    /// Resolve a request's `Authorization: Bearer` token to its [`AuthContext`],
42    /// or `None` if the credential is missing / invalid. `--no-auth` yields an
43    /// implicit `anonymous` admin so downstream authz + audit are uniform.
44    pub fn resolve(&self, bearer: Option<&str>) -> Option<AuthContext> {
45        match self {
46            AuthMode::None => Some(AuthContext {
47                principal: "anonymous".to_string(),
48                role: Role::Admin,
49                source_ip: None,
50            }),
51            AuthMode::Token(expected) => bearer
52                .filter(|t| crate::serve::auth::constant_time_eq(t.as_bytes(), expected.as_bytes()))
53                .map(|_| AuthContext {
54                    principal: "token".to_string(),
55                    role: Role::Admin,
56                    source_ip: None,
57                }),
58            AuthMode::Rbac(cfg) => bearer.and_then(|t| cfg.authenticate(t)),
59        }
60    }
61}
62
63/// Run-history storage backend selection (parsed from `--history`).
64#[derive(Debug, Clone)]
65pub enum HistoryBackendSpec {
66    /// In-process `DashMap`; lost on restart (default).
67    Memory,
68    /// Postgres connection URL (stored verbatim, e.g. `postgres://host/db`).
69    Postgres(String),
70    /// SQLite connection URL, stored verbatim including the `sqlite:` scheme
71    /// (e.g. `sqlite:runs.db`, `sqlite::memory:`) so the backend can hand it
72    /// straight to `sqlx` without re-deriving the form.
73    Sqlite(String),
74}
75
76/// Validated server configuration.
77#[derive(Debug, Clone)]
78pub struct ServeConfig {
79    pub listen: SocketAddr,
80    pub auth: AuthMode,
81    pub max_concurrent_runs: usize,
82    pub max_queued_runs: usize,
83    pub default_config_path: Option<PathBuf>,
84    pub history: HistoryBackendSpec,
85    pub cors_origins: Vec<String>,
86    pub body_limit_bytes: usize,
87    pub shutdown_grace: Duration,
88    pub retain_terminal_runs: Duration,
89    pub idempotency_retention: Duration,
90    /// Run-ownership lease TTL for multi-instance orphan fencing (#146 H7).
91    pub lease_ttl: Duration,
92    pub probe_timeout: Duration,
93    pub env_file: Option<PathBuf>,
94    pub no_env_file: bool,
95    /// Tracing filter directive for serve's own subscriber. Set from the
96    /// clap-resolved `--log-level` / `FAUCET_LOG`; defaults to `"info"`.
97    pub log_level: String,
98    /// Whether to serve the embedded web console. Built only when the `serve-ui`
99    /// feature is on; this gates serving at runtime (`--no-ui`).
100    #[cfg_attr(not(feature = "serve-ui"), allow(dead_code))]
101    pub ui_enabled: bool,
102    /// Clustered-execution settings (`--cluster*`). Disabled by default.
103    pub cluster: ClusterConfig,
104    /// Path to a `--triggers` file. `None` = no event-driven triggers. The file
105    /// is loaded + validated at startup (gated on the `triggers` feature).
106    pub triggers_path: Option<PathBuf>,
107}
108
109fn default_max_concurrent() -> usize {
110    std::thread::available_parallelism()
111        .map(|n| n.get())
112        .unwrap_or(4)
113        .clamp(1, 16)
114}
115
116impl ServeConfig {
117    /// Build + validate a `ServeConfig` from parsed CLI args. Enforces the
118    /// no-auth gate: a server with neither a token nor `--no-auth` refuses to start.
119    pub fn from_args(args: ServeArgs) -> CliResult<Self> {
120        // RBAC (`--auth-config`) takes precedence and is mutually exclusive with
121        // `--auth-token` / `--no-auth` (enforced by clap `conflicts_with`).
122        let auth = if let Some(path) = args.auth_config {
123            let rbac = RbacConfig::from_file(&path)?;
124            // Register every principal token so the RedactingWriter scrubs it
125            // from any tracing/log/error output for the process lifetime.
126            for token in rbac.tokens() {
127                crate::secrets::registry::register(token);
128            }
129            AuthMode::Rbac(Arc::new(rbac))
130        } else {
131            match (args.auth_token, args.no_auth) {
132                (Some(t), _) if t.is_empty() => {
133                    return Err(CliError::Serve(
134                        "--auth-token / FAUCET_SERVE_AUTH_TOKEN must not be empty \
135                         (use --no-auth to explicitly disable authentication)"
136                            .into(),
137                    ));
138                }
139                (Some(t), _) => {
140                    // Register so the RedactingWriter scrubs the token from any
141                    // tracing/log/error output for the lifetime of the process.
142                    crate::secrets::registry::register(&t);
143                    AuthMode::Token(t)
144                }
145                (None, true) => AuthMode::None,
146                (None, false) => {
147                    return Err(CliError::Serve(
148                        "refusing to start without authentication: pass --auth-token \
149                         (or FAUCET_SERVE_AUTH_TOKEN), --auth-config <file> for RBAC, \
150                         or --no-auth to explicitly disable it"
151                            .into(),
152                    ));
153                }
154            }
155        };
156
157        let listen: SocketAddr = args
158            .listen
159            .parse()
160            .map_err(|e| CliError::Serve(format!("invalid --listen '{}': {e}", args.listen)))?;
161
162        let history = match args.history {
163            None => HistoryBackendSpec::Memory,
164            Some(u) if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
165                HistoryBackendSpec::Postgres(u)
166            }
167            Some(u) if u.starts_with("sqlite:") => HistoryBackendSpec::Sqlite(u),
168            Some(u) => {
169                return Err(CliError::Serve(format!(
170                    "unrecognised --history '{u}': use a postgres:// URL or sqlite:<path>"
171                )));
172            }
173        };
174
175        if args.lease_ttl_secs == 0 {
176            return Err(CliError::Serve(
177                "--lease-ttl-secs must be > 0 (it gates multi-instance orphan recovery; \
178                 0 would immediately expire every run's lease and let a maintenance tick \
179                 fail in-flight runs)"
180                    .into(),
181            ));
182        }
183
184        // A zero drain window makes graceful shutdown `timeout(Duration::ZERO,
185        // wait_drained())` time out instantly and cancel in-flight runs —
186        // defeating the drain. Reject it (mirroring the lease-ttl gate).
187        // `--idempotency-retention-secs == 0` is NOT rejected: it is an explicit
188        // "dedup disabled" sentinel (every prior claim is immediately expired).
189        if args.shutdown_grace_secs == 0 {
190            return Err(CliError::Serve(
191                "--shutdown-grace-secs must be > 0 (0 makes graceful shutdown cancel \
192                 in-flight runs immediately instead of draining them; use a small \
193                 value like 1)"
194                    .into(),
195            ));
196        }
197
198        let cluster = if args.cluster {
199            if matches!(history, HistoryBackendSpec::Memory) {
200                return Err(CliError::Serve(
201                    "--cluster requires a persistent --history backend \
202                     (postgres://… or sqlite:…); the in-memory store is single-process"
203                        .into(),
204                ));
205            }
206            if args.cluster_poll_secs == 0 {
207                return Err(CliError::Serve(
208                    "--cluster-poll-secs must be > 0 (0 would spin the claim loop \
209                     with no back-off, saturating the history DB)"
210                        .into(),
211                ));
212            }
213            if args.cluster_max_attempts == 0 {
214                return Err(CliError::Serve(
215                    "--cluster-max-attempts must be > 0 (0 would never re-run \
216                     orphaned runs, defeating failover)"
217                        .into(),
218                ));
219            }
220            ClusterConfig {
221                enabled: true,
222                poll: Duration::from_secs(args.cluster_poll_secs),
223                max_attempts: args.cluster_max_attempts,
224            }
225        } else {
226            ClusterConfig::disabled()
227        };
228
229        let max_concurrent_runs = args
230            .max_concurrent_runs
231            .unwrap_or_else(default_max_concurrent)
232            .max(1);
233        let max_queued_runs = args
234            .max_queued_runs
235            .unwrap_or_else(|| max_concurrent_runs.saturating_mul(8))
236            .max(1);
237
238        Ok(Self {
239            listen,
240            auth,
241            max_concurrent_runs,
242            max_queued_runs,
243            default_config_path: args.default_config,
244            history,
245            cors_origins: args.cors_origin,
246            body_limit_bytes: args.body_limit_bytes,
247            shutdown_grace: Duration::from_secs(args.shutdown_grace_secs),
248            retain_terminal_runs: Duration::from_secs(args.retain_terminal_runs_secs),
249            idempotency_retention: Duration::from_secs(args.idempotency_retention_secs),
250            lease_ttl: Duration::from_secs(args.lease_ttl_secs),
251            probe_timeout: Duration::from_secs(args.probe_timeout_secs),
252            env_file: args.env_file,
253            no_env_file: args.no_env_file,
254            log_level: "info".to_string(),
255            ui_enabled: !args.no_ui,
256            cluster,
257            triggers_path: args.triggers,
258        })
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use std::net::SocketAddr;
266
267    fn base_args() -> crate::cli::ServeArgs {
268        crate::cli::ServeArgs {
269            listen: "127.0.0.1:8080".into(),
270            auth_token: None,
271            auth_config: None,
272            no_auth: false,
273            max_concurrent_runs: None,
274            max_queued_runs: None,
275            default_config: None,
276            history: None,
277            cors_origin: vec![],
278            body_limit_bytes: 1_048_576,
279            shutdown_grace_secs: 60,
280            retain_terminal_runs_secs: 604_800,
281            idempotency_retention_secs: 86_400,
282            lease_ttl_secs: 30,
283            probe_timeout_secs: 10,
284            env_file: None,
285            no_env_file: false,
286            no_ui: false,
287            cluster: false,
288            cluster_poll_secs: 2,
289            cluster_max_attempts: 3,
290            triggers: None,
291        }
292    }
293
294    #[test]
295    fn no_auth_gate_rejects_silent_unauthenticated_start() {
296        let err = ServeConfig::from_args(base_args()).unwrap_err();
297        assert!(err.to_string().contains("--no-auth"), "{err}");
298    }
299
300    #[test]
301    fn explicit_no_auth_is_allowed() {
302        let mut a = base_args();
303        a.no_auth = true;
304        let cfg = ServeConfig::from_args(a).unwrap();
305        assert!(matches!(cfg.auth, AuthMode::None));
306    }
307
308    #[test]
309    fn token_sets_token_auth() {
310        let mut a = base_args();
311        a.auth_token = Some("hunter2".into());
312        let cfg = ServeConfig::from_args(a).unwrap();
313        assert!(matches!(cfg.auth, AuthMode::Token(t) if t == "hunter2"));
314    }
315
316    #[test]
317    fn resolve_none_is_anonymous_admin() {
318        let ctx = AuthMode::None.resolve(None).unwrap();
319        assert_eq!(ctx.principal, "anonymous");
320        assert_eq!(ctx.role, Role::Admin);
321    }
322
323    #[test]
324    fn resolve_token_matches_only_exact() {
325        let mode = AuthMode::Token("s3cret".into());
326        let ctx = mode.resolve(Some("s3cret")).unwrap();
327        assert_eq!(ctx.principal, "token");
328        assert_eq!(ctx.role, Role::Admin);
329        assert!(mode.resolve(Some("wrong")).is_none());
330        assert!(mode.resolve(None).is_none());
331    }
332
333    #[test]
334    fn resolve_rbac_maps_token_to_principal() {
335        use crate::serve::rbac::{PrincipalSpec, RbacConfig};
336        let cfg = RbacConfig::new(vec![PrincipalSpec {
337            name: "bob".into(),
338            token: "viewer-tok".into(),
339            role: Role::Viewer,
340        }])
341        .unwrap();
342        let mode = AuthMode::Rbac(Arc::new(cfg));
343        let ctx = mode.resolve(Some("viewer-tok")).unwrap();
344        assert_eq!(ctx.principal, "bob");
345        assert_eq!(ctx.role, Role::Viewer);
346        assert!(mode.resolve(Some("nope")).is_none());
347    }
348
349    #[test]
350    fn auth_config_file_builds_rbac_and_registers_tokens() {
351        let dir = tempfile::tempdir().unwrap();
352        let path = dir.path().join("auth.yaml");
353        std::fs::write(
354            &path,
355            "principals:\n  - name: carol\n    token: rbacsecret12345\n    role: operator\n",
356        )
357        .unwrap();
358        let mut a = base_args();
359        a.auth_config = Some(path);
360        let cfg = ServeConfig::from_args(a).unwrap();
361        assert!(matches!(cfg.auth, AuthMode::Rbac(_)));
362        // Every principal token must be registered for log redaction.
363        let scrubbed = crate::secrets::registry::redact("t=rbacsecret12345 end").into_owned();
364        assert!(!scrubbed.contains("rbacsecret12345"), "{scrubbed}");
365    }
366
367    #[test]
368    fn auth_config_debug_masks_tokens() {
369        use crate::serve::rbac::{PrincipalSpec, RbacConfig};
370        let cfg = RbacConfig::new(vec![PrincipalSpec {
371            name: "x".into(),
372            token: "supersecretrbac".into(),
373            role: Role::Admin,
374        }])
375        .unwrap();
376        let s = format!("{:?}", AuthMode::Rbac(Arc::new(cfg)));
377        assert!(!s.contains("supersecretrbac"), "rbac token leaked: {s}");
378    }
379
380    #[test]
381    fn auth_mode_debug_masks_token() {
382        // ServeConfig derives Debug and embeds AuthMode; a {:?} must not print
383        // the bearer token in clear.
384        let s = format!("{:?}", AuthMode::Token("supersecrettoken".into()));
385        assert!(
386            !s.contains("supersecrettoken"),
387            "serve token leaked via Debug: {s}"
388        );
389        assert!(s.contains("***"), "token not masked: {s}");
390    }
391
392    #[test]
393    fn token_is_registered_for_redaction() {
394        let mut a = base_args();
395        a.auth_token = Some("uniqueserveauthsecret987".into());
396        let _cfg = ServeConfig::from_args(a).unwrap();
397        // The token must be registered so the RedactingWriter scrubs it from logs.
398        let scrubbed =
399            crate::secrets::registry::redact("hdr=uniqueserveauthsecret987 end").into_owned();
400        assert!(
401            !scrubbed.contains("uniqueserveauthsecret987"),
402            "serve token not registered for redaction: {scrubbed}"
403        );
404    }
405
406    #[test]
407    fn listen_parses_to_socket_addr() {
408        let mut a = base_args();
409        a.no_auth = true;
410        a.listen = "0.0.0.0:9999".into();
411        let cfg = ServeConfig::from_args(a).unwrap();
412        assert_eq!(cfg.listen, "0.0.0.0:9999".parse::<SocketAddr>().unwrap());
413    }
414
415    #[test]
416    fn history_url_selects_backend() {
417        let mut a = base_args();
418        a.no_auth = true;
419        a.history = Some("postgres://localhost/db".into());
420        assert!(matches!(
421            ServeConfig::from_args(a.clone()).unwrap().history,
422            HistoryBackendSpec::Postgres(_)
423        ));
424        // The `postgresql://` alias maps to the same backend.
425        a.history = Some("postgresql://localhost/db".into());
426        assert!(matches!(
427            ServeConfig::from_args(a.clone()).unwrap().history,
428            HistoryBackendSpec::Postgres(_)
429        ));
430        // SQLite is stored verbatim, scheme included (for direct sqlx use).
431        a.history = Some("sqlite:runs.db".into());
432        match ServeConfig::from_args(a).unwrap().history {
433            HistoryBackendSpec::Sqlite(url) => assert_eq!(url, "sqlite:runs.db"),
434            other => panic!("expected Sqlite, got {other:?}"),
435        }
436    }
437
438    #[test]
439    fn empty_token_is_rejected() {
440        let mut a = base_args();
441        a.auth_token = Some(String::new());
442        let err = ServeConfig::from_args(a).unwrap_err();
443        assert!(err.to_string().contains("must not be empty"), "{err}");
444    }
445
446    #[test]
447    fn invalid_listen_returns_error() {
448        let mut a = base_args();
449        a.no_auth = true;
450        a.listen = "not-a-socket".into();
451        let err = ServeConfig::from_args(a).unwrap_err();
452        assert!(err.to_string().contains("invalid --listen"), "{err}");
453    }
454
455    #[test]
456    fn unrecognised_history_scheme_returns_error() {
457        let mut a = base_args();
458        a.no_auth = true;
459        a.history = Some("mysql://localhost/db".into());
460        let err = ServeConfig::from_args(a).unwrap_err();
461        assert!(err.to_string().contains("unrecognised --history"), "{err}");
462    }
463
464    #[test]
465    fn zero_lease_ttl_is_rejected() {
466        let mut a = base_args();
467        a.no_auth = true;
468        a.lease_ttl_secs = 0;
469        let err = ServeConfig::from_args(a).unwrap_err();
470        assert!(err.to_string().contains("--lease-ttl-secs"), "{err}");
471    }
472
473    #[test]
474    fn lease_ttl_maps_to_duration() {
475        let mut a = base_args();
476        a.no_auth = true;
477        a.lease_ttl_secs = 45;
478        let cfg = ServeConfig::from_args(a).unwrap();
479        assert_eq!(cfg.lease_ttl, Duration::from_secs(45));
480    }
481
482    #[test]
483    fn zero_shutdown_grace_is_rejected() {
484        // A zero drain window makes graceful shutdown `timeout(Duration::ZERO,
485        // wait_drained())` cancel in-flight runs instantly — defeating the
486        // drain. Reject it the same way the lease-ttl gate does.
487        let mut a = base_args();
488        a.no_auth = true;
489        a.shutdown_grace_secs = 0;
490        let err = ServeConfig::from_args(a).unwrap_err();
491        assert!(err.to_string().contains("--shutdown-grace-secs"), "{err}");
492    }
493
494    #[test]
495    fn nonzero_shutdown_grace_is_accepted() {
496        let mut a = base_args();
497        a.no_auth = true;
498        a.shutdown_grace_secs = 5;
499        let cfg = ServeConfig::from_args(a).unwrap();
500        assert_eq!(cfg.shutdown_grace, Duration::from_secs(5));
501    }
502
503    #[test]
504    fn zero_idempotency_retention_is_accepted_as_dedup_disabled_sentinel() {
505        // `idempotency_retention_secs == 0` is an explicit "dedup disabled"
506        // sentinel (every prior claim is immediately expired), NOT an error.
507        let mut a = base_args();
508        a.no_auth = true;
509        a.idempotency_retention_secs = 0;
510        let cfg = ServeConfig::from_args(a).unwrap();
511        assert_eq!(cfg.idempotency_retention, Duration::ZERO);
512    }
513
514    #[test]
515    fn cluster_requires_persistent_history() {
516        let mut a = base_args();
517        a.no_auth = true;
518        a.cluster = true; // history defaults to memory
519        let err = ServeConfig::from_args(a).unwrap_err();
520        assert!(err.to_string().contains("--cluster requires"), "{err}");
521    }
522
523    #[test]
524    fn cluster_with_sqlite_is_enabled() {
525        let mut a = base_args();
526        a.no_auth = true;
527        a.cluster = true;
528        a.history = Some("sqlite:runs.db".into());
529        let cfg = ServeConfig::from_args(a).unwrap();
530        assert!(cfg.cluster.enabled);
531        assert_eq!(cfg.cluster.max_attempts, 3);
532    }
533
534    #[test]
535    fn cluster_disabled_by_default() {
536        let mut a = base_args();
537        a.no_auth = true;
538        assert!(!ServeConfig::from_args(a).unwrap().cluster.enabled);
539    }
540}