1use crate::cli::ServeArgs;
6use crate::error::{CliError, CliResult};
7use std::net::SocketAddr;
8use std::path::PathBuf;
9use std::time::Duration;
10
11#[derive(Clone)]
13pub enum AuthMode {
14 Token(String),
16 None,
18}
19
20impl std::fmt::Debug for AuthMode {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 match self {
27 AuthMode::Token(_) => f.debug_tuple("Token").field(&"***").finish(),
28 AuthMode::None => f.write_str("None"),
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
35pub enum HistoryBackendSpec {
36 Memory,
38 Postgres(String),
40 Sqlite(String),
44}
45
46#[derive(Debug, Clone)]
48pub struct ServeConfig {
49 pub listen: SocketAddr,
50 pub auth: AuthMode,
51 pub max_concurrent_runs: usize,
52 pub max_queued_runs: usize,
53 pub default_config_path: Option<PathBuf>,
54 pub history: HistoryBackendSpec,
55 pub cors_origins: Vec<String>,
56 pub body_limit_bytes: usize,
57 pub shutdown_grace: Duration,
58 pub retain_terminal_runs: Duration,
59 pub idempotency_retention: Duration,
60 pub lease_ttl: Duration,
62 pub probe_timeout: Duration,
63 pub env_file: Option<PathBuf>,
64 pub no_env_file: bool,
65 pub log_level: String,
68}
69
70fn default_max_concurrent() -> usize {
71 std::thread::available_parallelism()
72 .map(|n| n.get())
73 .unwrap_or(4)
74 .clamp(1, 16)
75}
76
77impl ServeConfig {
78 pub fn from_args(args: ServeArgs) -> CliResult<Self> {
81 let auth = match (args.auth_token, args.no_auth) {
82 (Some(t), _) if t.is_empty() => {
83 return Err(CliError::Serve(
84 "--auth-token / FAUCET_SERVE_AUTH_TOKEN must not be empty \
85 (use --no-auth to explicitly disable authentication)"
86 .into(),
87 ));
88 }
89 (Some(t), _) => {
90 crate::secrets::registry::register(&t);
93 AuthMode::Token(t)
94 }
95 (None, true) => AuthMode::None,
96 (None, false) => {
97 return Err(CliError::Serve(
98 "refusing to start without authentication: pass --auth-token \
99 (or FAUCET_SERVE_AUTH_TOKEN), or --no-auth to explicitly disable it"
100 .into(),
101 ));
102 }
103 };
104
105 let listen: SocketAddr = args
106 .listen
107 .parse()
108 .map_err(|e| CliError::Serve(format!("invalid --listen '{}': {e}", args.listen)))?;
109
110 let history = match args.history {
111 None => HistoryBackendSpec::Memory,
112 Some(u) if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
113 HistoryBackendSpec::Postgres(u)
114 }
115 Some(u) if u.starts_with("sqlite:") => HistoryBackendSpec::Sqlite(u),
116 Some(u) => {
117 return Err(CliError::Serve(format!(
118 "unrecognised --history '{u}': use a postgres:// URL or sqlite:<path>"
119 )));
120 }
121 };
122
123 if args.lease_ttl_secs == 0 {
124 return Err(CliError::Serve(
125 "--lease-ttl-secs must be > 0 (it gates multi-instance orphan recovery; \
126 0 would immediately expire every run's lease and let a maintenance tick \
127 fail in-flight runs)"
128 .into(),
129 ));
130 }
131
132 if args.shutdown_grace_secs == 0 {
138 return Err(CliError::Serve(
139 "--shutdown-grace-secs must be > 0 (0 makes graceful shutdown cancel \
140 in-flight runs immediately instead of draining them; use a small \
141 value like 1)"
142 .into(),
143 ));
144 }
145
146 let max_concurrent_runs = args
147 .max_concurrent_runs
148 .unwrap_or_else(default_max_concurrent)
149 .max(1);
150 let max_queued_runs = args
151 .max_queued_runs
152 .unwrap_or_else(|| max_concurrent_runs.saturating_mul(8))
153 .max(1);
154
155 Ok(Self {
156 listen,
157 auth,
158 max_concurrent_runs,
159 max_queued_runs,
160 default_config_path: args.default_config,
161 history,
162 cors_origins: args.cors_origin,
163 body_limit_bytes: args.body_limit_bytes,
164 shutdown_grace: Duration::from_secs(args.shutdown_grace_secs),
165 retain_terminal_runs: Duration::from_secs(args.retain_terminal_runs_secs),
166 idempotency_retention: Duration::from_secs(args.idempotency_retention_secs),
167 lease_ttl: Duration::from_secs(args.lease_ttl_secs),
168 probe_timeout: Duration::from_secs(args.probe_timeout_secs),
169 env_file: args.env_file,
170 no_env_file: args.no_env_file,
171 log_level: "info".to_string(),
172 })
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use std::net::SocketAddr;
180
181 fn base_args() -> crate::cli::ServeArgs {
182 crate::cli::ServeArgs {
183 listen: "127.0.0.1:8080".into(),
184 auth_token: None,
185 no_auth: false,
186 max_concurrent_runs: None,
187 max_queued_runs: None,
188 default_config: None,
189 history: None,
190 cors_origin: vec![],
191 body_limit_bytes: 1_048_576,
192 shutdown_grace_secs: 60,
193 retain_terminal_runs_secs: 604_800,
194 idempotency_retention_secs: 86_400,
195 lease_ttl_secs: 30,
196 probe_timeout_secs: 10,
197 env_file: None,
198 no_env_file: false,
199 }
200 }
201
202 #[test]
203 fn no_auth_gate_rejects_silent_unauthenticated_start() {
204 let err = ServeConfig::from_args(base_args()).unwrap_err();
205 assert!(err.to_string().contains("--no-auth"), "{err}");
206 }
207
208 #[test]
209 fn explicit_no_auth_is_allowed() {
210 let mut a = base_args();
211 a.no_auth = true;
212 let cfg = ServeConfig::from_args(a).unwrap();
213 assert!(matches!(cfg.auth, AuthMode::None));
214 }
215
216 #[test]
217 fn token_sets_token_auth() {
218 let mut a = base_args();
219 a.auth_token = Some("hunter2".into());
220 let cfg = ServeConfig::from_args(a).unwrap();
221 assert!(matches!(cfg.auth, AuthMode::Token(t) if t == "hunter2"));
222 }
223
224 #[test]
225 fn auth_mode_debug_masks_token() {
226 let s = format!("{:?}", AuthMode::Token("supersecrettoken".into()));
229 assert!(
230 !s.contains("supersecrettoken"),
231 "serve token leaked via Debug: {s}"
232 );
233 assert!(s.contains("***"), "token not masked: {s}");
234 }
235
236 #[test]
237 fn token_is_registered_for_redaction() {
238 let mut a = base_args();
239 a.auth_token = Some("uniqueserveauthsecret987".into());
240 let _cfg = ServeConfig::from_args(a).unwrap();
241 let scrubbed =
243 crate::secrets::registry::redact("hdr=uniqueserveauthsecret987 end").into_owned();
244 assert!(
245 !scrubbed.contains("uniqueserveauthsecret987"),
246 "serve token not registered for redaction: {scrubbed}"
247 );
248 }
249
250 #[test]
251 fn listen_parses_to_socket_addr() {
252 let mut a = base_args();
253 a.no_auth = true;
254 a.listen = "0.0.0.0:9999".into();
255 let cfg = ServeConfig::from_args(a).unwrap();
256 assert_eq!(cfg.listen, "0.0.0.0:9999".parse::<SocketAddr>().unwrap());
257 }
258
259 #[test]
260 fn history_url_selects_backend() {
261 let mut a = base_args();
262 a.no_auth = true;
263 a.history = Some("postgres://localhost/db".into());
264 assert!(matches!(
265 ServeConfig::from_args(a.clone()).unwrap().history,
266 HistoryBackendSpec::Postgres(_)
267 ));
268 a.history = Some("postgresql://localhost/db".into());
270 assert!(matches!(
271 ServeConfig::from_args(a.clone()).unwrap().history,
272 HistoryBackendSpec::Postgres(_)
273 ));
274 a.history = Some("sqlite:runs.db".into());
276 match ServeConfig::from_args(a).unwrap().history {
277 HistoryBackendSpec::Sqlite(url) => assert_eq!(url, "sqlite:runs.db"),
278 other => panic!("expected Sqlite, got {other:?}"),
279 }
280 }
281
282 #[test]
283 fn empty_token_is_rejected() {
284 let mut a = base_args();
285 a.auth_token = Some(String::new());
286 let err = ServeConfig::from_args(a).unwrap_err();
287 assert!(err.to_string().contains("must not be empty"), "{err}");
288 }
289
290 #[test]
291 fn invalid_listen_returns_error() {
292 let mut a = base_args();
293 a.no_auth = true;
294 a.listen = "not-a-socket".into();
295 let err = ServeConfig::from_args(a).unwrap_err();
296 assert!(err.to_string().contains("invalid --listen"), "{err}");
297 }
298
299 #[test]
300 fn unrecognised_history_scheme_returns_error() {
301 let mut a = base_args();
302 a.no_auth = true;
303 a.history = Some("mysql://localhost/db".into());
304 let err = ServeConfig::from_args(a).unwrap_err();
305 assert!(err.to_string().contains("unrecognised --history"), "{err}");
306 }
307
308 #[test]
309 fn zero_lease_ttl_is_rejected() {
310 let mut a = base_args();
311 a.no_auth = true;
312 a.lease_ttl_secs = 0;
313 let err = ServeConfig::from_args(a).unwrap_err();
314 assert!(err.to_string().contains("--lease-ttl-secs"), "{err}");
315 }
316
317 #[test]
318 fn lease_ttl_maps_to_duration() {
319 let mut a = base_args();
320 a.no_auth = true;
321 a.lease_ttl_secs = 45;
322 let cfg = ServeConfig::from_args(a).unwrap();
323 assert_eq!(cfg.lease_ttl, Duration::from_secs(45));
324 }
325
326 #[test]
327 fn zero_shutdown_grace_is_rejected() {
328 let mut a = base_args();
332 a.no_auth = true;
333 a.shutdown_grace_secs = 0;
334 let err = ServeConfig::from_args(a).unwrap_err();
335 assert!(err.to_string().contains("--shutdown-grace-secs"), "{err}");
336 }
337
338 #[test]
339 fn nonzero_shutdown_grace_is_accepted() {
340 let mut a = base_args();
341 a.no_auth = true;
342 a.shutdown_grace_secs = 5;
343 let cfg = ServeConfig::from_args(a).unwrap();
344 assert_eq!(cfg.shutdown_grace, Duration::from_secs(5));
345 }
346
347 #[test]
348 fn zero_idempotency_retention_is_accepted_as_dedup_disabled_sentinel() {
349 let mut a = base_args();
352 a.no_auth = true;
353 a.idempotency_retention_secs = 0;
354 let cfg = ServeConfig::from_args(a).unwrap();
355 assert_eq!(cfg.idempotency_retention, Duration::ZERO);
356 }
357}