use crate::cli::ServeArgs;
use crate::error::{CliError, CliResult};
use crate::serve::cluster::ClusterConfig;
use crate::serve::rbac::{AuthContext, RbacConfig, Role};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone)]
pub enum AuthMode {
Token(String),
Rbac(Arc<RbacConfig>),
None,
}
impl std::fmt::Debug for AuthMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthMode::Token(_) => f.debug_tuple("Token").field(&"***").finish(),
AuthMode::Rbac(cfg) => f.debug_tuple("Rbac").field(cfg).finish(),
AuthMode::None => f.write_str("None"),
}
}
}
impl AuthMode {
pub fn resolve(&self, bearer: Option<&str>) -> Option<AuthContext> {
match self {
AuthMode::None => Some(AuthContext {
principal: "anonymous".to_string(),
role: Role::Admin,
source_ip: None,
}),
AuthMode::Token(expected) => bearer
.filter(|t| crate::serve::auth::constant_time_eq(t.as_bytes(), expected.as_bytes()))
.map(|_| AuthContext {
principal: "token".to_string(),
role: Role::Admin,
source_ip: None,
}),
AuthMode::Rbac(cfg) => bearer.and_then(|t| cfg.authenticate(t)),
}
}
}
#[derive(Debug, Clone)]
pub enum HistoryBackendSpec {
Memory,
Postgres(String),
Sqlite(String),
}
#[derive(Debug, Clone)]
pub struct ServeConfig {
pub listen: SocketAddr,
pub auth: AuthMode,
pub max_concurrent_runs: usize,
pub max_queued_runs: usize,
pub default_config_path: Option<PathBuf>,
pub history: HistoryBackendSpec,
pub cors_origins: Vec<String>,
pub body_limit_bytes: usize,
pub shutdown_grace: Duration,
pub retain_terminal_runs: Duration,
pub idempotency_retention: Duration,
pub lease_ttl: Duration,
pub probe_timeout: Duration,
pub env_file: Option<PathBuf>,
pub no_env_file: bool,
pub log_level: String,
#[cfg_attr(not(feature = "serve-ui"), allow(dead_code))]
pub ui_enabled: bool,
pub cluster: ClusterConfig,
pub triggers_path: Option<PathBuf>,
}
fn default_max_concurrent() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.clamp(1, 16)
}
impl ServeConfig {
pub fn from_args(args: ServeArgs) -> CliResult<Self> {
let auth = if let Some(path) = args.auth_config {
let rbac = RbacConfig::from_file(&path)?;
for token in rbac.tokens() {
crate::secrets::registry::register(token);
}
AuthMode::Rbac(Arc::new(rbac))
} else {
match (args.auth_token, args.no_auth) {
(Some(t), _) if t.is_empty() => {
return Err(CliError::Serve(
"--auth-token / FAUCET_SERVE_AUTH_TOKEN must not be empty \
(use --no-auth to explicitly disable authentication)"
.into(),
));
}
(Some(t), _) => {
crate::secrets::registry::register(&t);
AuthMode::Token(t)
}
(None, true) => AuthMode::None,
(None, false) => {
return Err(CliError::Serve(
"refusing to start without authentication: pass --auth-token \
(or FAUCET_SERVE_AUTH_TOKEN), --auth-config <file> for RBAC, \
or --no-auth to explicitly disable it"
.into(),
));
}
}
};
let listen: SocketAddr = args
.listen
.parse()
.map_err(|e| CliError::Serve(format!("invalid --listen '{}': {e}", args.listen)))?;
let history = match args.history {
None => HistoryBackendSpec::Memory,
Some(u) if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
HistoryBackendSpec::Postgres(u)
}
Some(u) if u.starts_with("sqlite:") => HistoryBackendSpec::Sqlite(u),
Some(u) => {
return Err(CliError::Serve(format!(
"unrecognised --history '{u}': use a postgres:// URL or sqlite:<path>"
)));
}
};
if args.lease_ttl_secs == 0 {
return Err(CliError::Serve(
"--lease-ttl-secs must be > 0 (it gates multi-instance orphan recovery; \
0 would immediately expire every run's lease and let a maintenance tick \
fail in-flight runs)"
.into(),
));
}
if args.shutdown_grace_secs == 0 {
return Err(CliError::Serve(
"--shutdown-grace-secs must be > 0 (0 makes graceful shutdown cancel \
in-flight runs immediately instead of draining them; use a small \
value like 1)"
.into(),
));
}
let cluster = if args.cluster {
if matches!(history, HistoryBackendSpec::Memory) {
return Err(CliError::Serve(
"--cluster requires a persistent --history backend \
(postgres://… or sqlite:…); the in-memory store is single-process"
.into(),
));
}
if args.cluster_poll_secs == 0 {
return Err(CliError::Serve(
"--cluster-poll-secs must be > 0 (0 would spin the claim loop \
with no back-off, saturating the history DB)"
.into(),
));
}
if args.cluster_max_attempts == 0 {
return Err(CliError::Serve(
"--cluster-max-attempts must be > 0 (0 would never re-run \
orphaned runs, defeating failover)"
.into(),
));
}
ClusterConfig {
enabled: true,
poll: Duration::from_secs(args.cluster_poll_secs),
max_attempts: args.cluster_max_attempts,
}
} else {
ClusterConfig::disabled()
};
let max_concurrent_runs = args
.max_concurrent_runs
.unwrap_or_else(default_max_concurrent)
.max(1);
let max_queued_runs = args
.max_queued_runs
.unwrap_or_else(|| max_concurrent_runs.saturating_mul(8))
.max(1);
Ok(Self {
listen,
auth,
max_concurrent_runs,
max_queued_runs,
default_config_path: args.default_config,
history,
cors_origins: args.cors_origin,
body_limit_bytes: args.body_limit_bytes,
shutdown_grace: Duration::from_secs(args.shutdown_grace_secs),
retain_terminal_runs: Duration::from_secs(args.retain_terminal_runs_secs),
idempotency_retention: Duration::from_secs(args.idempotency_retention_secs),
lease_ttl: Duration::from_secs(args.lease_ttl_secs),
probe_timeout: Duration::from_secs(args.probe_timeout_secs),
env_file: args.env_file,
no_env_file: args.no_env_file,
log_level: "info".to_string(),
ui_enabled: !args.no_ui,
cluster,
triggers_path: args.triggers,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
fn base_args() -> crate::cli::ServeArgs {
crate::cli::ServeArgs {
listen: "127.0.0.1:8080".into(),
auth_token: None,
auth_config: None,
no_auth: false,
max_concurrent_runs: None,
max_queued_runs: None,
default_config: None,
history: None,
cors_origin: vec![],
body_limit_bytes: 1_048_576,
shutdown_grace_secs: 60,
retain_terminal_runs_secs: 604_800,
idempotency_retention_secs: 86_400,
lease_ttl_secs: 30,
probe_timeout_secs: 10,
env_file: None,
no_env_file: false,
no_ui: false,
cluster: false,
cluster_poll_secs: 2,
cluster_max_attempts: 3,
triggers: None,
mcp: false,
mcp_allow_mutations: false,
}
}
#[test]
fn no_auth_gate_rejects_silent_unauthenticated_start() {
let err = ServeConfig::from_args(base_args()).unwrap_err();
assert!(err.to_string().contains("--no-auth"), "{err}");
}
#[test]
fn explicit_no_auth_is_allowed() {
let mut a = base_args();
a.no_auth = true;
let cfg = ServeConfig::from_args(a).unwrap();
assert!(matches!(cfg.auth, AuthMode::None));
}
#[test]
fn token_sets_token_auth() {
let mut a = base_args();
a.auth_token = Some("hunter2".into());
let cfg = ServeConfig::from_args(a).unwrap();
assert!(matches!(cfg.auth, AuthMode::Token(t) if t == "hunter2"));
}
#[test]
fn resolve_none_is_anonymous_admin() {
let ctx = AuthMode::None.resolve(None).unwrap();
assert_eq!(ctx.principal, "anonymous");
assert_eq!(ctx.role, Role::Admin);
}
#[test]
fn resolve_token_matches_only_exact() {
let mode = AuthMode::Token("s3cret".into());
let ctx = mode.resolve(Some("s3cret")).unwrap();
assert_eq!(ctx.principal, "token");
assert_eq!(ctx.role, Role::Admin);
assert!(mode.resolve(Some("wrong")).is_none());
assert!(mode.resolve(None).is_none());
}
#[test]
fn resolve_rbac_maps_token_to_principal() {
use crate::serve::rbac::{PrincipalSpec, RbacConfig};
let cfg = RbacConfig::new(vec![PrincipalSpec {
name: "bob".into(),
token: "viewer-tok".into(),
role: Role::Viewer,
}])
.unwrap();
let mode = AuthMode::Rbac(Arc::new(cfg));
let ctx = mode.resolve(Some("viewer-tok")).unwrap();
assert_eq!(ctx.principal, "bob");
assert_eq!(ctx.role, Role::Viewer);
assert!(mode.resolve(Some("nope")).is_none());
}
#[test]
fn auth_config_file_builds_rbac_and_registers_tokens() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.yaml");
std::fs::write(
&path,
"principals:\n - name: carol\n token: rbacsecret12345\n role: operator\n",
)
.unwrap();
let mut a = base_args();
a.auth_config = Some(path);
let cfg = ServeConfig::from_args(a).unwrap();
assert!(matches!(cfg.auth, AuthMode::Rbac(_)));
let scrubbed = crate::secrets::registry::redact("t=rbacsecret12345 end").into_owned();
assert!(!scrubbed.contains("rbacsecret12345"), "{scrubbed}");
}
#[test]
fn auth_config_debug_masks_tokens() {
use crate::serve::rbac::{PrincipalSpec, RbacConfig};
let cfg = RbacConfig::new(vec![PrincipalSpec {
name: "x".into(),
token: "supersecretrbac".into(),
role: Role::Admin,
}])
.unwrap();
let s = format!("{:?}", AuthMode::Rbac(Arc::new(cfg)));
assert!(!s.contains("supersecretrbac"), "rbac token leaked: {s}");
}
#[test]
fn auth_mode_debug_masks_token() {
let s = format!("{:?}", AuthMode::Token("supersecrettoken".into()));
assert!(
!s.contains("supersecrettoken"),
"serve token leaked via Debug: {s}"
);
assert!(s.contains("***"), "token not masked: {s}");
}
#[test]
fn token_is_registered_for_redaction() {
let mut a = base_args();
a.auth_token = Some("uniqueserveauthsecret987".into());
let _cfg = ServeConfig::from_args(a).unwrap();
let scrubbed =
crate::secrets::registry::redact("hdr=uniqueserveauthsecret987 end").into_owned();
assert!(
!scrubbed.contains("uniqueserveauthsecret987"),
"serve token not registered for redaction: {scrubbed}"
);
}
#[test]
fn listen_parses_to_socket_addr() {
let mut a = base_args();
a.no_auth = true;
a.listen = "0.0.0.0:9999".into();
let cfg = ServeConfig::from_args(a).unwrap();
assert_eq!(cfg.listen, "0.0.0.0:9999".parse::<SocketAddr>().unwrap());
}
#[test]
fn history_url_selects_backend() {
let mut a = base_args();
a.no_auth = true;
a.history = Some("postgres://localhost/db".into());
assert!(matches!(
ServeConfig::from_args(a.clone()).unwrap().history,
HistoryBackendSpec::Postgres(_)
));
a.history = Some("postgresql://localhost/db".into());
assert!(matches!(
ServeConfig::from_args(a.clone()).unwrap().history,
HistoryBackendSpec::Postgres(_)
));
a.history = Some("sqlite:runs.db".into());
match ServeConfig::from_args(a).unwrap().history {
HistoryBackendSpec::Sqlite(url) => assert_eq!(url, "sqlite:runs.db"),
other => panic!("expected Sqlite, got {other:?}"),
}
}
#[test]
fn empty_token_is_rejected() {
let mut a = base_args();
a.auth_token = Some(String::new());
let err = ServeConfig::from_args(a).unwrap_err();
assert!(err.to_string().contains("must not be empty"), "{err}");
}
#[test]
fn invalid_listen_returns_error() {
let mut a = base_args();
a.no_auth = true;
a.listen = "not-a-socket".into();
let err = ServeConfig::from_args(a).unwrap_err();
assert!(err.to_string().contains("invalid --listen"), "{err}");
}
#[test]
fn unrecognised_history_scheme_returns_error() {
let mut a = base_args();
a.no_auth = true;
a.history = Some("mysql://localhost/db".into());
let err = ServeConfig::from_args(a).unwrap_err();
assert!(err.to_string().contains("unrecognised --history"), "{err}");
}
#[test]
fn zero_lease_ttl_is_rejected() {
let mut a = base_args();
a.no_auth = true;
a.lease_ttl_secs = 0;
let err = ServeConfig::from_args(a).unwrap_err();
assert!(err.to_string().contains("--lease-ttl-secs"), "{err}");
}
#[test]
fn lease_ttl_maps_to_duration() {
let mut a = base_args();
a.no_auth = true;
a.lease_ttl_secs = 45;
let cfg = ServeConfig::from_args(a).unwrap();
assert_eq!(cfg.lease_ttl, Duration::from_secs(45));
}
#[test]
fn zero_shutdown_grace_is_rejected() {
let mut a = base_args();
a.no_auth = true;
a.shutdown_grace_secs = 0;
let err = ServeConfig::from_args(a).unwrap_err();
assert!(err.to_string().contains("--shutdown-grace-secs"), "{err}");
}
#[test]
fn nonzero_shutdown_grace_is_accepted() {
let mut a = base_args();
a.no_auth = true;
a.shutdown_grace_secs = 5;
let cfg = ServeConfig::from_args(a).unwrap();
assert_eq!(cfg.shutdown_grace, Duration::from_secs(5));
}
#[test]
fn zero_idempotency_retention_is_accepted_as_dedup_disabled_sentinel() {
let mut a = base_args();
a.no_auth = true;
a.idempotency_retention_secs = 0;
let cfg = ServeConfig::from_args(a).unwrap();
assert_eq!(cfg.idempotency_retention, Duration::ZERO);
}
#[test]
fn cluster_requires_persistent_history() {
let mut a = base_args();
a.no_auth = true;
a.cluster = true; let err = ServeConfig::from_args(a).unwrap_err();
assert!(err.to_string().contains("--cluster requires"), "{err}");
}
#[test]
fn cluster_with_sqlite_is_enabled() {
let mut a = base_args();
a.no_auth = true;
a.cluster = true;
a.history = Some("sqlite:runs.db".into());
let cfg = ServeConfig::from_args(a).unwrap();
assert!(cfg.cluster.enabled);
assert_eq!(cfg.cluster.max_attempts, 3);
}
#[test]
fn cluster_disabled_by_default() {
let mut a = base_args();
a.no_auth = true;
assert!(!ServeConfig::from_args(a).unwrap().cluster.enabled);
}
}