1mod 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
36pub 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 let tls_config: Option<tls::SharedTlsConfig> = match &admin_config.tls {
66 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 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
132fn build_router(admin_config: &AdminConfig, state: Arc<SharedState>) -> Router {
136 let app = Router::new()
137 .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 #[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
173async 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 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 assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND);
226
227 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}