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