Skip to main content

postrust_proxy/config/
database.rs

1//! Database-backed configuration loading and persistence.
2//!
3//! The database equivalent of `config::file`: the same [`Route`] and
4//! [`Upstream`] values a TOML config parses into, read from and written to the
5//! tables in `migrations/20260901000001_proxy_config.sql`.
6//!
7//! These are the *global* proxy configuration tables, distinct from the
8//! `proxy_domain_*` tables the multi-tenant SaaS module owns, which are scoped
9//! to a tenant and a domain.
10//!
11//! Queries use runtime sqlx (`query`) rather than the compile-time-checked
12//! macros, matching `saas::db`, so the crate builds without a live database.
13//!
14//! # Decoding is strict
15//!
16//! Every enum column has a `CHECK` constraint, so an unrecognised value should
17//! be impossible. When one turns up anyway -- a hand-edited row, a database
18//! restored from a newer schema -- these functions return an error rather than
19//! substituting a default. A silent default here means a route quietly matching
20//! differently, or a rate limit quietly not applying, which is worse than
21//! refusing to start.
22
23use std::collections::HashMap;
24
25use sqlx::{PgPool, Row};
26use uuid::Uuid;
27
28use crate::config::{
29    Backend, HealthCheckConfig, LoadBalanceStrategy, PathMatchType, RateLimitKey, Route,
30    RouteMatch, RouteRateLimit, Upstream, UpstreamHttpVersion,
31};
32use crate::error::{ProxyError, ProxyResult};
33
34// ============================================================================
35// Enum <-> string
36//
37// The stored spelling is the serde-canonical form of each variant, so what is
38// in the database matches what a TOML config would say.
39// ============================================================================
40
41fn lb_strategy_str(strategy: &LoadBalanceStrategy) -> &'static str {
42    match strategy {
43        LoadBalanceStrategy::RoundRobin => "round_robin",
44        LoadBalanceStrategy::LeastConnections => "least_connections",
45        LoadBalanceStrategy::Weighted => "weighted",
46        LoadBalanceStrategy::Random => "random",
47        LoadBalanceStrategy::Sticky => "sticky",
48    }
49}
50
51fn lb_strategy_from(value: &str) -> ProxyResult<LoadBalanceStrategy> {
52    Ok(match value {
53        "round_robin" => LoadBalanceStrategy::RoundRobin,
54        "least_connections" => LoadBalanceStrategy::LeastConnections,
55        "weighted" => LoadBalanceStrategy::Weighted,
56        "random" => LoadBalanceStrategy::Random,
57        "sticky" => LoadBalanceStrategy::Sticky,
58        other => return Err(unknown("lb_strategy", other)),
59    })
60}
61
62fn path_type_str(path_type: &PathMatchType) -> &'static str {
63    match path_type {
64        PathMatchType::Prefix => "prefix",
65        PathMatchType::Exact => "exact",
66        PathMatchType::Regex => "regex",
67    }
68}
69
70fn path_type_from(value: &str) -> ProxyResult<PathMatchType> {
71    Ok(match value {
72        "prefix" => PathMatchType::Prefix,
73        "exact" => PathMatchType::Exact,
74        "regex" => PathMatchType::Regex,
75        other => return Err(unknown("match_path_type", other)),
76    })
77}
78
79fn http_version_str(version: &UpstreamHttpVersion) -> &'static str {
80    match version {
81        UpstreamHttpVersion::Http11 => "http11",
82        UpstreamHttpVersion::H2c => "h2c",
83    }
84}
85
86fn http_version_from(value: &str) -> ProxyResult<UpstreamHttpVersion> {
87    Ok(match value {
88        "http11" => UpstreamHttpVersion::Http11,
89        "h2c" => UpstreamHttpVersion::H2c,
90        other => return Err(unknown("http_version", other)),
91    })
92}
93
94/// `RateLimitKey` split into the two columns that hold it: a discriminant, and
95/// the header name that only the `Header` variant carries.
96fn rate_limit_key_columns(key: &RateLimitKey) -> (&'static str, Option<&str>) {
97    match key {
98        RateLimitKey::ClientIp => ("client_ip", None),
99        RateLimitKey::Header(name) => ("header", Some(name.as_str())),
100        RateLimitKey::Route => ("route", None),
101    }
102}
103
104fn rate_limit_key_from(kind: &str, header: Option<String>) -> ProxyResult<RateLimitKey> {
105    Ok(match (kind, header) {
106        ("client_ip", _) => RateLimitKey::ClientIp,
107        ("route", _) => RateLimitKey::Route,
108        ("header", Some(name)) => RateLimitKey::Header(name),
109        // The schema's `proxy_routes_rate_limit_header` constraint makes this
110        // unreachable through normal writes; say so rather than inventing a
111        // header name to limit on.
112        ("header", None) => {
113            return Err(ProxyError::Config(
114                "rate_limit_key is 'header' but rate_limit_header is NULL".into(),
115            ))
116        }
117        (other, _) => return Err(unknown("rate_limit_key", other)),
118    })
119}
120
121fn unknown(column: &str, value: &str) -> ProxyError {
122    ProxyError::Config(format!(
123        "unrecognised {column} value {value:?} in the proxy configuration tables"
124    ))
125}
126
127/// Widen a stored `INTEGER` into the `u32` the config types use.
128///
129/// The schema forbids negatives on every column this is applied to, so a
130/// failure here means the row was written around the constraints.
131fn positive(column: &str, value: i32) -> ProxyResult<u32> {
132    u32::try_from(value)
133        .map_err(|_| ProxyError::Config(format!("{column} is negative ({value}) in the database")))
134}
135
136/// Narrow a `u32` from the config types into a storable `INTEGER`.
137fn storable(column: &str, value: u32) -> ProxyResult<i32> {
138    i32::try_from(value).map_err(|_| {
139        ProxyError::Validation(format!(
140            "{column} is too large to store ({value} > 2147483647)"
141        ))
142    })
143}
144
145// ============================================================================
146// Loading
147// ============================================================================
148
149/// Load the whole proxy configuration: every route and every upstream.
150///
151/// Rows are returned whether or not they are `enabled`; that flag is part of
152/// the configuration, and the caller decides what to do with it, exactly as it
153/// would for a TOML config.
154pub async fn load_from_database(pool: &PgPool) -> ProxyResult<(Vec<Route>, Vec<Upstream>)> {
155    let routes = load_routes(pool).await?;
156    let upstreams = load_upstreams(pool).await?;
157    Ok((routes, upstreams))
158}
159
160/// Load every route, highest priority first.
161///
162/// The ordering is not cosmetic: routes are matched in priority order, so
163/// loading them in that order means the in-memory table is already in matching
164/// order. `name` breaks ties so that two runs against an unchanged database
165/// produce the same table.
166pub async fn load_routes(pool: &PgPool) -> ProxyResult<Vec<Route>> {
167    let rows = sqlx::query(
168        "SELECT id, name, description, match_host, match_path, match_path_type, \
169                match_headers, match_methods, priority, upstream, strip_path, \
170                add_headers, remove_headers, rate_limit_requests, \
171                rate_limit_window_secs, rate_limit_key, rate_limit_header, \
172                timeout_secs, retry_count, enabled \
173         FROM proxy_routes \
174         ORDER BY priority DESC, name ASC",
175    )
176    .fetch_all(pool)
177    .await?;
178
179    rows.into_iter().map(route_from_row).collect()
180}
181
182/// Load every upstream, each with its backends.
183///
184/// Two queries rather than one per upstream: the backends come back in a single
185/// pass and are grouped in memory. A proxy with a hundred upstreams should not
186/// make a hundred and one round trips to start.
187pub async fn load_upstreams(pool: &PgPool) -> ProxyResult<Vec<Upstream>> {
188    let rows = sqlx::query(
189        "SELECT id, name, description, lb_strategy, health_check_enabled, \
190                health_check_path, health_check_interval_secs, \
191                health_check_timeout_secs, healthy_threshold, \
192                unhealthy_threshold, enabled \
193         FROM proxy_upstreams \
194         ORDER BY name ASC",
195    )
196    .fetch_all(pool)
197    .await?;
198
199    let mut backends = load_all_backends(pool).await?;
200
201    rows.into_iter()
202        .map(|row| {
203            let id: Uuid = row.try_get("id")?;
204            Ok(Upstream {
205                id: Some(id),
206                name: row.try_get("name")?,
207                description: row.try_get("description")?,
208                lb_strategy: lb_strategy_from(row.try_get("lb_strategy")?)?,
209                backends: backends.remove(&id).unwrap_or_default(),
210                health_check: HealthCheckConfig {
211                    enabled: row.try_get("health_check_enabled")?,
212                    path: row.try_get("health_check_path")?,
213                    interval_secs: positive(
214                        "health_check_interval_secs",
215                        row.try_get("health_check_interval_secs")?,
216                    )?,
217                    timeout_secs: positive(
218                        "health_check_timeout_secs",
219                        row.try_get("health_check_timeout_secs")?,
220                    )?,
221                    healthy_threshold: positive(
222                        "healthy_threshold",
223                        row.try_get("healthy_threshold")?,
224                    )?,
225                    unhealthy_threshold: positive(
226                        "unhealthy_threshold",
227                        row.try_get("unhealthy_threshold")?,
228                    )?,
229                },
230                enabled: row.try_get("enabled")?,
231            })
232        })
233        .collect()
234}
235
236/// Every backend in the database, grouped by the upstream it belongs to.
237async fn load_all_backends(pool: &PgPool) -> ProxyResult<HashMap<Uuid, Vec<Backend>>> {
238    let rows = sqlx::query(
239        "SELECT id, upstream_id, address, scheme, weight, http_version, enabled \
240         FROM proxy_upstream_backends \
241         ORDER BY address ASC",
242    )
243    .fetch_all(pool)
244    .await?;
245
246    let mut grouped: HashMap<Uuid, Vec<Backend>> = HashMap::new();
247    for row in rows {
248        let upstream_id: Uuid = row.try_get("upstream_id")?;
249        grouped.entry(upstream_id).or_default().push(Backend {
250            id: Some(row.try_get("id")?),
251            address: row.try_get("address")?,
252            scheme: row.try_get("scheme")?,
253            weight: positive("weight", row.try_get("weight")?)?,
254            enabled: row.try_get("enabled")?,
255            http_version: http_version_from(row.try_get("http_version")?)?,
256        });
257    }
258    Ok(grouped)
259}
260
261fn route_from_row(row: sqlx::postgres::PgRow) -> ProxyResult<Route> {
262    // All three columns are present or none are, enforced by
263    // `proxy_routes_rate_limit_whole`; read the discriminant and let the other
264    // two follow it rather than assembling a partial limit.
265    let rate_limit = match row.try_get::<Option<String>, _>("rate_limit_key")? {
266        Some(kind) => Some(RouteRateLimit {
267            requests: positive(
268                "rate_limit_requests",
269                row.try_get::<Option<i32>, _>("rate_limit_requests")?
270                    .ok_or_else(|| {
271                        ProxyError::Config("rate_limit_requests is NULL with a key set".into())
272                    })?,
273            )?,
274            window_secs: positive(
275                "rate_limit_window_secs",
276                row.try_get::<Option<i32>, _>("rate_limit_window_secs")?
277                    .ok_or_else(|| {
278                        ProxyError::Config("rate_limit_window_secs is NULL with a key set".into())
279                    })?,
280            )?,
281            key: rate_limit_key_from(&kind, row.try_get("rate_limit_header")?)?,
282        }),
283        None => None,
284    };
285
286    let match_headers: sqlx::types::Json<HashMap<String, String>> = row.try_get("match_headers")?;
287    let add_headers: sqlx::types::Json<HashMap<String, String>> = row.try_get("add_headers")?;
288
289    Ok(Route {
290        id: Some(row.try_get("id")?),
291        name: row.try_get("name")?,
292        description: row.try_get("description")?,
293        match_: RouteMatch {
294            host: row.try_get("match_host")?,
295            path: row.try_get("match_path")?,
296            path_type: path_type_from(row.try_get("match_path_type")?)?,
297            headers: match_headers.0,
298            methods: row.try_get("match_methods")?,
299        },
300        priority: row.try_get("priority")?,
301        upstream: row.try_get("upstream")?,
302        strip_path: row.try_get("strip_path")?,
303        add_headers: add_headers.0,
304        remove_headers: row.try_get("remove_headers")?,
305        rate_limit,
306        timeout_secs: positive("timeout_secs", row.try_get("timeout_secs")?)?,
307        retry_count: positive("retry_count", row.try_get("retry_count")?)?,
308        enabled: row.try_get("enabled")?,
309    })
310}
311
312// ============================================================================
313// Persistence
314// ============================================================================
315
316/// Insert a route, or replace it if one with the same id is already stored.
317///
318/// Returns the route as stored, with `id` filled in. A route arriving without
319/// one gets a fresh id rather than being rejected: the admin API generates ids
320/// itself, but a caller building a `Route` by hand should not have to.
321pub async fn save_route(pool: &PgPool, route: &Route) -> ProxyResult<Route> {
322    let id = route.id.unwrap_or_else(Uuid::new_v4);
323    let (rate_limit_key, rate_limit_header) = match &route.rate_limit {
324        Some(limit) => {
325            let (kind, header) = rate_limit_key_columns(&limit.key);
326            (Some(kind), header.map(str::to_owned))
327        }
328        None => (None, None),
329    };
330    let (rate_limit_requests, rate_limit_window_secs) = match &route.rate_limit {
331        Some(limit) => (
332            Some(storable("rate_limit_requests", limit.requests)?),
333            Some(storable("rate_limit_window_secs", limit.window_secs)?),
334        ),
335        None => (None, None),
336    };
337
338    sqlx::query(
339        "INSERT INTO proxy_routes \
340            (id, name, description, match_host, match_path, match_path_type, \
341             match_headers, match_methods, priority, upstream, strip_path, \
342             add_headers, remove_headers, rate_limit_requests, \
343             rate_limit_window_secs, rate_limit_key, rate_limit_header, \
344             timeout_secs, retry_count, enabled) \
345         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, \
346                 $15, $16, $17, $18, $19, $20) \
347         ON CONFLICT (id) DO UPDATE SET \
348             name = EXCLUDED.name, \
349             description = EXCLUDED.description, \
350             match_host = EXCLUDED.match_host, \
351             match_path = EXCLUDED.match_path, \
352             match_path_type = EXCLUDED.match_path_type, \
353             match_headers = EXCLUDED.match_headers, \
354             match_methods = EXCLUDED.match_methods, \
355             priority = EXCLUDED.priority, \
356             upstream = EXCLUDED.upstream, \
357             strip_path = EXCLUDED.strip_path, \
358             add_headers = EXCLUDED.add_headers, \
359             remove_headers = EXCLUDED.remove_headers, \
360             rate_limit_requests = EXCLUDED.rate_limit_requests, \
361             rate_limit_window_secs = EXCLUDED.rate_limit_window_secs, \
362             rate_limit_key = EXCLUDED.rate_limit_key, \
363             rate_limit_header = EXCLUDED.rate_limit_header, \
364             timeout_secs = EXCLUDED.timeout_secs, \
365             retry_count = EXCLUDED.retry_count, \
366             enabled = EXCLUDED.enabled, \
367             updated_at = NOW()",
368    )
369    .bind(id)
370    .bind(&route.name)
371    .bind(&route.description)
372    .bind(&route.match_.host)
373    .bind(&route.match_.path)
374    .bind(path_type_str(&route.match_.path_type))
375    .bind(sqlx::types::Json(&route.match_.headers))
376    .bind(&route.match_.methods)
377    .bind(route.priority)
378    .bind(&route.upstream)
379    .bind(route.strip_path)
380    .bind(sqlx::types::Json(&route.add_headers))
381    .bind(&route.remove_headers)
382    .bind(rate_limit_requests)
383    .bind(rate_limit_window_secs)
384    .bind(rate_limit_key)
385    .bind(rate_limit_header)
386    .bind(storable("timeout_secs", route.timeout_secs)?)
387    .bind(storable("retry_count", route.retry_count)?)
388    .bind(route.enabled)
389    .execute(pool)
390    .await?;
391
392    Ok(Route {
393        id: Some(id),
394        ..route.clone()
395    })
396}
397
398/// Delete a route. Returns whether a row was removed.
399pub async fn delete_route(pool: &PgPool, id: Uuid) -> ProxyResult<bool> {
400    let result = sqlx::query("DELETE FROM proxy_routes WHERE id = $1")
401        .bind(id)
402        .execute(pool)
403        .await?;
404    Ok(result.rows_affected() > 0)
405}
406
407/// Insert an upstream and its backends, or replace them if the id is stored.
408///
409/// The upstream and its backend set are written in one transaction, and the
410/// backends are replaced wholesale rather than merged: `Upstream::backends` is
411/// the complete set, so a backend dropped from the value has to disappear from
412/// the database. Doing that outside a transaction would leave a window where
413/// the upstream has no backends at all and every request through it fails.
414pub async fn save_upstream(pool: &PgPool, upstream: &Upstream) -> ProxyResult<Upstream> {
415    let id = upstream.id.unwrap_or_else(|| upstream.resolved_id());
416    let health = &upstream.health_check;
417
418    let mut tx = pool.begin().await?;
419
420    sqlx::query(
421        "INSERT INTO proxy_upstreams \
422            (id, name, description, lb_strategy, health_check_enabled, \
423             health_check_path, health_check_interval_secs, \
424             health_check_timeout_secs, healthy_threshold, unhealthy_threshold, \
425             enabled) \
426         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
427         ON CONFLICT (id) DO UPDATE SET \
428             name = EXCLUDED.name, \
429             description = EXCLUDED.description, \
430             lb_strategy = EXCLUDED.lb_strategy, \
431             health_check_enabled = EXCLUDED.health_check_enabled, \
432             health_check_path = EXCLUDED.health_check_path, \
433             health_check_interval_secs = EXCLUDED.health_check_interval_secs, \
434             health_check_timeout_secs = EXCLUDED.health_check_timeout_secs, \
435             healthy_threshold = EXCLUDED.healthy_threshold, \
436             unhealthy_threshold = EXCLUDED.unhealthy_threshold, \
437             enabled = EXCLUDED.enabled, \
438             updated_at = NOW()",
439    )
440    .bind(id)
441    .bind(&upstream.name)
442    .bind(&upstream.description)
443    .bind(lb_strategy_str(&upstream.lb_strategy))
444    .bind(health.enabled)
445    .bind(&health.path)
446    .bind(storable(
447        "health_check_interval_secs",
448        health.interval_secs,
449    )?)
450    .bind(storable("health_check_timeout_secs", health.timeout_secs)?)
451    .bind(storable("healthy_threshold", health.healthy_threshold)?)
452    .bind(storable("unhealthy_threshold", health.unhealthy_threshold)?)
453    .bind(upstream.enabled)
454    .execute(&mut *tx)
455    .await?;
456
457    sqlx::query("DELETE FROM proxy_upstream_backends WHERE upstream_id = $1")
458        .bind(id)
459        .execute(&mut *tx)
460        .await?;
461
462    let mut stored = Vec::with_capacity(upstream.backends.len());
463    for backend in &upstream.backends {
464        let backend_id = backend.id.unwrap_or_else(Uuid::new_v4);
465        sqlx::query(
466            "INSERT INTO proxy_upstream_backends \
467                (id, upstream_id, address, scheme, weight, http_version, enabled) \
468             VALUES ($1, $2, $3, $4, $5, $6, $7)",
469        )
470        .bind(backend_id)
471        .bind(id)
472        .bind(&backend.address)
473        .bind(&backend.scheme)
474        .bind(storable("weight", backend.weight)?)
475        .bind(http_version_str(&backend.http_version))
476        .bind(backend.enabled)
477        .execute(&mut *tx)
478        .await?;
479
480        stored.push(Backend {
481            id: Some(backend_id),
482            ..backend.clone()
483        });
484    }
485
486    tx.commit().await?;
487
488    Ok(Upstream {
489        id: Some(id),
490        backends: stored,
491        ..upstream.clone()
492    })
493}
494
495/// Delete an upstream and, by cascade, its backends. Returns whether a row was
496/// removed.
497pub async fn delete_upstream(pool: &PgPool, id: Uuid) -> ProxyResult<bool> {
498    let result = sqlx::query("DELETE FROM proxy_upstreams WHERE id = $1")
499        .bind(id)
500        .execute(pool)
501        .await?;
502    Ok(result.rows_affected() > 0)
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    // The enum mappings are the part that can silently corrupt a config: a
510    // wrong string round-trips as a *different valid* value rather than an
511    // error. Every variant, both directions.
512
513    #[test]
514    fn lb_strategy_round_trips_every_variant() {
515        for strategy in [
516            LoadBalanceStrategy::RoundRobin,
517            LoadBalanceStrategy::LeastConnections,
518            LoadBalanceStrategy::Weighted,
519            LoadBalanceStrategy::Random,
520            LoadBalanceStrategy::Sticky,
521        ] {
522            let stored = lb_strategy_str(&strategy);
523            assert_eq!(
524                lb_strategy_from(stored).unwrap(),
525                strategy,
526                "via {stored:?}"
527            );
528        }
529    }
530
531    #[test]
532    fn path_type_round_trips_every_variant() {
533        for path_type in [
534            PathMatchType::Prefix,
535            PathMatchType::Exact,
536            PathMatchType::Regex,
537        ] {
538            let stored = path_type_str(&path_type);
539            assert_eq!(path_type_from(stored).unwrap(), path_type, "via {stored:?}");
540        }
541    }
542
543    #[test]
544    fn http_version_round_trips_every_variant() {
545        for version in [UpstreamHttpVersion::Http11, UpstreamHttpVersion::H2c] {
546            let stored = http_version_str(&version);
547            assert_eq!(
548                http_version_from(stored).unwrap(),
549                version,
550                "via {stored:?}"
551            );
552        }
553    }
554
555    #[test]
556    fn rate_limit_key_round_trips_every_variant() {
557        for key in [
558            RateLimitKey::ClientIp,
559            RateLimitKey::Route,
560            RateLimitKey::Header("x-api-key".into()),
561        ] {
562            let (kind, header) = rate_limit_key_columns(&key);
563            let back = rate_limit_key_from(kind, header.map(str::to_owned)).unwrap();
564            assert_eq!(
565                format!("{back:?}"),
566                format!("{key:?}"),
567                "via {kind:?}/{header:?}"
568            );
569        }
570    }
571
572    #[test]
573    fn only_the_header_variant_stores_a_header() {
574        assert_eq!(rate_limit_key_columns(&RateLimitKey::ClientIp).1, None);
575        assert_eq!(rate_limit_key_columns(&RateLimitKey::Route).1, None);
576        assert_eq!(
577            rate_limit_key_columns(&RateLimitKey::Header("x-tenant".into())).1,
578            Some("x-tenant")
579        );
580    }
581
582    #[test]
583    fn unknown_enum_values_are_refused_not_defaulted() {
584        // The bug this guards against is `unwrap_or_default()`, which would
585        // turn an unreadable row into a plausible, wrong configuration.
586        assert!(lb_strategy_from("round-robin").is_err());
587        assert!(lb_strategy_from("").is_err());
588        assert!(path_type_from("glob").is_err());
589        assert!(
590            http_version_from("h2").is_err(),
591            "alias is not the stored form"
592        );
593        assert!(rate_limit_key_from("ip", None).is_err());
594    }
595
596    #[test]
597    fn a_header_key_without_a_header_is_an_error() {
598        assert!(rate_limit_key_from("header", None).is_err());
599    }
600
601    #[test]
602    fn negative_integers_from_the_database_are_refused() {
603        assert!(positive("weight", -1).is_err());
604        assert_eq!(positive("weight", 0).unwrap(), 0);
605        assert_eq!(positive("weight", 7).unwrap(), 7);
606    }
607
608    #[test]
609    fn oversized_integers_are_refused_before_they_wrap() {
610        assert!(storable("timeout_secs", u32::MAX).is_err());
611        assert_eq!(storable("timeout_secs", 30).unwrap(), 30);
612        assert_eq!(storable("timeout_secs", i32::MAX as u32).unwrap(), i32::MAX);
613    }
614}