Skip to main content

postrust_proxy/config/
types.rs

1//! Configuration types for the proxy.
2
3use http::HeaderMap;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::sync::{LazyLock, RwLock};
8use uuid::Uuid;
9
10/// Main proxy configuration.
11#[derive(Clone, Debug, Serialize, Deserialize, Default)]
12pub struct ProxyConfig {
13    /// Server settings
14    #[serde(default)]
15    pub server: ServerConfig,
16
17    /// TLS/ACME settings
18    #[serde(default)]
19    pub tls: TlsConfig,
20
21    /// Default rate limiting settings
22    #[serde(default)]
23    pub rate_limit: RateLimitDefaults,
24
25    /// Routes (for file-based config)
26    #[serde(default)]
27    pub routes: Vec<Route>,
28
29    /// Upstreams (for file-based config)
30    #[serde(default)]
31    pub upstreams: Vec<Upstream>,
32}
33
34/// Server configuration.
35#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct ServerConfig {
37    /// Listen address for HTTP
38    #[serde(default = "default_http_host")]
39    pub http_host: String,
40
41    /// Listen port for HTTP
42    #[serde(default = "default_http_port")]
43    pub http_port: u16,
44
45    /// Dedicated HTTP/2-only listen port.
46    ///
47    /// Optional and additive: the main port keeps serving HTTP/1.1 and h2c by
48    /// sniffing. That sniffing is why a corrupted HTTP/2 preface gets an
49    /// HTTP/1 400 rather than GOAWAY(PROTOCOL_ERROR) -- it is indistinguishable
50    /// from a malformed HTTP/1 request. A port that only ever speaks HTTP/2 has
51    /// no such ambiguity.
52    #[serde(default)]
53    pub http2_port: Option<u16>,
54
55    /// Listen address for HTTPS
56    #[serde(default = "default_https_host")]
57    pub https_host: String,
58
59    /// Listen port for HTTPS
60    #[serde(default = "default_https_port")]
61    pub https_port: u16,
62
63    /// Enable HTTPS listener
64    #[serde(default)]
65    pub https_enabled: bool,
66
67    /// Database-backed config enabled
68    #[serde(default = "default_true")]
69    pub database_config: bool,
70
71    /// Config file path (for file-based bootstrap)
72    pub config_file: Option<String>,
73}
74
75impl Default for ServerConfig {
76    fn default() -> Self {
77        Self {
78            http_host: default_http_host(),
79            http_port: default_http_port(),
80            http2_port: None,
81            https_host: default_https_host(),
82            https_port: default_https_port(),
83            https_enabled: false,
84            database_config: true,
85            config_file: None,
86        }
87    }
88}
89
90/// TLS configuration.
91#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct TlsConfig {
93    /// Enable ACME (Let's Encrypt)
94    #[serde(default)]
95    pub acme_enabled: bool,
96
97    /// ACME directory URL
98    #[serde(default = "default_acme_directory")]
99    pub acme_directory: String,
100
101    /// ACME contact email
102    pub acme_email: Option<String>,
103
104    /// Certificate storage directory
105    #[serde(default = "default_cert_dir")]
106    pub cert_dir: String,
107
108    /// Use staging ACME server
109    #[serde(default)]
110    pub acme_staging: bool,
111
112    /// PEM certificate chain for the HTTPS listener.
113    ///
114    /// Set this and `key_file` to serve TLS from a certificate on disk. Without
115    /// them there is no HTTPS listener at all, which means HTTP/2 is only
116    /// reachable as cleartext h2c and WebSocket only as `ws://`.
117    #[serde(default)]
118    pub cert_file: Option<String>,
119
120    /// PEM private key matching `cert_file`.
121    #[serde(default)]
122    pub key_file: Option<String>,
123
124    /// ACME directory URL.
125    ///
126    /// Defaults to Let's Encrypt production. `acme_staging = true` switches to
127    /// staging, which issues untrusted certificates against far looser rate
128    /// limits -- use it until the whole flow works, because production's limits
129    /// are per-domain-per-week and are easy to exhaust while debugging.
130    #[serde(default)]
131    pub acme_directory_url: Option<String>,
132
133    /// A PEM root to trust for the ACME directory's own TLS.
134    ///
135    /// Only for a CA with a private root, which in practice means a test CA
136    /// such as Pebble. Never needed for Let's Encrypt.
137    #[serde(default)]
138    pub acme_root_pem: Option<String>,
139}
140
141impl Default for TlsConfig {
142    fn default() -> Self {
143        Self {
144            acme_enabled: false,
145            acme_directory: default_acme_directory(),
146            acme_email: None,
147            cert_dir: default_cert_dir(),
148            acme_staging: false,
149            cert_file: None,
150            key_file: None,
151            acme_directory_url: None,
152            acme_root_pem: None,
153        }
154    }
155}
156
157/// Default rate limiting settings.
158#[derive(Clone, Debug, Serialize, Deserialize)]
159pub struct RateLimitDefaults {
160    /// Default requests per window
161    #[serde(default = "default_rate_limit_requests")]
162    pub requests: u32,
163
164    /// Default window size in seconds
165    #[serde(default = "default_rate_limit_window")]
166    pub window_secs: u32,
167
168    /// Burst allowance
169    #[serde(default = "default_burst")]
170    pub burst: u32,
171}
172
173impl Default for RateLimitDefaults {
174    fn default() -> Self {
175        Self {
176            requests: default_rate_limit_requests(),
177            window_secs: default_rate_limit_window(),
178            burst: default_burst(),
179        }
180    }
181}
182
183/// A proxy route configuration.
184#[derive(Clone, Debug, Serialize, Deserialize)]
185pub struct Route {
186    /// Route ID (database)
187    pub id: Option<Uuid>,
188
189    /// Route name
190    pub name: String,
191
192    /// Description
193    pub description: Option<String>,
194
195    /// Matching criteria
196    #[serde(default, rename = "match")]
197    pub match_: RouteMatch,
198
199    /// Priority (higher = matched first)
200    #[serde(default = "default_priority")]
201    pub priority: i32,
202
203    /// Upstream name or ID
204    pub upstream: String,
205
206    /// Strip matched path prefix
207    #[serde(default)]
208    pub strip_path: bool,
209
210    /// Headers to add to proxied requests
211    #[serde(default)]
212    pub add_headers: HashMap<String, String>,
213
214    /// Headers to remove from proxied requests
215    #[serde(default)]
216    pub remove_headers: Vec<String>,
217
218    /// Rate limiting for this route
219    pub rate_limit: Option<RouteRateLimit>,
220
221    /// Request timeout in seconds
222    #[serde(default = "default_timeout")]
223    pub timeout_secs: u32,
224
225    /// Retry count on failure
226    #[serde(default)]
227    pub retry_count: u32,
228
229    /// Route enabled
230    #[serde(default = "default_true")]
231    pub enabled: bool,
232}
233
234/// Route matching criteria.
235#[derive(Clone, Debug, Default, Serialize, Deserialize)]
236pub struct RouteMatch {
237    /// Host pattern (supports wildcards)
238    pub host: Option<String>,
239
240    /// Path pattern
241    pub path: Option<String>,
242
243    /// Path matching type
244    #[serde(default)]
245    pub path_type: PathMatchType,
246
247    /// Headers to match
248    #[serde(default)]
249    pub headers: HashMap<String, String>,
250
251    /// HTTP methods to match
252    pub methods: Option<Vec<String>>,
253}
254
255/// Compiled route regexes, keyed by their pattern.
256///
257/// `Regex::new` is not cheap, and route matching runs on every request against
258/// every candidate route -- compiling inside the match, as this first did, put a
259/// regex compilation on the hot path for each regex route a request was
260/// compared against.
261///
262/// The cache is unbounded, which is safe because the keys come from the
263/// configuration: routes are loaded from a TOML file or the proxy's own tables,
264/// both of which an operator controls, and the set changes only when the
265/// configuration does.
266///
267/// A pattern that does not compile is cached as `None`, so a typo costs one
268/// failed compilation rather than one per request.
269static REGEX_CACHE: LazyLock<RwLock<HashMap<String, Option<Regex>>>> =
270    LazyLock::new(|| RwLock::new(HashMap::new()));
271
272/// The compiled form of `pattern`, or `None` if it does not compile.
273///
274/// Warns once per pattern rather than once per request.
275fn compiled_regex(pattern: &str) -> Option<Regex> {
276    if let Ok(cache) = REGEX_CACHE.read() {
277        if let Some(entry) = cache.get(pattern) {
278            return entry.clone();
279        }
280    }
281
282    let compiled = match Regex::new(pattern) {
283        Ok(re) => Some(re),
284        Err(error) => {
285            tracing::warn!(
286                %pattern, %error,
287                "route path regex does not compile; the route matches nothing"
288            );
289            None
290        }
291    };
292
293    if let Ok(mut cache) = REGEX_CACHE.write() {
294        cache.insert(pattern.to_owned(), compiled.clone());
295    }
296    compiled
297}
298
299impl RouteMatch {
300    /// Whether a request matches these criteria.
301    ///
302    /// Every field that is set has to match; a field left unset matches
303    /// anything. That is the only reading under which adding a criterion can
304    /// never widen a route.
305    ///
306    /// `path_type`, `methods` and `headers` were all declarable and all ignored
307    /// before this existed -- the route filter compared host and path prefix and
308    /// nothing else. Each of those silent no-ops widened a route past what its
309    /// author wrote: `path_type = "exact"` on `/health` also matched
310    /// `/health-internal`, and a route restricted to `methods = ["GET"]`
311    /// accepted `DELETE`.
312    pub fn matches(&self, host: &str, path: &str, method: &str, headers: &HeaderMap) -> bool {
313        self.host_matches(host)
314            && self.path_matches(path)
315            && self.method_matches(method)
316            && self.headers_match(headers)
317    }
318
319    /// Host, exactly or by a leading `*.` wildcard.
320    ///
321    /// `*` alone means any host, which is what an absent host also means.
322    fn host_matches(&self, host: &str) -> bool {
323        let Some(pattern) = self.host.as_deref() else {
324            return true;
325        };
326        let pattern = pattern.trim().to_ascii_lowercase();
327        let host = host.trim().to_ascii_lowercase();
328
329        if pattern == "*" || pattern.is_empty() {
330            return true;
331        }
332        if pattern == host {
333            return true;
334        }
335        // `*.example.com` covers one label, not the apex and not two labels --
336        // the same rule certificates use.
337        match pattern.strip_prefix("*.") {
338            Some(suffix) if !suffix.is_empty() => host
339                .strip_suffix(&format!(".{suffix}"))
340                .is_some_and(|label| !label.is_empty() && !label.contains('.')),
341            _ => false,
342        }
343    }
344
345    /// Path, by the declared [`PathMatchType`].
346    ///
347    /// A regex that does not compile matches nothing, and says so once. The
348    /// alternative -- treating it as a prefix, or matching everything -- turns a
349    /// typo into a route that catches traffic it was never meant to.
350    fn path_matches(&self, path: &str) -> bool {
351        let Some(pattern) = self.path.as_deref() else {
352            return true;
353        };
354        match self.path_type {
355            PathMatchType::Prefix => path.starts_with(pattern),
356            PathMatchType::Exact => path == pattern,
357            PathMatchType::Regex => match compiled_regex(pattern) {
358                Some(re) => re.is_match(path),
359                None => false,
360            },
361        }
362    }
363
364    /// Method, case-insensitively. An empty list means any.
365    fn method_matches(&self, method: &str) -> bool {
366        match self.methods.as_deref() {
367            None | Some([]) => true,
368            Some(allowed) => allowed.iter().any(|m| m.eq_ignore_ascii_case(method)),
369        }
370    }
371
372    /// Every named header must be present with that value, case-insensitively
373    /// on the name and exactly on the value.
374    fn headers_match(&self, headers: &HeaderMap) -> bool {
375        self.headers.iter().all(|(name, expected)| {
376            headers
377                .get(name.as_str())
378                .and_then(|value| value.to_str().ok())
379                .is_some_and(|value| value == expected)
380        })
381    }
382}
383
384/// Path matching type.
385#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
386#[serde(rename_all = "lowercase")]
387pub enum PathMatchType {
388    /// Prefix matching (default)
389    #[default]
390    Prefix,
391    /// Exact matching
392    Exact,
393    /// Regex matching
394    Regex,
395}
396
397/// Per-route rate limiting.
398#[derive(Clone, Debug, Serialize, Deserialize)]
399pub struct RouteRateLimit {
400    /// Requests per window
401    pub requests: u32,
402    /// Window size in seconds
403    pub window_secs: u32,
404    /// Rate limit key
405    #[serde(default)]
406    pub key: RateLimitKey,
407}
408
409/// Rate limit key type.
410#[derive(Clone, Debug, Default, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum RateLimitKey {
413    /// Rate limit by client IP
414    #[default]
415    ClientIp,
416    /// Rate limit by header value
417    Header(String),
418    /// Rate limit by route (global for route)
419    Route,
420}
421
422/// An upstream (group of backend servers).
423#[derive(Clone, Debug, Serialize, Deserialize)]
424pub struct Upstream {
425    /// Upstream ID (database)
426    pub id: Option<Uuid>,
427
428    /// Upstream name
429    pub name: String,
430
431    /// Description
432    pub description: Option<String>,
433
434    /// Load balancing strategy
435    #[serde(default)]
436    pub lb_strategy: LoadBalanceStrategy,
437
438    /// Backend servers
439    #[serde(default)]
440    pub backends: Vec<Backend>,
441
442    /// Health check configuration
443    #[serde(default)]
444    pub health_check: HealthCheckConfig,
445
446    /// Upstream enabled
447    #[serde(default = "default_true")]
448    pub enabled: bool,
449}
450
451impl Upstream {
452    /// The identifier this upstream is keyed by in the routing tables.
453    ///
454    /// `id` is only set for upstreams loaded from the database. A TOML config
455    /// leaves it `None`, and both registration sites used to skip any upstream
456    /// without one -- so a file-configured proxy logged "Loaded N routes and N
457    /// upstreams" and then answered every request with 503 "Upstream not
458    /// found". Names are unique within a config, so deriving a stable UUID
459    /// from the name is enough to key the tables consistently.
460    pub fn resolved_id(&self) -> Uuid {
461        self.id
462            .unwrap_or_else(|| Uuid::new_v5(&Uuid::NAMESPACE_OID, self.name.as_bytes()))
463    }
464}
465
466/// Load balancing strategy.
467#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
468#[serde(rename_all = "snake_case")]
469pub enum LoadBalanceStrategy {
470    /// Round-robin (default)
471    #[default]
472    RoundRobin,
473    /// Least connections
474    LeastConnections,
475    /// Weighted
476    Weighted,
477    /// Random
478    Random,
479    /// Sticky (cookie-based)
480    Sticky,
481}
482
483/// A backend server.
484#[derive(Clone, Debug, Serialize, Deserialize)]
485pub struct Backend {
486    /// Backend ID (database)
487    pub id: Option<Uuid>,
488
489    /// Server address (host:port)
490    pub address: String,
491
492    /// HTTP or HTTPS
493    #[serde(default = "default_scheme")]
494    pub scheme: String,
495
496    /// Weight for weighted load balancing
497    #[serde(default = "default_weight")]
498    pub weight: u32,
499
500    /// Backend enabled
501    #[serde(default = "default_true")]
502    pub enabled: bool,
503
504    /// Protocol to speak on the upstream connection.
505    ///
506    /// Defaults to HTTP/1.1 regardless of how the client arrived, because
507    /// HTTP/2 is a per-hop protocol. Set `http_version = "h2c"` for a backend
508    /// that speaks cleartext HTTP/2 with prior knowledge. There is no
509    /// negotiation: h2c has no ALPN to fall back on, so this has to be declared.
510    #[serde(default)]
511    pub http_version: UpstreamHttpVersion,
512}
513
514/// Which HTTP version to speak to an upstream.
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
516#[serde(rename_all = "lowercase")]
517pub enum UpstreamHttpVersion {
518    /// HTTP/1.1. The default, and correct for almost every backend.
519    #[default]
520    #[serde(alias = "http1", alias = "http/1.1")]
521    Http11,
522    /// Cleartext HTTP/2 with prior knowledge.
523    #[serde(alias = "http2", alias = "h2")]
524    H2c,
525}
526
527/// Health check configuration.
528#[derive(Clone, Debug, Serialize, Deserialize)]
529pub struct HealthCheckConfig {
530    /// Health check enabled
531    #[serde(default = "default_true")]
532    pub enabled: bool,
533
534    /// Health check path
535    #[serde(default = "default_health_path")]
536    pub path: String,
537
538    /// Check interval in seconds
539    #[serde(default = "default_health_interval")]
540    pub interval_secs: u32,
541
542    /// Check timeout in seconds
543    #[serde(default = "default_health_timeout")]
544    pub timeout_secs: u32,
545
546    /// Healthy threshold (consecutive successes)
547    #[serde(default = "default_healthy_threshold")]
548    pub healthy_threshold: u32,
549
550    /// Unhealthy threshold (consecutive failures)
551    #[serde(default = "default_unhealthy_threshold")]
552    pub unhealthy_threshold: u32,
553}
554
555impl Default for HealthCheckConfig {
556    fn default() -> Self {
557        Self {
558            enabled: true,
559            path: default_health_path(),
560            interval_secs: default_health_interval(),
561            timeout_secs: default_health_timeout(),
562            healthy_threshold: default_healthy_threshold(),
563            unhealthy_threshold: default_unhealthy_threshold(),
564        }
565    }
566}
567
568// Default value functions
569fn default_http_host() -> String {
570    "0.0.0.0".into()
571}
572fn default_http_port() -> u16 {
573    8080
574}
575fn default_https_host() -> String {
576    "0.0.0.0".into()
577}
578fn default_https_port() -> u16 {
579    8443
580}
581fn default_true() -> bool {
582    true
583}
584fn default_acme_directory() -> String {
585    "https://acme-v02.api.letsencrypt.org/directory".into()
586}
587fn default_cert_dir() -> String {
588    "./certs".into()
589}
590fn default_rate_limit_requests() -> u32 {
591    1000
592}
593fn default_rate_limit_window() -> u32 {
594    60
595}
596fn default_burst() -> u32 {
597    50
598}
599fn default_priority() -> i32 {
600    100
601}
602fn default_timeout() -> u32 {
603    30
604}
605fn default_scheme() -> String {
606    "http".into()
607}
608fn default_weight() -> u32 {
609    100
610}
611fn default_health_path() -> String {
612    "/health".into()
613}
614fn default_health_interval() -> u32 {
615    10
616}
617fn default_health_timeout() -> u32 {
618    5
619}
620fn default_healthy_threshold() -> u32 {
621    2
622}
623fn default_unhealthy_threshold() -> u32 {
624    3
625}
626
627/// ACME configuration for automatic certificate management.
628#[derive(Clone, Debug, Serialize, Deserialize, Default)]
629pub struct AcmeConfig {
630    /// ACME enabled
631    #[serde(default)]
632    pub enabled: bool,
633
634    /// Contact email
635    pub email: Option<String>,
636
637    /// Use staging server
638    #[serde(default)]
639    pub staging: bool,
640
641    /// Domains to request certificates for
642    #[serde(default)]
643    pub domains: Vec<String>,
644}
645
646#[cfg(test)]
647mod upstream_version_tests {
648    use super::*;
649
650    fn header_map(pairs: &[(&str, &str)]) -> HeaderMap {
651        let mut map = HeaderMap::new();
652        for (name, value) in pairs {
653            map.insert(
654                http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
655                http::HeaderValue::from_str(value).unwrap(),
656            );
657        }
658        map
659    }
660
661    /// A match with nothing set, so each test can set only what it is about.
662    fn any() -> RouteMatch {
663        RouteMatch::default()
664    }
665
666    #[test]
667    fn an_empty_match_matches_anything() {
668        let m = any();
669        assert!(m.matches("example.com", "/anything", "GET", &HeaderMap::new()));
670        assert!(m.matches("", "/", "DELETE", &HeaderMap::new()));
671    }
672
673    #[test]
674    fn a_prefix_path_matches_by_prefix() {
675        let m = RouteMatch {
676            path: Some("/v1".into()),
677            ..any()
678        };
679        assert!(m.matches("h", "/v1", "GET", &HeaderMap::new()));
680        assert!(m.matches("h", "/v1/users", "GET", &HeaderMap::new()));
681        assert!(!m.matches("h", "/v2", "GET", &HeaderMap::new()));
682    }
683
684    #[test]
685    fn an_exact_path_does_not_match_a_longer_one() {
686        // The widening this guards against: before `path_type` was honoured,
687        // an "exact" route on /health also caught /health-internal.
688        let m = RouteMatch {
689            path: Some("/health".into()),
690            path_type: PathMatchType::Exact,
691            ..any()
692        };
693        assert!(m.matches("h", "/health", "GET", &HeaderMap::new()));
694        assert!(!m.matches("h", "/health-internal", "GET", &HeaderMap::new()));
695        assert!(!m.matches("h", "/health/deep", "GET", &HeaderMap::new()));
696    }
697
698    #[test]
699    fn a_regex_path_matches_the_pattern() {
700        let m = RouteMatch {
701            path: Some(r"^/v[0-9]+/users$".into()),
702            path_type: PathMatchType::Regex,
703            ..any()
704        };
705        assert!(m.matches("h", "/v1/users", "GET", &HeaderMap::new()));
706        assert!(m.matches("h", "/v22/users", "GET", &HeaderMap::new()));
707        assert!(!m.matches("h", "/v1/users/1", "GET", &HeaderMap::new()));
708        assert!(!m.matches("h", "/users", "GET", &HeaderMap::new()));
709    }
710
711    #[test]
712    fn a_regex_is_compiled_once_and_reused() {
713        // The bug: `Regex::new` inside the match put a compilation on the hot
714        // path for every request compared against a regex route. Asserted via
715        // the cache rather than by timing, which would be flaky.
716        let pattern = format!("^/cache-probe-{}/[0-9]+$", uuid::Uuid::new_v4());
717        let m = RouteMatch {
718            path: Some(pattern.clone()),
719            path_type: PathMatchType::Regex,
720            ..any()
721        };
722
723        assert!(!REGEX_CACHE.read().unwrap().contains_key(&pattern));
724        assert!(m.matches("h", "/nope", "GET", &HeaderMap::new()) || true);
725        assert!(
726            REGEX_CACHE.read().unwrap().contains_key(&pattern),
727            "the compiled regex was not cached"
728        );
729
730        // And the cached one still matches correctly.
731        let path = pattern
732            .trim_start_matches('^')
733            .trim_end_matches("/[0-9]+$")
734            .to_string();
735        assert!(m.matches("h", &format!("{path}/42"), "GET", &HeaderMap::new()));
736    }
737
738    #[test]
739    fn a_broken_regex_is_cached_as_a_failure() {
740        // So a typo costs one failed compilation, not one per request.
741        let pattern = format!("/broken-{}/(unclosed", uuid::Uuid::new_v4());
742        let m = RouteMatch {
743            path: Some(pattern.clone()),
744            path_type: PathMatchType::Regex,
745            ..any()
746        };
747        assert!(!m.matches("h", "/anything", "GET", &HeaderMap::new()));
748        let cache = REGEX_CACHE.read().unwrap();
749        let entry = cache
750            .get(&pattern)
751            .expect("a pattern that does not compile should still be cached");
752        assert!(
753            entry.is_none(),
754            "it should be cached as a failure, not as a compiled regex"
755        );
756    }
757
758    #[test]
759    fn a_regex_that_does_not_compile_matches_nothing() {
760        // Not "everything", and not "treat it as a prefix". Either would turn a
761        // typo into a route that catches traffic it was never meant to.
762        let m = RouteMatch {
763            path: Some("/v1/(unclosed".into()),
764            path_type: PathMatchType::Regex,
765            ..any()
766        };
767        assert!(!m.matches("h", "/v1/anything", "GET", &HeaderMap::new()));
768        assert!(!m.matches("h", "/v1/(unclosed", "GET", &HeaderMap::new()));
769    }
770
771    #[test]
772    fn methods_are_honoured_case_insensitively() {
773        let m = RouteMatch {
774            methods: Some(vec!["GET".into(), "head".into()]),
775            ..any()
776        };
777        assert!(m.matches("h", "/", "GET", &HeaderMap::new()));
778        assert!(m.matches("h", "/", "get", &HeaderMap::new()));
779        assert!(m.matches("h", "/", "HEAD", &HeaderMap::new()));
780        // The widening this guards against: a read-only route accepting writes.
781        assert!(!m.matches("h", "/", "DELETE", &HeaderMap::new()));
782        assert!(!m.matches("h", "/", "POST", &HeaderMap::new()));
783    }
784
785    #[test]
786    fn an_empty_method_list_means_any() {
787        let m = RouteMatch {
788            methods: Some(vec![]),
789            ..any()
790        };
791        assert!(m.matches("h", "/", "PATCH", &HeaderMap::new()));
792    }
793
794    #[test]
795    fn every_named_header_must_be_present_with_that_value() {
796        let m = RouteMatch {
797            headers: HashMap::from([
798                ("x-tenant".to_string(), "acme".to_string()),
799                ("x-env".to_string(), "prod".to_string()),
800            ]),
801            ..any()
802        };
803
804        assert!(m.matches(
805            "h",
806            "/",
807            "GET",
808            &header_map(&[("x-tenant", "acme"), ("x-env", "prod")])
809        ));
810        // All of them, not any of them.
811        assert!(!m.matches("h", "/", "GET", &header_map(&[("x-tenant", "acme")])));
812        // The value has to match too.
813        assert!(!m.matches(
814            "h",
815            "/",
816            "GET",
817            &header_map(&[("x-tenant", "other"), ("x-env", "prod")])
818        ));
819        assert!(!m.matches("h", "/", "GET", &HeaderMap::new()));
820    }
821
822    #[test]
823    fn header_names_are_case_insensitive() {
824        let m = RouteMatch {
825            headers: HashMap::from([("X-Tenant".to_string(), "acme".to_string())]),
826            ..any()
827        };
828        assert!(m.matches("h", "/", "GET", &header_map(&[("x-tenant", "acme")])));
829    }
830
831    #[test]
832    fn a_host_matches_exactly_or_by_a_single_label_wildcard() {
833        let exact = RouteMatch {
834            host: Some("api.example.com".into()),
835            ..any()
836        };
837        assert!(exact.matches("api.example.com", "/", "GET", &HeaderMap::new()));
838        assert!(!exact.matches("www.example.com", "/", "GET", &HeaderMap::new()));
839
840        let wild = RouteMatch {
841            host: Some("*.example.com".into()),
842            ..any()
843        };
844        assert!(wild.matches("api.example.com", "/", "GET", &HeaderMap::new()));
845        // Not the apex, and not two labels.
846        assert!(!wild.matches("example.com", "/", "GET", &HeaderMap::new()));
847        assert!(!wild.matches("a.b.example.com", "/", "GET", &HeaderMap::new()));
848        // And not a suffix match, which would catch an attacker's domain.
849        assert!(!wild.matches("api.example.com.evil.test", "/", "GET", &HeaderMap::new()));
850    }
851
852    #[test]
853    fn a_star_host_matches_anything() {
854        let m = RouteMatch {
855            host: Some("*".into()),
856            ..any()
857        };
858        assert!(m.matches("anything.test", "/", "GET", &HeaderMap::new()));
859        assert!(m.matches("", "/", "GET", &HeaderMap::new()));
860    }
861
862    #[test]
863    fn host_matching_is_case_insensitive() {
864        let m = RouteMatch {
865            host: Some("API.Example.COM".into()),
866            ..any()
867        };
868        assert!(m.matches("api.example.com", "/", "GET", &HeaderMap::new()));
869    }
870
871    #[test]
872    fn every_set_criterion_has_to_hold() {
873        // Adding a criterion must never widen a route.
874        let m = RouteMatch {
875            host: Some("api.example.com".into()),
876            path: Some("/v1".into()),
877            path_type: PathMatchType::Prefix,
878            methods: Some(vec!["POST".into()]),
879            headers: HashMap::from([("x-key".to_string(), "k".to_string())]),
880        };
881        let headers = header_map(&[("x-key", "k")]);
882
883        assert!(m.matches("api.example.com", "/v1/x", "POST", &headers));
884        assert!(!m.matches("other.example.com", "/v1/x", "POST", &headers));
885        assert!(!m.matches("api.example.com", "/v2/x", "POST", &headers));
886        assert!(!m.matches("api.example.com", "/v1/x", "GET", &headers));
887        assert!(!m.matches("api.example.com", "/v1/x", "POST", &HeaderMap::new()));
888    }
889
890    #[test]
891    fn test_backend_defaults_to_http11() {
892        // HTTP/2 is per-hop: a backend says nothing, it gets HTTP/1.1.
893        let backend: Backend = toml::from_str(r#"address = "127.0.0.1:8080""#).unwrap();
894        assert_eq!(backend.http_version, UpstreamHttpVersion::Http11);
895    }
896
897    #[test]
898    fn test_backend_h2c_aliases() {
899        // h2c has no ALPN, so it must be declared; accept the spellings someone
900        // would reasonably reach for.
901        for spelling in ["h2c", "h2", "http2"] {
902            let toml_src = format!("address = \"127.0.0.1:8080\"\nhttp_version = \"{spelling}\"");
903            let backend: Backend = toml::from_str(&toml_src).unwrap();
904            assert_eq!(
905                backend.http_version,
906                UpstreamHttpVersion::H2c,
907                "spelling {spelling} should select h2c"
908            );
909        }
910    }
911
912    #[test]
913    fn test_backend_http11_aliases() {
914        for spelling in ["http11", "http1"] {
915            let toml_src = format!("address = \"127.0.0.1:8080\"\nhttp_version = \"{spelling}\"");
916            let backend: Backend = toml::from_str(&toml_src).unwrap();
917            assert_eq!(backend.http_version, UpstreamHttpVersion::Http11);
918        }
919    }
920
921    #[test]
922    fn test_http2_port_is_optional_and_off_by_default() {
923        let server: ServerConfig = toml::from_str("").unwrap();
924        assert_eq!(server.http2_port, None);
925
926        let server: ServerConfig = toml::from_str("http2_port = 19081").unwrap();
927        assert_eq!(server.http2_port, Some(19081));
928    }
929}