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            mcp: false,
292            mcp_allow_mutations: false,
293        }
294    }
295
296    #[test]
297    fn no_auth_gate_rejects_silent_unauthenticated_start() {
298        let err = ServeConfig::from_args(base_args()).unwrap_err();
299        assert!(err.to_string().contains("--no-auth"), "{err}");
300    }
301
302    #[test]
303    fn explicit_no_auth_is_allowed() {
304        let mut a = base_args();
305        a.no_auth = true;
306        let cfg = ServeConfig::from_args(a).unwrap();
307        assert!(matches!(cfg.auth, AuthMode::None));
308    }
309
310    #[test]
311    fn token_sets_token_auth() {
312        let mut a = base_args();
313        a.auth_token = Some("hunter2".into());
314        let cfg = ServeConfig::from_args(a).unwrap();
315        assert!(matches!(cfg.auth, AuthMode::Token(t) if t == "hunter2"));
316    }
317
318    #[test]
319    fn resolve_none_is_anonymous_admin() {
320        let ctx = AuthMode::None.resolve(None).unwrap();
321        assert_eq!(ctx.principal, "anonymous");
322        assert_eq!(ctx.role, Role::Admin);
323    }
324
325    #[test]
326    fn resolve_token_matches_only_exact() {
327        let mode = AuthMode::Token("s3cret".into());
328        let ctx = mode.resolve(Some("s3cret")).unwrap();
329        assert_eq!(ctx.principal, "token");
330        assert_eq!(ctx.role, Role::Admin);
331        assert!(mode.resolve(Some("wrong")).is_none());
332        assert!(mode.resolve(None).is_none());
333    }
334
335    #[test]
336    fn resolve_rbac_maps_token_to_principal() {
337        use crate::serve::rbac::{PrincipalSpec, RbacConfig};
338        let cfg = RbacConfig::new(vec![PrincipalSpec {
339            name: "bob".into(),
340            token: "viewer-tok".into(),
341            role: Role::Viewer,
342        }])
343        .unwrap();
344        let mode = AuthMode::Rbac(Arc::new(cfg));
345        let ctx = mode.resolve(Some("viewer-tok")).unwrap();
346        assert_eq!(ctx.principal, "bob");
347        assert_eq!(ctx.role, Role::Viewer);
348        assert!(mode.resolve(Some("nope")).is_none());
349    }
350
351    #[test]
352    fn auth_config_file_builds_rbac_and_registers_tokens() {
353        let dir = tempfile::tempdir().unwrap();
354        let path = dir.path().join("auth.yaml");
355        std::fs::write(
356            &path,
357            "principals:\n  - name: carol\n    token: rbacsecret12345\n    role: operator\n",
358        )
359        .unwrap();
360        let mut a = base_args();
361        a.auth_config = Some(path);
362        let cfg = ServeConfig::from_args(a).unwrap();
363        assert!(matches!(cfg.auth, AuthMode::Rbac(_)));
364        // Every principal token must be registered for log redaction.
365        let scrubbed = crate::secrets::registry::redact("t=rbacsecret12345 end").into_owned();
366        assert!(!scrubbed.contains("rbacsecret12345"), "{scrubbed}");
367    }
368
369    #[test]
370    fn auth_config_debug_masks_tokens() {
371        use crate::serve::rbac::{PrincipalSpec, RbacConfig};
372        let cfg = RbacConfig::new(vec![PrincipalSpec {
373            name: "x".into(),
374            token: "supersecretrbac".into(),
375            role: Role::Admin,
376        }])
377        .unwrap();
378        let s = format!("{:?}", AuthMode::Rbac(Arc::new(cfg)));
379        assert!(!s.contains("supersecretrbac"), "rbac token leaked: {s}");
380    }
381
382    #[test]
383    fn auth_mode_debug_masks_token() {
384        // ServeConfig derives Debug and embeds AuthMode; a {:?} must not print
385        // the bearer token in clear.
386        let s = format!("{:?}", AuthMode::Token("supersecrettoken".into()));
387        assert!(
388            !s.contains("supersecrettoken"),
389            "serve token leaked via Debug: {s}"
390        );
391        assert!(s.contains("***"), "token not masked: {s}");
392    }
393
394    #[test]
395    fn token_is_registered_for_redaction() {
396        let mut a = base_args();
397        a.auth_token = Some("uniqueserveauthsecret987".into());
398        let _cfg = ServeConfig::from_args(a).unwrap();
399        // The token must be registered so the RedactingWriter scrubs it from logs.
400        let scrubbed =
401            crate::secrets::registry::redact("hdr=uniqueserveauthsecret987 end").into_owned();
402        assert!(
403            !scrubbed.contains("uniqueserveauthsecret987"),
404            "serve token not registered for redaction: {scrubbed}"
405        );
406    }
407
408    #[test]
409    fn listen_parses_to_socket_addr() {
410        let mut a = base_args();
411        a.no_auth = true;
412        a.listen = "0.0.0.0:9999".into();
413        let cfg = ServeConfig::from_args(a).unwrap();
414        assert_eq!(cfg.listen, "0.0.0.0:9999".parse::<SocketAddr>().unwrap());
415    }
416
417    #[test]
418    fn history_url_selects_backend() {
419        let mut a = base_args();
420        a.no_auth = true;
421        a.history = Some("postgres://localhost/db".into());
422        assert!(matches!(
423            ServeConfig::from_args(a.clone()).unwrap().history,
424            HistoryBackendSpec::Postgres(_)
425        ));
426        // The `postgresql://` alias maps to the same backend.
427        a.history = Some("postgresql://localhost/db".into());
428        assert!(matches!(
429            ServeConfig::from_args(a.clone()).unwrap().history,
430            HistoryBackendSpec::Postgres(_)
431        ));
432        // SQLite is stored verbatim, scheme included (for direct sqlx use).
433        a.history = Some("sqlite:runs.db".into());
434        match ServeConfig::from_args(a).unwrap().history {
435            HistoryBackendSpec::Sqlite(url) => assert_eq!(url, "sqlite:runs.db"),
436            other => panic!("expected Sqlite, got {other:?}"),
437        }
438    }
439
440    #[test]
441    fn empty_token_is_rejected() {
442        let mut a = base_args();
443        a.auth_token = Some(String::new());
444        let err = ServeConfig::from_args(a).unwrap_err();
445        assert!(err.to_string().contains("must not be empty"), "{err}");
446    }
447
448    #[test]
449    fn invalid_listen_returns_error() {
450        let mut a = base_args();
451        a.no_auth = true;
452        a.listen = "not-a-socket".into();
453        let err = ServeConfig::from_args(a).unwrap_err();
454        assert!(err.to_string().contains("invalid --listen"), "{err}");
455    }
456
457    #[test]
458    fn unrecognised_history_scheme_returns_error() {
459        let mut a = base_args();
460        a.no_auth = true;
461        a.history = Some("mysql://localhost/db".into());
462        let err = ServeConfig::from_args(a).unwrap_err();
463        assert!(err.to_string().contains("unrecognised --history"), "{err}");
464    }
465
466    #[test]
467    fn zero_lease_ttl_is_rejected() {
468        let mut a = base_args();
469        a.no_auth = true;
470        a.lease_ttl_secs = 0;
471        let err = ServeConfig::from_args(a).unwrap_err();
472        assert!(err.to_string().contains("--lease-ttl-secs"), "{err}");
473    }
474
475    #[test]
476    fn lease_ttl_maps_to_duration() {
477        let mut a = base_args();
478        a.no_auth = true;
479        a.lease_ttl_secs = 45;
480        let cfg = ServeConfig::from_args(a).unwrap();
481        assert_eq!(cfg.lease_ttl, Duration::from_secs(45));
482    }
483
484    #[test]
485    fn zero_shutdown_grace_is_rejected() {
486        // A zero drain window makes graceful shutdown `timeout(Duration::ZERO,
487        // wait_drained())` cancel in-flight runs instantly — defeating the
488        // drain. Reject it the same way the lease-ttl gate does.
489        let mut a = base_args();
490        a.no_auth = true;
491        a.shutdown_grace_secs = 0;
492        let err = ServeConfig::from_args(a).unwrap_err();
493        assert!(err.to_string().contains("--shutdown-grace-secs"), "{err}");
494    }
495
496    #[test]
497    fn nonzero_shutdown_grace_is_accepted() {
498        let mut a = base_args();
499        a.no_auth = true;
500        a.shutdown_grace_secs = 5;
501        let cfg = ServeConfig::from_args(a).unwrap();
502        assert_eq!(cfg.shutdown_grace, Duration::from_secs(5));
503    }
504
505    #[test]
506    fn zero_idempotency_retention_is_accepted_as_dedup_disabled_sentinel() {
507        // `idempotency_retention_secs == 0` is an explicit "dedup disabled"
508        // sentinel (every prior claim is immediately expired), NOT an error.
509        let mut a = base_args();
510        a.no_auth = true;
511        a.idempotency_retention_secs = 0;
512        let cfg = ServeConfig::from_args(a).unwrap();
513        assert_eq!(cfg.idempotency_retention, Duration::ZERO);
514    }
515
516    #[test]
517    fn cluster_requires_persistent_history() {
518        let mut a = base_args();
519        a.no_auth = true;
520        a.cluster = true; // history defaults to memory
521        let err = ServeConfig::from_args(a).unwrap_err();
522        assert!(err.to_string().contains("--cluster requires"), "{err}");
523    }
524
525    #[test]
526    fn cluster_with_sqlite_is_enabled() {
527        let mut a = base_args();
528        a.no_auth = true;
529        a.cluster = true;
530        a.history = Some("sqlite:runs.db".into());
531        let cfg = ServeConfig::from_args(a).unwrap();
532        assert!(cfg.cluster.enabled);
533        assert_eq!(cfg.cluster.max_attempts, 3);
534    }
535
536    #[test]
537    fn cluster_disabled_by_default() {
538        let mut a = base_args();
539        a.no_auth = true;
540        assert!(!ServeConfig::from_args(a).unwrap().cluster.enabled);
541    }
542}