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    /// Allowlist of hosts a per-run completion callback may target (#481).
108    /// Empty = any host except link-local / cloud-metadata addresses.
109    pub callback_allow_hosts: Vec<String>,
110}
111
112fn default_max_concurrent() -> usize {
113    std::thread::available_parallelism()
114        .map(|n| n.get())
115        .unwrap_or(4)
116        .clamp(1, 16)
117}
118
119impl ServeConfig {
120    /// Build + validate a `ServeConfig` from parsed CLI args. Enforces the
121    /// no-auth gate: a server with neither a token nor `--no-auth` refuses to start.
122    pub fn from_args(args: ServeArgs) -> CliResult<Self> {
123        // RBAC (`--auth-config`) takes precedence and is mutually exclusive with
124        // `--auth-token` / `--no-auth` (enforced by clap `conflicts_with`).
125        let auth = if let Some(path) = args.auth_config {
126            let rbac = RbacConfig::from_file(&path)?;
127            // Register every principal token so the RedactingWriter scrubs it
128            // from any tracing/log/error output for the process lifetime.
129            for token in rbac.tokens() {
130                crate::secrets::registry::register(token);
131            }
132            AuthMode::Rbac(Arc::new(rbac))
133        } else {
134            match (args.auth_token, args.no_auth) {
135                (Some(t), _) if t.is_empty() => {
136                    return Err(CliError::Serve(
137                        "--auth-token / FAUCET_SERVE_AUTH_TOKEN must not be empty \
138                         (use --no-auth to explicitly disable authentication)"
139                            .into(),
140                    ));
141                }
142                (Some(t), _) => {
143                    // Register so the RedactingWriter scrubs the token from any
144                    // tracing/log/error output for the lifetime of the process.
145                    crate::secrets::registry::register(&t);
146                    AuthMode::Token(t)
147                }
148                (None, true) => AuthMode::None,
149                (None, false) => {
150                    return Err(CliError::Serve(
151                        "refusing to start without authentication: pass --auth-token \
152                         (or FAUCET_SERVE_AUTH_TOKEN), --auth-config <file> for RBAC, \
153                         or --no-auth to explicitly disable it"
154                            .into(),
155                    ));
156                }
157            }
158        };
159
160        let listen: SocketAddr = args
161            .listen
162            .parse()
163            .map_err(|e| CliError::Serve(format!("invalid --listen '{}': {e}", args.listen)))?;
164
165        let history = match args.history {
166            None => HistoryBackendSpec::Memory,
167            Some(u) if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
168                HistoryBackendSpec::Postgres(u)
169            }
170            Some(u) if u.starts_with("sqlite:") => HistoryBackendSpec::Sqlite(u),
171            Some(u) => {
172                return Err(CliError::Serve(format!(
173                    "unrecognised --history '{u}': use a postgres:// URL or sqlite:<path>"
174                )));
175            }
176        };
177
178        if args.lease_ttl_secs == 0 {
179            return Err(CliError::Serve(
180                "--lease-ttl-secs must be > 0 (it gates multi-instance orphan recovery; \
181                 0 would immediately expire every run's lease and let a maintenance tick \
182                 fail in-flight runs)"
183                    .into(),
184            ));
185        }
186
187        // A zero drain window makes graceful shutdown `timeout(Duration::ZERO,
188        // wait_drained())` time out instantly and cancel in-flight runs —
189        // defeating the drain. Reject it (mirroring the lease-ttl gate).
190        // `--idempotency-retention-secs == 0` is NOT rejected: it is an explicit
191        // "dedup disabled" sentinel (every prior claim is immediately expired).
192        if args.shutdown_grace_secs == 0 {
193            return Err(CliError::Serve(
194                "--shutdown-grace-secs must be > 0 (0 makes graceful shutdown cancel \
195                 in-flight runs immediately instead of draining them; use a small \
196                 value like 1)"
197                    .into(),
198            ));
199        }
200
201        let cluster = if args.cluster {
202            if matches!(history, HistoryBackendSpec::Memory) {
203                return Err(CliError::Serve(
204                    "--cluster requires a persistent --history backend \
205                     (postgres://… or sqlite:…); the in-memory store is single-process"
206                        .into(),
207                ));
208            }
209            if args.cluster_poll_secs == 0 {
210                return Err(CliError::Serve(
211                    "--cluster-poll-secs must be > 0 (0 would spin the claim loop \
212                     with no back-off, saturating the history DB)"
213                        .into(),
214                ));
215            }
216            if args.cluster_max_attempts == 0 {
217                return Err(CliError::Serve(
218                    "--cluster-max-attempts must be > 0 (0 would never re-run \
219                     orphaned runs, defeating failover)"
220                        .into(),
221                ));
222            }
223            ClusterConfig {
224                enabled: true,
225                poll: Duration::from_secs(args.cluster_poll_secs),
226                max_attempts: args.cluster_max_attempts,
227            }
228        } else {
229            ClusterConfig::disabled()
230        };
231
232        let max_concurrent_runs = args
233            .max_concurrent_runs
234            .unwrap_or_else(default_max_concurrent)
235            .max(1);
236        let max_queued_runs = args
237            .max_queued_runs
238            .unwrap_or_else(|| max_concurrent_runs.saturating_mul(8))
239            .max(1);
240
241        Ok(Self {
242            listen,
243            auth,
244            max_concurrent_runs,
245            max_queued_runs,
246            default_config_path: args.default_config,
247            history,
248            cors_origins: args.cors_origin,
249            body_limit_bytes: args.body_limit_bytes,
250            shutdown_grace: Duration::from_secs(args.shutdown_grace_secs),
251            retain_terminal_runs: Duration::from_secs(args.retain_terminal_runs_secs),
252            idempotency_retention: Duration::from_secs(args.idempotency_retention_secs),
253            lease_ttl: Duration::from_secs(args.lease_ttl_secs),
254            probe_timeout: Duration::from_secs(args.probe_timeout_secs),
255            env_file: args.env_file,
256            no_env_file: args.no_env_file,
257            log_level: "info".to_string(),
258            ui_enabled: !args.no_ui,
259            cluster,
260            triggers_path: args.triggers,
261            callback_allow_hosts: args.callback_allow_host,
262        })
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use std::net::SocketAddr;
270
271    fn base_args() -> crate::cli::ServeArgs {
272        crate::cli::ServeArgs {
273            listen: "127.0.0.1:8080".into(),
274            auth_token: None,
275            auth_config: None,
276            no_auth: false,
277            max_concurrent_runs: None,
278            max_queued_runs: None,
279            default_config: None,
280            history: None,
281            cors_origin: vec![],
282            body_limit_bytes: 1_048_576,
283            shutdown_grace_secs: 60,
284            retain_terminal_runs_secs: 604_800,
285            idempotency_retention_secs: 86_400,
286            lease_ttl_secs: 30,
287            probe_timeout_secs: 10,
288            env_file: None,
289            no_env_file: false,
290            no_ui: false,
291            cluster: false,
292            cluster_poll_secs: 2,
293            cluster_max_attempts: 3,
294            triggers: None,
295            callback_allow_host: Vec::new(),
296            mcp: false,
297            mcp_allow_mutations: false,
298        }
299    }
300
301    #[test]
302    fn no_auth_gate_rejects_silent_unauthenticated_start() {
303        let err = ServeConfig::from_args(base_args()).unwrap_err();
304        assert!(err.to_string().contains("--no-auth"), "{err}");
305    }
306
307    #[test]
308    fn explicit_no_auth_is_allowed() {
309        let mut a = base_args();
310        a.no_auth = true;
311        let cfg = ServeConfig::from_args(a).unwrap();
312        assert!(matches!(cfg.auth, AuthMode::None));
313    }
314
315    #[test]
316    fn token_sets_token_auth() {
317        let mut a = base_args();
318        a.auth_token = Some("hunter2".into());
319        let cfg = ServeConfig::from_args(a).unwrap();
320        assert!(matches!(cfg.auth, AuthMode::Token(t) if t == "hunter2"));
321    }
322
323    #[test]
324    fn resolve_none_is_anonymous_admin() {
325        let ctx = AuthMode::None.resolve(None).unwrap();
326        assert_eq!(ctx.principal, "anonymous");
327        assert_eq!(ctx.role, Role::Admin);
328    }
329
330    #[test]
331    fn resolve_token_matches_only_exact() {
332        let mode = AuthMode::Token("s3cret".into());
333        let ctx = mode.resolve(Some("s3cret")).unwrap();
334        assert_eq!(ctx.principal, "token");
335        assert_eq!(ctx.role, Role::Admin);
336        assert!(mode.resolve(Some("wrong")).is_none());
337        assert!(mode.resolve(None).is_none());
338    }
339
340    #[test]
341    fn resolve_rbac_maps_token_to_principal() {
342        use crate::serve::rbac::{PrincipalSpec, RbacConfig};
343        let cfg = RbacConfig::new(vec![PrincipalSpec {
344            name: "bob".into(),
345            token: "viewer-tok".into(),
346            role: Role::Viewer,
347        }])
348        .unwrap();
349        let mode = AuthMode::Rbac(Arc::new(cfg));
350        let ctx = mode.resolve(Some("viewer-tok")).unwrap();
351        assert_eq!(ctx.principal, "bob");
352        assert_eq!(ctx.role, Role::Viewer);
353        assert!(mode.resolve(Some("nope")).is_none());
354    }
355
356    #[test]
357    fn auth_config_file_builds_rbac_and_registers_tokens() {
358        let dir = tempfile::tempdir().unwrap();
359        let path = dir.path().join("auth.yaml");
360        std::fs::write(
361            &path,
362            "principals:\n  - name: carol\n    token: rbacsecret12345\n    role: operator\n",
363        )
364        .unwrap();
365        let mut a = base_args();
366        a.auth_config = Some(path);
367        let cfg = ServeConfig::from_args(a).unwrap();
368        assert!(matches!(cfg.auth, AuthMode::Rbac(_)));
369        // Every principal token must be registered for log redaction.
370        let scrubbed = crate::secrets::registry::redact("t=rbacsecret12345 end").into_owned();
371        assert!(!scrubbed.contains("rbacsecret12345"), "{scrubbed}");
372    }
373
374    #[test]
375    fn auth_config_debug_masks_tokens() {
376        use crate::serve::rbac::{PrincipalSpec, RbacConfig};
377        let cfg = RbacConfig::new(vec![PrincipalSpec {
378            name: "x".into(),
379            token: "supersecretrbac".into(),
380            role: Role::Admin,
381        }])
382        .unwrap();
383        let s = format!("{:?}", AuthMode::Rbac(Arc::new(cfg)));
384        assert!(!s.contains("supersecretrbac"), "rbac token leaked: {s}");
385    }
386
387    #[test]
388    fn auth_mode_debug_masks_token() {
389        // ServeConfig derives Debug and embeds AuthMode; a {:?} must not print
390        // the bearer token in clear.
391        let s = format!("{:?}", AuthMode::Token("supersecrettoken".into()));
392        assert!(
393            !s.contains("supersecrettoken"),
394            "serve token leaked via Debug: {s}"
395        );
396        assert!(s.contains("***"), "token not masked: {s}");
397    }
398
399    #[test]
400    fn token_is_registered_for_redaction() {
401        let mut a = base_args();
402        a.auth_token = Some("uniqueserveauthsecret987".into());
403        let _cfg = ServeConfig::from_args(a).unwrap();
404        // The token must be registered so the RedactingWriter scrubs it from logs.
405        let scrubbed =
406            crate::secrets::registry::redact("hdr=uniqueserveauthsecret987 end").into_owned();
407        assert!(
408            !scrubbed.contains("uniqueserveauthsecret987"),
409            "serve token not registered for redaction: {scrubbed}"
410        );
411    }
412
413    #[test]
414    fn listen_parses_to_socket_addr() {
415        let mut a = base_args();
416        a.no_auth = true;
417        a.listen = "0.0.0.0:9999".into();
418        let cfg = ServeConfig::from_args(a).unwrap();
419        assert_eq!(cfg.listen, "0.0.0.0:9999".parse::<SocketAddr>().unwrap());
420    }
421
422    #[test]
423    fn history_url_selects_backend() {
424        let mut a = base_args();
425        a.no_auth = true;
426        a.history = Some("postgres://localhost/db".into());
427        assert!(matches!(
428            ServeConfig::from_args(a.clone()).unwrap().history,
429            HistoryBackendSpec::Postgres(_)
430        ));
431        // The `postgresql://` alias maps to the same backend.
432        a.history = Some("postgresql://localhost/db".into());
433        assert!(matches!(
434            ServeConfig::from_args(a.clone()).unwrap().history,
435            HistoryBackendSpec::Postgres(_)
436        ));
437        // SQLite is stored verbatim, scheme included (for direct sqlx use).
438        a.history = Some("sqlite:runs.db".into());
439        match ServeConfig::from_args(a).unwrap().history {
440            HistoryBackendSpec::Sqlite(url) => assert_eq!(url, "sqlite:runs.db"),
441            other => panic!("expected Sqlite, got {other:?}"),
442        }
443    }
444
445    #[test]
446    fn empty_token_is_rejected() {
447        let mut a = base_args();
448        a.auth_token = Some(String::new());
449        let err = ServeConfig::from_args(a).unwrap_err();
450        assert!(err.to_string().contains("must not be empty"), "{err}");
451    }
452
453    #[test]
454    fn invalid_listen_returns_error() {
455        let mut a = base_args();
456        a.no_auth = true;
457        a.listen = "not-a-socket".into();
458        let err = ServeConfig::from_args(a).unwrap_err();
459        assert!(err.to_string().contains("invalid --listen"), "{err}");
460    }
461
462    #[test]
463    fn unrecognised_history_scheme_returns_error() {
464        let mut a = base_args();
465        a.no_auth = true;
466        a.history = Some("mysql://localhost/db".into());
467        let err = ServeConfig::from_args(a).unwrap_err();
468        assert!(err.to_string().contains("unrecognised --history"), "{err}");
469    }
470
471    #[test]
472    fn zero_lease_ttl_is_rejected() {
473        let mut a = base_args();
474        a.no_auth = true;
475        a.lease_ttl_secs = 0;
476        let err = ServeConfig::from_args(a).unwrap_err();
477        assert!(err.to_string().contains("--lease-ttl-secs"), "{err}");
478    }
479
480    #[test]
481    fn lease_ttl_maps_to_duration() {
482        let mut a = base_args();
483        a.no_auth = true;
484        a.lease_ttl_secs = 45;
485        let cfg = ServeConfig::from_args(a).unwrap();
486        assert_eq!(cfg.lease_ttl, Duration::from_secs(45));
487    }
488
489    #[test]
490    fn zero_shutdown_grace_is_rejected() {
491        // A zero drain window makes graceful shutdown `timeout(Duration::ZERO,
492        // wait_drained())` cancel in-flight runs instantly — defeating the
493        // drain. Reject it the same way the lease-ttl gate does.
494        let mut a = base_args();
495        a.no_auth = true;
496        a.shutdown_grace_secs = 0;
497        let err = ServeConfig::from_args(a).unwrap_err();
498        assert!(err.to_string().contains("--shutdown-grace-secs"), "{err}");
499    }
500
501    #[test]
502    fn nonzero_shutdown_grace_is_accepted() {
503        let mut a = base_args();
504        a.no_auth = true;
505        a.shutdown_grace_secs = 5;
506        let cfg = ServeConfig::from_args(a).unwrap();
507        assert_eq!(cfg.shutdown_grace, Duration::from_secs(5));
508    }
509
510    #[test]
511    fn zero_idempotency_retention_is_accepted_as_dedup_disabled_sentinel() {
512        // `idempotency_retention_secs == 0` is an explicit "dedup disabled"
513        // sentinel (every prior claim is immediately expired), NOT an error.
514        let mut a = base_args();
515        a.no_auth = true;
516        a.idempotency_retention_secs = 0;
517        let cfg = ServeConfig::from_args(a).unwrap();
518        assert_eq!(cfg.idempotency_retention, Duration::ZERO);
519    }
520
521    #[test]
522    fn cluster_requires_persistent_history() {
523        let mut a = base_args();
524        a.no_auth = true;
525        a.cluster = true; // history defaults to memory
526        let err = ServeConfig::from_args(a).unwrap_err();
527        assert!(err.to_string().contains("--cluster requires"), "{err}");
528    }
529
530    #[test]
531    fn cluster_with_sqlite_is_enabled() {
532        let mut a = base_args();
533        a.no_auth = true;
534        a.cluster = true;
535        a.history = Some("sqlite:runs.db".into());
536        let cfg = ServeConfig::from_args(a).unwrap();
537        assert!(cfg.cluster.enabled);
538        assert_eq!(cfg.cluster.max_attempts, 3);
539    }
540
541    #[test]
542    fn cluster_disabled_by_default() {
543        let mut a = base_args();
544        a.no_auth = true;
545        assert!(!ServeConfig::from_args(a).unwrap().cluster.enabled);
546    }
547}