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