1use crate::config::{Backend, HealthCheckConfig, LoadBalanceStrategy, Route, RouteMatch, Upstream};
4use crate::ProxyState;
5use axum::{
6 extract::{Path, State},
7 http::StatusCode,
8 response::IntoResponse,
9 routing::{delete, get, post, put},
10 Json, Router,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15use uuid::Uuid;
16
17#[derive(Serialize)]
19pub struct ApiResponse<T> {
20 pub success: bool,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub data: Option<T>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub error: Option<String>,
29}
30
31impl<T: Serialize> ApiResponse<T> {
32 pub fn success(data: T) -> Self {
34 Self {
35 success: true,
36 data: Some(data),
37 error: None,
38 }
39 }
40
41 pub fn error(message: impl Into<String>) -> ApiResponse<()> {
47 ApiResponse {
48 success: false,
49 data: None,
50 error: Some(message.into()),
51 }
52 }
53}
54
55fn persistence_failed(error: crate::error::ProxyError) -> axum::response::Response {
61 tracing::error!(%error, "admin API could not persist a configuration change");
62 (
63 StatusCode::INTERNAL_SERVER_ERROR,
64 Json(ApiResponse::<()>::error(format!(
65 "could not persist the change: {error}"
66 ))),
67 )
68 .into_response()
69}
70
71async fn persists(state: &ProxyState) -> bool {
77 state.config.read().await.server.database_config
78}
79
80pub fn admin_router() -> Router<Arc<ProxyState>> {
86 Router::new()
87 .route("/routes", get(list_routes))
89 .route("/routes", post(create_route))
90 .route("/routes/{id}", get(get_route))
91 .route("/routes/{id}", put(update_route))
92 .route("/routes/{id}", delete(delete_route))
93 .route("/upstreams", get(list_upstreams))
95 .route("/upstreams", post(create_upstream))
96 .route("/upstreams/{id}", get(get_upstream))
97 .route("/upstreams/{id}", put(update_upstream))
98 .route("/upstreams/{id}", delete(delete_upstream))
99 .route("/upstreams/{id}/backends", get(list_backends))
101 .route("/upstreams/{id}/backends", post(add_backend))
102 .route(
103 "/upstreams/{id}/backends/{backend_id}",
104 delete(remove_backend),
105 )
106 .route("/health", get(health_status))
108 .route("/health/{backend_id}", get(backend_health))
109 .route("/stats", get(get_stats))
112}
113
114async fn list_routes(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
117 let config = state.config.read().await;
118 let routes = config.routes.clone();
119 Json(ApiResponse::success(routes))
120}
121
122async fn get_route(
123 State(state): State<Arc<ProxyState>>,
124 Path(id): Path<Uuid>,
125) -> impl IntoResponse {
126 let config = state.config.read().await;
127 match config.routes.iter().find(|r| r.id == Some(id)) {
128 Some(route) => Json(ApiResponse::success(route)).into_response(),
129 None => (
130 StatusCode::NOT_FOUND,
131 Json(ApiResponse::<()>::error("Route not found")),
132 )
133 .into_response(),
134 }
135}
136
137#[derive(Deserialize)]
139pub struct CreateRouteRequest {
140 pub name: String,
142 pub host: Option<String>,
144 pub path: Option<String>,
146 pub upstream: String,
148 pub strip_path: Option<bool>,
150 pub priority: Option<i32>,
152 pub add_headers: Option<HashMap<String, String>>,
154 pub remove_headers: Option<Vec<String>>,
156}
157
158async fn create_route(
159 State(state): State<Arc<ProxyState>>,
160 Json(req): Json<CreateRouteRequest>,
161) -> impl IntoResponse {
162 let route = Route {
163 id: Some(Uuid::new_v4()),
164 name: req.name,
165 description: None,
166 match_: RouteMatch {
167 host: req.host,
168 path: req.path,
169 path_type: Default::default(),
170 headers: HashMap::new(),
171 methods: None,
172 },
173 priority: req.priority.unwrap_or(100),
174 upstream: req.upstream,
175 strip_path: req.strip_path.unwrap_or(false),
176 add_headers: req.add_headers.unwrap_or_default(),
177 remove_headers: req.remove_headers.unwrap_or_default(),
178 rate_limit: None,
179 timeout_secs: 30,
180 retry_count: 0,
181 enabled: true,
182 };
183
184 if persists(&state).await {
185 if let Err(error) = crate::config::save_route(&state.pool, &route).await {
189 return persistence_failed(error);
190 }
191 }
192
193 let mut config = state.config.write().await;
194 config.routes.push(route.clone());
195
196 (StatusCode::CREATED, Json(ApiResponse::success(route))).into_response()
197}
198
199async fn update_route(
200 State(state): State<Arc<ProxyState>>,
201 Path(id): Path<Uuid>,
202 Json(req): Json<CreateRouteRequest>,
203) -> impl IntoResponse {
204 let Some(mut updated) = state
207 .config
208 .read()
209 .await
210 .routes
211 .iter()
212 .find(|r| r.id == Some(id))
213 .cloned()
214 else {
215 return (
216 StatusCode::NOT_FOUND,
217 Json(ApiResponse::<()>::error("Route not found")),
218 )
219 .into_response();
220 };
221
222 updated.name = req.name;
223 updated.match_.host = req.host;
224 updated.match_.path = req.path;
225 updated.upstream = req.upstream;
226 updated.strip_path = req.strip_path.unwrap_or(false);
227 updated.priority = req.priority.unwrap_or(100);
228 if let Some(add_headers) = req.add_headers {
229 updated.add_headers = add_headers;
230 }
231 if let Some(remove_headers) = req.remove_headers {
232 updated.remove_headers = remove_headers;
233 }
234
235 if persists(&state).await {
236 if let Err(error) = crate::config::save_route(&state.pool, &updated).await {
237 return persistence_failed(error);
238 }
239 }
240
241 let mut config = state.config.write().await;
242 match config.routes.iter_mut().find(|r| r.id == Some(id)) {
243 Some(route) => {
244 *route = updated.clone();
245 Json(ApiResponse::success(updated)).into_response()
246 }
247 None => (
250 StatusCode::CONFLICT,
251 Json(ApiResponse::<()>::error(
252 "Route was removed while the update was being saved",
253 )),
254 )
255 .into_response(),
256 }
257}
258
259async fn delete_route(
260 State(state): State<Arc<ProxyState>>,
261 Path(id): Path<Uuid>,
262) -> impl IntoResponse {
263 if !state
264 .config
265 .read()
266 .await
267 .routes
268 .iter()
269 .any(|r| r.id == Some(id))
270 {
271 return (
272 StatusCode::NOT_FOUND,
273 Json(ApiResponse::<()>::error("Route not found")),
274 )
275 .into_response();
276 }
277
278 if persists(&state).await {
279 if let Err(error) = crate::config::delete_route(&state.pool, id).await {
283 return persistence_failed(error);
284 }
285 }
286
287 state
288 .config
289 .write()
290 .await
291 .routes
292 .retain(|r| r.id != Some(id));
293
294 Json(ApiResponse::success(())).into_response()
295}
296
297async fn list_upstreams(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
300 let config = state.config.read().await;
301 let upstreams = config.upstreams.clone();
302 Json(ApiResponse::success(upstreams))
303}
304
305async fn get_upstream(
306 State(state): State<Arc<ProxyState>>,
307 Path(id): Path<Uuid>,
308) -> impl IntoResponse {
309 let config = state.config.read().await;
310 match config.upstreams.iter().find(|u| u.id == Some(id)) {
311 Some(upstream) => Json(ApiResponse::success(upstream)).into_response(),
312 None => (
313 StatusCode::NOT_FOUND,
314 Json(ApiResponse::<()>::error("Upstream not found")),
315 )
316 .into_response(),
317 }
318}
319
320#[derive(Deserialize)]
322pub struct CreateUpstreamRequest {
323 pub name: String,
325 pub lb_strategy: Option<LoadBalanceStrategy>,
327 pub backends: Vec<BackendRequest>,
334}
335
336#[derive(Deserialize)]
338pub struct BackendRequest {
339 pub address: String,
341 pub weight: Option<u32>,
343 pub scheme: Option<String>,
345}
346
347async fn create_upstream(
348 State(state): State<Arc<ProxyState>>,
349 Json(req): Json<CreateUpstreamRequest>,
350) -> impl IntoResponse {
351 let backends: Vec<Backend> = req
352 .backends
353 .into_iter()
354 .map(|b| Backend {
355 id: Some(Uuid::new_v4()),
356 address: b.address,
357 weight: b.weight.unwrap_or(100),
358 scheme: b.scheme.unwrap_or_else(|| "http".to_string()),
359 enabled: true,
360 http_version: Default::default(),
361 })
362 .collect();
363
364 let upstream = Upstream {
365 id: Some(Uuid::new_v4()),
366 name: req.name,
367 description: None,
368 lb_strategy: req.lb_strategy.unwrap_or_default(),
369 backends,
370 health_check: HealthCheckConfig::default(),
371 enabled: true,
372 };
373
374 if persists(&state).await {
375 if let Err(error) = crate::config::save_upstream(&state.pool, &upstream).await {
376 return persistence_failed(error);
377 }
378 }
379
380 let mut config = state.config.write().await;
381 config.upstreams.push(upstream.clone());
382
383 (StatusCode::CREATED, Json(ApiResponse::success(upstream))).into_response()
384}
385
386async fn update_upstream(
387 State(state): State<Arc<ProxyState>>,
388 Path(id): Path<Uuid>,
389 Json(req): Json<CreateUpstreamRequest>,
390) -> impl IntoResponse {
391 let Some(mut updated) = state
392 .config
393 .read()
394 .await
395 .upstreams
396 .iter()
397 .find(|u| u.id == Some(id))
398 .cloned()
399 else {
400 return (
401 StatusCode::NOT_FOUND,
402 Json(ApiResponse::<()>::error("Upstream not found")),
403 )
404 .into_response();
405 };
406
407 updated.name = req.name;
408 updated.lb_strategy = req.lb_strategy.unwrap_or_default();
409 if persists(&state).await {
416 if let Err(error) = crate::config::save_upstream(&state.pool, &updated).await {
417 return persistence_failed(error);
418 }
419 }
420
421 let mut config = state.config.write().await;
422 match config.upstreams.iter_mut().find(|u| u.id == Some(id)) {
423 Some(upstream) => {
424 *upstream = updated.clone();
425 Json(ApiResponse::success(updated)).into_response()
426 }
427 None => (
428 StatusCode::CONFLICT,
429 Json(ApiResponse::<()>::error(
430 "Upstream was removed while the update was being saved",
431 )),
432 )
433 .into_response(),
434 }
435}
436
437async fn delete_upstream(
438 State(state): State<Arc<ProxyState>>,
439 Path(id): Path<Uuid>,
440) -> impl IntoResponse {
441 if !state
442 .config
443 .read()
444 .await
445 .upstreams
446 .iter()
447 .any(|u| u.id == Some(id))
448 {
449 return (
450 StatusCode::NOT_FOUND,
451 Json(ApiResponse::<()>::error("Upstream not found")),
452 )
453 .into_response();
454 }
455
456 if persists(&state).await {
457 if let Err(error) = crate::config::delete_upstream(&state.pool, id).await {
458 return persistence_failed(error);
459 }
460 }
461
462 state
463 .config
464 .write()
465 .await
466 .upstreams
467 .retain(|u| u.id != Some(id));
468
469 Json(ApiResponse::success(())).into_response()
470}
471
472async fn list_backends(
475 State(state): State<Arc<ProxyState>>,
476 Path(upstream_id): Path<Uuid>,
477) -> impl IntoResponse {
478 let config = state.config.read().await;
479 match config.upstreams.iter().find(|u| u.id == Some(upstream_id)) {
480 Some(upstream) => {
481 let backends = upstream.backends.clone();
482 Json(ApiResponse::success(backends)).into_response()
483 }
484 None => (
485 StatusCode::NOT_FOUND,
486 Json(ApiResponse::<()>::error("Upstream not found")),
487 )
488 .into_response(),
489 }
490}
491
492async fn add_backend(
493 State(state): State<Arc<ProxyState>>,
494 Path(upstream_id): Path<Uuid>,
495 Json(req): Json<BackendRequest>,
496) -> impl IntoResponse {
497 let Some(mut updated) = state
498 .config
499 .read()
500 .await
501 .upstreams
502 .iter()
503 .find(|u| u.id == Some(upstream_id))
504 .cloned()
505 else {
506 return (
507 StatusCode::NOT_FOUND,
508 Json(ApiResponse::<()>::error("Upstream not found")),
509 )
510 .into_response();
511 };
512
513 let backend = Backend {
514 id: Some(Uuid::new_v4()),
515 address: req.address,
516 weight: req.weight.unwrap_or(100),
517 scheme: req.scheme.unwrap_or_else(|| "http".to_string()),
518 enabled: true,
519 http_version: Default::default(),
520 };
521 updated.backends.push(backend.clone());
522
523 if persists(&state).await {
524 if let Err(error) = crate::config::save_upstream(&state.pool, &updated).await {
527 return persistence_failed(error);
528 }
529 }
530
531 let mut config = state.config.write().await;
532 match config
533 .upstreams
534 .iter_mut()
535 .find(|u| u.id == Some(upstream_id))
536 {
537 Some(upstream) => {
538 *upstream = updated;
539 (StatusCode::CREATED, Json(ApiResponse::success(backend))).into_response()
540 }
541 None => (
542 StatusCode::CONFLICT,
543 Json(ApiResponse::<()>::error(
544 "Upstream was removed while the backend was being saved",
545 )),
546 )
547 .into_response(),
548 }
549}
550
551async fn remove_backend(
552 State(state): State<Arc<ProxyState>>,
553 Path((upstream_id, backend_id)): Path<(Uuid, Uuid)>,
554) -> impl IntoResponse {
555 let Some(mut updated) = state
556 .config
557 .read()
558 .await
559 .upstreams
560 .iter()
561 .find(|u| u.id == Some(upstream_id))
562 .cloned()
563 else {
564 return (
565 StatusCode::NOT_FOUND,
566 Json(ApiResponse::<()>::error("Upstream not found")),
567 )
568 .into_response();
569 };
570
571 let before = updated.backends.len();
572 updated.backends.retain(|b| b.id != Some(backend_id));
573 if updated.backends.len() == before {
574 return (
575 StatusCode::NOT_FOUND,
576 Json(ApiResponse::<()>::error("Backend not found")),
577 )
578 .into_response();
579 }
580
581 if persists(&state).await {
582 if let Err(error) = crate::config::save_upstream(&state.pool, &updated).await {
583 return persistence_failed(error);
584 }
585 }
586
587 let mut config = state.config.write().await;
588 match config
589 .upstreams
590 .iter_mut()
591 .find(|u| u.id == Some(upstream_id))
592 {
593 Some(upstream) => {
594 *upstream = updated;
595 Json(ApiResponse::success(())).into_response()
596 }
597 None => (
598 StatusCode::CONFLICT,
599 Json(ApiResponse::<()>::error(
600 "Upstream was removed while the backend was being deleted",
601 )),
602 )
603 .into_response(),
604 }
605}
606
607#[derive(Serialize)]
611pub struct HealthSummary {
612 pub total_backends: usize,
614 pub healthy_backends: usize,
616 pub unhealthy_backends: usize,
620}
621
622async fn health_status(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
623 let config = state.config.read().await;
624
625 let mut total = 0;
626 let mut healthy = 0;
627
628 for upstream in &config.upstreams {
629 for backend in &upstream.backends {
630 total += 1;
631 if let Some(id) = backend.id {
632 if state.health_checker.is_healthy(id) {
633 healthy += 1;
634 }
635 }
636 }
637 }
638
639 Json(ApiResponse::success(HealthSummary {
640 total_backends: total,
641 healthy_backends: healthy,
642 unhealthy_backends: total - healthy,
643 }))
644}
645
646async fn backend_health(
647 State(state): State<Arc<ProxyState>>,
648 Path(backend_id): Path<Uuid>,
649) -> impl IntoResponse {
650 match state.health_checker.get_health(backend_id) {
651 Some(health) => Json(ApiResponse::success(health)).into_response(),
652 None => (
653 StatusCode::NOT_FOUND,
654 Json(ApiResponse::<()>::error("Backend not found")),
655 )
656 .into_response(),
657 }
658}
659
660#[derive(Serialize)]
664pub struct ProxyStats {
665 pub routes_count: usize,
667 pub upstreams_count: usize,
669 pub backends_count: usize,
671}
672
673async fn get_stats(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
674 let config = state.config.read().await;
675
676 let backends_count: usize = config.upstreams.iter().map(|u| u.backends.len()).sum();
677
678 Json(ApiResponse::success(ProxyStats {
679 routes_count: config.routes.len(),
680 upstreams_count: config.upstreams.len(),
681 backends_count,
682 }))
683}