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;
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}
52
53/// Debug mode: per-request policy-execution tracing plus the plugin sandbox.
54///
55/// Off by default. Because `system.yaml` is read once at startup and never
56/// hot-reloaded, **toggling debug mode requires a restart** — which is also the
57/// safety property that keeps a compromised Admin API credential from switching
58/// on request-context capture.
59///
60/// ```yaml
61/// debug:
62///   enabled: ${FEATHERBIT_DEBUG:-false}
63///   capture_bodies: ${FEATHERBIT_DEBUG_BODIES:-false}
64/// ```
65///
66/// Always keep the `:-` default in interpolated values: `${FEATHERBIT_DEBUG}`
67/// with the variable unset expands to empty text, which YAML parses as null and
68/// serde then rejects for a `bool`.
69#[derive(Debug, Deserialize, Clone)]
70#[serde(default)]
71pub struct DebugConfig {
72    /// Master switch. When false nothing is traced and every `/api/debug/*`
73    /// route except `GET /api/debug/config` responds `404`.
74    pub enabled: bool,
75    /// Allows `POST /api/debug/sandbox` (only meaningful while `enabled`), so a
76    /// deployment can trace requests without exposing plugin execution.
77    pub sandbox: bool,
78    /// Request header whose presence opts a single request into tracing.
79    /// Lowercased when the settings are resolved.
80    pub trigger_header: String,
81    /// Trace every request instead of waiting for `trigger_header`. A firehose:
82    /// it snapshots the context once per node for all traffic.
83    pub trace_all: bool,
84    /// Capture request/response bodies in snapshots. Off by default because it
85    /// is the expensive part; bodies are also the one thing redaction cannot
86    /// clean.
87    pub capture_bodies: bool,
88    /// Per-body truncation limit, in bytes, when `capture_bodies` is on.
89    pub max_body_bytes: usize,
90    /// Ring-buffer capacity. `0` disables storage.
91    pub max_traces: usize,
92    /// Maximum steps recorded per trace, bounding a runaway policy's trace.
93    pub max_steps: usize,
94    /// Deadline for one sandbox run, in seconds.
95    pub sandbox_timeout_seconds: u64,
96    /// Header names to redact **in addition to** the built-in denylist.
97    pub redact_headers: Vec<String>,
98    /// Query parameter names to redact in addition to the built-in denylist.
99    pub redact_query_params: Vec<String>,
100    /// `context.message` keys to redact in addition to the built-in denylist.
101    pub redact_message_keys: Vec<String>,
102}
103
104impl Default for DebugConfig {
105    fn default() -> Self {
106        Self {
107            enabled: false,
108            sandbox: true,
109            trigger_header: default_trigger_header(),
110            trace_all: false,
111            capture_bodies: false,
112            max_body_bytes: 8192,
113            max_traces: 50,
114            max_steps: 200,
115            sandbox_timeout_seconds: 30,
116            redact_headers: Vec::new(),
117            redact_query_params: Vec::new(),
118            redact_message_keys: Vec::new(),
119        }
120    }
121}
122
123fn default_trigger_header() -> String {
124    "x-featherbit-debug".to_string()
125}
126
127/// Selects the gateway-config backend.
128///
129/// ```yaml
130/// config:
131///   source: etcd          # file (default) | etcd
132///   etcd:
133///     endpoints: ["http://etcd:2379"]
134///     prefix: /featherbit
135/// ```
136#[derive(Debug, Deserialize, Clone, Default)]
137pub struct ConfigSourceConfig {
138    /// `file` (default, single-node) or `etcd` (shared config for an HA cluster).
139    #[serde(default)]
140    pub source: ConfigSourceKind,
141    /// etcd connection settings; required when `source: etcd`.
142    #[serde(default)]
143    pub etcd: Option<EtcdConfig>,
144}
145
146/// Which config backend to use.
147#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
148#[serde(rename_all = "lowercase")]
149pub enum ConfigSourceKind {
150    /// Load from and apply Admin edits to the local `gateway.yaml` (default).
151    #[default]
152    File,
153    /// Load from and write to etcd; watch for cluster-wide changes.
154    Etcd,
155}
156
157/// etcd connection settings (used when `config.source` is `etcd`).
158#[derive(Debug, Deserialize, Clone)]
159pub struct EtcdConfig {
160    /// etcd endpoints, e.g. `["http://127.0.0.1:2379"]`. Required.
161    pub endpoints: Vec<String>,
162    /// Key prefix under which resources are stored; defaults to `/featherbit`.
163    #[serde(default = "default_etcd_prefix")]
164    pub prefix: String,
165    /// Optional username for etcd authentication.
166    #[serde(default)]
167    pub user: Option<String>,
168    /// Optional password for etcd authentication.
169    #[serde(default)]
170    pub password: Option<String>,
171    /// Connect/operation timeout in milliseconds; defaults to `3000`.
172    #[serde(default = "default_etcd_timeout")]
173    pub timeout_ms: u64,
174}
175
176fn default_etcd_prefix() -> String {
177    "/featherbit".to_string()
178}
179
180fn default_etcd_timeout() -> u64 {
181    3000
182}
183
184/// Bind address and port for the data-plane HTTP listener.
185#[derive(Debug, Deserialize, Clone)]
186pub struct ListenerConfig {
187    /// Interface to bind; defaults to `0.0.0.0`.
188    #[serde(default = "default_bind")]
189    pub bind: String,
190    /// TCP port; defaults to `8080`.
191    #[serde(default = "default_port")]
192    pub port: u16,
193}
194
195/// An L4 stream listener: binds `bind:port` and proxies raw TCP or UDP to an
196/// upstream pool. Bound once at startup (fail-fast), like the HTTP listener.
197#[derive(Debug, Deserialize, Clone)]
198pub struct StreamListenerConfig {
199    /// Transport protocol; defaults to `tcp`.
200    #[serde(default)]
201    pub protocol: StreamProtocol,
202    /// Interface to bind; defaults to `0.0.0.0`.
203    #[serde(default = "default_bind")]
204    pub bind: String,
205    /// TCP/UDP port to listen on. Required.
206    pub port: u16,
207    /// Backend pool this listener forwards to. When `sni_routes` are set, this
208    /// is the fallback for connections whose SNI matches no route (and for
209    /// non-TLS / no-SNI connections).
210    pub upstream: StreamUpstreamConfig,
211    /// SNI-based passthrough routes (TCP only). Each maps a ClientHello SNI
212    /// hostname to its own upstream pool without terminating TLS. Ignored (with
213    /// a warning) for UDP listeners.
214    #[serde(default)]
215    pub sni_routes: Vec<SniRoute>,
216}
217
218/// One SNI passthrough route: an exact or single-label-wildcard server name
219/// mapped to its own upstream pool.
220#[derive(Debug, Deserialize, Clone)]
221pub struct SniRoute {
222    /// SNI hostname to match: exact (`api.example.com`) or single-label
223    /// wildcard (`*.example.com`). Case-insensitive.
224    pub server_name: String,
225    /// Backend pool for connections whose SNI matches `server_name`.
226    pub upstream: StreamUpstreamConfig,
227}
228
229/// Transport protocol for an L4 stream listener.
230#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
231#[serde(rename_all = "lowercase")]
232pub enum StreamProtocol {
233    #[default]
234    Tcp,
235    Udp,
236}
237
238/// Upstream pool for an L4 stream listener.
239#[derive(Debug, Deserialize, Clone)]
240pub struct StreamUpstreamConfig {
241    /// Backend targets (`host`/`port`); at least one required.
242    pub targets: Vec<crate::balancer::Target>,
243    /// Load-balancing strategy: `round_robin` (default), `least_connections`,
244    /// or `ip_hash`. Absent means round-robin.
245    #[serde(default)]
246    pub load_balancing: Option<String>,
247}
248
249/// TLS termination settings for a listener (data plane or admin).
250#[derive(Debug, Deserialize, Clone)]
251pub struct TlsConfig {
252    /// Path to the PEM certificate chain. Required.
253    pub cert_path: String,
254    /// Path to the PEM private key. Required.
255    pub key_path: String,
256    /// Minimum TLS protocol version, `"1.2"` or `"1.3"`; defaults to `"1.2"`.
257    #[serde(default = "default_tls_min_version")]
258    pub min_version: String,
259    /// PEM CA bundle used to verify **client** certificates (mTLS). When set,
260    /// the listener requests and validates a client cert during the handshake.
261    #[serde(default)]
262    pub client_ca_path: Option<String>,
263    /// When mTLS is enabled (`client_ca_path` set), whether a valid client cert
264    /// is **required** (default) — clients without one are rejected — or
265    /// optional (`false`, anonymous clients allowed; presented certs are still
266    /// validated). Ignored when `client_ca_path` is unset.
267    #[serde(default = "default_true")]
268    pub client_cert_required: bool,
269    /// Additional certificates selected by the ClientHello SNI hostname. When
270    /// none match (or no SNI is sent), `cert_path`/`key_path` above is the
271    /// default/fallback.
272    #[serde(default)]
273    pub sni_certs: Vec<SniCert>,
274}
275
276/// One SNI-selected certificate for multi-domain TLS termination: an exact or
277/// single-label-wildcard server name mapped to its own cert/key.
278#[derive(Debug, Deserialize, Clone)]
279pub struct SniCert {
280    /// SNI hostname to match: exact (`api.example.com`) or single-label
281    /// wildcard (`*.example.com`). Case-insensitive.
282    pub server_name: String,
283    /// PEM certificate chain to present for this hostname.
284    pub cert_path: String,
285    /// PEM private key for this hostname's certificate.
286    pub key_path: String,
287}
288
289/// HTTP/2 support toggle; enabled by default.
290#[derive(Debug, Deserialize, Clone)]
291pub struct Http2Config {
292    #[serde(default = "default_true")]
293    pub enabled: bool,
294}
295
296/// Connection lifecycle timeouts in seconds.
297///
298/// `connection`/`read`/`write` default to 30s; `idle` defaults to 300s;
299/// `shutdown` (the graceful-drain deadline) defaults to 30s.
300#[derive(Debug, Deserialize, Clone)]
301pub struct TimeoutConfig {
302    #[serde(default = "default_timeout_30")]
303    pub connection_seconds: u64,
304    // Accepted and documented in `system.yaml`, but not yet enforced by the
305    // data plane (see the roadmap). Kept so existing configs stay valid.
306    #[allow(dead_code)]
307    #[serde(default = "default_timeout_30")]
308    pub read_seconds: u64,
309    #[allow(dead_code)]
310    #[serde(default = "default_timeout_30")]
311    pub write_seconds: u64,
312    #[serde(default = "default_timeout_300")]
313    pub idle_seconds: u64,
314    /// Max time to drain in-flight connections on graceful shutdown before
315    /// forcing exit.
316    #[serde(default = "default_timeout_30")]
317    pub shutdown_timeout_seconds: u64,
318}
319
320/// Logging configuration for the `tracing` subscriber.
321///
322/// `RUST_LOG`, when set, overrides `level` at startup.
323#[derive(Debug, Deserialize, Clone)]
324pub struct LoggingConfig {
325    /// Log level filter (`trace`..`error`); defaults to `info`.
326    #[serde(default = "default_log_level")]
327    pub level: String,
328    /// Output format: `json` (default) or any other value for plain text.
329    #[serde(default = "default_log_format")]
330    pub format: String,
331}
332
333/// Admin REST API settings; presence of this section enables the admin server
334/// on a separate port from the data plane.
335#[derive(Debug, Deserialize, Clone)]
336pub struct AdminConfig {
337    /// Interface to bind; defaults to `0.0.0.0`.
338    #[serde(default = "default_bind")]
339    pub bind: String,
340    /// TCP port; defaults to `9090`.
341    #[serde(default = "default_admin_port")]
342    pub port: u16,
343    /// Basic Auth username. Required (typically supplied via `${ENV_VAR}`).
344    pub username: String,
345    /// Basic Auth password. Required (typically supplied via `${ENV_VAR}`).
346    pub password: String,
347    /// Serve the embedded web UI (node-graph editor) as the unauthenticated
348    /// fallback. `false` returns 404 for non-API paths. Restart-gated like
349    /// the rest of this file; inert in binaries compiled without the `ui`
350    /// feature (the headless image), which never serve the UI.
351    #[serde(default = "default_true")]
352    // Only read by `build_router` when compiled with the `ui` feature (see
353    // src/admin/mod.rs); still parsed and stored either way so a
354    // `system.yaml` with `ui_enabled` set doesn't fail to parse on a
355    // headless build.
356    #[cfg_attr(not(feature = "ui"), allow(dead_code))]
357    pub ui_enabled: bool,
358    /// TLS termination for the admin listener; `None` (the default) serves
359    /// plain HTTP. Reuses the same [`TlsConfig`] as the data plane.
360    #[serde(default)]
361    pub tls: Option<TlsConfig>,
362}
363
364fn default_listener() -> ListenerConfig {
365    ListenerConfig {
366        bind: default_bind(),
367        port: default_port(),
368    }
369}
370
371fn default_bind() -> String {
372    "0.0.0.0".to_string()
373}
374fn default_port() -> u16 {
375    8080
376}
377fn default_admin_port() -> u16 {
378    9090
379}
380fn default_true() -> bool {
381    true
382}
383fn default_tls_min_version() -> String {
384    "1.2".to_string()
385}
386fn default_timeout_30() -> u64 {
387    30
388}
389fn default_timeout_300() -> u64 {
390    300
391}
392fn default_log_level() -> String {
393    "info".to_string()
394}
395fn default_log_format() -> String {
396    "json".to_string()
397}
398
399impl Default for Http2Config {
400    fn default() -> Self {
401        Self { enabled: true }
402    }
403}
404
405impl Default for TimeoutConfig {
406    fn default() -> Self {
407        Self {
408            connection_seconds: 30,
409            read_seconds: 30,
410            write_seconds: 30,
411            idle_seconds: 300,
412            shutdown_timeout_seconds: 30,
413        }
414    }
415}
416
417impl Default for LoggingConfig {
418    fn default() -> Self {
419        Self {
420            level: "info".to_string(),
421            format: "json".to_string(),
422        }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_shutdown_timeout_default_and_parse() {
432        // Absent -> 30 (via serde default) and matches the Default impl.
433        let cfg: TimeoutConfig = serde_yaml::from_str("{}").unwrap();
434        assert_eq!(cfg.shutdown_timeout_seconds, 30);
435        assert_eq!(TimeoutConfig::default().shutdown_timeout_seconds, 30);
436
437        // Explicit value is honored.
438        let cfg: TimeoutConfig = serde_yaml::from_str("shutdown_timeout_seconds: 5").unwrap();
439        assert_eq!(cfg.shutdown_timeout_seconds, 5);
440    }
441
442    /// Debug mode must be off unless explicitly switched on — an omitted
443    /// section can never enable context capture.
444    #[test]
445    fn test_debug_defaults_to_disabled() {
446        let cfg: DebugConfig = serde_yaml::from_str("{}").unwrap();
447        assert!(!cfg.enabled);
448        assert!(!cfg.trace_all);
449        assert!(!cfg.capture_bodies);
450        assert!(cfg.sandbox, "sandbox is allowed once debug itself is on");
451        assert_eq!(cfg.trigger_header, "x-featherbit-debug");
452        assert_eq!(cfg.max_traces, 50);
453        assert_eq!(cfg.max_steps, 200);
454        assert_eq!(cfg.max_body_bytes, 8192);
455        assert_eq!(cfg.sandbox_timeout_seconds, 30);
456        assert!(cfg.redact_headers.is_empty());
457    }
458
459    /// A `system.yaml` with no `debug:` section at all still parses, leaving
460    /// debug off.
461    #[test]
462    fn test_system_config_without_debug_section() {
463        let cfg: SystemConfig = serde_yaml::from_str("listener: { port: 8080 }").unwrap();
464        assert!(!cfg.debug.enabled);
465    }
466
467    #[test]
468    fn test_debug_explicit_block_parses() {
469        let cfg: DebugConfig = serde_yaml::from_str(
470            "enabled: true\ncapture_bodies: true\nmax_traces: 5\nredact_headers: [x-custom]\n",
471        )
472        .unwrap();
473        assert!(cfg.enabled);
474        assert!(cfg.capture_bodies);
475        assert_eq!(cfg.max_traces, 5);
476        assert_eq!(cfg.redact_headers, vec!["x-custom".to_string()]);
477        // Unset fields still fall back to their defaults.
478        assert_eq!(cfg.trigger_header, "x-featherbit-debug");
479        assert_eq!(cfg.max_steps, 200);
480    }
481
482    #[test]
483    fn test_admin_ui_enabled_defaults_true() {
484        let cfg: AdminConfig = serde_yaml::from_str("username: u\npassword: p\n").unwrap();
485        assert!(cfg.ui_enabled);
486    }
487
488    #[test]
489    fn test_admin_ui_enabled_false_parses() {
490        let cfg: AdminConfig =
491            serde_yaml::from_str("username: u\npassword: p\nui_enabled: false\n").unwrap();
492        assert!(!cfg.ui_enabled);
493    }
494}