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