1use 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
34fn 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
94fn 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 ("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
127fn 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
136fn 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
145pub 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
160pub 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
182pub 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
236async 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 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
312pub 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
398pub 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
407pub 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
495pub 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 #[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 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}