Skip to main content

postrust_proxy/config/
types.rs

1//! Configuration types for the proxy.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use uuid::Uuid;
6
7/// Main proxy configuration.
8#[derive(Clone, Debug, Serialize, Deserialize, Default)]
9pub struct ProxyConfig {
10    /// Server settings
11    #[serde(default)]
12    pub server: ServerConfig,
13
14    /// TLS/ACME settings
15    #[serde(default)]
16    pub tls: TlsConfig,
17
18    /// Default rate limiting settings
19    #[serde(default)]
20    pub rate_limit: RateLimitDefaults,
21
22    /// Routes (for file-based config)
23    #[serde(default)]
24    pub routes: Vec<Route>,
25
26    /// Upstreams (for file-based config)
27    #[serde(default)]
28    pub upstreams: Vec<Upstream>,
29}
30
31/// Server configuration.
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct ServerConfig {
34    /// Listen address for HTTP
35    #[serde(default = "default_http_host")]
36    pub http_host: String,
37
38    /// Listen port for HTTP
39    #[serde(default = "default_http_port")]
40    pub http_port: u16,
41
42    /// Dedicated HTTP/2-only listen port.
43    ///
44    /// Optional and additive: the main port keeps serving HTTP/1.1 and h2c by
45    /// sniffing. That sniffing is why a corrupted HTTP/2 preface gets an
46    /// HTTP/1 400 rather than GOAWAY(PROTOCOL_ERROR) -- it is indistinguishable
47    /// from a malformed HTTP/1 request. A port that only ever speaks HTTP/2 has
48    /// no such ambiguity.
49    #[serde(default)]
50    pub http2_port: Option<u16>,
51
52    /// Listen address for HTTPS
53    #[serde(default = "default_https_host")]
54    pub https_host: String,
55
56    /// Listen port for HTTPS
57    #[serde(default = "default_https_port")]
58    pub https_port: u16,
59
60    /// Enable HTTPS listener
61    #[serde(default)]
62    pub https_enabled: bool,
63
64    /// Database-backed config enabled
65    #[serde(default = "default_true")]
66    pub database_config: bool,
67
68    /// Config file path (for file-based bootstrap)
69    pub config_file: Option<String>,
70
71    /// Enable file watcher for hot-reload
72    #[serde(default)]
73    pub watch_config_file: bool,
74}
75
76impl Default for ServerConfig {
77    fn default() -> Self {
78        Self {
79            http_host: default_http_host(),
80            http_port: default_http_port(),
81            http2_port: None,
82            https_host: default_https_host(),
83            https_port: default_https_port(),
84            https_enabled: false,
85            database_config: true,
86            config_file: None,
87            watch_config_file: false,
88        }
89    }
90}
91
92/// TLS configuration.
93#[derive(Clone, Debug, Serialize, Deserialize)]
94pub struct TlsConfig {
95    /// Enable ACME (Let's Encrypt)
96    #[serde(default)]
97    pub acme_enabled: bool,
98
99    /// ACME directory URL
100    #[serde(default = "default_acme_directory")]
101    pub acme_directory: String,
102
103    /// ACME contact email
104    pub acme_email: Option<String>,
105
106    /// Certificate storage directory
107    #[serde(default = "default_cert_dir")]
108    pub cert_dir: String,
109
110    /// Use staging ACME server
111    #[serde(default)]
112    pub acme_staging: bool,
113
114    /// PEM certificate chain for the HTTPS listener.
115    ///
116    /// Set this and `key_file` to serve TLS from a certificate on disk. Without
117    /// them there is no HTTPS listener at all, which means HTTP/2 is only
118    /// reachable as cleartext h2c and WebSocket only as `ws://`.
119    #[serde(default)]
120    pub cert_file: Option<String>,
121
122    /// PEM private key matching `cert_file`.
123    #[serde(default)]
124    pub key_file: Option<String>,
125}
126
127impl Default for TlsConfig {
128    fn default() -> Self {
129        Self {
130            acme_enabled: false,
131            acme_directory: default_acme_directory(),
132            acme_email: None,
133            cert_dir: default_cert_dir(),
134            acme_staging: false,
135            cert_file: None,
136            key_file: None,
137        }
138    }
139}
140
141/// Default rate limiting settings.
142#[derive(Clone, Debug, Serialize, Deserialize)]
143pub struct RateLimitDefaults {
144    /// Default requests per window
145    #[serde(default = "default_rate_limit_requests")]
146    pub requests: u32,
147
148    /// Default window size in seconds
149    #[serde(default = "default_rate_limit_window")]
150    pub window_secs: u32,
151
152    /// Burst allowance
153    #[serde(default = "default_burst")]
154    pub burst: u32,
155}
156
157impl Default for RateLimitDefaults {
158    fn default() -> Self {
159        Self {
160            requests: default_rate_limit_requests(),
161            window_secs: default_rate_limit_window(),
162            burst: default_burst(),
163        }
164    }
165}
166
167/// A proxy route configuration.
168#[derive(Clone, Debug, Serialize, Deserialize)]
169pub struct Route {
170    /// Route ID (database)
171    pub id: Option<Uuid>,
172
173    /// Route name
174    pub name: String,
175
176    /// Description
177    pub description: Option<String>,
178
179    /// Matching criteria
180    #[serde(default, rename = "match")]
181    pub match_: RouteMatch,
182
183    /// Priority (higher = matched first)
184    #[serde(default = "default_priority")]
185    pub priority: i32,
186
187    /// Upstream name or ID
188    pub upstream: String,
189
190    /// Strip matched path prefix
191    #[serde(default)]
192    pub strip_path: bool,
193
194    /// Headers to add to proxied requests
195    #[serde(default)]
196    pub add_headers: HashMap<String, String>,
197
198    /// Headers to remove from proxied requests
199    #[serde(default)]
200    pub remove_headers: Vec<String>,
201
202    /// Rate limiting for this route
203    pub rate_limit: Option<RouteRateLimit>,
204
205    /// Request timeout in seconds
206    #[serde(default = "default_timeout")]
207    pub timeout_secs: u32,
208
209    /// Retry count on failure
210    #[serde(default)]
211    pub retry_count: u32,
212
213    /// Route enabled
214    #[serde(default = "default_true")]
215    pub enabled: bool,
216}
217
218/// Route matching criteria.
219#[derive(Clone, Debug, Default, Serialize, Deserialize)]
220pub struct RouteMatch {
221    /// Host pattern (supports wildcards)
222    pub host: Option<String>,
223
224    /// Path pattern
225    pub path: Option<String>,
226
227    /// Path matching type
228    #[serde(default)]
229    pub path_type: PathMatchType,
230
231    /// Headers to match
232    #[serde(default)]
233    pub headers: HashMap<String, String>,
234
235    /// HTTP methods to match
236    pub methods: Option<Vec<String>>,
237}
238
239/// Path matching type.
240#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
241#[serde(rename_all = "lowercase")]
242pub enum PathMatchType {
243    /// Prefix matching (default)
244    #[default]
245    Prefix,
246    /// Exact matching
247    Exact,
248    /// Regex matching
249    Regex,
250}
251
252/// Per-route rate limiting.
253#[derive(Clone, Debug, Serialize, Deserialize)]
254pub struct RouteRateLimit {
255    /// Requests per window
256    pub requests: u32,
257    /// Window size in seconds
258    pub window_secs: u32,
259    /// Rate limit key
260    #[serde(default)]
261    pub key: RateLimitKey,
262}
263
264/// Rate limit key type.
265#[derive(Clone, Debug, Default, Serialize, Deserialize)]
266#[serde(rename_all = "snake_case")]
267pub enum RateLimitKey {
268    /// Rate limit by client IP
269    #[default]
270    ClientIp,
271    /// Rate limit by header value
272    Header(String),
273    /// Rate limit by route (global for route)
274    Route,
275}
276
277/// An upstream (group of backend servers).
278#[derive(Clone, Debug, Serialize, Deserialize)]
279pub struct Upstream {
280    /// Upstream ID (database)
281    pub id: Option<Uuid>,
282
283    /// Upstream name
284    pub name: String,
285
286    /// Description
287    pub description: Option<String>,
288
289    /// Load balancing strategy
290    #[serde(default)]
291    pub lb_strategy: LoadBalanceStrategy,
292
293    /// Backend servers
294    #[serde(default)]
295    pub backends: Vec<Backend>,
296
297    /// Health check configuration
298    #[serde(default)]
299    pub health_check: HealthCheckConfig,
300
301    /// Upstream enabled
302    #[serde(default = "default_true")]
303    pub enabled: bool,
304}
305
306impl Upstream {
307    /// The identifier this upstream is keyed by in the routing tables.
308    ///
309    /// `id` is only set for upstreams loaded from the database. A TOML config
310    /// leaves it `None`, and both registration sites used to skip any upstream
311    /// without one -- so a file-configured proxy logged "Loaded N routes and N
312    /// upstreams" and then answered every request with 503 "Upstream not
313    /// found". Names are unique within a config, so deriving a stable UUID
314    /// from the name is enough to key the tables consistently.
315    pub fn resolved_id(&self) -> Uuid {
316        self.id
317            .unwrap_or_else(|| Uuid::new_v5(&Uuid::NAMESPACE_OID, self.name.as_bytes()))
318    }
319}
320
321/// Load balancing strategy.
322#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
323#[serde(rename_all = "snake_case")]
324pub enum LoadBalanceStrategy {
325    /// Round-robin (default)
326    #[default]
327    RoundRobin,
328    /// Least connections
329    LeastConnections,
330    /// Weighted
331    Weighted,
332    /// Random
333    Random,
334    /// Sticky (cookie-based)
335    Sticky,
336}
337
338/// A backend server.
339#[derive(Clone, Debug, Serialize, Deserialize)]
340pub struct Backend {
341    /// Backend ID (database)
342    pub id: Option<Uuid>,
343
344    /// Server address (host:port)
345    pub address: String,
346
347    /// HTTP or HTTPS
348    #[serde(default = "default_scheme")]
349    pub scheme: String,
350
351    /// Weight for weighted load balancing
352    #[serde(default = "default_weight")]
353    pub weight: u32,
354
355    /// Backend enabled
356    #[serde(default = "default_true")]
357    pub enabled: bool,
358
359    /// Protocol to speak on the upstream connection.
360    ///
361    /// Defaults to HTTP/1.1 regardless of how the client arrived, because
362    /// HTTP/2 is a per-hop protocol. Set `http_version = "h2c"` for a backend
363    /// that speaks cleartext HTTP/2 with prior knowledge. There is no
364    /// negotiation: h2c has no ALPN to fall back on, so this has to be declared.
365    #[serde(default)]
366    pub http_version: UpstreamHttpVersion,
367}
368
369/// Which HTTP version to speak to an upstream.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
371#[serde(rename_all = "lowercase")]
372pub enum UpstreamHttpVersion {
373    /// HTTP/1.1. The default, and correct for almost every backend.
374    #[default]
375    #[serde(alias = "http1", alias = "http/1.1")]
376    Http11,
377    /// Cleartext HTTP/2 with prior knowledge.
378    #[serde(alias = "http2", alias = "h2")]
379    H2c,
380}
381
382/// Health check configuration.
383#[derive(Clone, Debug, Serialize, Deserialize)]
384pub struct HealthCheckConfig {
385    /// Health check enabled
386    #[serde(default = "default_true")]
387    pub enabled: bool,
388
389    /// Health check path
390    #[serde(default = "default_health_path")]
391    pub path: String,
392
393    /// Check interval in seconds
394    #[serde(default = "default_health_interval")]
395    pub interval_secs: u32,
396
397    /// Check timeout in seconds
398    #[serde(default = "default_health_timeout")]
399    pub timeout_secs: u32,
400
401    /// Healthy threshold (consecutive successes)
402    #[serde(default = "default_healthy_threshold")]
403    pub healthy_threshold: u32,
404
405    /// Unhealthy threshold (consecutive failures)
406    #[serde(default = "default_unhealthy_threshold")]
407    pub unhealthy_threshold: u32,
408}
409
410impl Default for HealthCheckConfig {
411    fn default() -> Self {
412        Self {
413            enabled: true,
414            path: default_health_path(),
415            interval_secs: default_health_interval(),
416            timeout_secs: default_health_timeout(),
417            healthy_threshold: default_healthy_threshold(),
418            unhealthy_threshold: default_unhealthy_threshold(),
419        }
420    }
421}
422
423// Default value functions
424fn default_http_host() -> String {
425    "0.0.0.0".into()
426}
427fn default_http_port() -> u16 {
428    8080
429}
430fn default_https_host() -> String {
431    "0.0.0.0".into()
432}
433fn default_https_port() -> u16 {
434    8443
435}
436fn default_true() -> bool {
437    true
438}
439fn default_acme_directory() -> String {
440    "https://acme-v02.api.letsencrypt.org/directory".into()
441}
442fn default_cert_dir() -> String {
443    "./certs".into()
444}
445fn default_rate_limit_requests() -> u32 {
446    1000
447}
448fn default_rate_limit_window() -> u32 {
449    60
450}
451fn default_burst() -> u32 {
452    50
453}
454fn default_priority() -> i32 {
455    100
456}
457fn default_timeout() -> u32 {
458    30
459}
460fn default_scheme() -> String {
461    "http".into()
462}
463fn default_weight() -> u32 {
464    100
465}
466fn default_health_path() -> String {
467    "/health".into()
468}
469fn default_health_interval() -> u32 {
470    10
471}
472fn default_health_timeout() -> u32 {
473    5
474}
475fn default_healthy_threshold() -> u32 {
476    2
477}
478fn default_unhealthy_threshold() -> u32 {
479    3
480}
481
482/// ACME configuration for automatic certificate management.
483#[derive(Clone, Debug, Serialize, Deserialize, Default)]
484pub struct AcmeConfig {
485    /// ACME enabled
486    #[serde(default)]
487    pub enabled: bool,
488
489    /// Contact email
490    pub email: Option<String>,
491
492    /// Use staging server
493    #[serde(default)]
494    pub staging: bool,
495
496    /// Domains to request certificates for
497    #[serde(default)]
498    pub domains: Vec<String>,
499}
500
501#[cfg(test)]
502mod upstream_version_tests {
503    use super::*;
504
505    #[test]
506    fn test_backend_defaults_to_http11() {
507        // HTTP/2 is per-hop: a backend says nothing, it gets HTTP/1.1.
508        let backend: Backend = toml::from_str(r#"address = "127.0.0.1:8080""#).unwrap();
509        assert_eq!(backend.http_version, UpstreamHttpVersion::Http11);
510    }
511
512    #[test]
513    fn test_backend_h2c_aliases() {
514        // h2c has no ALPN, so it must be declared; accept the spellings someone
515        // would reasonably reach for.
516        for spelling in ["h2c", "h2", "http2"] {
517            let toml_src = format!("address = \"127.0.0.1:8080\"\nhttp_version = \"{spelling}\"");
518            let backend: Backend = toml::from_str(&toml_src).unwrap();
519            assert_eq!(
520                backend.http_version,
521                UpstreamHttpVersion::H2c,
522                "spelling {spelling} should select h2c"
523            );
524        }
525    }
526
527    #[test]
528    fn test_backend_http11_aliases() {
529        for spelling in ["http11", "http1"] {
530            let toml_src = format!("address = \"127.0.0.1:8080\"\nhttp_version = \"{spelling}\"");
531            let backend: Backend = toml::from_str(&toml_src).unwrap();
532            assert_eq!(backend.http_version, UpstreamHttpVersion::Http11);
533        }
534    }
535
536    #[test]
537    fn test_http2_port_is_optional_and_off_by_default() {
538        let server: ServerConfig = toml::from_str("").unwrap();
539        assert_eq!(server.http2_port, None);
540
541        let server: ServerConfig = toml::from_str("http2_port = 19081").unwrap();
542        assert_eq!(server.http2_port, Some(19081));
543    }
544}