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 #[serde(skip_serializing_if = "Option::is_none")]
34 pub code: Option<&'static str>,
35}
36
37impl<T: Serialize> ApiResponse<T> {
38 pub fn success(data: T) -> Self {
40 Self {
41 success: true,
42 data: Some(data),
43 error: None,
44 code: None,
45 }
46 }
47
48 pub fn error(message: impl Into<String>) -> ApiResponse<()> {
54 ApiResponse {
55 success: false,
56 data: None,
57 error: Some(message.into()),
58 code: None,
59 }
60 }
61
62 pub fn error_with_code(code: &'static str, message: impl Into<String>) -> ApiResponse<()> {
64 ApiResponse {
65 success: false,
66 data: None,
67 error: Some(message.into()),
68 code: Some(code),
69 }
70 }
71}
72
73fn persistence_failed(error: crate::error::ProxyError) -> axum::response::Response {
83 tracing::error!(%error, "admin API could not persist a configuration change");
84 (
85 StatusCode::INTERNAL_SERVER_ERROR,
86 Json(ApiResponse::<()>::error_with_code(
87 "persistence_failed",
88 "could not persist the change; see the proxy's log",
89 )),
90 )
91 .into_response()
92}
93
94fn invalid_backend(req: &BackendRequest) -> Option<axum::response::Response> {
101 let scheme = req.scheme.as_deref().unwrap_or("http");
102 let error = crate::saas::validation::validate_scheme(scheme).err()?;
103 Some(
104 (
105 StatusCode::BAD_REQUEST,
106 Json(ApiResponse::<()>::error_with_code(
107 "validation_failed",
108 match error {
109 crate::error::ProxyError::Validation(message) => message,
110 other => other.to_string(),
111 },
112 )),
113 )
114 .into_response(),
115 )
116}
117
118async fn persists(state: &ProxyState) -> bool {
124 state.config.read().await.server.database_config
125}
126
127pub fn admin_router() -> Router<Arc<ProxyState>> {
133 Router::new()
134 .route("/routes", get(list_routes))
136 .route("/routes", post(create_route))
137 .route("/routes/{id}", get(get_route))
138 .route("/routes/{id}", put(update_route))
139 .route("/routes/{id}", delete(delete_route))
140 .route("/upstreams", get(list_upstreams))
142 .route("/upstreams", post(create_upstream))
143 .route("/upstreams/{id}", get(get_upstream))
144 .route("/upstreams/{id}", put(update_upstream))
145 .route("/upstreams/{id}", delete(delete_upstream))
146 .route("/upstreams/{id}/backends", get(list_backends))
148 .route("/upstreams/{id}/backends", post(add_backend))
149 .route(
150 "/upstreams/{id}/backends/{backend_id}",
151 delete(remove_backend),
152 )
153 .route("/health", get(health_status))
155 .route("/health/{backend_id}", get(backend_health))
156 .route("/stats", get(get_stats))
159}
160
161async fn list_routes(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
164 let config = state.config.read().await;
165 let routes = config.routes.clone();
166 Json(ApiResponse::success(routes))
167}
168
169async fn get_route(
170 State(state): State<Arc<ProxyState>>,
171 Path(id): Path<Uuid>,
172) -> impl IntoResponse {
173 let config = state.config.read().await;
174 match config.routes.iter().find(|r| r.id == Some(id)) {
175 Some(route) => Json(ApiResponse::success(route)).into_response(),
176 None => (
177 StatusCode::NOT_FOUND,
178 Json(ApiResponse::<()>::error("Route not found")),
179 )
180 .into_response(),
181 }
182}
183
184#[derive(Deserialize)]
186pub struct CreateRouteRequest {
187 pub name: String,
189 pub host: Option<String>,
191 pub path: Option<String>,
193 pub upstream: String,
195 pub strip_path: Option<bool>,
197 pub priority: Option<i32>,
199 pub add_headers: Option<HashMap<String, String>>,
201 pub remove_headers: Option<Vec<String>>,
203}
204
205async fn create_route(
206 State(state): State<Arc<ProxyState>>,
207 Json(req): Json<CreateRouteRequest>,
208) -> impl IntoResponse {
209 let route = Route {
210 id: Some(Uuid::new_v4()),
211 name: req.name,
212 description: None,
213 match_: RouteMatch {
214 host: req.host,
215 path: req.path,
216 path_type: Default::default(),
217 headers: HashMap::new(),
218 methods: None,
219 },
220 priority: req.priority.unwrap_or(100),
221 upstream: req.upstream,
222 strip_path: req.strip_path.unwrap_or(false),
223 add_headers: req.add_headers.unwrap_or_default(),
224 remove_headers: req.remove_headers.unwrap_or_default(),
225 rate_limit: None,
226 timeout_secs: 30,
227 retry_count: 0,
228 enabled: true,
229 };
230
231 if persists(&state).await {
232 if let Err(error) = crate::config::save_route(&state.pool, &route).await {
236 return persistence_failed(error);
237 }
238 }
239
240 let mut config = state.config.write().await;
241 config.routes.push(route.clone());
242
243 (StatusCode::CREATED, Json(ApiResponse::success(route))).into_response()
244}
245
246async fn update_route(
247 State(state): State<Arc<ProxyState>>,
248 Path(id): Path<Uuid>,
249 Json(req): Json<CreateRouteRequest>,
250) -> impl IntoResponse {
251 let Some(mut updated) = state
254 .config
255 .read()
256 .await
257 .routes
258 .iter()
259 .find(|r| r.id == Some(id))
260 .cloned()
261 else {
262 return (
263 StatusCode::NOT_FOUND,
264 Json(ApiResponse::<()>::error("Route not found")),
265 )
266 .into_response();
267 };
268
269 updated.name = req.name;
270 updated.match_.host = req.host;
271 updated.match_.path = req.path;
272 updated.upstream = req.upstream;
273 updated.strip_path = req.strip_path.unwrap_or(false);
274 updated.priority = req.priority.unwrap_or(100);
275 if let Some(add_headers) = req.add_headers {
276 updated.add_headers = add_headers;
277 }
278 if let Some(remove_headers) = req.remove_headers {
279 updated.remove_headers = remove_headers;
280 }
281
282 if persists(&state).await {
283 if let Err(error) = crate::config::save_route(&state.pool, &updated).await {
284 return persistence_failed(error);
285 }
286 }
287
288 let mut config = state.config.write().await;
289 match config.routes.iter_mut().find(|r| r.id == Some(id)) {
290 Some(route) => {
291 *route = updated.clone();
292 Json(ApiResponse::success(updated)).into_response()
293 }
294 None => (
297 StatusCode::CONFLICT,
298 Json(ApiResponse::<()>::error(
299 "Route was removed while the update was being saved",
300 )),
301 )
302 .into_response(),
303 }
304}
305
306async fn delete_route(
307 State(state): State<Arc<ProxyState>>,
308 Path(id): Path<Uuid>,
309) -> impl IntoResponse {
310 if !state
311 .config
312 .read()
313 .await
314 .routes
315 .iter()
316 .any(|r| r.id == Some(id))
317 {
318 return (
319 StatusCode::NOT_FOUND,
320 Json(ApiResponse::<()>::error("Route not found")),
321 )
322 .into_response();
323 }
324
325 if persists(&state).await {
326 if let Err(error) = crate::config::delete_route(&state.pool, id).await {
330 return persistence_failed(error);
331 }
332 }
333
334 state
335 .config
336 .write()
337 .await
338 .routes
339 .retain(|r| r.id != Some(id));
340
341 Json(ApiResponse::success(())).into_response()
342}
343
344async fn list_upstreams(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
347 let config = state.config.read().await;
348 let upstreams = config.upstreams.clone();
349 Json(ApiResponse::success(upstreams))
350}
351
352async fn get_upstream(
353 State(state): State<Arc<ProxyState>>,
354 Path(id): Path<Uuid>,
355) -> impl IntoResponse {
356 let config = state.config.read().await;
357 match config.upstreams.iter().find(|u| u.id == Some(id)) {
358 Some(upstream) => Json(ApiResponse::success(upstream)).into_response(),
359 None => (
360 StatusCode::NOT_FOUND,
361 Json(ApiResponse::<()>::error("Upstream not found")),
362 )
363 .into_response(),
364 }
365}
366
367#[derive(Deserialize)]
369pub struct CreateUpstreamRequest {
370 pub name: String,
372 pub lb_strategy: Option<LoadBalanceStrategy>,
374 pub backends: Vec<BackendRequest>,
381}
382
383#[derive(Deserialize)]
385pub struct BackendRequest {
386 pub address: String,
388 pub weight: Option<u32>,
390 pub scheme: Option<String>,
392}
393
394async fn create_upstream(
395 State(state): State<Arc<ProxyState>>,
396 Json(req): Json<CreateUpstreamRequest>,
397) -> impl IntoResponse {
398 if let Some(refusal) = req.backends.iter().find_map(invalid_backend) {
399 return refusal;
400 }
401
402 let backends: Vec<Backend> = req
403 .backends
404 .into_iter()
405 .map(|b| Backend {
406 id: Some(Uuid::new_v4()),
407 address: b.address,
408 weight: b.weight.unwrap_or(100),
409 scheme: b.scheme.unwrap_or_else(|| "http".to_string()),
410 enabled: true,
411 http_version: Default::default(),
412 })
413 .collect();
414
415 let upstream = Upstream {
416 id: Some(Uuid::new_v4()),
417 name: req.name,
418 description: None,
419 lb_strategy: req.lb_strategy.unwrap_or_default(),
420 backends,
421 health_check: HealthCheckConfig::default(),
422 enabled: true,
423 };
424
425 if persists(&state).await {
426 if let Err(error) = crate::config::save_upstream(&state.pool, &upstream).await {
427 return persistence_failed(error);
428 }
429 }
430
431 let mut config = state.config.write().await;
432 config.upstreams.push(upstream.clone());
433
434 (StatusCode::CREATED, Json(ApiResponse::success(upstream))).into_response()
435}
436
437async fn update_upstream(
438 State(state): State<Arc<ProxyState>>,
439 Path(id): Path<Uuid>,
440 Json(req): Json<CreateUpstreamRequest>,
441) -> impl IntoResponse {
442 let Some(mut updated) = state
443 .config
444 .read()
445 .await
446 .upstreams
447 .iter()
448 .find(|u| u.id == Some(id))
449 .cloned()
450 else {
451 return (
452 StatusCode::NOT_FOUND,
453 Json(ApiResponse::<()>::error("Upstream not found")),
454 )
455 .into_response();
456 };
457
458 updated.name = req.name;
459 updated.lb_strategy = req.lb_strategy.unwrap_or_default();
460 if persists(&state).await {
467 if let Err(error) = crate::config::save_upstream(&state.pool, &updated).await {
468 return persistence_failed(error);
469 }
470 }
471
472 let mut config = state.config.write().await;
473 match config.upstreams.iter_mut().find(|u| u.id == Some(id)) {
474 Some(upstream) => {
475 *upstream = updated.clone();
476 Json(ApiResponse::success(updated)).into_response()
477 }
478 None => (
479 StatusCode::CONFLICT,
480 Json(ApiResponse::<()>::error(
481 "Upstream was removed while the update was being saved",
482 )),
483 )
484 .into_response(),
485 }
486}
487
488async fn delete_upstream(
489 State(state): State<Arc<ProxyState>>,
490 Path(id): Path<Uuid>,
491) -> impl IntoResponse {
492 if !state
493 .config
494 .read()
495 .await
496 .upstreams
497 .iter()
498 .any(|u| u.id == Some(id))
499 {
500 return (
501 StatusCode::NOT_FOUND,
502 Json(ApiResponse::<()>::error("Upstream not found")),
503 )
504 .into_response();
505 }
506
507 if persists(&state).await {
508 if let Err(error) = crate::config::delete_upstream(&state.pool, id).await {
509 return persistence_failed(error);
510 }
511 }
512
513 state
514 .config
515 .write()
516 .await
517 .upstreams
518 .retain(|u| u.id != Some(id));
519
520 Json(ApiResponse::success(())).into_response()
521}
522
523async fn list_backends(
526 State(state): State<Arc<ProxyState>>,
527 Path(upstream_id): Path<Uuid>,
528) -> impl IntoResponse {
529 let config = state.config.read().await;
530 match config.upstreams.iter().find(|u| u.id == Some(upstream_id)) {
531 Some(upstream) => {
532 let backends = upstream.backends.clone();
533 Json(ApiResponse::success(backends)).into_response()
534 }
535 None => (
536 StatusCode::NOT_FOUND,
537 Json(ApiResponse::<()>::error("Upstream not found")),
538 )
539 .into_response(),
540 }
541}
542
543async fn add_backend(
544 State(state): State<Arc<ProxyState>>,
545 Path(upstream_id): Path<Uuid>,
546 Json(req): Json<BackendRequest>,
547) -> impl IntoResponse {
548 if let Some(refusal) = invalid_backend(&req) {
549 return refusal;
550 }
551
552 let Some(mut updated) = state
553 .config
554 .read()
555 .await
556 .upstreams
557 .iter()
558 .find(|u| u.id == Some(upstream_id))
559 .cloned()
560 else {
561 return (
562 StatusCode::NOT_FOUND,
563 Json(ApiResponse::<()>::error("Upstream not found")),
564 )
565 .into_response();
566 };
567
568 let backend = Backend {
569 id: Some(Uuid::new_v4()),
570 address: req.address,
571 weight: req.weight.unwrap_or(100),
572 scheme: req.scheme.unwrap_or_else(|| "http".to_string()),
573 enabled: true,
574 http_version: Default::default(),
575 };
576 updated.backends.push(backend.clone());
577
578 if persists(&state).await {
579 if let Err(error) = crate::config::save_upstream(&state.pool, &updated).await {
582 return persistence_failed(error);
583 }
584 }
585
586 let mut config = state.config.write().await;
587 match config
588 .upstreams
589 .iter_mut()
590 .find(|u| u.id == Some(upstream_id))
591 {
592 Some(upstream) => {
593 *upstream = updated;
594 (StatusCode::CREATED, Json(ApiResponse::success(backend))).into_response()
595 }
596 None => (
597 StatusCode::CONFLICT,
598 Json(ApiResponse::<()>::error(
599 "Upstream was removed while the backend was being saved",
600 )),
601 )
602 .into_response(),
603 }
604}
605
606async fn remove_backend(
607 State(state): State<Arc<ProxyState>>,
608 Path((upstream_id, backend_id)): Path<(Uuid, Uuid)>,
609) -> impl IntoResponse {
610 let Some(mut updated) = state
611 .config
612 .read()
613 .await
614 .upstreams
615 .iter()
616 .find(|u| u.id == Some(upstream_id))
617 .cloned()
618 else {
619 return (
620 StatusCode::NOT_FOUND,
621 Json(ApiResponse::<()>::error("Upstream not found")),
622 )
623 .into_response();
624 };
625
626 let before = updated.backends.len();
627 updated.backends.retain(|b| b.id != Some(backend_id));
628 if updated.backends.len() == before {
629 return (
630 StatusCode::NOT_FOUND,
631 Json(ApiResponse::<()>::error("Backend not found")),
632 )
633 .into_response();
634 }
635
636 if persists(&state).await {
637 if let Err(error) = crate::config::save_upstream(&state.pool, &updated).await {
638 return persistence_failed(error);
639 }
640 }
641
642 let mut config = state.config.write().await;
643 match config
644 .upstreams
645 .iter_mut()
646 .find(|u| u.id == Some(upstream_id))
647 {
648 Some(upstream) => {
649 *upstream = updated;
650 Json(ApiResponse::success(())).into_response()
651 }
652 None => (
653 StatusCode::CONFLICT,
654 Json(ApiResponse::<()>::error(
655 "Upstream was removed while the backend was being deleted",
656 )),
657 )
658 .into_response(),
659 }
660}
661
662#[derive(Serialize)]
666pub struct HealthSummary {
667 pub total_backends: usize,
669 pub healthy_backends: usize,
671 pub unhealthy_backends: usize,
675}
676
677async fn health_status(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
678 let config = state.config.read().await;
679
680 let mut total = 0;
681 let mut healthy = 0;
682
683 for upstream in &config.upstreams {
684 for backend in &upstream.backends {
685 total += 1;
686 if let Some(id) = backend.id {
687 if state.health_checker.is_healthy(id) {
688 healthy += 1;
689 }
690 }
691 }
692 }
693
694 Json(ApiResponse::success(HealthSummary {
695 total_backends: total,
696 healthy_backends: healthy,
697 unhealthy_backends: total - healthy,
698 }))
699}
700
701async fn backend_health(
702 State(state): State<Arc<ProxyState>>,
703 Path(backend_id): Path<Uuid>,
704) -> impl IntoResponse {
705 match state.health_checker.get_health(backend_id) {
706 Some(health) => Json(ApiResponse::success(health)).into_response(),
707 None => (
708 StatusCode::NOT_FOUND,
709 Json(ApiResponse::<()>::error("Backend not found")),
710 )
711 .into_response(),
712 }
713}
714
715#[derive(Serialize)]
719pub struct ProxyStats {
720 pub routes_count: usize,
722 pub upstreams_count: usize,
724 pub backends_count: usize,
726}
727
728async fn get_stats(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
729 let config = state.config.read().await;
730
731 let backends_count: usize = config.upstreams.iter().map(|u| u.backends.len()).sum();
732
733 Json(ApiResponse::success(ProxyStats {
734 routes_count: config.routes.len(),
735 upstreams_count: config.upstreams.len(),
736 backends_count,
737 }))
738}