Skip to main content

featherbit/
main.rs

1//! # featherbit
2//!
3//! A high-performance API gateway delivered as a single Rust binary.
4//!
5//! Featherbit routes traffic through **node-graph policies** declared in YAML:
6//! each policy is a pipeline of nodes wired together by success/error ports.
7//! Plugins come in two tiers — 13 native Rust plugins (proxying, auth,
8//! rate-limiting, CORS, logging, ...) plus scripted plugins written in Lua.
9//! A [`context::Context`] object (`request`, `response`, `message`, `errors`)
10//! flows through every node in the pipeline. Operations are handled by an
11//! admin REST API with an embedded React UI, configuration hot-reload via a
12//! file watcher, and Prometheus metrics per route and per node.
13//!
14//! # Architecture
15//!
16//! Request flow: HTTP request → `server::listener` matches a route → builds a
17//! `Context` → `CompiledGraph::execute()` walks the policy's nodes following
18//! success/error ports → the final `Context.response` is sent to the client.
19//!
20//! Configuration lives in two files: `system.yaml` (listeners, timeouts,
21//! admin API, logging) and `gateway.yaml` (routes and policies). Both support
22//! `${ENV_VAR:-default}` interpolation and the latter is hot-reloaded on change.
23
24// `PluginExecutionError` deliberately carries the whole `Context` by value so the
25// graph engine can route a failing node's context out through its `error` port
26// (see `plugins::PluginExecutionError`). That makes the `Err` variant large by
27// design; boxing it would ripple through the `Plugin` trait and every plugin.
28#![allow(clippy::result_large_err)]
29
30mod acme;
31mod admin;
32mod balancer;
33mod batch;
34mod config;
35mod config_store;
36mod consumers;
37mod context;
38mod debug;
39mod graph;
40mod hot_reload;
41mod mcp;
42mod metrics;
43mod outbound;
44mod plugins;
45mod ratelimit;
46mod routing;
47mod server;
48mod sessions;
49mod state;
50mod stores;
51mod stream;
52#[cfg(test)]
53mod test_log;
54mod traffic;
55mod vars;
56
57use std::path::PathBuf;
58use std::sync::Arc;
59
60use clap::Parser;
61use tracing::{error, info};
62
63use crate::config::{ConfigSourceKind, GatewayConfig, SystemConfig};
64use crate::config_store::{ConfigStore, FileConfigStore};
65use crate::state::SharedState;
66
67/// Command-line arguments: paths to the two YAML configuration files.
68#[derive(Parser)]
69#[command(name = "featherbit", about = "A lightweight API gateway")]
70struct Cli {
71    /// Path to system.yaml
72    #[arg(long, default_value = "config/system.yaml")]
73    system_config: PathBuf,
74
75    /// Path to gateway.yaml
76    #[arg(long, default_value = "config/gateway.yaml")]
77    gateway_config: PathBuf,
78}
79
80#[tokio::main]
81async fn main() {
82    let cli = Cli::parse();
83
84    // Load system config
85    let system: SystemConfig = match config::load_yaml_with_env(&cli.system_config) {
86        Ok(c) => c,
87        Err(e) => {
88            eprintln!("Failed to load system config: {}", e);
89            std::process::exit(1);
90        }
91    };
92
93    if let Err(e) = system.validate() {
94        eprintln!("Invalid system config: {}", e);
95        std::process::exit(1);
96    }
97
98    // Initialize logging
99    init_logging(&system.logging);
100
101    info!("Starting featherbit v{}", env!("CARGO_PKG_VERSION"));
102
103    // Select the config backend and load the initial gateway config.
104    let (config_store, gateway, config_path): (
105        Arc<dyn ConfigStore>,
106        GatewayConfig,
107        Option<PathBuf>,
108    ) = match system.config.source {
109        ConfigSourceKind::File => {
110            let store = Arc::new(FileConfigStore::new(cli.gateway_config.clone()));
111            let gw = match store.load_all().await {
112                Ok(c) => c,
113                Err(e) => {
114                    error!("Failed to load gateway config: {}", e);
115                    std::process::exit(1);
116                }
117            };
118            (store, gw, Some(cli.gateway_config.clone()))
119        }
120        ConfigSourceKind::Etcd => match build_etcd_source(&system, &cli.gateway_config).await {
121            Ok(v) => v,
122            Err(e) => {
123                error!("etcd config source: {}", e);
124                std::process::exit(1);
125            }
126        },
127    };
128
129    if let Err(e) = system.validate_against_gateway(&gateway) {
130        eprintln!("Invalid config: {}", e);
131        std::process::exit(1);
132    }
133
134    // Build shared state
135    let state = match SharedState::new(system.clone(), gateway, config_path, config_store) {
136        Ok(s) => Arc::new(s),
137        Err(e) => {
138            error!("Failed to initialize gateway: {}", e);
139            std::process::exit(1);
140        }
141    };
142
143    state
144        .resources
145        .traffic
146        .cache
147        .set_capacity(system.cache.max_entries);
148
149    {
150        let routes = state.routes.read().await;
151        for (route, _) in routes.iter() {
152            info!(
153                "Route '{}' -> policy '{}' (match: {:?})",
154                route.name, route.policy, route.match_rule.path
155            );
156        }
157    }
158
159    // Shutdown coordination: a signal task flips this to `true` on SIGTERM /
160    // Ctrl+C; every accept loop watches it, stops accepting, and drains.
161    let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
162    tokio::spawn(async move {
163        shutdown_signal().await;
164        info!("Shutdown signal received; draining…");
165        let _ = shutdown_tx.send(true);
166    });
167
168    // Start the config-change watcher appropriate to the source.
169    match system.config.source {
170        ConfigSourceKind::File => {
171            let reload_state = state.clone();
172            let watch_path = cli.gateway_config.clone();
173            tokio::spawn(async move {
174                hot_reload::watch_config(reload_state, watch_path).await;
175            });
176        }
177        ConfigSourceKind::Etcd => {
178            spawn_etcd_watch(state.clone(), &system);
179        }
180    }
181
182    let drain_timeout = std::time::Duration::from_secs(system.timeouts.shutdown_timeout_seconds);
183
184    // Start admin API (if configured), keeping its handle so we can await its
185    // drain before exiting.
186    let admin_handle = system.admin.as_ref().map(|admin_config| {
187        let admin_state = state.clone();
188        let admin_cfg = admin_config.clone();
189        let admin_shutdown = shutdown_rx.clone();
190        tokio::spawn(async move {
191            if let Err(e) =
192                admin::start_admin_server(&admin_cfg, admin_state, admin_shutdown, drain_timeout)
193                    .await
194            {
195                error!("Admin API error: {}", e);
196            }
197        })
198    });
199
200    // Start L4 (TCP/UDP) stream listeners, if any. Binds fail-fast before the
201    // data plane; each listener then runs in its own detached task.
202    if !system.stream.is_empty() {
203        if let Err(e) =
204            stream::start_all(&system.stream, &system.timeouts, shutdown_rx.clone()).await
205        {
206            error!("Failed to start stream listeners: {}", e);
207            std::process::exit(1);
208        }
209    }
210
211    // Start the data-plane server. It blocks until a shutdown signal, then
212    // drains in-flight connections and returns.
213    if let Err(e) = server::start_server(&system, state, shutdown_rx).await {
214        error!("Server error: {}", e);
215        std::process::exit(1);
216    }
217
218    // Let the admin API finish draining too before the process exits.
219    if let Some(handle) = admin_handle {
220        let _ = handle.await;
221    }
222    info!("Shutdown complete");
223}
224
225/// Completes when the process receives a termination signal: Ctrl+C on any
226/// platform, or `SIGTERM` on Unix (the signal container orchestrators send).
227async fn shutdown_signal() {
228    let ctrl_c = async {
229        let _ = tokio::signal::ctrl_c().await;
230    };
231
232    #[cfg(unix)]
233    let terminate = async {
234        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
235            Ok(mut sig) => {
236                sig.recv().await;
237            }
238            Err(_) => std::future::pending::<()>().await,
239        }
240    };
241    #[cfg(not(unix))]
242    let terminate = std::future::pending::<()>();
243
244    tokio::select! {
245        _ = ctrl_c => {},
246        _ = terminate => {},
247    }
248}
249
250/// Builds the etcd config store and the initial gateway config (seeding etcd
251/// from the local file when the prefix is empty).
252async fn build_etcd_source(
253    system: &SystemConfig,
254    seed_path: &std::path::Path,
255) -> Result<(Arc<dyn ConfigStore>, GatewayConfig, Option<PathBuf>), String> {
256    config_store::etcd::build_source(system, seed_path).await
257}
258
259/// Spawns the etcd watch task (cluster-wide config convergence).
260fn spawn_etcd_watch(state: Arc<SharedState>, system: &SystemConfig) {
261    config_store::etcd::spawn_watch(state, system);
262}
263
264/// Initializes the global `tracing` subscriber in JSON or plain-text format.
265///
266/// The `RUST_LOG` environment variable, when set, takes precedence over the
267/// level configured in `system.yaml`.
268fn init_logging(config: &config::LoggingConfig) {
269    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
270        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&config.level));
271
272    match config.format.as_str() {
273        "json" => {
274            tracing_subscriber::fmt()
275                .json()
276                .with_env_filter(env_filter)
277                .init();
278        }
279        _ => {
280            tracing_subscriber::fmt().with_env_filter(env_filter).init();
281        }
282    }
283}