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