1mod 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
45pub 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 let tls_config: Option<tls::SharedTlsConfig> = match &admin_config.tls {
75 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 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
141pub(crate) fn build_router(admin_config: &AdminConfig, state: Arc<SharedState>) -> Router {
145 let api = Router::new()
146 .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 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 #[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
219async 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 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 assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND);
272
273 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 #[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 #[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 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 .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}