Skip to main content

featherbit/config/
system.rs

1//! Schema for `system.yaml`: process-level settings (data-plane listener,
2//! TLS, HTTP/2, timeouts, logging, admin API). Loaded once at startup and
3//! never hot-reloaded; every top-level field has a serde default, so any
4//! section may be omitted.
5
6use serde::{Deserialize, Serialize};
7
8/// Root of `system.yaml`.
9///
10/// ```yaml
11/// listener: { bind: 0.0.0.0, port: 8080 }
12/// admin:
13///   port: 9090
14///   username: ${ADMIN_USER:-admin}
15///   password: ${ADMIN_PASS}
16/// logging: { level: info, format: json }
17/// ```
18#[derive(Debug, Deserialize, Clone)]
19pub struct SystemConfig {
20    /// Data-plane listener; defaults to `0.0.0.0:8080` when the section is omitted.
21    #[serde(default = "default_listener")]
22    pub listener: ListenerConfig,
23    /// TLS termination settings; `None` (the default) serves plain HTTP.
24    #[serde(default)]
25    pub tls: Option<TlsConfig>,
26    /// HTTP/2 toggle; enabled by default. When on, the listener serves HTTP/2
27    /// alongside HTTP/1.1 (ALPN-negotiated over TLS, h2c prior-knowledge over
28    /// plaintext).
29    #[serde(default)]
30    pub http2: Http2Config,
31    /// Connection/read/write/idle timeouts, in seconds.
32    #[serde(default)]
33    pub timeouts: TimeoutConfig,
34    /// Log level and output format; defaults to `info` / `json`.
35    #[serde(default)]
36    pub logging: LoggingConfig,
37    /// Admin REST API settings; `None` (the default) disables the admin server entirely.
38    #[serde(default)]
39    pub admin: Option<AdminConfig>,
40    /// Where gateway config (routes/policies/consumers) is loaded from and
41    /// where Admin API writes are persisted. Defaults to the local file.
42    #[serde(default)]
43    pub config: ConfigSourceConfig,
44    /// L4 (TCP/UDP) stream listeners. Each binds a port at startup and proxies
45    /// raw bytes to an upstream pool, independent of the HTTP data plane.
46    #[serde(default)]
47    pub stream: Vec<StreamListenerConfig>,
48    /// Policy-execution tracing and the plugin sandbox; disabled by default.
49    #[serde(default)]
50    pub debug: DebugConfig,
51    /// Automatic certificates via ACME (RFC 8555, TLS-ALPN-01). `None` (the
52    /// default) disables the feature; any `tls.acme` / `sni_certs[].acme` slot
53    /// then fails validation.
54    #[serde(default)]
55    pub acme: Option<AcmeConfig>,
56    /// Response-cache limits for `proxy-cache`'s `policy: local` backend.
57    #[serde(default)]
58    pub cache: CacheConfig,
59}
60
61/// Process-wide response-cache limits.
62///
63/// `max_entries` is here rather than on the node because every `policy: local`
64/// node shares one cache; a per-node value would be a setting that silently
65/// meant something else.
66#[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/// Debug mode: per-request policy-execution tracing plus the plugin sandbox.
81///
82/// Off by default. Because `system.yaml` is read once at startup and never
83/// hot-reloaded, **toggling debug mode requires a restart** — which is also the
84/// safety property that keeps a compromised Admin API credential from switching
85/// on request-context capture.
86///
87/// ```yaml
88/// debug:
89///   enabled: ${FEATHERBIT_DEBUG:-false}
90///   capture_bodies: ${FEATHERBIT_DEBUG_BODIES:-false}
91/// ```
92///
93/// Always keep the `:-` default in interpolated values: `${FEATHERBIT_DEBUG}`
94/// with the variable unset expands to empty text, which YAML parses as null and
95/// serde then rejects for a `bool`.
96#[derive(Debug, Deserialize, Clone)]
97#[serde(default)]
98pub struct DebugConfig {
99    /// Master switch. When false nothing is traced and every `/api/debug/*`
100    /// route except `GET /api/debug/config` responds `404`.
101    pub enabled: bool,
102    /// Allows `POST /api/debug/sandbox` (only meaningful while `enabled`), so a
103    /// deployment can trace requests without exposing plugin execution.
104    pub sandbox: bool,
105    /// Request header whose presence opts a single request into tracing.
106    /// Lowercased when the settings are resolved.
107    pub trigger_header: String,
108    /// Trace every request instead of waiting for `trigger_header`. A firehose:
109    /// it snapshots the context once per node for all traffic.
110    pub trace_all: bool,
111    /// Capture request/response bodies in snapshots. Off by default because it
112    /// is the expensive part; bodies are also the one thing redaction cannot
113    /// clean.
114    pub capture_bodies: bool,
115    /// Per-body truncation limit, in bytes, when `capture_bodies` is on.
116    pub max_body_bytes: usize,
117    /// Ring-buffer capacity. `0` disables storage.
118    pub max_traces: usize,
119    /// Maximum steps recorded per trace, bounding a runaway policy's trace.
120    pub max_steps: usize,
121    /// Deadline for one sandbox run, in seconds.
122    pub sandbox_timeout_seconds: u64,
123    /// Header names to redact **in addition to** the built-in denylist.
124    pub redact_headers: Vec<String>,
125    /// Query parameter names to redact in addition to the built-in denylist.
126    pub redact_query_params: Vec<String>,
127    /// `context.message` keys to redact in addition to the built-in denylist.
128    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/// Selects the gateway-config backend.
155///
156/// ```yaml
157/// config:
158///   source: etcd          # file (default) | etcd
159///   etcd:
160///     endpoints: ["http://etcd:2379"]
161///     prefix: /featherbit
162/// ```
163#[derive(Debug, Deserialize, Clone, Default)]
164pub struct ConfigSourceConfig {
165    /// `file` (default, single-node) or `etcd` (shared config for an HA cluster).
166    #[serde(default)]
167    pub source: ConfigSourceKind,
168    /// etcd connection settings; required when `source: etcd`.
169    #[serde(default)]
170    pub etcd: Option<EtcdConfig>,
171}
172
173/// Which config backend to use.
174#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
175#[serde(rename_all = "lowercase")]
176pub enum ConfigSourceKind {
177    /// Load from and apply Admin edits to the local `gateway.yaml` (default).
178    #[default]
179    File,
180    /// Load from and write to etcd; watch for cluster-wide changes.
181    Etcd,
182}
183
184/// etcd connection settings (used when `config.source` is `etcd`).
185#[derive(Debug, Deserialize, Clone)]
186pub struct EtcdConfig {
187    /// etcd endpoints, e.g. `["http://127.0.0.1:2379"]`. Required.
188    pub endpoints: Vec<String>,
189    /// Key prefix under which resources are stored; defaults to `/featherbit`.
190    #[serde(default = "default_etcd_prefix")]
191    pub prefix: String,
192    /// Optional username for etcd authentication.
193    #[serde(default)]
194    pub user: Option<String>,
195    /// Optional password for etcd authentication.
196    #[serde(default)]
197    pub password: Option<String>,
198    /// Connect/operation timeout in milliseconds; defaults to `3000`.
199    #[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/// Bind address and port for the data-plane HTTP listener.
212#[derive(Debug, Deserialize, Clone)]
213pub struct ListenerConfig {
214    /// Interface to bind; defaults to `0.0.0.0`.
215    #[serde(default = "default_bind")]
216    pub bind: String,
217    /// TCP port; defaults to `8080`.
218    #[serde(default = "default_port")]
219    pub port: u16,
220}
221
222/// An L4 stream listener: binds `bind:port` and proxies raw TCP or UDP to an
223/// upstream pool. Bound once at startup (fail-fast), like the HTTP listener.
224#[derive(Debug, Deserialize, Clone)]
225pub struct StreamListenerConfig {
226    /// Transport protocol; defaults to `tcp`.
227    #[serde(default)]
228    pub protocol: StreamProtocol,
229    /// Interface to bind; defaults to `0.0.0.0`.
230    #[serde(default = "default_bind")]
231    pub bind: String,
232    /// TCP/UDP port to listen on. Required.
233    pub port: u16,
234    /// Backend pool this listener forwards to. When `sni_routes` are set, this
235    /// is the fallback for connections whose SNI matches no route (and for
236    /// non-TLS / no-SNI connections).
237    pub upstream: StreamUpstreamConfig,
238    /// SNI-based passthrough routes (TCP only). Each maps a ClientHello SNI
239    /// hostname to its own upstream pool without terminating TLS. Ignored (with
240    /// a warning) for UDP listeners.
241    #[serde(default)]
242    pub sni_routes: Vec<SniRoute>,
243}
244
245/// One SNI passthrough route: an exact or single-label-wildcard server name
246/// mapped to its own upstream pool.
247#[derive(Debug, Deserialize, Clone)]
248pub struct SniRoute {
249    /// SNI hostname to match: exact (`api.example.com`) or single-label
250    /// wildcard (`*.example.com`). Case-insensitive.
251    pub server_name: String,
252    /// Backend pool for connections whose SNI matches `server_name`.
253    pub upstream: StreamUpstreamConfig,
254}
255
256/// Transport protocol for an L4 stream listener.
257#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
258#[serde(rename_all = "lowercase")]
259pub enum StreamProtocol {
260    #[default]
261    Tcp,
262    Udp,
263}
264
265/// Upstream pool for an L4 stream listener.
266#[derive(Debug, Deserialize, Clone)]
267pub struct StreamUpstreamConfig {
268    /// Backend targets (`host`/`port`); at least one required.
269    pub targets: Vec<crate::balancer::Target>,
270    /// Load-balancing strategy: `round_robin` (default), `least_connections`,
271    /// or `ip_hash`. Absent means round-robin.
272    #[serde(default)]
273    pub load_balancing: Option<String>,
274}
275
276/// TLS termination settings for a listener (data plane or admin).
277#[derive(Debug, Deserialize, Clone)]
278pub struct TlsConfig {
279    /// Path to the PEM certificate chain. Required unless this slot is
280    /// ACME-managed (`acme`).
281    #[serde(default)]
282    pub cert_path: Option<String>,
283    /// Path to the PEM private key. Required unless this slot is ACME-managed.
284    #[serde(default)]
285    pub key_path: Option<String>,
286    /// Minimum TLS protocol version, `"1.2"` or `"1.3"`; defaults to `"1.2"`.
287    #[serde(default = "default_tls_min_version")]
288    pub min_version: String,
289    /// PEM CA bundle used to verify **client** certificates (mTLS). When set,
290    /// the listener requests and validates a client cert during the handshake.
291    #[serde(default)]
292    pub client_ca_path: Option<String>,
293    /// When mTLS is enabled (`client_ca_path` set), whether a valid client cert
294    /// is **required** (default) — clients without one are rejected — or
295    /// optional (`false`, anonymous clients allowed; presented certs are still
296    /// validated). Ignored when `client_ca_path` is unset.
297    #[serde(default = "default_true")]
298    pub client_cert_required: bool,
299    /// Additional certificates selected by the ClientHello SNI hostname. When
300    /// none match (or no SNI is sent), `cert_path`/`key_path` above is the
301    /// default/fallback.
302    #[serde(default)]
303    pub sni_certs: Vec<SniCert>,
304    /// Obtain this certificate automatically via ACME instead of files.
305    /// Mutually exclusive with `cert_path`/`key_path`; requires the top-level
306    /// `acme:` block. `domains` is mandatory here (a default cert has no
307    /// server name to infer from).
308    #[serde(default)]
309    pub acme: Option<AcmeSlot>,
310}
311
312/// One SNI-selected certificate for multi-domain TLS termination: an exact or
313/// single-label-wildcard server name mapped to its own cert/key.
314#[derive(Debug, Deserialize, Clone)]
315pub struct SniCert {
316    /// SNI hostname to match: exact (`api.example.com`) or single-label
317    /// wildcard (`*.example.com`). Case-insensitive.
318    pub server_name: String,
319    /// PEM certificate chain to present for this hostname. Required unless
320    /// this slot is ACME-managed (`acme`).
321    #[serde(default)]
322    pub cert_path: Option<String>,
323    /// PEM private key for this hostname's certificate. Required unless this
324    /// slot is ACME-managed.
325    #[serde(default)]
326    pub key_path: Option<String>,
327    /// Obtain this certificate automatically via ACME instead of files.
328    /// Mutually exclusive with `cert_path`/`key_path`; requires the top-level
329    /// `acme:` block. `domains` defaults to `[server_name]` when omitted.
330    #[serde(default)]
331    pub acme: Option<AcmeSlot>,
332}
333
334/// One ACME-managed certificate slot.
335#[derive(Debug, Deserialize, Clone, Default)]
336pub struct AcmeSlot {
337    /// DNS names on the certificate. On an `sni_certs` entry an empty list
338    /// means `[server_name]`. Wildcards are rejected (TLS-ALPN-01 cannot
339    /// issue them).
340    #[serde(default)]
341    pub domains: Vec<String>,
342}
343
344/// Top-level ACME settings (`acme:` in `system.yaml`).
345#[derive(Debug, Deserialize, Clone)]
346pub struct AcmeConfig {
347    /// ACME directory URL; defaults to Let's Encrypt production. Must be `https://`.
348    #[serde(default = "default_acme_directory")]
349    pub directory_url: String,
350    /// PEM trust root(s) for the CA's own HTTPS endpoint (private CAs, Pebble).
351    /// Replaces the system roots for that connection only.
352    #[serde(default)]
353    pub directory_ca_path: Option<String>,
354    /// Account contacts, e.g. `mailto:ops@example.com`.
355    #[serde(default)]
356    pub contact: Vec<String>,
357    /// Must be `true`: registering an account asserts agreement to the CA's terms.
358    #[serde(default)]
359    pub terms_of_service_agreed: bool,
360    /// External Account Binding (ZeroSSL, Google Trust Services, step-ca).
361    #[serde(default)]
362    pub eab: Option<AcmeEabConfig>,
363    /// Certificate key type: `ecdsa-p256` (default) or `ecdsa-p384`.
364    #[serde(default = "default_acme_key_type")]
365    pub key_type: String,
366    /// Renew when less than this remains before `not_after` (`30d`, `12h`,
367    /// `90s`, or bare seconds); the CA's ARI window, when offered, may renew earlier.
368    #[serde(default = "default_acme_renew_before")]
369    pub renew_before: String,
370    /// Where account, keys, certificates, challenges and leases live.
371    #[serde(default)]
372    pub storage: AcmeStorageConfig,
373}
374
375/// External Account Binding credentials issued by the CA.
376#[derive(Debug, Deserialize, Clone)]
377pub struct AcmeEabConfig {
378    pub key_id: String,
379    /// base64url (or standard base64) HMAC key as handed out by the CA.
380    pub hmac_key: String,
381}
382
383/// ACME state storage backend (`acme.storage.type`).
384#[derive(Debug, Deserialize, Clone)]
385#[serde(tag = "type", rename_all = "lowercase")]
386pub enum AcmeStorageConfig {
387    /// A directory on local disk (default `/var/lib/featherbit/acme`).
388    Filesystem {
389        #[serde(default = "default_acme_dir")]
390        dir: String,
391    },
392    /// A declared `stores:` entry from `gateway.yaml`; keys are sealed with
393    /// `encryption_key` (AES-256-GCM, key derived by SHA-256) before storage.
394    Store {
395        store: String,
396        /// Only read when the `redis-store` feature is on (the headless build
397        /// refuses `type: store` at validation, so nothing consumes it there).
398        #[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
425/// Parses `30d` / `12h` / `5m` / `90s` / bare seconds into a [`Duration`].
426pub 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
450/// Lowercases and validates one ACME DNS identifier: no wildcards, no IPs, only
451/// `[a-z0-9.-]`, non-empty labels.
452pub 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    /// Normalized ACME domains for this entry, or `None` when file-based.
479    /// An empty `acme.domains` means `[server_name]`.
480    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    /// Normalized domain lists of every ACME-managed slot, default cert first,
498    /// then `sni_certs` in order. Empty when nothing is managed. Assumes
499    /// [`TlsConfig::validate`] passed.
500    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    /// Structural validation of every cert slot. `acme_enabled` is whether the
519    /// top-level `acme:` block exists; `label` names the block in errors
520    /// (`"tls"` / `"admin.tls"`).
521    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    /// Fail-fast structural validation, run once after loading `system.yaml`.
623    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    /// Cross-file checks that need `gateway.yaml`: an ACME `store` must be declared.
648    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/// HTTP/2 support toggle; enabled by default.
668#[derive(Debug, Deserialize, Clone)]
669pub struct Http2Config {
670    #[serde(default = "default_true")]
671    pub enabled: bool,
672}
673
674/// Connection lifecycle timeouts in seconds.
675///
676/// `connection`/`read`/`write` default to 30s; `idle` defaults to 300s;
677/// `shutdown` (the graceful-drain deadline) defaults to 30s.
678#[derive(Debug, Deserialize, Clone)]
679pub struct TimeoutConfig {
680    #[serde(default = "default_timeout_30")]
681    pub connection_seconds: u64,
682    // Accepted and documented in `system.yaml`, but not yet enforced by the
683    // data plane (see the roadmap). Kept so existing configs stay valid.
684    #[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    /// Max time to drain in-flight connections on graceful shutdown before
693    /// forcing exit.
694    #[serde(default = "default_timeout_30")]
695    pub shutdown_timeout_seconds: u64,
696}
697
698/// Logging configuration for the `tracing` subscriber.
699///
700/// `RUST_LOG`, when set, overrides `level` at startup.
701#[derive(Debug, Deserialize, Clone)]
702pub struct LoggingConfig {
703    /// Log level filter (`trace`..`error`); defaults to `info`.
704    #[serde(default = "default_log_level")]
705    pub level: String,
706    /// Output format: `json` (default) or any other value for plain text.
707    #[serde(default = "default_log_format")]
708    pub format: String,
709}
710
711/// Admin REST API settings; presence of this section enables the admin server
712/// on a separate port from the data plane.
713#[derive(Debug, Deserialize, Clone)]
714pub struct AdminConfig {
715    /// Interface to bind; defaults to `0.0.0.0`.
716    #[serde(default = "default_bind")]
717    pub bind: String,
718    /// TCP port; defaults to `9090`.
719    #[serde(default = "default_admin_port")]
720    pub port: u16,
721    /// Basic Auth username. Required (typically supplied via `${ENV_VAR}`).
722    pub username: String,
723    /// Basic Auth password. Required (typically supplied via `${ENV_VAR}`).
724    pub password: String,
725    /// Serve the embedded web UI (node-graph editor) as the unauthenticated
726    /// fallback. `false` returns 404 for non-API paths. Restart-gated like
727    /// the rest of this file; inert in binaries compiled without the `ui`
728    /// feature (the headless image), which never serve the UI.
729    #[serde(default = "default_true")]
730    // Only read by `build_router` when compiled with the `ui` feature (see
731    // src/admin/mod.rs); still parsed and stored either way so a
732    // `system.yaml` with `ui_enabled` set doesn't fail to parse on a
733    // headless build.
734    #[cfg_attr(not(feature = "ui"), allow(dead_code))]
735    pub ui_enabled: bool,
736    /// TLS termination for the admin listener; `None` (the default) serves
737    /// plain HTTP. Reuses the same [`TlsConfig`] as the data plane.
738    #[serde(default)]
739    pub tls: Option<TlsConfig>,
740    /// Model Context Protocol server for AI agents, served on this listener
741    /// at `mcp.path` behind its own bearer tokens (never Basic Auth). `None`
742    /// (the default) means no MCP. Parsed in every build; only honored when
743    /// the binary is compiled with the `mcp` feature.
744    #[serde(default)]
745    pub mcp: Option<McpConfig>,
746}
747
748/// Minimum accepted length of an MCP bearer token, in characters.
749pub const MCP_MIN_TOKEN_LEN: usize = 16;
750
751/// `admin.mcp` — the MCP server exposed to agents.
752#[derive(Debug, Deserialize, Clone)]
753pub struct McpConfig {
754    /// Master switch; off by default. Typically `${FEATHERBIT_MCP_ENABLED:-false}`.
755    #[serde(default)]
756    pub enabled: bool,
757    /// Mount path on the admin listener; defaults to `/mcp`. Must be absolute
758    /// and outside `/api`, `/healthz`, `/readyz`, `/metrics`.
759    #[serde(default = "default_mcp_path")]
760    pub path: String,
761    /// Bearer tokens and their scopes. Required (non-empty) when `enabled`.
762    #[serde(default)]
763    pub tokens: Vec<McpTokenConfig>,
764    /// Browser origins allowed to call the endpoint, in addition to the
765    /// request's own origin (an `Origin` whose `host[:port]` equals the
766    /// request's `Host` is always accepted, so the embedded web UI's chat
767    /// works with the empty default). Any other `Origin` is refused
768    /// (DNS-rebinding defence); non-browser agents send none. List the Vite
769    /// dev server here (`http://localhost:5173`) when developing the UI.
770    #[serde(default)]
771    pub allowed_origins: Vec<String>,
772}
773
774/// One MCP bearer token.
775#[derive(Debug, Deserialize, Clone)]
776pub struct McpTokenConfig {
777    /// The secret; usually `${FEATHERBIT_MCP_READ_TOKEN}`. At least 16 chars.
778    pub token: String,
779    /// `read` (list/get/validate/traces/sandbox) or `write` (also mutations).
780    pub scope: McpScope,
781    /// Optional label used in logs only; never returned by any endpoint.
782    #[serde(default)]
783    pub name: Option<String>,
784}
785
786/// What an MCP token may do. `write` implies `read`.
787#[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    /// Whether a token with this scope may use a tool requiring `required`.
796    #[cfg_attr(not(feature = "mcp"), allow(dead_code))] // scope-gates tools in src/mcp/server.rs
797    pub fn allows(self, required: McpScope) -> bool {
798        self == McpScope::Write || required == McpScope::Read
799    }
800
801    /// The wire/log spelling. Consumed in every build by
802    /// `src/admin/mcp.rs`'s `status()` handler (and, with the `mcp`
803    /// feature, logged by `src/mcp/server.rs`).
804    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    /// Fail-fast validation, run from [`SystemConfig::validate`].
818    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        // Absent -> 30 (via serde default) and matches the Default impl.
923        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        // Explicit value is honored.
928        let cfg: TimeoutConfig = serde_yaml::from_str("shutdown_timeout_seconds: 5").unwrap();
929        assert_eq!(cfg.shutdown_timeout_seconds, 5);
930    }
931
932    /// Debug mode must be off unless explicitly switched on — an omitted
933    /// section can never enable context capture.
934    #[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    /// A `system.yaml` with no `debug:` section at all still parses, leaving
950    /// debug off.
951    #[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        // Unset fields still fall back to their defaults.
968        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        // `n * mult` used to wrap in release builds and panic in debug ones.
1112        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}