1use std::{net::IpAddr, time::Duration};
4
5use chrono::TimeDelta;
6use ipnet::IpNet;
7
8use crate::{
9 checks::{Checks, Thresholds},
10 cookie::CookieConfig,
11 error::ConfigError,
12};
13
14const MAX_DURATION: Duration = Duration::from_hours(100 * 365 * 24);
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
19pub enum Mode {
20 #[default]
22 Enforce,
23 Shadow,
26}
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum StepUpPolicy {
32 #[default]
35 Require,
36 Revoke,
39 Allow,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct RotationConfig {
47 pub interval: Duration,
49 pub grace: Duration,
52 pub reuse_detection: Duration,
55}
56
57impl Default for RotationConfig {
58 fn default() -> Self {
59 Self {
60 interval: Duration::from_mins(10),
61 grace: Duration::from_secs(30),
62 reuse_detection: Duration::from_hours(7 * 24),
63 }
64 }
65}
66
67#[derive(Clone, Debug)]
70pub struct GuardConfig {
71 pub(crate) cookie: CookieConfig,
72 pub(crate) session_lifetime: TimeDelta,
73 pub(crate) idle_timeout: TimeDelta,
74 pub(crate) trusted_proxies: Vec<IpNet>,
75 pub(crate) tls_fingerprint_header: Option<String>,
76 pub(crate) rotation_interval: TimeDelta,
77 pub(crate) rotation_grace: TimeDelta,
78 pub(crate) reuse_detection: TimeDelta,
79 pub(crate) checks: Checks,
80 pub(crate) thresholds: Thresholds,
81 pub(crate) mode: Mode,
82 pub(crate) step_up_policy: StepUpPolicy,
83 pub(crate) ping_pong_window: TimeDelta,
84 pub(crate) last_seen_write_interval: TimeDelta,
85 pub(crate) event_retention: TimeDelta,
86 pub(crate) minimize_event_ip: bool,
87 pub(crate) shadow_dedup_window: TimeDelta,
88 pub(crate) max_header_len: usize,
89 pub(crate) token_prefix: Option<String>,
90}
91
92impl Default for GuardConfig {
93 fn default() -> Self {
94 GuardConfigBuilder::default()
95 .build()
96 .unwrap_or_else(|_| unreachable!("default configuration is valid"))
97 }
98}
99
100impl GuardConfig {
101 pub fn builder() -> GuardConfigBuilder {
103 GuardConfigBuilder::default()
104 }
105
106 pub fn cookie(&self) -> &CookieConfig {
108 &self.cookie
109 }
110
111 pub fn session_lifetime(&self) -> Duration {
113 to_std(self.session_lifetime)
114 }
115
116 pub fn idle_timeout(&self) -> Duration {
118 to_std(self.idle_timeout)
119 }
120
121 pub fn trusted_proxies(&self) -> &[IpNet] {
123 &self.trusted_proxies
124 }
125
126 pub fn tls_fingerprint_header(&self) -> Option<&str> {
128 self.tls_fingerprint_header.as_deref()
129 }
130
131 pub fn rotation(&self) -> RotationConfig {
133 RotationConfig {
134 interval: to_std(self.rotation_interval),
135 grace: to_std(self.rotation_grace),
136 reuse_detection: to_std(self.reuse_detection),
137 }
138 }
139
140 pub fn checks(&self) -> &Checks {
142 &self.checks
143 }
144
145 pub fn thresholds(&self) -> Thresholds {
147 self.thresholds
148 }
149
150 pub fn mode(&self) -> Mode {
152 self.mode
153 }
154
155 pub fn step_up_policy(&self) -> StepUpPolicy {
157 self.step_up_policy
158 }
159
160 pub fn ping_pong_window(&self) -> Duration {
162 to_std(self.ping_pong_window)
163 }
164
165 pub fn last_seen_write_interval(&self) -> Duration {
167 to_std(self.last_seen_write_interval)
168 }
169
170 pub fn event_retention(&self) -> Duration {
173 to_std(self.event_retention)
174 }
175
176 pub fn minimize_event_ip(&self) -> bool {
178 self.minimize_event_ip
179 }
180
181 pub fn shadow_dedup_window(&self) -> Duration {
183 to_std(self.shadow_dedup_window)
184 }
185
186 pub fn max_header_len(&self) -> usize {
188 self.max_header_len
189 }
190
191 pub fn token_prefix(&self) -> Option<&str> {
193 self.token_prefix.as_deref()
194 }
195}
196
197fn to_std(delta: TimeDelta) -> Duration {
198 delta.to_std().unwrap_or_default()
199}
200
201#[derive(Clone, Debug)]
203#[must_use]
204pub struct GuardConfigBuilder {
205 cookie: CookieConfig,
206 session_lifetime: Duration,
207 idle_timeout: Duration,
208 trusted_proxies: Vec<IpNet>,
209 tls_fingerprint_header: Option<String>,
210 rotation: RotationConfig,
211 checks: Checks,
212 thresholds: Thresholds,
213 mode: Mode,
214 step_up_policy: StepUpPolicy,
215 ping_pong_window: Duration,
216 last_seen_write_interval: Duration,
217 event_retention: Duration,
218 minimize_event_ip: bool,
219 shadow_dedup_window: Duration,
220 max_header_len: usize,
221 token_prefix: Option<String>,
222}
223
224impl Default for GuardConfigBuilder {
225 fn default() -> Self {
226 const MINUTE: u64 = 60;
227 const DAY: u64 = 24 * 60 * MINUTE;
228 Self {
229 cookie: CookieConfig::default(),
230 session_lifetime: Duration::from_secs(30 * DAY),
231 idle_timeout: Duration::from_secs(7 * DAY),
232 trusted_proxies: Vec::new(),
233 tls_fingerprint_header: None,
234 rotation: RotationConfig::default(),
235 checks: Checks::default(),
236 thresholds: Thresholds::default(),
237 mode: Mode::Enforce,
238 step_up_policy: StepUpPolicy::Require,
239 ping_pong_window: Duration::from_secs(5 * MINUTE),
240 last_seen_write_interval: Duration::from_secs(MINUTE),
241 event_retention: Duration::from_secs(90 * DAY),
242 minimize_event_ip: false,
243 shadow_dedup_window: Duration::from_secs(10 * MINUTE),
244 max_header_len: 512,
245 token_prefix: None,
246 }
247 }
248}
249
250impl GuardConfigBuilder {
251 pub fn cookie(mut self, cookie: CookieConfig) -> Self {
253 self.cookie = cookie;
254 self
255 }
256
257 pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
259 self.cookie = self.cookie.name(name);
260 self
261 }
262
263 pub fn session_lifetime(mut self, lifetime: Duration) -> Self {
266 self.session_lifetime = lifetime;
267 self
268 }
269
270 pub fn idle_timeout(mut self, timeout: Duration) -> Self {
272 self.idle_timeout = timeout;
273 self
274 }
275
276 pub fn trusted_proxies<I, T>(mut self, proxies: I) -> Result<Self, ConfigError>
283 where
284 I: IntoIterator<Item = T>,
285 T: AsRef<str>,
286 {
287 self.trusted_proxies = proxies
288 .into_iter()
289 .map(|p| {
290 let p = p.as_ref().trim();
291 p.parse::<IpNet>()
292 .or_else(|_| p.parse::<IpAddr>().map(IpNet::from))
293 .map(|net| net.trunc())
294 .map_err(|_| ConfigError::InvalidProxy(p.to_owned()))
295 })
296 .collect::<Result<_, _>>()?;
297 Ok(self)
298 }
299
300 pub fn tls_fingerprint_header(mut self, header: impl Into<String>) -> Self {
304 self.tls_fingerprint_header = Some(header.into().to_ascii_lowercase());
305 self
306 }
307
308 pub fn rotation(mut self, rotation: RotationConfig) -> Self {
310 self.rotation = rotation;
311 self
312 }
313
314 pub fn checks(mut self, checks: Checks) -> Self {
316 self.checks = checks;
317 self
318 }
319
320 pub fn thresholds(mut self, thresholds: Thresholds) -> Self {
322 self.thresholds = thresholds;
323 self
324 }
325
326 pub fn mode(mut self, mode: Mode) -> Self {
328 self.mode = mode;
329 self
330 }
331
332 pub fn step_up_policy(mut self, policy: StepUpPolicy) -> Self {
334 self.step_up_policy = policy;
335 self
336 }
337
338 pub fn ping_pong_window(mut self, window: Duration) -> Self {
340 self.ping_pong_window = window;
341 self
342 }
343
344 pub fn last_seen_write_interval(mut self, interval: Duration) -> Self {
347 self.last_seen_write_interval = interval;
348 self
349 }
350
351 pub fn event_retention(mut self, retention: Duration) -> Self {
353 self.event_retention = retention;
354 self
355 }
356
357 pub fn minimize_event_ip(mut self, minimize: bool) -> Self {
359 self.minimize_event_ip = minimize;
360 self
361 }
362
363 pub fn shadow_dedup_window(mut self, window: Duration) -> Self {
365 self.shadow_dedup_window = window;
366 self
367 }
368
369 pub fn max_header_len(mut self, len: usize) -> Self {
371 self.max_header_len = len;
372 self
373 }
374
375 pub fn token_prefix(mut self, prefix: impl Into<String>) -> Self {
383 self.token_prefix = Some(prefix.into());
384 self
385 }
386
387 pub fn build(self) -> Result<GuardConfig, ConfigError> {
394 self.cookie.validate()?;
395 if !self.thresholds.is_valid() {
396 return Err(ConfigError::InvalidThresholds);
397 }
398 if self.rotation.grace >= self.rotation.interval {
399 return Err(ConfigError::GraceTooLong);
400 }
401 if self.max_header_len == 0 {
402 return Err(ConfigError::InvalidDuration("max_header_len"));
403 }
404 if let Some(h) = &self.tls_fingerprint_header
405 && (h.is_empty() || !h.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
406 {
407 return Err(ConfigError::InvalidHeaderName(h.clone()));
408 }
409 if let Some(p) = &self.token_prefix
410 && (p.is_empty() || p.len() > 32 || !p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
411 {
412 return Err(ConfigError::InvalidTokenPrefix(p.clone()));
413 }
414 Ok(GuardConfig {
415 cookie: self.cookie,
416 session_lifetime: positive(self.session_lifetime, "session_lifetime")?,
417 idle_timeout: positive(self.idle_timeout, "idle_timeout")?,
418 trusted_proxies: self.trusted_proxies,
419 tls_fingerprint_header: self.tls_fingerprint_header,
420 rotation_interval: positive(self.rotation.interval, "rotation.interval")?,
421 rotation_grace: bounded(self.rotation.grace, "rotation.grace")?,
422 reuse_detection: positive(self.rotation.reuse_detection, "rotation.reuse_detection")?,
423 checks: self.checks,
424 thresholds: self.thresholds,
425 mode: self.mode,
426 step_up_policy: self.step_up_policy,
427 ping_pong_window: bounded(self.ping_pong_window, "ping_pong_window")?,
428 last_seen_write_interval: bounded(self.last_seen_write_interval, "last_seen_write_interval")?,
429 event_retention: positive(self.event_retention, "event_retention")?,
430 minimize_event_ip: self.minimize_event_ip,
431 shadow_dedup_window: bounded(self.shadow_dedup_window, "shadow_dedup_window")?,
432 max_header_len: self.max_header_len,
433 token_prefix: self.token_prefix,
434 })
435 }
436}
437
438fn positive(d: Duration, name: &'static str) -> Result<TimeDelta, ConfigError> {
439 if d.is_zero() {
440 return Err(ConfigError::InvalidDuration(name));
441 }
442 bounded(d, name)
443}
444
445fn bounded(d: Duration, name: &'static str) -> Result<TimeDelta, ConfigError> {
446 if d > MAX_DURATION {
447 return Err(ConfigError::InvalidDuration(name));
448 }
449 TimeDelta::from_std(d).map_err(|_| ConfigError::InvalidDuration(name))
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455 use crate::checks::Check;
456
457 #[test]
458 fn defaults_match_the_spec() {
459 let c = GuardConfig::default();
460 assert_eq!(c.cookie().cookie_name(), "__Host-sid");
461 assert_eq!(c.session_lifetime(), Duration::from_hours(30 * 24));
462 assert_eq!(c.idle_timeout(), Duration::from_hours(7 * 24));
463 assert_eq!(c.rotation(), RotationConfig::default());
464 assert_eq!(c.rotation().interval, Duration::from_secs(600));
465 assert_eq!(c.rotation().grace, Duration::from_secs(30));
466 assert_eq!(c.mode(), Mode::Enforce);
467 assert_eq!(c.step_up_policy(), StepUpPolicy::Require);
468 assert_eq!(c.checks(), &Checks::default());
469 assert_eq!(c.thresholds(), Thresholds::default());
470 assert_eq!(c.event_retention(), Duration::from_hours(90 * 24));
471 assert!(!c.minimize_event_ip());
472 assert_eq!(c.trusted_proxies(), &[]);
473 }
474
475 #[test]
476 fn builder_sets_everything() {
477 let c = GuardConfig::builder()
478 .cookie_name("__Host-app")
479 .session_lifetime(Duration::from_secs(3600))
480 .idle_timeout(Duration::from_secs(600))
481 .trusted_proxies(["10.0.0.0/8", "::1", " 192.168.1.7 "])
482 .unwrap()
483 .tls_fingerprint_header("X-JA4")
484 .checks(Checks::default().disable(Check::AsnChanged))
485 .thresholds(Thresholds {
486 step_up: 30,
487 revoke: 90,
488 })
489 .mode(Mode::Shadow)
490 .step_up_policy(StepUpPolicy::Allow)
491 .minimize_event_ip(true)
492 .build()
493 .unwrap();
494 assert_eq!(c.cookie().cookie_name(), "__Host-app");
495 assert_eq!(c.trusted_proxies().len(), 3);
496 assert!(
497 c.trusted_proxies()
498 .iter()
499 .any(|n| n.contains(&"10.1.2.3".parse::<IpAddr>().unwrap()))
500 );
501 assert!(
502 c.trusted_proxies()
503 .iter()
504 .any(|n| n.contains(&"192.168.1.7".parse::<IpAddr>().unwrap()))
505 );
506 assert!(
507 !c.trusted_proxies()
508 .iter()
509 .any(|n| n.contains(&"192.168.1.8".parse::<IpAddr>().unwrap()))
510 );
511 assert_eq!(c.tls_fingerprint_header(), Some("x-ja4"));
512 assert_eq!(c.mode(), Mode::Shadow);
513 assert_eq!(c.step_up_policy(), StepUpPolicy::Allow);
514 assert!(c.minimize_event_ip());
515 }
516
517 #[test]
518 fn invalid_configs_are_rejected() {
519 let b = GuardConfig::builder;
520 assert_eq!(
521 b().trusted_proxies(["nope"]).unwrap_err(),
522 ConfigError::InvalidProxy("nope".into())
523 );
524 assert!(matches!(
525 b().cookie(CookieConfig::default().secure(false)).build(),
526 Err(ConfigError::InvalidCookie(_))
527 ));
528 assert_eq!(
529 b().thresholds(Thresholds {
530 step_up: 90,
531 revoke: 80
532 })
533 .build()
534 .unwrap_err(),
535 ConfigError::InvalidThresholds
536 );
537 assert_eq!(
538 b().rotation(RotationConfig {
539 grace: Duration::from_secs(600),
540 ..RotationConfig::default()
541 })
542 .build()
543 .unwrap_err(),
544 ConfigError::GraceTooLong
545 );
546 assert_eq!(
547 b().session_lifetime(Duration::ZERO).build().unwrap_err(),
548 ConfigError::InvalidDuration("session_lifetime")
549 );
550 assert_eq!(
551 b().idle_timeout(Duration::MAX).build().unwrap_err(),
552 ConfigError::InvalidDuration("idle_timeout")
553 );
554 assert!(matches!(
555 b().tls_fingerprint_header("x ja4").build(),
556 Err(ConfigError::InvalidHeaderName(_))
557 ));
558 }
559
560 #[test]
561 fn token_prefix_is_optional_and_validated() {
562 assert_eq!(GuardConfig::default().token_prefix(), None);
563 let c = GuardConfig::builder().token_prefix("sess_").build().unwrap();
564 assert_eq!(c.token_prefix(), Some("sess_"));
565 for bad in ["", "se ss_", "sess;", "sess=", "é_", &"a".repeat(33)] {
566 assert_eq!(
567 GuardConfig::builder().token_prefix(bad).build().unwrap_err(),
568 ConfigError::InvalidTokenPrefix(bad.to_owned()),
569 "{bad:?}"
570 );
571 }
572 }
573
574 #[test]
575 fn zero_is_allowed_where_it_means_off() {
576 let c = GuardConfig::builder()
577 .ping_pong_window(Duration::ZERO)
578 .last_seen_write_interval(Duration::ZERO)
579 .shadow_dedup_window(Duration::ZERO)
580 .rotation(RotationConfig {
581 grace: Duration::ZERO,
582 ..RotationConfig::default()
583 })
584 .build()
585 .unwrap();
586 assert_eq!(c.rotation().grace, Duration::ZERO);
587 }
588}