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}