1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Deserialize, Clone)]
19pub struct SystemConfig {
20 #[serde(default = "default_listener")]
22 pub listener: ListenerConfig,
23 #[serde(default)]
25 pub tls: Option<TlsConfig>,
26 #[serde(default)]
30 pub http2: Http2Config,
31 #[serde(default)]
33 pub timeouts: TimeoutConfig,
34 #[serde(default)]
36 pub logging: LoggingConfig,
37 #[serde(default)]
39 pub admin: Option<AdminConfig>,
40 #[serde(default)]
43 pub config: ConfigSourceConfig,
44 #[serde(default)]
47 pub stream: Vec<StreamListenerConfig>,
48 #[serde(default)]
50 pub debug: DebugConfig,
51 #[serde(default)]
55 pub acme: Option<AcmeConfig>,
56 #[serde(default)]
58 pub cache: CacheConfig,
59}
60
61#[derive(Debug, Deserialize, Clone)]
67#[serde(default)]
68pub struct CacheConfig {
69 pub max_entries: usize,
70}
71
72impl Default for CacheConfig {
73 fn default() -> Self {
74 Self {
75 max_entries: 10_000,
76 }
77 }
78}
79
80#[derive(Debug, Deserialize, Clone)]
97#[serde(default)]
98pub struct DebugConfig {
99 pub enabled: bool,
102 pub sandbox: bool,
105 pub trigger_header: String,
108 pub trace_all: bool,
111 pub capture_bodies: bool,
115 pub max_body_bytes: usize,
117 pub max_traces: usize,
119 pub max_steps: usize,
121 pub sandbox_timeout_seconds: u64,
123 pub redact_headers: Vec<String>,
125 pub redact_query_params: Vec<String>,
127 pub redact_message_keys: Vec<String>,
129}
130
131impl Default for DebugConfig {
132 fn default() -> Self {
133 Self {
134 enabled: false,
135 sandbox: true,
136 trigger_header: default_trigger_header(),
137 trace_all: false,
138 capture_bodies: false,
139 max_body_bytes: 8192,
140 max_traces: 1000,
141 max_steps: 200,
142 sandbox_timeout_seconds: 30,
143 redact_headers: Vec::new(),
144 redact_query_params: Vec::new(),
145 redact_message_keys: Vec::new(),
146 }
147 }
148}
149
150fn default_trigger_header() -> String {
151 "x-featherbit-debug".to_string()
152}
153
154#[derive(Debug, Deserialize, Clone, Default)]
164pub struct ConfigSourceConfig {
165 #[serde(default)]
167 pub source: ConfigSourceKind,
168 #[serde(default)]
170 pub etcd: Option<EtcdConfig>,
171}
172
173#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
175#[serde(rename_all = "lowercase")]
176pub enum ConfigSourceKind {
177 #[default]
179 File,
180 Etcd,
182}
183
184#[derive(Debug, Deserialize, Clone)]
186pub struct EtcdConfig {
187 pub endpoints: Vec<String>,
189 #[serde(default = "default_etcd_prefix")]
191 pub prefix: String,
192 #[serde(default)]
194 pub user: Option<String>,
195 #[serde(default)]
197 pub password: Option<String>,
198 #[serde(default = "default_etcd_timeout")]
200 pub timeout_ms: u64,
201}
202
203fn default_etcd_prefix() -> String {
204 "/featherbit".to_string()
205}
206
207fn default_etcd_timeout() -> u64 {
208 3000
209}
210
211#[derive(Debug, Deserialize, Clone)]
213pub struct ListenerConfig {
214 #[serde(default = "default_bind")]
216 pub bind: String,
217 #[serde(default = "default_port")]
219 pub port: u16,
220}
221
222#[derive(Debug, Deserialize, Clone)]
225pub struct StreamListenerConfig {
226 #[serde(default)]
228 pub protocol: StreamProtocol,
229 #[serde(default = "default_bind")]
231 pub bind: String,
232 pub port: u16,
234 pub upstream: StreamUpstreamConfig,
238 #[serde(default)]
242 pub sni_routes: Vec<SniRoute>,
243}
244
245#[derive(Debug, Deserialize, Clone)]
248pub struct SniRoute {
249 pub server_name: String,
252 pub upstream: StreamUpstreamConfig,
254}
255
256#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
258#[serde(rename_all = "lowercase")]
259pub enum StreamProtocol {
260 #[default]
261 Tcp,
262 Udp,
263}
264
265#[derive(Debug, Deserialize, Clone)]
267pub struct StreamUpstreamConfig {
268 pub targets: Vec<crate::balancer::Target>,
270 #[serde(default)]
273 pub load_balancing: Option<String>,
274}
275
276#[derive(Debug, Deserialize, Clone)]
278pub struct TlsConfig {
279 #[serde(default)]
282 pub cert_path: Option<String>,
283 #[serde(default)]
285 pub key_path: Option<String>,
286 #[serde(default = "default_tls_min_version")]
288 pub min_version: String,
289 #[serde(default)]
292 pub client_ca_path: Option<String>,
293 #[serde(default = "default_true")]
298 pub client_cert_required: bool,
299 #[serde(default)]
303 pub sni_certs: Vec<SniCert>,
304 #[serde(default)]
309 pub acme: Option<AcmeSlot>,
310}
311
312#[derive(Debug, Deserialize, Clone)]
315pub struct SniCert {
316 pub server_name: String,
319 #[serde(default)]
322 pub cert_path: Option<String>,
323 #[serde(default)]
326 pub key_path: Option<String>,
327 #[serde(default)]
331 pub acme: Option<AcmeSlot>,
332}
333
334#[derive(Debug, Deserialize, Clone, Default)]
336pub struct AcmeSlot {
337 #[serde(default)]
341 pub domains: Vec<String>,
342}
343
344#[derive(Debug, Deserialize, Clone)]
346pub struct AcmeConfig {
347 #[serde(default = "default_acme_directory")]
349 pub directory_url: String,
350 #[serde(default)]
353 pub directory_ca_path: Option<String>,
354 #[serde(default)]
356 pub contact: Vec<String>,
357 #[serde(default)]
359 pub terms_of_service_agreed: bool,
360 #[serde(default)]
362 pub eab: Option<AcmeEabConfig>,
363 #[serde(default = "default_acme_key_type")]
365 pub key_type: String,
366 #[serde(default = "default_acme_renew_before")]
369 pub renew_before: String,
370 #[serde(default)]
372 pub storage: AcmeStorageConfig,
373}
374
375#[derive(Debug, Deserialize, Clone)]
377pub struct AcmeEabConfig {
378 pub key_id: String,
379 pub hmac_key: String,
381}
382
383#[derive(Debug, Deserialize, Clone)]
385#[serde(tag = "type", rename_all = "lowercase")]
386pub enum AcmeStorageConfig {
387 Filesystem {
389 #[serde(default = "default_acme_dir")]
390 dir: String,
391 },
392 Store {
395 store: String,
396 #[cfg_attr(not(feature = "redis-store"), allow(dead_code))]
399 #[serde(default)]
400 encryption_key: String,
401 },
402}
403
404impl Default for AcmeStorageConfig {
405 fn default() -> Self {
406 Self::Filesystem {
407 dir: default_acme_dir(),
408 }
409 }
410}
411
412fn default_acme_directory() -> String {
413 "https://acme-v02.api.letsencrypt.org/directory".to_string()
414}
415fn default_acme_key_type() -> String {
416 "ecdsa-p256".to_string()
417}
418fn default_acme_renew_before() -> String {
419 "30d".to_string()
420}
421fn default_acme_dir() -> String {
422 "/var/lib/featherbit/acme".to_string()
423}
424
425pub fn parse_duration(s: &str) -> Result<std::time::Duration, String> {
427 let s = s.trim();
428 if s.is_empty() {
429 return Err("duration must not be empty".to_string());
430 }
431 let (num, mult) = match s.chars().last().unwrap() {
432 'd' => (&s[..s.len() - 1], 86_400u64),
433 'h' => (&s[..s.len() - 1], 3_600),
434 'm' => (&s[..s.len() - 1], 60),
435 's' => (&s[..s.len() - 1], 1),
436 c if c.is_ascii_digit() => (s, 1),
437 other => {
438 return Err(format!(
439 "unknown duration unit '{other}' in '{s}' (use d/h/m/s)"
440 ))
441 }
442 };
443 let n: u64 = num.parse().map_err(|_| format!("invalid duration '{s}'"))?;
444 let secs = n
445 .checked_mul(mult)
446 .ok_or_else(|| format!("duration '{s}' is out of range"))?;
447 Ok(std::time::Duration::from_secs(secs))
448}
449
450pub fn normalize_domain(d: &str) -> Result<String, String> {
453 let d = d.trim().to_ascii_lowercase();
454 if d.is_empty() {
455 return Err("domain must not be empty".to_string());
456 }
457 if d.contains('*') {
458 return Err(format!(
459 "'{d}': TLS-ALPN-01 cannot issue wildcard certificates; use a file-based cert"
460 ));
461 }
462 if d.parse::<std::net::IpAddr>().is_ok() || d.contains(':') {
463 return Err(format!(
464 "'{d}': IP addresses are not supported ACME identifiers"
465 ));
466 }
467 if !d
468 .bytes()
469 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
470 || d.split('.').any(|label| label.is_empty())
471 {
472 return Err(format!("'{d}' is not a valid DNS name"));
473 }
474 Ok(d)
475}
476
477impl SniCert {
478 pub fn acme_domains(&self) -> Result<Option<Vec<String>>, String> {
481 match &self.acme {
482 None => Ok(None),
483 Some(slot) if slot.domains.is_empty() => {
484 Ok(Some(vec![normalize_domain(&self.server_name)?]))
485 }
486 Some(slot) => Ok(Some(
487 slot.domains
488 .iter()
489 .map(|d| normalize_domain(d))
490 .collect::<Result<Vec<_>, _>>()?,
491 )),
492 }
493 }
494}
495
496impl TlsConfig {
497 pub fn managed_domains(&self) -> Vec<Vec<String>> {
501 let mut out = Vec::new();
502 if let Some(slot) = &self.acme {
503 out.push(
504 slot.domains
505 .iter()
506 .filter_map(|d| normalize_domain(d).ok())
507 .collect(),
508 );
509 }
510 for sc in &self.sni_certs {
511 if let Ok(Some(domains)) = sc.acme_domains() {
512 out.push(domains);
513 }
514 }
515 out
516 }
517
518 pub fn validate(&self, acme_enabled: bool, label: &str) -> Result<(), String> {
522 fn check_slot(
523 what: &str,
524 cert: &Option<String>,
525 key: &Option<String>,
526 acme: &Option<AcmeSlot>,
527 acme_enabled: bool,
528 ) -> Result<(), String> {
529 match (cert, key, acme) {
530 (Some(_), Some(_), None) => Ok(()),
531 (None, None, Some(_)) if acme_enabled => Ok(()),
532 (None, None, Some(_)) => Err(format!(
533 "{what}: acme slot requires the top-level `acme:` block in system.yaml"
534 )),
535 (None, None, None) => Err(format!("{what}: set cert_path + key_path, or acme")),
536 (Some(_), None, _) | (None, Some(_), _) => Err(format!(
537 "{what}: cert_path and key_path must be set together"
538 )),
539 (Some(_), Some(_), Some(_)) => Err(format!(
540 "{what}: set exactly one of cert_path/key_path or acme"
541 )),
542 }
543 }
544 check_slot(
545 label,
546 &self.cert_path,
547 &self.key_path,
548 &self.acme,
549 acme_enabled,
550 )?;
551 if let Some(slot) = &self.acme {
552 if slot.domains.is_empty() {
553 return Err(format!(
554 "{label}.acme: a managed default certificate needs explicit `domains`"
555 ));
556 }
557 for d in &slot.domains {
558 normalize_domain(d).map_err(|e| format!("{label}.acme.domains: {e}"))?;
559 }
560 }
561 for (i, sc) in self.sni_certs.iter().enumerate() {
562 let what = format!("{label}.sni_certs[{i}] ({})", sc.server_name);
563 check_slot(&what, &sc.cert_path, &sc.key_path, &sc.acme, acme_enabled)?;
564 sc.acme_domains().map_err(|e| format!("{what}: {e}"))?;
565 }
566 Ok(())
567 }
568}
569
570impl AcmeConfig {
571 pub fn renew_before_duration(&self) -> Result<std::time::Duration, String> {
572 parse_duration(&self.renew_before).map_err(|e| format!("acme.renew_before: {e}"))
573 }
574
575 pub fn validate(&self) -> Result<(), String> {
576 if !self.terms_of_service_agreed {
577 return Err("acme.terms_of_service_agreed must be true to register an account".into());
578 }
579 if !self.directory_url.starts_with("https://") {
580 return Err(format!(
581 "acme.directory_url must be https:// (got '{}')",
582 self.directory_url
583 ));
584 }
585 if let Some(p) = &self.directory_ca_path {
586 std::fs::metadata(p)
587 .map_err(|e| format!("acme.directory_ca_path '{p}' is not readable: {e}"))?;
588 }
589 if !matches!(self.key_type.as_str(), "ecdsa-p256" | "ecdsa-p384") {
590 return Err(format!(
591 "acme.key_type '{}' is not supported; use ecdsa-p256 or ecdsa-p384 (RSA needs a non-ring crypto backend)",
592 self.key_type
593 ));
594 }
595 self.renew_before_duration()?;
596 if let Some(eab) = &self.eab {
597 if eab.key_id.is_empty() || eab.hmac_key.is_empty() {
598 return Err("acme.eab: key_id and hmac_key must both be set".into());
599 }
600 }
601 match &self.storage {
602 AcmeStorageConfig::Filesystem { dir } if dir.trim().is_empty() => {
603 Err("acme.storage.dir must not be empty".into())
604 }
605 AcmeStorageConfig::Filesystem { .. } => Ok(()),
606 #[cfg(not(feature = "redis-store"))]
607 AcmeStorageConfig::Store { .. } => Err(
608 "acme.storage.type: store — this binary was built without the redis-store feature"
609 .into(),
610 ),
611 #[cfg(feature = "redis-store")]
612 AcmeStorageConfig::Store { encryption_key, .. } if encryption_key.trim().is_empty() => {
613 Err("acme.storage.encryption_key is required for type: store".into())
614 }
615 #[cfg(feature = "redis-store")]
616 AcmeStorageConfig::Store { .. } => Ok(()),
617 }
618 }
619}
620
621impl SystemConfig {
622 pub fn validate(&self) -> Result<(), String> {
624 if let Some(acme) = &self.acme {
625 acme.validate()?;
626 }
627 if let Some(tls) = &self.tls {
628 tls.validate(self.acme.is_some(), "tls")?;
629 }
630 if let Some(admin) = &self.admin {
631 if let Some(tls) = &admin.tls {
632 if tls.acme.is_some() || tls.sni_certs.iter().any(|s| s.acme.is_some()) {
633 return Err(
634 "admin.tls does not support acme (the admin listener is not validated by the CA); use cert_path/key_path"
635 .into(),
636 );
637 }
638 tls.validate(false, "admin.tls")?;
639 }
640 if let Some(mcp) = &admin.mcp {
641 mcp.validate()?;
642 }
643 }
644 Ok(())
645 }
646
647 pub fn validate_against_gateway(
649 &self,
650 gw: &crate::config::GatewayConfig,
651 ) -> Result<(), String> {
652 if let Some(AcmeConfig {
653 storage: AcmeStorageConfig::Store { store, .. },
654 ..
655 }) = &self.acme
656 {
657 if !gw.stores.iter().any(|s| &s.name == store) {
658 return Err(format!(
659 "acme.storage.store references unknown store '{store}' (declare it under `stores:` in gateway.yaml)"
660 ));
661 }
662 }
663 Ok(())
664 }
665}
666
667#[derive(Debug, Deserialize, Clone)]
669pub struct Http2Config {
670 #[serde(default = "default_true")]
671 pub enabled: bool,
672}
673
674#[derive(Debug, Deserialize, Clone)]
679pub struct TimeoutConfig {
680 #[serde(default = "default_timeout_30")]
681 pub connection_seconds: u64,
682 #[allow(dead_code)]
685 #[serde(default = "default_timeout_30")]
686 pub read_seconds: u64,
687 #[allow(dead_code)]
688 #[serde(default = "default_timeout_30")]
689 pub write_seconds: u64,
690 #[serde(default = "default_timeout_300")]
691 pub idle_seconds: u64,
692 #[serde(default = "default_timeout_30")]
695 pub shutdown_timeout_seconds: u64,
696}
697
698#[derive(Debug, Deserialize, Clone)]
702pub struct LoggingConfig {
703 #[serde(default = "default_log_level")]
705 pub level: String,
706 #[serde(default = "default_log_format")]
708 pub format: String,
709}
710
711#[derive(Debug, Deserialize, Clone)]
714pub struct AdminConfig {
715 #[serde(default = "default_bind")]
717 pub bind: String,
718 #[serde(default = "default_admin_port")]
720 pub port: u16,
721 pub username: String,
723 pub password: String,
725 #[serde(default = "default_true")]
730 #[cfg_attr(not(feature = "ui"), allow(dead_code))]
735 pub ui_enabled: bool,
736 #[serde(default)]
739 pub tls: Option<TlsConfig>,
740 #[serde(default)]
745 pub mcp: Option<McpConfig>,
746}
747
748pub const MCP_MIN_TOKEN_LEN: usize = 16;
750
751#[derive(Debug, Deserialize, Clone)]
753pub struct McpConfig {
754 #[serde(default)]
756 pub enabled: bool,
757 #[serde(default = "default_mcp_path")]
760 pub path: String,
761 #[serde(default)]
763 pub tokens: Vec<McpTokenConfig>,
764 #[serde(default)]
771 pub allowed_origins: Vec<String>,
772}
773
774#[derive(Debug, Deserialize, Clone)]
776pub struct McpTokenConfig {
777 pub token: String,
779 pub scope: McpScope,
781 #[serde(default)]
783 pub name: Option<String>,
784}
785
786#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
788#[serde(rename_all = "lowercase")]
789pub enum McpScope {
790 Read,
791 Write,
792}
793
794impl McpScope {
795 #[cfg_attr(not(feature = "mcp"), allow(dead_code))] pub fn allows(self, required: McpScope) -> bool {
798 self == McpScope::Write || required == McpScope::Read
799 }
800
801 pub fn as_str(self) -> &'static str {
805 match self {
806 McpScope::Read => "read",
807 McpScope::Write => "write",
808 }
809 }
810}
811
812fn default_mcp_path() -> String {
813 "/mcp".to_string()
814}
815
816impl McpConfig {
817 pub fn validate(&self) -> Result<(), String> {
819 let reserved = ["/", "/api", "/healthz", "/readyz", "/metrics"];
820 if !self.path.starts_with('/')
821 || reserved.contains(&self.path.as_str())
822 || self.path.starts_with("/api/")
823 {
824 return Err(format!(
825 "admin.mcp.path '{}' must be an absolute path outside /api (and not /healthz, /readyz, /metrics)",
826 self.path
827 ));
828 }
829 if self.enabled && self.tokens.is_empty() {
830 return Err(
831 "admin.mcp.tokens must declare at least one token when admin.mcp.enabled is true"
832 .into(),
833 );
834 }
835 for (i, t) in self.tokens.iter().enumerate() {
836 if t.token.is_empty() {
837 return Err(format!(
838 "admin.mcp.tokens[{i}].token is empty (is the environment variable set?)"
839 ));
840 }
841 if t.token.chars().count() < MCP_MIN_TOKEN_LEN {
842 return Err(format!(
843 "admin.mcp.tokens[{i}].token must be at least {MCP_MIN_TOKEN_LEN} characters"
844 ));
845 }
846 if let Some(j) = self.tokens[..i].iter().position(|o| o.token == t.token) {
847 return Err(format!("admin.mcp.tokens[{i}] duplicates tokens[{j}]"));
848 }
849 }
850 Ok(())
851 }
852}
853
854fn default_listener() -> ListenerConfig {
855 ListenerConfig {
856 bind: default_bind(),
857 port: default_port(),
858 }
859}
860
861fn default_bind() -> String {
862 "0.0.0.0".to_string()
863}
864fn default_port() -> u16 {
865 8080
866}
867fn default_admin_port() -> u16 {
868 9090
869}
870fn default_true() -> bool {
871 true
872}
873fn default_tls_min_version() -> String {
874 "1.2".to_string()
875}
876fn default_timeout_30() -> u64 {
877 30
878}
879fn default_timeout_300() -> u64 {
880 300
881}
882fn default_log_level() -> String {
883 "info".to_string()
884}
885fn default_log_format() -> String {
886 "json".to_string()
887}
888
889impl Default for Http2Config {
890 fn default() -> Self {
891 Self { enabled: true }
892 }
893}
894
895impl Default for TimeoutConfig {
896 fn default() -> Self {
897 Self {
898 connection_seconds: 30,
899 read_seconds: 30,
900 write_seconds: 30,
901 idle_seconds: 300,
902 shutdown_timeout_seconds: 30,
903 }
904 }
905}
906
907impl Default for LoggingConfig {
908 fn default() -> Self {
909 Self {
910 level: "info".to_string(),
911 format: "json".to_string(),
912 }
913 }
914}
915
916#[cfg(test)]
917mod tests {
918 use super::*;
919
920 #[test]
921 fn test_shutdown_timeout_default_and_parse() {
922 let cfg: TimeoutConfig = serde_yaml::from_str("{}").unwrap();
924 assert_eq!(cfg.shutdown_timeout_seconds, 30);
925 assert_eq!(TimeoutConfig::default().shutdown_timeout_seconds, 30);
926
927 let cfg: TimeoutConfig = serde_yaml::from_str("shutdown_timeout_seconds: 5").unwrap();
929 assert_eq!(cfg.shutdown_timeout_seconds, 5);
930 }
931
932 #[test]
935 fn test_debug_defaults_to_disabled() {
936 let cfg: DebugConfig = serde_yaml::from_str("{}").unwrap();
937 assert!(!cfg.enabled);
938 assert!(!cfg.trace_all);
939 assert!(!cfg.capture_bodies);
940 assert!(cfg.sandbox, "sandbox is allowed once debug itself is on");
941 assert_eq!(cfg.trigger_header, "x-featherbit-debug");
942 assert_eq!(cfg.max_traces, 1000);
943 assert_eq!(cfg.max_steps, 200);
944 assert_eq!(cfg.max_body_bytes, 8192);
945 assert_eq!(cfg.sandbox_timeout_seconds, 30);
946 assert!(cfg.redact_headers.is_empty());
947 }
948
949 #[test]
952 fn test_system_config_without_debug_section() {
953 let cfg: SystemConfig = serde_yaml::from_str("listener: { port: 8080 }").unwrap();
954 assert!(!cfg.debug.enabled);
955 }
956
957 #[test]
958 fn test_debug_explicit_block_parses() {
959 let cfg: DebugConfig = serde_yaml::from_str(
960 "enabled: true\ncapture_bodies: true\nmax_traces: 5\nredact_headers: [x-custom]\n",
961 )
962 .unwrap();
963 assert!(cfg.enabled);
964 assert!(cfg.capture_bodies);
965 assert_eq!(cfg.max_traces, 5);
966 assert_eq!(cfg.redact_headers, vec!["x-custom".to_string()]);
967 assert_eq!(cfg.trigger_header, "x-featherbit-debug");
969 assert_eq!(cfg.max_steps, 200);
970 }
971
972 #[test]
973 fn test_admin_ui_enabled_defaults_true() {
974 let cfg: AdminConfig = serde_yaml::from_str("username: u\npassword: p\n").unwrap();
975 assert!(cfg.ui_enabled);
976 }
977
978 #[test]
979 fn test_admin_ui_enabled_false_parses() {
980 let cfg: AdminConfig =
981 serde_yaml::from_str("username: u\npassword: p\nui_enabled: false\n").unwrap();
982 assert!(!cfg.ui_enabled);
983 }
984
985 fn admin_with_mcp(mcp_yaml: &str) -> AdminConfig {
986 let yaml = format!("username: u\npassword: p\nmcp:\n{}", mcp_yaml);
987 serde_yaml::from_str(&yaml).unwrap()
988 }
989
990 #[test]
991 fn test_admin_mcp_absent_by_default() {
992 let cfg: AdminConfig = serde_yaml::from_str("username: u\npassword: p\n").unwrap();
993 assert!(cfg.mcp.is_none());
994 }
995
996 #[test]
997 fn test_mcp_defaults() {
998 let cfg = admin_with_mcp(" enabled: false\n");
999 let mcp = cfg.mcp.unwrap();
1000 assert!(!mcp.enabled);
1001 assert_eq!(mcp.path, "/mcp");
1002 assert!(mcp.tokens.is_empty());
1003 assert!(mcp.allowed_origins.is_empty());
1004 assert!(mcp.validate().is_ok());
1005 }
1006
1007 #[test]
1008 fn test_mcp_enabled_requires_tokens() {
1009 let mcp = admin_with_mcp(" enabled: true\n").mcp.unwrap();
1010 let err = mcp.validate().unwrap_err();
1011 assert!(
1012 err.contains("admin.mcp.tokens must declare at least one token"),
1013 "{err}"
1014 );
1015 }
1016
1017 #[test]
1018 fn test_mcp_empty_token_rejected() {
1019 let mcp =
1020 admin_with_mcp(" enabled: true\n tokens:\n - token: \"\"\n scope: read\n")
1021 .mcp
1022 .unwrap();
1023 let err = mcp.validate().unwrap_err();
1024 assert!(err.contains("admin.mcp.tokens[0].token is empty"), "{err}");
1025 }
1026
1027 #[test]
1028 fn test_mcp_short_token_rejected() {
1029 let mcp =
1030 admin_with_mcp(" enabled: true\n tokens:\n - token: short\n scope: read\n")
1031 .mcp
1032 .unwrap();
1033 let err = mcp.validate().unwrap_err();
1034 assert!(
1035 err.contains("admin.mcp.tokens[0].token must be at least 16 characters"),
1036 "{err}"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_mcp_duplicate_token_rejected() {
1042 let mcp = admin_with_mcp(
1043 " enabled: true\n tokens:\n - token: aaaaaaaaaaaaaaaaaaaa\n scope: read\n - token: aaaaaaaaaaaaaaaaaaaa\n scope: write\n",
1044 )
1045 .mcp
1046 .unwrap();
1047 let err = mcp.validate().unwrap_err();
1048 assert!(
1049 err.contains("admin.mcp.tokens[1] duplicates tokens[0]"),
1050 "{err}"
1051 );
1052 }
1053
1054 #[test]
1055 fn test_mcp_bad_paths_rejected() {
1056 for bad in [
1057 "mcp", "/", "/api", "/api/mcp", "/healthz", "/readyz", "/metrics",
1058 ] {
1059 let mcp = admin_with_mcp(&format!(" path: \"{bad}\"\n")).mcp.unwrap();
1060 let err = mcp.validate().unwrap_err();
1061 assert!(err.contains("admin.mcp.path"), "{bad}: {err}");
1062 }
1063 }
1064
1065 #[test]
1066 fn test_mcp_valid_config_and_scope_semantics() {
1067 let mcp = admin_with_mcp(
1068 " enabled: true\n path: /agent\n tokens:\n - token: rrrrrrrrrrrrrrrrrrrr\n scope: read\n name: local\n - token: wwwwwwwwwwwwwwwwwwww\n scope: write\n allowed_origins: [\"http://localhost:5173\"]\n",
1069 )
1070 .mcp
1071 .unwrap();
1072 assert!(mcp.validate().is_ok());
1073 assert_eq!(mcp.tokens[0].name.as_deref(), Some("local"));
1074 assert_eq!(mcp.tokens[1].name, None);
1075 assert!(McpScope::Write.allows(McpScope::Read));
1076 assert!(McpScope::Write.allows(McpScope::Write));
1077 assert!(McpScope::Read.allows(McpScope::Read));
1078 assert!(!McpScope::Read.allows(McpScope::Write));
1079 assert_eq!(McpScope::Read.as_str(), "read");
1080 assert_eq!(McpScope::Write.as_str(), "write");
1081 }
1082
1083 #[test]
1084 fn test_system_validate_runs_mcp_validate() {
1085 let s: SystemConfig = serde_yaml::from_str(
1086 "admin:\n username: u\n password: p\n mcp:\n enabled: true\n",
1087 )
1088 .unwrap();
1089 assert!(s.validate().unwrap_err().contains("admin.mcp.tokens"));
1090 }
1091}
1092
1093#[cfg(test)]
1094mod acme_config_tests {
1095 use super::*;
1096
1097 fn sys(yaml: &str) -> SystemConfig {
1098 serde_yaml::from_str(yaml).unwrap()
1099 }
1100
1101 #[test]
1102 fn parse_duration_units() {
1103 assert_eq!(parse_duration("30d").unwrap().as_secs(), 30 * 86_400);
1104 assert_eq!(parse_duration("12h").unwrap().as_secs(), 12 * 3_600);
1105 assert_eq!(parse_duration("5m").unwrap().as_secs(), 300);
1106 assert_eq!(parse_duration("90s").unwrap().as_secs(), 90);
1107 assert_eq!(parse_duration("42").unwrap().as_secs(), 42);
1108 assert!(parse_duration("").is_err());
1109 assert!(parse_duration("3w").is_err());
1110 assert!(parse_duration("-1d").is_err());
1111 assert_eq!(
1113 parse_duration("18446744073709551615d").unwrap_err(),
1114 "duration '18446744073709551615d' is out of range"
1115 );
1116 assert!(parse_duration("999999999999999999h").is_err());
1117 assert_eq!(
1118 parse_duration("18446744073709551615").unwrap().as_secs(),
1119 u64::MAX,
1120 "bare seconds have no multiplier to overflow"
1121 );
1122 }
1123
1124 #[test]
1125 fn normalize_domain_rules() {
1126 assert_eq!(
1127 normalize_domain("API.Example.com").unwrap(),
1128 "api.example.com"
1129 );
1130 assert!(normalize_domain("*.example.com")
1131 .unwrap_err()
1132 .contains("wildcard"));
1133 assert!(normalize_domain("10.0.0.1").is_err());
1134 assert!(normalize_domain("::1").is_err());
1135 assert!(normalize_domain("").is_err());
1136 assert!(normalize_domain("bad_host.example.com").is_err());
1137 }
1138
1139 #[test]
1140 fn file_tls_without_acme_block_is_valid_and_has_no_managed_domains() {
1141 let s = sys("tls:\n cert_path: a.pem\n key_path: a.key\n");
1142 s.validate().unwrap();
1143 assert!(s.tls.as_ref().unwrap().managed_domains().is_empty());
1144 }
1145
1146 #[test]
1147 fn half_file_pair_is_rejected() {
1148 let s = sys("tls:\n cert_path: a.pem\n");
1149 assert!(s.validate().unwrap_err().contains("cert_path"));
1150 }
1151
1152 #[test]
1153 fn acme_slot_requires_top_level_block() {
1154 let s = sys("tls:\n acme:\n domains: [api.example.com]\n");
1155 assert!(s.validate().unwrap_err().contains("acme:"));
1156 }
1157
1158 #[test]
1159 fn acme_slot_and_file_pair_are_mutually_exclusive() {
1160 let s = sys(
1161 "acme:\n terms_of_service_agreed: true\ntls:\n cert_path: a.pem\n key_path: a.key\n acme:\n domains: [api.example.com]\n",
1162 );
1163 assert!(s.validate().unwrap_err().contains("exactly one"));
1164 }
1165
1166 #[test]
1167 fn managed_default_needs_explicit_domains_and_sni_defaults_to_server_name() {
1168 let s = sys("acme:\n terms_of_service_agreed: true\ntls:\n acme: {}\n");
1169 assert!(s.validate().unwrap_err().contains("domains"));
1170
1171 let s = sys(
1172 "acme:\n terms_of_service_agreed: true\ntls:\n acme:\n domains: [B.example.com, a.example.com]\n sni_certs:\n - server_name: Tenant.example.com\n acme: {}\n",
1173 );
1174 s.validate().unwrap();
1175 assert_eq!(
1176 s.tls.as_ref().unwrap().managed_domains(),
1177 vec![
1178 vec!["b.example.com".to_string(), "a.example.com".to_string()],
1179 vec!["tenant.example.com".to_string()]
1180 ]
1181 );
1182 }
1183
1184 #[test]
1185 fn wildcard_acme_domains_are_rejected() {
1186 let s = sys(
1187 "acme:\n terms_of_service_agreed: true\ntls:\n acme:\n domains: [\"*.example.com\"]\n",
1188 );
1189 assert!(s.validate().unwrap_err().contains("wildcard"));
1190 }
1191
1192 #[test]
1193 fn tos_must_be_agreed() {
1194 let s = sys("acme:\n terms_of_service_agreed: false\ntls:\n acme:\n domains: [a.example.com]\n");
1195 assert!(s
1196 .validate()
1197 .unwrap_err()
1198 .contains("terms_of_service_agreed"));
1199 }
1200
1201 #[test]
1202 fn directory_must_be_https_and_key_type_ecdsa() {
1203 let s =
1204 sys("acme:\n terms_of_service_agreed: true\n directory_url: http://ca.local/dir\n");
1205 assert!(s.validate().unwrap_err().contains("https"));
1206 let s = sys("acme:\n terms_of_service_agreed: true\n key_type: rsa-2048\n");
1207 let err = s.validate().unwrap_err();
1208 assert!(
1209 err.contains("ecdsa-p256") && err.contains("ecdsa-p384"),
1210 "{err}"
1211 );
1212 }
1213
1214 #[test]
1215 fn admin_tls_rejects_acme() {
1216 let s = sys(
1217 "acme:\n terms_of_service_agreed: true\nadmin:\n username: a\n password: b\n tls:\n acme:\n domains: [admin.example.com]\n",
1218 );
1219 assert!(s.validate().unwrap_err().contains("admin.tls"));
1220 }
1221
1222 #[test]
1223 fn store_storage_requires_encryption_key_and_declared_store() {
1224 let s = sys(
1225 "acme:\n terms_of_service_agreed: true\n storage:\n type: store\n store: r\n",
1226 );
1227 let err = s.validate().unwrap_err();
1228 #[cfg(feature = "redis-store")]
1229 assert!(err.contains("encryption_key"), "{err}");
1230 #[cfg(not(feature = "redis-store"))]
1231 assert!(err.contains("redis-store"), "{err}");
1232
1233 #[cfg(feature = "redis-store")]
1234 {
1235 let s = sys(
1236 "acme:\n terms_of_service_agreed: true\n storage:\n type: store\n store: r\n encryption_key: k\n",
1237 );
1238 s.validate().unwrap();
1239 let gw: crate::config::GatewayConfig = serde_yaml::from_str("{}").unwrap();
1240 assert!(s.validate_against_gateway(&gw).unwrap_err().contains("'r'"));
1241 let gw: crate::config::GatewayConfig = serde_yaml::from_str(
1242 "stores:\n - name: r\n type: redis\n url: redis://127.0.0.1:6379\n",
1243 )
1244 .unwrap();
1245 s.validate_against_gateway(&gw).unwrap();
1246 }
1247 }
1248
1249 #[test]
1250 fn defaults() {
1251 let s = sys("acme:\n terms_of_service_agreed: true\n");
1252 let a = s.acme.unwrap();
1253 assert_eq!(
1254 a.directory_url,
1255 "https://acme-v02.api.letsencrypt.org/directory"
1256 );
1257 assert_eq!(a.key_type, "ecdsa-p256");
1258 assert_eq!(a.renew_before_duration().unwrap().as_secs(), 30 * 86_400);
1259 assert!(
1260 matches!(a.storage, AcmeStorageConfig::Filesystem { ref dir } if dir == "/var/lib/featherbit/acme")
1261 );
1262 }
1263}