1use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use std::time::Duration;
9
10#[derive(Clone, Serialize, Deserialize)]
18#[non_exhaustive]
19pub struct DashboardConfig {
20 pub bind_address: String,
22
23 pub port: u16,
25
26 pub database_url: String,
28
29 pub pool_size: u32,
31
32 pub static_dir: PathBuf,
34
35 pub auth: AuthConfig,
37
38 pub websocket: WebSocketConfig,
40
41 pub enable_cors: bool,
46
47 #[serde(default)]
52 pub allowed_origins: Vec<String>,
53}
54
55impl Default for DashboardConfig {
56 fn default() -> Self {
57 Self {
58 bind_address: "127.0.0.1".to_string(),
59 port: 8080,
60 database_url: "postgresql://localhost/hammerwork".to_string(),
61 pool_size: 5,
62 static_dir: PathBuf::from("./assets"),
63 auth: AuthConfig::default(),
64 websocket: WebSocketConfig::default(),
65 enable_cors: false,
66 allowed_origins: Vec::new(),
67 }
68 }
69}
70
71impl DashboardConfig {
72 pub fn new() -> Self {
75 Self::default()
76 }
77
78 pub fn with_bind_address(mut self, address: &str, port: u16) -> Self {
81 self.bind_address = address.to_string();
82 self.port = port;
83 self
84 }
85
86 pub fn with_database_url(mut self, url: &str) -> Self {
91 self.database_url = url.to_string();
92 self
93 }
94
95 pub fn with_static_dir(mut self, dir: PathBuf) -> Self {
98 self.static_dir = dir;
99 self
100 }
101
102 pub fn with_auth(mut self, username: &str, password_hash: &str) -> Self {
108 self.auth.enabled = true;
109 self.auth.username = username.to_string();
110 self.auth.password_hash = password_hash.to_string();
111 self
112 }
113
114 pub fn with_cors(mut self, enabled: bool) -> Self {
120 self.enable_cors = enabled;
121 self
122 }
123
124 pub fn with_allowed_origin(mut self, origin: &str) -> Self {
127 self.allowed_origins.push(origin.to_string());
128 self
129 }
130
131 pub fn from_file(path: &str) -> crate::Result<Self> {
133 let content = std::fs::read_to_string(path)?;
134 let config: Self = toml::from_str(&content)?;
135 config.validate()?;
136 Ok(config)
137 }
138
139 pub fn validate(&self) -> crate::Result<()> {
148 if self.auth.enabled && !cfg!(feature = "auth") {
149 anyhow::bail!(
150 "Authentication is enabled, but hammerwork-web was built without the `auth` \
151 feature and cannot verify bcrypt password hashes. Rebuild with \
152 `--features auth` (a default feature), or disable authentication"
153 );
154 }
155 crate::security::AllowedOrigins::new(&self.allowed_origins)?;
156 if self.enable_cors && self.allowed_origins.is_empty() {
157 anyhow::bail!(
158 "enable_cors requires at least one entry in allowed_origins: CORS is never \
159 granted to every origin"
160 );
161 }
162 if self.websocket.ping_interval.is_zero() {
163 anyhow::bail!("websocket.ping_interval must be greater than zero");
164 }
165 if self.websocket.message_buffer_size == 0 {
166 anyhow::bail!("websocket.message_buffer_size must be greater than zero");
167 }
168 if self.websocket.max_message_size == 0 {
169 anyhow::bail!("websocket.max_message_size must be greater than zero");
170 }
171 if !self.websocket.live_update_interval.is_zero()
172 && self.websocket.live_update_max_jobs == 0
173 {
174 anyhow::bail!(
175 "websocket.live_update_max_jobs must be greater than zero (set \
176 websocket.live_update_interval to zero to disable live updates)"
177 );
178 }
179 Ok(())
180 }
181
182 pub fn save_to_file(&self, path: &str) -> crate::Result<()> {
184 let content = toml::to_string_pretty(self)?;
185 std::fs::write(path, content)?;
186 Ok(())
187 }
188
189 pub fn bind_addr(&self) -> String {
191 format!("{}:{}", self.bind_address, self.port)
192 }
193}
194
195#[derive(Clone, Serialize, Deserialize)]
203#[non_exhaustive]
204pub struct AuthConfig {
205 pub enabled: bool,
207
208 pub username: String,
210
211 pub password_hash: String,
214
215 #[serde(with = "hammerwork::config::serde_duration")]
218 pub session_timeout: Duration,
219
220 pub max_failed_attempts: u32,
222
223 #[serde(with = "hammerwork::config::serde_duration")]
225 pub lockout_duration: Duration,
226}
227
228impl Default for AuthConfig {
229 fn default() -> Self {
230 Self {
231 enabled: true, username: "admin".to_string(),
233 password_hash: String::new(),
234 session_timeout: Duration::from_secs(8 * 60 * 60), max_failed_attempts: 5,
236 lockout_duration: Duration::from_secs(15 * 60), }
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243#[non_exhaustive]
244pub struct WebSocketConfig {
245 #[serde(with = "hammerwork::config::serde_duration")]
247 pub ping_interval: Duration,
248
249 pub max_connections: usize,
251
252 pub message_buffer_size: usize,
255
256 pub max_message_size: usize,
258
259 #[serde(
264 default = "default_live_update_interval",
265 with = "hammerwork::config::serde_duration"
266 )]
267 pub live_update_interval: Duration,
268
269 #[serde(default = "default_live_update_max_jobs")]
273 pub live_update_max_jobs: u32,
274}
275
276fn default_live_update_interval() -> Duration {
277 Duration::from_secs(2)
278}
279
280fn default_live_update_max_jobs() -> u32 {
281 100
282}
283
284impl Default for WebSocketConfig {
285 fn default() -> Self {
286 Self {
287 ping_interval: Duration::from_secs(30),
288 max_connections: 100,
289 message_buffer_size: 1024,
290 max_message_size: 64 * 1024, live_update_interval: default_live_update_interval(),
292 live_update_max_jobs: default_live_update_max_jobs(),
293 }
294 }
295}
296
297macro_rules! with_setters {
300 ($ty:ty { $($name:ident($field:ident): $fty:ty),* $(,)? }) => {
301 impl $ty {
302 $(
303 #[doc = concat!("Sets [`", stringify!($field), "`](Self::", stringify!($field), ").")]
304 #[must_use]
305 pub fn $name(mut self, value: $fty) -> Self {
306 self.$field = value;
307 self
308 }
309 )*
310 }
311 };
312}
313
314with_setters! {
315 DashboardConfig {
316 with_pool_size(pool_size): u32,
317 with_auth_config(auth): AuthConfig,
318 with_websocket(websocket): WebSocketConfig,
319 with_allowed_origins(allowed_origins): Vec<String>,
320 }
321}
322
323with_setters! {
324 AuthConfig {
325 with_enabled(enabled): bool,
326 with_username(username): String,
327 with_password_hash(password_hash): String,
328 with_session_timeout(session_timeout): Duration,
329 with_max_failed_attempts(max_failed_attempts): u32,
330 with_lockout_duration(lockout_duration): Duration,
331 }
332}
333
334impl AuthConfig {
335 pub fn disabled() -> Self {
338 Self::default().with_enabled(false)
339 }
340}
341
342with_setters! {
343 WebSocketConfig {
344 with_ping_interval(ping_interval): Duration,
345 with_max_connections(max_connections): usize,
346 with_message_buffer_size(message_buffer_size): usize,
347 with_max_message_size(max_message_size): usize,
348 with_live_update_interval(live_update_interval): Duration,
349 with_live_update_max_jobs(live_update_max_jobs): u32,
350 }
351}
352
353impl std::fmt::Debug for DashboardConfig {
354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 f.debug_struct("DashboardConfig")
356 .field("bind_address", &self.bind_address)
357 .field("port", &self.port)
358 .field(
359 "database_url",
360 &hammerwork::config::redact_url(&self.database_url),
361 )
362 .field("pool_size", &self.pool_size)
363 .field("static_dir", &self.static_dir)
364 .field("auth", &self.auth)
365 .field("websocket", &self.websocket)
366 .field("enable_cors", &self.enable_cors)
367 .finish()
368 }
369}
370
371impl std::fmt::Debug for AuthConfig {
372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373 f.debug_struct("AuthConfig")
375 .field("enabled", &self.enabled)
376 .field("username", &self.username)
377 .field("password_hash", &"[REDACTED]")
378 .field("session_timeout", &self.session_timeout)
379 .field("max_failed_attempts", &self.max_failed_attempts)
380 .field("lockout_duration", &self.lockout_duration)
381 .finish_non_exhaustive()
382 }
383}
384
385#[cfg(test)]
386mod debug_redaction_tests {
387 use super::*;
388
389 #[test]
390 fn debug_does_not_print_secrets() {
391 let mut config = DashboardConfig {
392 database_url: "postgres://app:hunter2-db@db.internal/jobs".to_string(),
393 ..Default::default()
394 };
395 config.auth.password_hash = "$2b$12$hunter2hashhunter2hashhu".to_string();
396 let debug = format!("{config:?}");
397 assert!(!debug.contains("hunter2"), "{debug}");
398 assert!(
399 debug.contains("postgres://app:***@db.internal/jobs"),
400 "{debug}"
401 );
402 assert!(debug.contains("[REDACTED]"), "{debug}");
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use tempfile::tempdir;
410
411 #[test]
413 fn readme_configuration_example_loads() {
414 let readme = include_str!("../README.md");
415 let start = readme
416 .find("```toml\nbind_address")
417 .expect("README has a configuration example");
418 let body = &readme[start + "```toml\n".len()..];
419 let example = &body[..body.find("```").unwrap()];
420 let config: DashboardConfig = toml::from_str(example).unwrap();
421 assert_eq!(config.auth.session_timeout, Duration::from_secs(8 * 3600));
422 assert_eq!(config.auth.lockout_duration, Duration::from_secs(15 * 60));
423 assert_eq!(config.websocket.ping_interval, Duration::from_secs(30));
424 assert_eq!(
425 config.websocket.live_update_interval,
426 Duration::from_secs(2)
427 );
428 }
429
430 #[test]
433 fn durations_accept_the_old_table_form_and_save_as_strings() {
434 let mut value = toml::Value::try_from(DashboardConfig::new()).unwrap();
435 value["websocket"]["ping_interval"] =
436 toml::from_str::<toml::Table>("v = { secs = 45, nanos = 0 }").unwrap()["v"].clone();
437 let config: DashboardConfig = toml::from_str(&toml::to_string(&value).unwrap()).unwrap();
438 assert_eq!(config.websocket.ping_interval, Duration::from_secs(45));
439
440 let saved = toml::to_string(&DashboardConfig::new()).unwrap();
441 assert!(saved.contains("ping_interval = \"30s\""), "{saved}");
442 let reloaded: DashboardConfig = toml::from_str(&saved).unwrap();
443 assert_eq!(reloaded.auth.session_timeout, Duration::from_secs(8 * 3600));
444 }
445
446 fn defaults() -> DashboardConfig {
448 let mut config = DashboardConfig::new();
449 config.auth.enabled = cfg!(feature = "auth");
450 config
451 }
452
453 #[test]
454 fn test_config_creation() {
455 let config = DashboardConfig::new()
456 .with_bind_address("0.0.0.0", 9090)
457 .with_database_url("mysql://localhost/test")
458 .with_cors(true);
459
460 assert_eq!(config.bind_address, "0.0.0.0");
461 assert_eq!(config.port, 9090);
462 assert_eq!(config.database_url, "mysql://localhost/test");
463 assert!(config.enable_cors);
464 assert_eq!(config.bind_addr(), "0.0.0.0:9090");
465 }
466
467 #[test]
468 fn test_validate_rejects_zero_ping_interval() {
469 let mut config = defaults();
470 assert!(config.validate().is_ok());
471 config.websocket.ping_interval = Duration::ZERO;
472 let err = config.validate().unwrap_err().to_string();
473 assert!(err.contains("ping_interval"), "{err}");
474 }
475
476 #[test]
477 fn test_from_file_rejects_zero_ping_interval() {
478 let dir = tempdir().unwrap();
479 let path = dir.path().join("bad.toml");
480 let mut config = defaults();
481 config.websocket.ping_interval = Duration::ZERO;
482 config.save_to_file(path.to_str().unwrap()).unwrap();
483 assert!(DashboardConfig::from_file(path.to_str().unwrap()).is_err());
484 }
485
486 #[test]
487 fn test_config_file_operations() {
488 let dir = tempdir().unwrap();
489 let config_path = dir.path().join("config.toml");
490
491 let config = defaults()
492 .with_bind_address("192.168.1.100", 8888)
493 .with_database_url("postgresql://test/db");
494
495 config.save_to_file(config_path.to_str().unwrap()).unwrap();
497
498 let loaded_config = DashboardConfig::from_file(config_path.to_str().unwrap()).unwrap();
500
501 assert_eq!(loaded_config.bind_address, "192.168.1.100");
502 assert_eq!(loaded_config.port, 8888);
503 assert_eq!(loaded_config.database_url, "postgresql://test/db");
504 }
505
506 #[test]
507 fn test_auth_config_defaults() {
508 let auth = AuthConfig::default();
509 assert!(auth.enabled); assert_eq!(auth.username, "admin");
511 assert_eq!(auth.max_failed_attempts, 5);
512 assert_eq!(auth.lockout_duration.as_secs(), 15 * 60); assert_eq!(auth.session_timeout.as_secs(), 8 * 60 * 60); }
515
516 #[test]
517 fn test_validate_rejects_unusable_websocket_limits() {
518 let mut config = defaults();
519 config.websocket.message_buffer_size = 0;
520 let err = config.validate().unwrap_err().to_string();
521 assert!(err.contains("message_buffer_size"), "{err}");
522 let mut config = defaults();
523 config.websocket.max_message_size = 0;
524 let err = config.validate().unwrap_err().to_string();
525 assert!(err.contains("max_message_size"), "{err}");
526 }
527
528 #[test]
529 fn cors_is_only_granted_to_listed_origins() {
530 let err = defaults()
532 .with_cors(true)
533 .validate()
534 .unwrap_err()
535 .to_string();
536 assert!(err.contains("allowed_origins"), "{err}");
537 let config = defaults()
538 .with_cors(true)
539 .with_allowed_origin("https://ops.example.com");
540 assert!(config.validate().is_ok());
541 let err = defaults()
542 .with_allowed_origin("*")
543 .validate()
544 .unwrap_err()
545 .to_string();
546 assert!(err.contains("invalid origin"), "{err}");
547 }
548
549 #[test]
550 fn config_files_without_allowed_origins_still_load() {
551 let text = toml::to_string(&DashboardConfig::new()).unwrap();
552 let text: String = text
553 .lines()
554 .filter(|line| !line.starts_with("allowed_origins"))
555 .collect::<Vec<_>>()
556 .join("\n");
557 let config: DashboardConfig = toml::from_str(&text).unwrap();
558 assert!(config.allowed_origins.is_empty());
559 }
560
561 #[test]
564 fn config_files_with_removed_request_timeout_still_load() {
565 let text = format!(
566 "{}\n[request_timeout]\nsecs = 30\nnanos = 0\n",
567 toml::to_string(&DashboardConfig::new()).unwrap()
568 );
569 let config: DashboardConfig = toml::from_str(&text).unwrap();
570 assert_eq!(config.port, DashboardConfig::new().port);
571 }
572
573 #[cfg(feature = "auth")]
574 #[test]
575 fn auth_builds_accept_authentication() {
576 assert!(DashboardConfig::new().validate().is_ok());
577 }
578
579 #[cfg(not(feature = "auth"))]
580 #[test]
581 fn builds_without_bcrypt_refuse_to_enable_authentication() {
582 let err = DashboardConfig::new().validate().unwrap_err().to_string();
584 assert!(err.contains("`auth`"), "{err}");
585 let mut config = DashboardConfig::new();
586 config.auth.enabled = false;
587 assert!(config.validate().is_ok());
588 }
589
590 #[test]
591 fn test_websocket_config_defaults() {
592 let ws_config = WebSocketConfig::default();
593 assert_eq!(ws_config.ping_interval, Duration::from_secs(30));
594 assert_eq!(ws_config.max_connections, 100);
595 assert_eq!(ws_config.max_message_size, 64 * 1024);
596 assert_eq!(ws_config.live_update_interval, Duration::from_secs(2));
597 assert_eq!(ws_config.live_update_max_jobs, 100);
598 }
599
600 #[test]
601 fn test_live_update_settings_are_optional_and_validated() {
602 let toml = r#"
604 ping_interval = { secs = 30, nanos = 0 }
605 max_connections = 10
606 message_buffer_size = 16
607 max_message_size = 1024
608 "#;
609 let ws: WebSocketConfig = toml::from_str(toml).unwrap();
610 assert_eq!(ws.live_update_interval, Duration::from_secs(2));
611 assert_eq!(ws.live_update_max_jobs, 100);
612
613 let mut config = DashboardConfig::new();
614 config.auth.enabled = false;
615 config.websocket.live_update_max_jobs = 0;
616 let err = config.validate().unwrap_err().to_string();
617 assert!(err.contains("live_update_max_jobs"), "{err}");
618 config.websocket.live_update_interval = Duration::ZERO;
620 assert!(config.validate().is_ok());
621 }
622
623 #[test]
624 fn test_config_builders() {
625 let auth = AuthConfig::default()
626 .with_enabled(true)
627 .with_username("ops".to_string())
628 .with_password_hash("$2b$hash".to_string())
629 .with_session_timeout(Duration::from_secs(60))
630 .with_max_failed_attempts(2)
631 .with_lockout_duration(Duration::from_secs(30));
632 assert!(auth.enabled);
633 assert_eq!(
634 (auth.username.as_str(), auth.password_hash.as_str()),
635 ("ops", "$2b$hash")
636 );
637 assert_eq!(auth.session_timeout, Duration::from_secs(60));
638 assert_eq!(auth.max_failed_attempts, 2);
639 assert_eq!(auth.lockout_duration, Duration::from_secs(30));
640 assert!(!AuthConfig::disabled().enabled);
641
642 let websocket = WebSocketConfig::default()
643 .with_ping_interval(Duration::from_secs(5))
644 .with_max_connections(3)
645 .with_message_buffer_size(8)
646 .with_max_message_size(256)
647 .with_live_update_interval(Duration::ZERO)
648 .with_live_update_max_jobs(7);
649 assert_eq!(websocket.ping_interval, Duration::from_secs(5));
650 assert_eq!(
651 (
652 websocket.max_connections,
653 websocket.message_buffer_size,
654 websocket.max_message_size
655 ),
656 (3, 8, 256)
657 );
658 assert!(websocket.live_update_interval.is_zero());
659 assert_eq!(websocket.live_update_max_jobs, 7);
660
661 let config = DashboardConfig::new()
662 .with_pool_size(4)
663 .with_auth_config(auth)
664 .with_websocket(websocket)
665 .with_allowed_origins(vec!["https://ops.example.com".to_string()]);
666 assert_eq!(config.pool_size, 4);
667 assert_eq!(config.auth.username, "ops");
668 assert_eq!(config.websocket.max_connections, 3);
669 assert_eq!(config.allowed_origins, ["https://ops.example.com"]);
670 }
671}
672
673#[cfg(test)]
675mod doc_examples {
676
677 #[test]
678 fn example_1() {
679 use crate::config::DashboardConfig;
680
681 let config = DashboardConfig::new()
682 .with_bind_address("127.0.0.1", 8080)
683 .with_database_url("postgresql://localhost/hammerwork");
684
685 assert_eq!(config.bind_addr(), "127.0.0.1:8080");
686 }
687
688 #[test]
689 fn example_2() {
690 use crate::config::DashboardConfig;
691
692 let config = DashboardConfig::new()
693 .with_auth("admin", "$2b$12$hash...")
694 .with_cors(true);
695
696 assert!(config.auth.enabled);
697 assert_eq!(config.auth.username, "admin");
698 assert!(config.enable_cors);
699 }
700
701 #[test]
702 fn example_3() {
703 use crate::config::DashboardConfig;
704
705 let config = DashboardConfig::default();
707 assert_eq!(config.bind_address, "127.0.0.1");
708 assert_eq!(config.port, 8080);
709
710 let config = DashboardConfig::new()
712 .with_bind_address("0.0.0.0", 9090)
713 .with_database_url("postgresql://localhost/hammerwork")
714 .with_cors(true);
715
716 assert_eq!(config.bind_addr(), "0.0.0.0:9090");
717 assert!(config.enable_cors);
718 }
719
720 #[test]
721 fn example_4() {
722 use crate::config::DashboardConfig;
723
724 let config = DashboardConfig::new();
725 assert_eq!(config.bind_address, "127.0.0.1");
726 assert_eq!(config.port, 8080);
727 assert_eq!(config.database_url, "postgresql://localhost/hammerwork");
728 }
729
730 #[test]
731 fn example_5() {
732 use crate::config::DashboardConfig;
733
734 let config = DashboardConfig::new().with_bind_address("0.0.0.0", 9090);
735
736 assert_eq!(config.bind_address, "0.0.0.0");
737 assert_eq!(config.port, 9090);
738 assert_eq!(config.bind_addr(), "0.0.0.0:9090");
739 }
740
741 #[test]
742 fn example_6() {
743 use crate::config::DashboardConfig;
744
745 let pg_config =
747 DashboardConfig::new().with_database_url("postgresql://user:pass@localhost/hammerwork");
748 assert_eq!(
749 pg_config.database_url,
750 "postgresql://user:pass@localhost/hammerwork"
751 );
752
753 let mysql_config =
755 DashboardConfig::new().with_database_url("mysql://root:password@localhost/hammerwork");
756 assert_eq!(
757 mysql_config.database_url,
758 "mysql://root:password@localhost/hammerwork"
759 );
760 }
761
762 #[test]
763 fn example_7() {
764 use crate::config::DashboardConfig;
765 use std::path::PathBuf;
766
767 let config = DashboardConfig::new().with_static_dir(PathBuf::from("/var/www/dashboard"));
768
769 assert_eq!(config.static_dir, PathBuf::from("/var/www/dashboard"));
770 }
771
772 #[test]
773 fn example_8() {
774 use crate::config::DashboardConfig;
775
776 let config = DashboardConfig::new().with_auth("admin", "$2b$12$hash...");
777
778 assert!(config.auth.enabled);
779 assert_eq!(config.auth.username, "admin");
780 assert_eq!(config.auth.password_hash, "$2b$12$hash...");
781 }
782
783 #[test]
784 fn example_9() {
785 use crate::config::DashboardConfig;
786
787 let config = DashboardConfig::new()
788 .with_cors(true)
789 .with_allowed_origin("https://ops.example.com");
790
791 assert!(config.enable_cors);
792 assert_eq!(config.allowed_origins, ["https://ops.example.com"]);
793
794 let config = DashboardConfig::new().with_cors(false);
795
796 assert!(!config.enable_cors);
797 }
798
799 #[test]
800 fn example_10() {
801 use crate::config::AuthConfig;
802 use std::time::Duration;
803
804 let auth_config = AuthConfig::default();
806 assert!(auth_config.enabled);
807 assert_eq!(auth_config.username, "admin");
808 assert_eq!(auth_config.max_failed_attempts, 5);
809
810 let auth_config = AuthConfig::default()
812 .with_enabled(true)
813 .with_username("dashboard_admin".to_string())
814 .with_password_hash("$2b$12$hash...".to_string())
815 .with_session_timeout(Duration::from_secs(4 * 60 * 60)) .with_max_failed_attempts(3)
817 .with_lockout_duration(Duration::from_secs(30 * 60)); assert_eq!(auth_config.username, "dashboard_admin");
820 assert_eq!(auth_config.max_failed_attempts, 3);
821 }
822}