1use 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#[derive(Clone)]
16pub enum AuthMode {
17 Token(String),
20 Rbac(Arc<RbacConfig>),
22 None,
24}
25
26impl 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 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#[derive(Debug, Clone)]
65pub enum HistoryBackendSpec {
66 Memory,
68 Postgres(String),
70 Sqlite(String),
74}
75
76#[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 pub lease_ttl: Duration,
92 pub probe_timeout: Duration,
93 pub env_file: Option<PathBuf>,
94 pub no_env_file: bool,
95 pub log_level: String,
98 #[cfg_attr(not(feature = "serve-ui"), allow(dead_code))]
101 pub ui_enabled: bool,
102 pub cluster: ClusterConfig,
104 pub triggers_path: Option<PathBuf>,
107 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 pub fn from_args(args: ServeArgs) -> CliResult<Self> {
123 let auth = if let Some(path) = args.auth_config {
126 let rbac = RbacConfig::from_file(&path)?;
127 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 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 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 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 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 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 a.history = Some("postgresql://localhost/db".into());
433 assert!(matches!(
434 ServeConfig::from_args(a.clone()).unwrap().history,
435 HistoryBackendSpec::Postgres(_)
436 ));
437 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 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 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; 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}