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 acme;
10mod auth;
11mod cache;
12mod consumers;
13mod debug;
14mod env_vars;
15mod mcp;
16mod plugin_configs;
17pub(crate) mod policies;
18mod routes;
19mod sessions;
20mod status;
21pub(crate) mod stores;
22mod supernodes;
23#[cfg(feature = "ui")]
24mod ui;
25mod vars;
26
27use std::sync::Arc;
28
29use std::time::Duration;
30
31#[cfg(feature = "ui")]
32use axum::routing::get;
33use axum::Router;
34use hyper_util::rt::TokioIo;
35use hyper_util::server::graceful::GracefulShutdown;
36use hyper_util::service::TowerToHyperService;
37use tokio::net::TcpListener;
38use tokio::sync::watch;
39use tracing::{info, warn};
40
41use crate::config::AdminConfig;
42use crate::server::tls;
43use crate::state::SharedState;
44
45/// Binds the admin listener on `admin_config.bind:port` and serves the admin
46/// API and UI until the server exits.
47///
48/// The API routes (`/api/*`, `/healthz`, `/readyz`, `/metrics`) are wrapped in
49/// the Basic Auth middleware using credentials from [`AdminConfig`]; any path
50/// not matched by the API falls back to the embedded SPA — when the binary is
51/// compiled with the `ui` feature and `admin.ui_enabled` is true (the
52/// default) — served without auth (the SPA's own API calls carry
53/// credentials).
54///
55/// When `admin_config.tls` is set, the admin listener is TLS-terminated using
56/// the same acceptor helper as the data plane; otherwise it serves plain HTTP.
57///
58/// On shutdown (`shutdown_rx` flips to `true`) the accept loop stops and
59/// in-flight requests are drained (up to `drain_timeout`), then this returns.
60///
61/// Returns an error for a fail-fast startup problem (bind failure, or an
62/// unreadable cert/key when TLS is configured). Per-connection errors —
63/// including TLS handshake failures — are logged and do not stop the server.
64pub async fn start_admin_server(
65    admin_config: &AdminConfig,
66    state: Arc<SharedState>,
67    mut shutdown_rx: watch::Receiver<bool>,
68    drain_timeout: Duration,
69) -> Result<(), Box<dyn std::error::Error>> {
70    let app = build_router(admin_config, state);
71
72    // Fail-fast on a broken TLS setup before binding. Hot-reloadable — a
73    // cert-file change swaps in for new admin connections without a restart.
74    let tls_config: Option<tls::SharedTlsConfig> = match &admin_config.tls {
75        // HTTP/2 is fine for the admin API; the auto builder still serves h1.
76        Some(tls_cfg) => {
77            let shared = tls::build_reloadable(tls_cfg, true, None)?;
78            tls::spawn_cert_watcher(tls_cfg.clone(), true, shared.clone(), "admin", None);
79            Some(shared)
80        }
81        None => None,
82    };
83
84    let addr = format!("{}:{}", admin_config.bind, admin_config.port);
85    let listener = TcpListener::bind(&addr).await?;
86    info!(
87        "Admin API + UI listening on {} ({})",
88        addr,
89        if tls_config.is_some() {
90            "https"
91        } else {
92            "http"
93        },
94    );
95
96    // Manual accept loop (instead of `axum::serve`) so TLS reuses the shared
97    // acceptor + connection builder, and so shutdown drains in-flight requests.
98    // The axum `Router` is a tower `Service`; `TowerToHyperService` adapts it.
99    let graceful = GracefulShutdown::new();
100    loop {
101        tokio::select! {
102            accepted = listener.accept() => {
103                let (stream, _peer) = accepted?;
104                let app = app.clone();
105                let tls_config = tls_config.clone();
106                let watcher = graceful.watcher();
107
108                tokio::spawn(async move {
109                    let svc = TowerToHyperService::new(app);
110                    match tls_config.as_ref().map(tls::current_acceptor) {
111                        Some(acc) => match acc.accept(stream).await {
112                            Ok(tls_stream) => {
113                                let conn = tls::build_connection(TokioIo::new(tls_stream), svc, true);
114                                if let Err(err) = watcher.watch(conn).await {
115                                    warn!("Admin connection error: {}", err);
116                                }
117                            }
118                            Err(err) => warn!("Admin TLS handshake failed: {}", err),
119                        },
120                        None => {
121                            let conn = tls::build_connection(TokioIo::new(stream), svc, true);
122                            if let Err(err) = watcher.watch(conn).await {
123                                warn!("Admin connection error: {}", err);
124                            }
125                        }
126                    }
127                });
128            }
129            _ = shutdown_rx.changed() => break,
130        }
131    }
132
133    drop(listener);
134    tokio::select! {
135        _ = graceful.shutdown() => info!("Admin API drained"),
136        _ = tokio::time::sleep(drain_timeout) => warn!("Admin drain timed out; forcing exit"),
137    }
138    Ok(())
139}
140
141/// Builds the admin router: authed API routes, plus — only when compiled with
142/// the `ui` feature AND `admin.ui_enabled` is true — the unauthenticated SPA
143/// fallback. Without it, non-API paths get axum's default 404.
144pub(crate) fn build_router(admin_config: &AdminConfig, state: Arc<SharedState>) -> Router {
145    let api = Router::new()
146        // API routes (with auth)
147        .merge(routes::router())
148        .merge(acme::router())
149        .merge(cache::router())
150        .merge(policies::router())
151        .merge(plugin_configs::router())
152        .merge(supernodes::router())
153        .merge(consumers::router())
154        .merge(mcp::router())
155        .merge(sessions::router())
156        .merge(status::router())
157        .merge(stores::router())
158        .merge(debug::router())
159        .merge(vars::router())
160        .merge(env_vars::router())
161        .layer(axum::middleware::from_fn_with_state(
162            Arc::new(auth::AuthState {
163                username: admin_config.username.clone(),
164                password: admin_config.password.clone(),
165            }),
166            auth::basic_auth_middleware,
167        ))
168        .with_state(state.clone());
169
170    // MCP lives OUTSIDE the Basic Auth layer: it has its own bearer tokens,
171    // and its own explicit 404-when-disabled route, so `ui_enabled` cannot
172    // turn the path into the SPA index.
173    let mcp_path = admin_config
174        .mcp
175        .as_ref()
176        .map(|m| m.path.clone())
177        .unwrap_or_else(|| "/mcp".to_string());
178    #[cfg(feature = "mcp")]
179    let mcp_router = match &admin_config.mcp {
180        Some(cfg) if cfg.enabled => crate::mcp::router(cfg, state),
181        _ => {
182            info!("MCP server disabled (admin.mcp.enabled = false)");
183            crate::mcp::disabled_router(&mcp_path)
184        }
185    };
186    #[cfg(not(feature = "mcp"))]
187    let mcp_router = {
188        if admin_config.mcp.as_ref().is_some_and(|m| m.enabled) {
189            warn!(
190                "MCP server not compiled in (built without the \"mcp\" feature); admin.mcp ignored"
191            );
192        }
193        let _ = &state;
194        crate::mcp::disabled_router(&mcp_path)
195    };
196    let app = api.merge(mcp_router);
197
198    // UI static files (no auth — the API calls from the UI will authenticate).
199    //
200    // Both branches below call `.fallback()` explicitly, even the 404 one:
201    // `.layer()` above wraps whatever fallback the router already has at
202    // that point, including the implicit default "no route matched"
203    // handler. Leaving that implicit default in place would mean an
204    // unmatched path runs through the Basic Auth middleware and answers 401
205    // instead of 404. Setting an explicit fallback afterward replaces it
206    // with an unwrapped one, same as the SPA fallback below.
207    #[cfg(feature = "ui")]
208    let app = if admin_config.ui_enabled {
209        app.fallback(get(ui::serve_ui))
210    } else {
211        app.fallback(not_found)
212    };
213    #[cfg(not(feature = "ui"))]
214    let app = app.fallback(not_found);
215
216    app
217}
218
219/// Unauthenticated 404 for non-API paths when the SPA fallback isn't
220/// mounted (`ui_enabled: false`, or a binary built without the `ui`
221/// feature). See the comment in [`build_router`] for why this must be set
222/// explicitly rather than left as the router's implicit default.
223async fn not_found() -> axum::http::StatusCode {
224    axum::http::StatusCode::NOT_FOUND
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::config::{GatewayConfig, SystemConfig};
231    use crate::config_store::FileConfigStore;
232    use axum::body::Body;
233    use axum::http::{Request, StatusCode};
234    use tower::ServiceExt;
235
236    fn test_state() -> Arc<SharedState> {
237        // Every section of both configs has a serde default, so an empty
238        // document is the cheapest way to get a valid baseline.
239        let system: SystemConfig = serde_yaml::from_str("{}").unwrap();
240        let gateway: GatewayConfig = serde_yaml::from_str("{}").unwrap();
241        Arc::new(
242            SharedState::new(
243                system,
244                gateway,
245                None,
246                Arc::new(FileConfigStore::new(std::path::PathBuf::from(
247                    "gateway.yaml",
248                ))),
249            )
250            .unwrap(),
251        )
252    }
253
254    fn admin_config(ui_enabled: bool) -> AdminConfig {
255        let yaml = format!("username: u\npassword: p\nui_enabled: {}\n", ui_enabled);
256        serde_yaml::from_str(&yaml).unwrap()
257    }
258
259    #[cfg(feature = "ui")]
260    #[tokio::test]
261    async fn test_non_api_path_serves_spa_when_ui_enabled() {
262        let app = build_router(&admin_config(true), test_state());
263        let resp = app
264            .oneshot(Request::get("/some/spa/route").body(Body::empty()).unwrap())
265            .await
266            .unwrap();
267        // ui/dist may be absent in dev checkouts; the fallback is mounted
268        // either way. 200 = SPA served; 404 only when the embedded bundle is
269        // empty — both prove the fallback handler answered, so assert on the
270        // handler's contract, not the bundle's presence.
271        assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND);
272
273        // The API surface is mounted regardless: /healthz exists (401 without
274        // credentials proves it hit the authed API router, not the fallback).
275        let app = build_router(&admin_config(true), test_state());
276        let resp = app
277            .oneshot(Request::get("/healthz").body(Body::empty()).unwrap())
278            .await
279            .unwrap();
280        assert_ne!(resp.status(), StatusCode::NOT_FOUND);
281    }
282
283    #[tokio::test]
284    async fn test_mcp_path_is_404_when_disabled_even_with_ui() {
285        let app = build_router(&admin_config(true), test_state());
286        let resp = app
287            .oneshot(Request::post("/mcp").body(Body::empty()).unwrap())
288            .await
289            .unwrap();
290        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
291        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
292            .await
293            .unwrap();
294        assert_eq!(&body[..], br#"{"error":"not_found"}"#);
295    }
296
297    /// Restructuring `build_router` to mount `mcp::router()` alongside the
298    /// other `.merge()`d API routers is easy to get subtly wrong (e.g.
299    /// merging it inside vs. outside the Basic Auth `.layer()`, or before vs.
300    /// after `.with_state()`). The other tests here only assert `/api/*` is
301    /// *not 404*, or that `/mcp` behaves correctly — none of them positively
302    /// prove Basic Auth still gates `/api/*`. This closes that gap: a real
303    /// 401 without credentials, a real 200 with correct ones, and `/healthz`
304    /// staying exempt either way.
305    #[tokio::test]
306    async fn test_api_path_still_behind_basic_auth_after_mcp_restructure() {
307        use base64::engine::general_purpose::STANDARD;
308        use base64::Engine;
309
310        let app = build_router(&admin_config(true), test_state());
311        let resp = app
312            .oneshot(Request::get("/api/status").body(Body::empty()).unwrap())
313            .await
314            .unwrap();
315        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
316
317        let creds = STANDARD.encode("u:p");
318        let app = build_router(&admin_config(true), test_state());
319        let resp = app
320            .oneshot(
321                Request::get("/api/status")
322                    .header("Authorization", format!("Basic {creds}"))
323                    .body(Body::empty())
324                    .unwrap(),
325            )
326            .await
327            .unwrap();
328        assert_eq!(resp.status(), StatusCode::OK);
329
330        let app = build_router(&admin_config(true), test_state());
331        let resp = app
332            .oneshot(Request::get("/healthz").body(Body::empty()).unwrap())
333            .await
334            .unwrap();
335        assert_eq!(resp.status(), StatusCode::OK);
336    }
337
338    #[tokio::test]
339    async fn test_non_api_path_404_when_ui_disabled() {
340        let app = build_router(&admin_config(false), test_state());
341        let resp = app
342            .oneshot(Request::get("/some/spa/route").body(Body::empty()).unwrap())
343            .await
344            .unwrap();
345        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
346    }
347
348    /// The Admin API guide's endpoint table is the reference operators work
349    /// from, and it has silently missed endpoints before (`/api/vars`,
350    /// `/api/env-vars`, and the ACME and MCP companions all shipped
351    /// undocumented). Every path the admin router registers must appear in it.
352    ///
353    /// The table writes path parameters Express-style (`:name`) while axum
354    /// registers them as `{name}`, so compare on the normalized form.
355    #[test]
356    fn every_admin_endpoint_is_in_the_admin_api_reference() {
357        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/admin");
358        let doc = std::fs::read_to_string(concat!(
359            env!("CARGO_MANIFEST_DIR"),
360            "/website/docs/guides/admin-api.md"
361        ))
362        .expect("admin-api.md");
363
364        let mut sources = String::new();
365        let mut stack = vec![std::path::PathBuf::from(dir)];
366        while let Some(path) = stack.pop() {
367            for entry in std::fs::read_dir(&path).expect("read src/admin").flatten() {
368                let p = entry.path();
369                if p.is_dir() {
370                    stack.push(p);
371                } else if p.extension().is_some_and(|e| e == "rs") {
372                    sources.push_str(&std::fs::read_to_string(&p).expect("read source"));
373                }
374            }
375        }
376        // `.route(` and its path literal are often split across lines.
377        let flat = regex::Regex::new(r"\s+")
378            .unwrap()
379            .replace_all(&sources, " ")
380            .into_owned();
381
382        let route = regex::Regex::new(r#"\.route\(\s*"([^"]+)""#).unwrap();
383        let param = regex::Regex::new(r"\{([a-z_]+)\}").unwrap();
384        let mut undocumented: Vec<String> = route
385            .captures_iter(&flat)
386            .map(|c| c[1].to_string())
387            // Test modules build throwaway routers over the same paths, so a
388            // duplicate here is harmless; only absence from the doc matters.
389            .filter(|path| {
390                let normalized = param.replace_all(path, ":$1").into_owned();
391                !doc.contains(&normalized) && !doc.contains(path.as_str())
392            })
393            .collect();
394        undocumented.sort();
395        undocumented.dedup();
396
397        assert!(
398            undocumented.is_empty(),
399            "admin endpoints missing from website/docs/guides/admin-api.md: {undocumented:#?}"
400        );
401    }
402}