Skip to main content

featherbit/admin/
mod.rs

1//! Admin API and UI server.
2//!
3//! Runs an axum [`Router`] on a dedicated port, separate from the data plane.
4//! Exposes Basic-Auth-protected CRUD endpoints for routes and policies,
5//! status/health/metrics endpoints, and serves the embedded React SPA
6//! (node-graph editor) as an unauthenticated fallback
7//! (compile-time `ui` feature + runtime `admin.ui_enabled`).
8
9mod auth;
10mod consumers;
11mod debug;
12mod policies;
13mod routes;
14mod status;
15#[cfg(feature = "ui")]
16mod ui;
17
18use std::sync::Arc;
19
20use std::time::Duration;
21
22#[cfg(feature = "ui")]
23use axum::routing::get;
24use axum::Router;
25use hyper_util::rt::TokioIo;
26use hyper_util::server::graceful::GracefulShutdown;
27use hyper_util::service::TowerToHyperService;
28use tokio::net::TcpListener;
29use tokio::sync::watch;
30use tracing::{info, warn};
31
32use crate::config::AdminConfig;
33use crate::server::tls;
34use crate::state::SharedState;
35
36/// Binds the admin listener on `admin_config.bind:port` and serves the admin
37/// API and UI until the server exits.
38///
39/// The API routes (`/api/*`, `/healthz`, `/readyz`, `/metrics`) are wrapped in
40/// the Basic Auth middleware using credentials from [`AdminConfig`]; any path
41/// not matched by the API falls back to the embedded SPA — when the binary is
42/// compiled with the `ui` feature and `admin.ui_enabled` is true (the
43/// default) — served without auth (the SPA's own API calls carry
44/// credentials).
45///
46/// When `admin_config.tls` is set, the admin listener is TLS-terminated using
47/// the same acceptor helper as the data plane; otherwise it serves plain HTTP.
48///
49/// On shutdown (`shutdown_rx` flips to `true`) the accept loop stops and
50/// in-flight requests are drained (up to `drain_timeout`), then this returns.
51///
52/// Returns an error for a fail-fast startup problem (bind failure, or an
53/// unreadable cert/key when TLS is configured). Per-connection errors —
54/// including TLS handshake failures — are logged and do not stop the server.
55pub async fn start_admin_server(
56    admin_config: &AdminConfig,
57    state: Arc<SharedState>,
58    mut shutdown_rx: watch::Receiver<bool>,
59    drain_timeout: Duration,
60) -> Result<(), Box<dyn std::error::Error>> {
61    let app = build_router(admin_config, state);
62
63    // Fail-fast on a broken TLS setup before binding. Hot-reloadable — a
64    // cert-file change swaps in for new admin connections without a restart.
65    let tls_config: Option<tls::SharedTlsConfig> = match &admin_config.tls {
66        // HTTP/2 is fine for the admin API; the auto builder still serves h1.
67        Some(tls_cfg) => {
68            let shared = tls::build_reloadable(tls_cfg, true)?;
69            tls::spawn_cert_watcher(tls_cfg.clone(), true, shared.clone(), "admin");
70            Some(shared)
71        }
72        None => None,
73    };
74
75    let addr = format!("{}:{}", admin_config.bind, admin_config.port);
76    let listener = TcpListener::bind(&addr).await?;
77    info!(
78        "Admin API + UI listening on {} ({})",
79        addr,
80        if tls_config.is_some() {
81            "https"
82        } else {
83            "http"
84        },
85    );
86
87    // Manual accept loop (instead of `axum::serve`) so TLS reuses the shared
88    // acceptor + connection builder, and so shutdown drains in-flight requests.
89    // The axum `Router` is a tower `Service`; `TowerToHyperService` adapts it.
90    let graceful = GracefulShutdown::new();
91    loop {
92        tokio::select! {
93            accepted = listener.accept() => {
94                let (stream, _peer) = accepted?;
95                let app = app.clone();
96                let tls_config = tls_config.clone();
97                let watcher = graceful.watcher();
98
99                tokio::spawn(async move {
100                    let svc = TowerToHyperService::new(app);
101                    match tls_config.as_ref().map(tls::current_acceptor) {
102                        Some(acc) => match acc.accept(stream).await {
103                            Ok(tls_stream) => {
104                                let conn = tls::build_connection(TokioIo::new(tls_stream), svc, true);
105                                if let Err(err) = watcher.watch(conn).await {
106                                    warn!("Admin connection error: {}", err);
107                                }
108                            }
109                            Err(err) => warn!("Admin TLS handshake failed: {}", err),
110                        },
111                        None => {
112                            let conn = tls::build_connection(TokioIo::new(stream), svc, true);
113                            if let Err(err) = watcher.watch(conn).await {
114                                warn!("Admin connection error: {}", err);
115                            }
116                        }
117                    }
118                });
119            }
120            _ = shutdown_rx.changed() => break,
121        }
122    }
123
124    drop(listener);
125    tokio::select! {
126        _ = graceful.shutdown() => info!("Admin API drained"),
127        _ = tokio::time::sleep(drain_timeout) => warn!("Admin drain timed out; forcing exit"),
128    }
129    Ok(())
130}
131
132/// Builds the admin router: authed API routes, plus — only when compiled with
133/// the `ui` feature AND `admin.ui_enabled` is true — the unauthenticated SPA
134/// fallback. Without it, non-API paths get axum's default 404.
135fn build_router(admin_config: &AdminConfig, state: Arc<SharedState>) -> Router {
136    let app = Router::new()
137        // API routes (with auth)
138        .merge(routes::router())
139        .merge(policies::router())
140        .merge(consumers::router())
141        .merge(status::router())
142        .merge(debug::router())
143        .layer(axum::middleware::from_fn_with_state(
144            Arc::new(auth::AuthState {
145                username: admin_config.username.clone(),
146                password: admin_config.password.clone(),
147            }),
148            auth::basic_auth_middleware,
149        ))
150        .with_state(state);
151
152    // UI static files (no auth — the API calls from the UI will authenticate).
153    //
154    // Both branches below call `.fallback()` explicitly, even the 404 one:
155    // `.layer()` above wraps whatever fallback the router already has at
156    // that point, including the implicit default "no route matched"
157    // handler. Leaving that implicit default in place would mean an
158    // unmatched path runs through the Basic Auth middleware and answers 401
159    // instead of 404. Setting an explicit fallback afterward replaces it
160    // with an unwrapped one, same as the SPA fallback below.
161    #[cfg(feature = "ui")]
162    let app = if admin_config.ui_enabled {
163        app.fallback(get(ui::serve_ui))
164    } else {
165        app.fallback(not_found)
166    };
167    #[cfg(not(feature = "ui"))]
168    let app = app.fallback(not_found);
169
170    app
171}
172
173/// Unauthenticated 404 for non-API paths when the SPA fallback isn't
174/// mounted (`ui_enabled: false`, or a binary built without the `ui`
175/// feature). See the comment in [`build_router`] for why this must be set
176/// explicitly rather than left as the router's implicit default.
177async fn not_found() -> axum::http::StatusCode {
178    axum::http::StatusCode::NOT_FOUND
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::config::{GatewayConfig, SystemConfig};
185    use crate::config_store::FileConfigStore;
186    use axum::body::Body;
187    use axum::http::{Request, StatusCode};
188    use tower::ServiceExt;
189
190    fn test_state() -> Arc<SharedState> {
191        // Every section of both configs has a serde default, so an empty
192        // document is the cheapest way to get a valid baseline.
193        let system: SystemConfig = serde_yaml::from_str("{}").unwrap();
194        let gateway: GatewayConfig = serde_yaml::from_str("{}").unwrap();
195        Arc::new(
196            SharedState::new(
197                system,
198                gateway,
199                None,
200                Arc::new(FileConfigStore::new(std::path::PathBuf::from(
201                    "gateway.yaml",
202                ))),
203            )
204            .unwrap(),
205        )
206    }
207
208    fn admin_config(ui_enabled: bool) -> AdminConfig {
209        let yaml = format!("username: u\npassword: p\nui_enabled: {}\n", ui_enabled);
210        serde_yaml::from_str(&yaml).unwrap()
211    }
212
213    #[cfg(feature = "ui")]
214    #[tokio::test]
215    async fn test_non_api_path_serves_spa_when_ui_enabled() {
216        let app = build_router(&admin_config(true), test_state());
217        let resp = app
218            .oneshot(Request::get("/some/spa/route").body(Body::empty()).unwrap())
219            .await
220            .unwrap();
221        // ui/dist may be absent in dev checkouts; the fallback is mounted
222        // either way. 200 = SPA served; 404 only when the embedded bundle is
223        // empty — both prove the fallback handler answered, so assert on the
224        // handler's contract, not the bundle's presence.
225        assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND);
226
227        // The API surface is mounted regardless: /healthz exists (401 without
228        // credentials proves it hit the authed API router, not the fallback).
229        let app = build_router(&admin_config(true), test_state());
230        let resp = app
231            .oneshot(Request::get("/healthz").body(Body::empty()).unwrap())
232            .await
233            .unwrap();
234        assert_ne!(resp.status(), StatusCode::NOT_FOUND);
235    }
236
237    #[tokio::test]
238    async fn test_non_api_path_404_when_ui_disabled() {
239        let app = build_router(&admin_config(false), test_state());
240        let resp = app
241            .oneshot(Request::get("/some/spa/route").body(Body::empty()).unwrap())
242            .await
243            .unwrap();
244        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
245    }
246}