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 log_retention: Duration,
93 pub log_max_lines_per_run: usize,
96 pub lease_ttl: Duration,
98 pub probe_timeout: Duration,
99 pub env_file: Option<PathBuf>,
100 pub no_env_file: bool,
101 pub log_level: String,
104 #[cfg_attr(not(feature = "serve-ui"), allow(dead_code))]
107 pub ui_enabled: bool,
108 pub cluster: ClusterConfig,
110 pub triggers_path: Option<PathBuf>,
113 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 pub fn from_args(args: ServeArgs) -> CliResult<Self> {
129 let auth = if let Some(path) = args.auth_config {
132 let rbac = RbacConfig::from_file(&path)?;
133 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 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 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 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 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 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 a.history = Some("postgresql://localhost/db".into());
443 assert!(matches!(
444 ServeConfig::from_args(a.clone()).unwrap().history,
445 HistoryBackendSpec::Postgres(_)
446 ));
447 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 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 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; 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}