Skip to main content

postrust_proxy/admin/
api.rs

1//! REST API endpoints for proxy management.
2
3use 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/// API response wrapper.
18#[derive(Serialize)]
19pub struct ApiResponse<T> {
20    /// Whether the request did what was asked. Read this, not the presence of
21    /// `data`.
22    pub success: bool,
23    /// The payload, on success.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub data: Option<T>,
26    /// What went wrong, on failure.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub error: Option<String>,
29}
30
31impl<T: Serialize> ApiResponse<T> {
32    /// A successful response carrying `data`.
33    pub fn success(data: T) -> Self {
34        Self {
35            success: true,
36            data: Some(data),
37            error: None,
38        }
39    }
40
41    /// A failure response carrying `message` and no data.
42    ///
43    /// Returns `ApiResponse<()>` regardless of `T`: there is no payload to
44    /// type, and requiring the caller to name one at every error site would be
45    /// noise.
46    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
55/// A 500 for a persistence failure.
56///
57/// The distinction from a 404 matters: "the database refused this" and "there
58/// is no such route" are different answers, and returning success for either
59/// is what this module used to do.
60fn 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
71/// Whether edits go to the database as well as to the running config.
72///
73/// `server.database_config` defaults to true. Setting it false is an explicit
74/// "this proxy is configured from a file", and edits then last as long as the
75/// process -- which is what was asked for, rather than a silent loss.
76async fn persists(state: &ProxyState) -> bool {
77    state.config.read().await.server.database_config
78}
79
80/// Create the admin API router.
81/// Build the admin API router.
82///
83/// **No authentication of its own.** Mount it behind something that
84/// authenticates, or on an address only operators can reach.
85pub fn admin_router() -> Router<Arc<ProxyState>> {
86    Router::new()
87        // Routes
88        .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        // Upstreams
94        .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        // Backends
100        .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        // Health
107        .route("/health", get(health_status))
108        .route("/health/{backend_id}", get(backend_health))
109        // Config
110        // Stats
111        .route("/stats", get(get_stats))
112}
113
114// Route handlers
115
116async 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/// Body of `POST /routes` and `PUT /routes/{id}`.
138#[derive(Deserialize)]
139pub struct CreateRouteRequest {
140    /// Route name, unique in the config.
141    pub name: String,
142    /// Host to match. Omit for any.
143    pub host: Option<String>,
144    /// Path to match, as a prefix.
145    pub path: Option<String>,
146    /// Name of the upstream to forward to.
147    pub upstream: String,
148    /// Whether to remove the matched prefix. Defaults to false.
149    pub strip_path: Option<bool>,
150    /// Higher is matched first. Defaults to 100.
151    pub priority: Option<i32>,
152    /// Headers to add to the forwarded request.
153    pub add_headers: Option<HashMap<String, String>>,
154    /// Headers to remove from the forwarded request.
155    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        // Write through before touching the running config: a create that
186        // answers 201 and is gone after a restart is worse than one that
187        // fails now and says why.
188        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    // Build the updated route from a snapshot rather than mutating in place,
205    // so the running config is only touched once the database has accepted it.
206    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        // Deleted while we were writing. The database now holds it and the
248        // running config does not; say so rather than reporting success.
249        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        // A `false` here is not an error: a route that came from a TOML config
280        // has no row to delete. The 404 above already established that the
281        // route existed in the running config, which is what was asked about.
282        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
297// Upstream handlers
298
299async 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/// Body of `POST /upstreams` and `PUT /upstreams/{id}`.
321#[derive(Deserialize)]
322pub struct CreateUpstreamRequest {
323    /// Upstream name. Routes reference it.
324    pub name: String,
325    /// How requests are spread across the backends. Defaults to round-robin.
326    pub lb_strategy: Option<LoadBalanceStrategy>,
327    /// Backends to create with it.
328    ///
329    /// Only read on create. `update_upstream` ignores this field, because the
330    /// type cannot express a backend's id -- applying it would re-create every
331    /// backend under a fresh id, and an empty list sent by a caller that meant
332    /// only to rename would remove them all.
333    pub backends: Vec<BackendRequest>,
334}
335
336/// A backend, inside an upstream request or `POST /upstreams/{id}/backends`.
337#[derive(Deserialize)]
338pub struct BackendRequest {
339    /// `host:port`.
340    pub address: String,
341    /// Relative share of traffic under a weighted strategy.
342    pub weight: Option<u32>,
343    /// `http` or `https`. Defaults to `http`.
344    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    // `req.backends` is deliberately not applied. CreateUpstreamRequest cannot
410    // express a backend's id, so treating its list as the new set would
411    // re-create every backend under a fresh id on each update, and an empty
412    // list -- which is what a caller sends when it only means to rename --
413    // would silently remove every backend. Use the backend endpoints instead.
414
415    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
472// Backend handlers
473
474async 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        // `save_upstream` writes the whole backend set in one transaction, so
525        // the upstream is never briefly left without backends.
526        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// Health handlers
608
609/// Backend health across the whole running config.
610#[derive(Serialize)]
611pub struct HealthSummary {
612    /// Backends across every upstream.
613    pub total_backends: usize,
614    /// How many are currently passing their health check.
615    pub healthy_backends: usize,
616    /// How many are not. An unknown backend counts as healthy, so this is
617    /// backends known to be failing rather than backends not known to be
618    /// passing.
619    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// Stats handlers
661
662/// How much configuration the proxy is currently holding.
663#[derive(Serialize)]
664pub struct ProxyStats {
665    /// Routes in the running config, enabled or not.
666    pub routes_count: usize,
667    /// Upstreams in the running config.
668    pub upstreams_count: usize,
669    /// Backends across all of them.
670    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}