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    /// A stable, machine-readable name for what went wrong, on failure.
30    ///
31    /// `error` is prose and may be reworded; this is what a client should
32    /// match on. Absent on success, and on the few failures that predate it.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub code: Option<&'static str>,
35}
36
37impl<T: Serialize> ApiResponse<T> {
38    /// A successful response carrying `data`.
39    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    /// A failure response carrying `message` and no data.
49    ///
50    /// Returns `ApiResponse<()>` regardless of `T`: there is no payload to
51    /// type, and requiring the caller to name one at every error site would be
52    /// noise.
53    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    /// A failure response carrying a stable `code` as well as `message`.
63    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
73/// A 500 for a persistence failure.
74///
75/// The distinction from a 404 matters: "the database refused this" and "there
76/// is no such route" are different answers, and returning success for either
77/// is what this module used to do.
78///
79/// The error itself is logged and not returned. It is usually a `sqlx::Error`,
80/// whose text names tables, constraints and sometimes values -- detail for the
81/// operator's log, not for whoever is on the other end of the request.
82fn 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
94/// A 400 for a backend this proxy could not use.
95///
96/// Checked before anything is stored or applied. The tables refuse an unknown
97/// scheme with a CHECK constraint, but that only runs when the change is
98/// persisted -- with `database_config` off, the backend went straight into the
99/// running config.
100fn 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
118/// Whether edits go to the database as well as to the running config.
119///
120/// `server.database_config` defaults to true. Setting it false is an explicit
121/// "this proxy is configured from a file", and edits then last as long as the
122/// process -- which is what was asked for, rather than a silent loss.
123async fn persists(state: &ProxyState) -> bool {
124    state.config.read().await.server.database_config
125}
126
127/// Create the admin API router.
128/// Build the admin API router.
129///
130/// **No authentication of its own.** Mount it behind something that
131/// authenticates, or on an address only operators can reach.
132pub fn admin_router() -> Router<Arc<ProxyState>> {
133    Router::new()
134        // Routes
135        .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        // Upstreams
141        .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        // Backends
147        .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        // Health
154        .route("/health", get(health_status))
155        .route("/health/{backend_id}", get(backend_health))
156        // Config
157        // Stats
158        .route("/stats", get(get_stats))
159}
160
161// Route handlers
162
163async 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/// Body of `POST /routes` and `PUT /routes/{id}`.
185#[derive(Deserialize)]
186pub struct CreateRouteRequest {
187    /// Route name, unique in the config.
188    pub name: String,
189    /// Host to match. Omit for any.
190    pub host: Option<String>,
191    /// Path to match, as a prefix.
192    pub path: Option<String>,
193    /// Name of the upstream to forward to.
194    pub upstream: String,
195    /// Whether to remove the matched prefix. Defaults to false.
196    pub strip_path: Option<bool>,
197    /// Higher is matched first. Defaults to 100.
198    pub priority: Option<i32>,
199    /// Headers to add to the forwarded request.
200    pub add_headers: Option<HashMap<String, String>>,
201    /// Headers to remove from the forwarded request.
202    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        // Write through before touching the running config: a create that
233        // answers 201 and is gone after a restart is worse than one that
234        // fails now and says why.
235        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    // Build the updated route from a snapshot rather than mutating in place,
252    // so the running config is only touched once the database has accepted it.
253    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        // Deleted while we were writing. The database now holds it and the
295        // running config does not; say so rather than reporting success.
296        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        // A `false` here is not an error: a route that came from a TOML config
327        // has no row to delete. The 404 above already established that the
328        // route existed in the running config, which is what was asked about.
329        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
344// Upstream handlers
345
346async 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/// Body of `POST /upstreams` and `PUT /upstreams/{id}`.
368#[derive(Deserialize)]
369pub struct CreateUpstreamRequest {
370    /// Upstream name. Routes reference it.
371    pub name: String,
372    /// How requests are spread across the backends. Defaults to round-robin.
373    pub lb_strategy: Option<LoadBalanceStrategy>,
374    /// Backends to create with it.
375    ///
376    /// Only read on create. `update_upstream` ignores this field, because the
377    /// type cannot express a backend's id -- applying it would re-create every
378    /// backend under a fresh id, and an empty list sent by a caller that meant
379    /// only to rename would remove them all.
380    pub backends: Vec<BackendRequest>,
381}
382
383/// A backend, inside an upstream request or `POST /upstreams/{id}/backends`.
384#[derive(Deserialize)]
385pub struct BackendRequest {
386    /// `host:port`.
387    pub address: String,
388    /// Relative share of traffic under a weighted strategy.
389    pub weight: Option<u32>,
390    /// `http` or `https`. Defaults to `http`.
391    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    // `req.backends` is deliberately not applied. CreateUpstreamRequest cannot
461    // express a backend's id, so treating its list as the new set would
462    // re-create every backend under a fresh id on each update, and an empty
463    // list -- which is what a caller sends when it only means to rename --
464    // would silently remove every backend. Use the backend endpoints instead.
465
466    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
523// Backend handlers
524
525async 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        // `save_upstream` writes the whole backend set in one transaction, so
580        // the upstream is never briefly left without backends.
581        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// Health handlers
663
664/// Backend health across the whole running config.
665#[derive(Serialize)]
666pub struct HealthSummary {
667    /// Backends across every upstream.
668    pub total_backends: usize,
669    /// How many are currently passing their health check.
670    pub healthy_backends: usize,
671    /// How many are not. An unknown backend counts as healthy, so this is
672    /// backends known to be failing rather than backends not known to be
673    /// passing.
674    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// Stats handlers
716
717/// How much configuration the proxy is currently holding.
718#[derive(Serialize)]
719pub struct ProxyStats {
720    /// Routes in the running config, enabled or not.
721    pub routes_count: usize,
722    /// Upstreams in the running config.
723    pub upstreams_count: usize,
724    /// Backends across all of them.
725    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}