Skip to main content

zlayer_proxy/
lib.rs

1//! `ZLayer` Reverse Proxy
2//!
3//! This crate provides a high-performance reverse proxy for routing HTTP/HTTPS
4//! traffic to backend services. It supports:
5//!
6//! - Host and path-based routing via `ServiceRegistry`
7//! - Round-robin backend selection
8//! - Health-aware backend selection for L4 streams
9//! - HTTP/1.1 support with upgrade (WebSocket) pass-through
10//! - Forwarding headers (X-Forwarded-For, etc.)
11//! - TLS termination with dynamic SNI certificate selection
12//! - ACME (Let's Encrypt) automatic certificate provisioning
13//! - L4 TCP/UDP stream proxying
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use zlayer_proxy::{ProxyConfig, ProxyServer, ServiceRegistry, RouteEntry};
19//! use std::sync::Arc;
20//!
21//! #[tokio::main]
22//! async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
23//!     let registry = Arc::new(ServiceRegistry::new());
24//!
25//!     // Register HTTP services
26//!     registry.register(RouteEntry { /* ... */ }).await;
27//!
28//!     // Start proxy server
29//!     let lb = Arc::new(LoadBalancer::new());
30//!     let server = ProxyServer::new(ProxyConfig::default(), registry, lb);
31//!     server.run().await?;
32//!
33//!     Ok(())
34//! }
35//! ```
36
37// Core modules
38pub mod cf_ip_list;
39pub mod config;
40pub mod error;
41pub mod network_policy;
42pub mod server;
43pub mod service;
44pub mod tls;
45pub mod trust;
46pub mod tunnel;
47
48// Load balancing and service routing
49pub mod acme;
50pub mod lb;
51pub mod routes;
52pub mod sni_resolver;
53pub mod stream;
54
55use std::path::PathBuf;
56use std::time::Duration;
57
58// Re-export main types
59pub use config::{
60    HeaderConfig, PoolConfig, ProxyConfig, ServerConfig, TimeoutConfig, TlsConfig, TlsVersion,
61};
62pub use error::{ProxyError, Result};
63pub use network_policy::NetworkPolicyChecker;
64pub use server::ProxyServer;
65pub use service::{empty_body, full_body, BoxBody, ReverseProxyService};
66pub use tls::{create_tls_acceptor, TlsServerConfig};
67pub use tunnel::{
68    is_upgrade_request, is_upgrade_response, is_websocket_upgrade, proxy_tunnel, proxy_upgrade,
69};
70
71// Re-export load balancer types
72pub use lb::{
73    Backend, BackendGroup, BackendGroupSnapshot, BackendSnapshot, ConnectionGuard, HealthStatus,
74    LbStrategy, LoadBalancer,
75};
76
77// Re-export service routing types
78pub use acme::{CertManager, CertMetadata};
79pub use routes::{ResolvedService, RouteEntry, ServiceRegistry};
80pub use sni_resolver::SniCertResolver;
81
82// Re-export stream (L4) proxy types
83pub use stream::{
84    BackendHealth as StreamBackendHealth, StreamRegistry, StreamService, TcpListenerConfig,
85    TcpStreamService, UdpListenerConfig, UdpStreamService, DEFAULT_UDP_SESSION_TIMEOUT,
86};
87
88// ============================================================================
89// Proxy Configuration & Certificate Utilities
90// ============================================================================
91
92/// Default UDP session timeout for stream proxying
93fn default_udp_session_timeout() -> Duration {
94    stream::DEFAULT_UDP_SESSION_TIMEOUT
95}
96
97/// Controls whether Cloudflare's published edge IP ranges are treated as
98/// trusted proxies for the purpose of honoring `CF-Connecting-IP` /
99/// `X-Forwarded-For` request headers.
100///
101/// Cloudflare's edge rotates IPs frequently, so hardcoding them is brittle.
102/// `AutoRefresh` periodically re-fetches `https://www.cloudflare.com/ips-v4`
103/// and `…/ips-v6`; `Static` uses a baked-in fallback list only.
104#[derive(Debug, Clone, Default)]
105pub enum CloudflareTrust {
106    /// Don't treat any CF range as trusted. Safest default for servers that
107    /// are not behind Cloudflare.
108    #[default]
109    Off,
110    /// Use the baked-in fallback list of CF CIDRs without refreshing.
111    Static,
112    /// Start with the baked-in list, then re-fetch CF's published ranges on
113    /// the given interval.
114    AutoRefresh {
115        /// How often to re-fetch CF's IP list.
116        interval: std::time::Duration,
117    },
118}
119
120/// Configuration for the `ZLayer` proxy server
121///
122/// This configuration struct controls the behavior of the proxy,
123/// including listener addresses, ACME/TLS settings, and L4 stream config.
124#[derive(Debug, Clone)]
125pub struct ZLayerProxyConfig {
126    /// HTTP listener address (default: "0.0.0.0:80")
127    pub http_addr: String,
128    /// HTTPS listener address (default: "0.0.0.0:443")
129    pub https_addr: String,
130    /// Optional ACME email for Let's Encrypt certificate provisioning
131    pub acme_email: Option<String>,
132    /// Path to store TLS certificates (default: `zlayer_paths::ZLayerDirs::system_default().certs()`)
133    pub cert_storage_path: String,
134    /// Enable automatic ACME certificate provisioning
135    pub acme_enabled: bool,
136    /// Use Let's Encrypt staging environment (for testing)
137    pub acme_staging: bool,
138    /// Custom ACME directory URL (for non-LE CAs like `ZeroSSL`)
139    pub acme_directory_url: Option<String>,
140    /// Domains to auto-provision certificates for on startup
141    pub auto_provision_domains: Vec<String>,
142    /// TCP stream proxy listeners for L4 proxying
143    pub tcp: Vec<stream::TcpListenerConfig>,
144    /// UDP stream proxy listeners for L4 proxying
145    pub udp: Vec<stream::UdpListenerConfig>,
146    /// Default UDP session timeout (default: 60 seconds)
147    pub udp_session_timeout: Duration,
148    /// CIDR ranges whose peer IPs are trusted to set `CF-Connecting-IP` /
149    /// `X-Forwarded-For` headers identifying the real client. Defaults to
150    /// localhost only (`127.0.0.0/8`, `::1/128`) so a public `ZLayer` node that
151    /// accidentally receives direct requests (bypassing any upstream proxy)
152    /// cannot be tricked by spoofed headers.
153    pub trusted_proxy_cidrs: Vec<ipnet::IpNet>,
154    /// Cloudflare-specific trust policy. When enabled, CF's published edge
155    /// ranges are treated as trusted in addition to `trusted_proxy_cidrs`.
156    pub cloudflare_trust: CloudflareTrust,
157}
158
159impl Default for ZLayerProxyConfig {
160    fn default() -> Self {
161        Self {
162            http_addr: "0.0.0.0:80".to_string(),
163            https_addr: "0.0.0.0:443".to_string(),
164            acme_email: None,
165            cert_storage_path: zlayer_paths::ZLayerDirs::system_default()
166                .certs()
167                .to_string_lossy()
168                .into_owned(),
169            acme_enabled: false,
170            acme_staging: false,
171            acme_directory_url: None,
172            auto_provision_domains: vec![],
173            tcp: vec![],
174            udp: vec![],
175            udp_session_timeout: default_udp_session_timeout(),
176            trusted_proxy_cidrs: vec![
177                "127.0.0.0/8"
178                    .parse()
179                    .expect("hardcoded loopback CIDR is valid"),
180                "::1/128"
181                    .parse()
182                    .expect("hardcoded IPv6 loopback CIDR is valid"),
183            ],
184            cloudflare_trust: CloudflareTrust::default(),
185        }
186    }
187}
188
189impl ZLayerProxyConfig {
190    /// Get the ACME directory URL based on configuration
191    ///
192    /// Returns the custom directory URL if set, otherwise returns the appropriate
193    /// Let's Encrypt URL based on whether staging mode is enabled.
194    #[must_use]
195    pub fn acme_directory(&self) -> &str {
196        match &self.acme_directory_url {
197            Some(url) => url.as_str(),
198            None if self.acme_staging => "https://acme-staging-v02.api.letsencrypt.org/directory",
199            None => "https://acme-v02.api.letsencrypt.org/directory",
200        }
201    }
202}
203
204/// Backwards-compatible alias for `ZLayerProxyConfig`.
205pub type PingoraProxyConfig = ZLayerProxyConfig;
206
207/// Error type for proxy startup failures
208#[derive(Debug, thiserror::Error)]
209pub enum ProxyStartError {
210    /// Failed to create certificate manager
211    #[error("Certificate manager error: {0}")]
212    CertManager(String),
213    /// Failed to create server
214    #[error("Server creation error: {0}")]
215    ServerCreation(String),
216    /// Failed to set up TLS
217    #[error("TLS setup error: {0}")]
218    TlsSetup(String),
219    /// IO error (e.g., reading certificate files)
220    #[error("IO error: {0}")]
221    Io(#[from] std::io::Error),
222}
223
224/// Information about a discovered certificate on disk
225#[derive(Debug, Clone)]
226pub struct DiscoveredCert {
227    /// Domain name (extracted from filename)
228    pub domain: String,
229    /// Path to the certificate file
230    pub cert_path: PathBuf,
231    /// Path to the private key file
232    pub key_path: PathBuf,
233}
234
235/// Find all certificates in the storage directory
236///
237/// Scans the given directory for `.crt` files and their corresponding `.key` files.
238/// Returns a list of discovered certificates.
239///
240/// # Arguments
241///
242/// * `storage_path` - Path to the certificate storage directory
243///
244/// # Returns
245///
246/// Vector of `DiscoveredCert` structs for each valid cert/key pair found
247pub fn discover_certificates(storage_path: &PathBuf) -> Vec<DiscoveredCert> {
248    let mut certs = Vec::new();
249
250    // Read the directory
251    let entries = match std::fs::read_dir(storage_path) {
252        Ok(entries) => entries,
253        Err(e) => {
254            tracing::warn!(
255                path = %storage_path.display(),
256                error = %e,
257                "Failed to read certificate storage directory"
258            );
259            return certs;
260        }
261    };
262
263    for entry in entries.flatten() {
264        let path = entry.path();
265
266        // Look for .crt files
267        if let Some(extension) = path.extension() {
268            if extension == "crt" {
269                // Extract domain from filename (e.g., "example.com.crt" -> "example.com")
270                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
271                    let key_path = storage_path.join(format!("{stem}.key"));
272
273                    // Check if corresponding key file exists
274                    if key_path.exists() {
275                        tracing::debug!(
276                            domain = %stem,
277                            cert_path = %path.display(),
278                            key_path = %key_path.display(),
279                            "Discovered certificate"
280                        );
281                        certs.push(DiscoveredCert {
282                            domain: stem.to_string(),
283                            cert_path: path.clone(),
284                            key_path,
285                        });
286                    } else {
287                        tracing::warn!(
288                            domain = %stem,
289                            cert_path = %path.display(),
290                            "Certificate found but missing corresponding key file"
291                        );
292                    }
293                }
294            }
295        }
296    }
297
298    certs
299}
300
301/// Load existing certificates into the SNI resolver
302///
303/// # Arguments
304///
305/// * `cert_manager` - The certificate manager to load certificates from
306/// * `sni_resolver` - The SNI resolver to populate with certificates
307///
308/// # Returns
309///
310/// The number of certificates loaded
311///
312/// # Errors
313///
314/// Returns an error if reading certificate files from disk fails.
315pub async fn load_existing_certs_into_resolver(
316    cert_manager: &CertManager,
317    sni_resolver: &SniCertResolver,
318) -> std::result::Result<usize, ProxyStartError> {
319    let storage_path = cert_manager.storage_path().clone();
320    let discovered = discover_certificates(&storage_path);
321    let mut loaded = 0;
322
323    for cert_info in discovered {
324        // Read certificate and key files
325        let cert_pem = match tokio::fs::read_to_string(&cert_info.cert_path).await {
326            Ok(content) => content,
327            Err(e) => {
328                tracing::warn!(
329                    domain = %cert_info.domain,
330                    path = %cert_info.cert_path.display(),
331                    error = %e,
332                    "Failed to read certificate file"
333                );
334                continue;
335            }
336        };
337
338        let key_pem = match tokio::fs::read_to_string(&cert_info.key_path).await {
339            Ok(content) => content,
340            Err(e) => {
341                tracing::warn!(
342                    domain = %cert_info.domain,
343                    path = %cert_info.key_path.display(),
344                    error = %e,
345                    "Failed to read key file"
346                );
347                continue;
348            }
349        };
350
351        // Load into SNI resolver
352        match sni_resolver.load_cert(&cert_info.domain, &cert_pem, &key_pem) {
353            Ok(()) => {
354                tracing::info!(
355                    domain = %cert_info.domain,
356                    "Loaded certificate into SNI resolver"
357                );
358                loaded += 1;
359            }
360            Err(e) => {
361                tracing::warn!(
362                    domain = %cert_info.domain,
363                    error = %e,
364                    "Failed to load certificate into SNI resolver"
365                );
366            }
367        }
368    }
369
370    Ok(loaded)
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_proxy_config_default() {
379        let config = ZLayerProxyConfig::default();
380        assert_eq!(config.http_addr, "0.0.0.0:80");
381        assert_eq!(config.https_addr, "0.0.0.0:443");
382        assert!(config.acme_email.is_none());
383        assert_eq!(
384            config.cert_storage_path,
385            zlayer_paths::ZLayerDirs::system_default()
386                .certs()
387                .to_string_lossy()
388        );
389        assert!(!config.acme_enabled);
390        assert!(!config.acme_staging);
391        assert!(config.acme_directory_url.is_none());
392        assert!(config.auto_provision_domains.is_empty());
393        assert!(config.tcp.is_empty());
394        assert!(config.udp.is_empty());
395        assert_eq!(config.udp_session_timeout, DEFAULT_UDP_SESSION_TIMEOUT);
396    }
397
398    #[test]
399    fn test_proxy_config_custom() {
400        let config = ZLayerProxyConfig {
401            http_addr: "127.0.0.1:8080".to_string(),
402            https_addr: "127.0.0.1:8443".to_string(),
403            acme_email: Some("admin@example.com".to_string()),
404            cert_storage_path: "/tmp/certs".to_string(),
405            acme_enabled: true,
406            acme_staging: true,
407            acme_directory_url: None,
408            auto_provision_domains: vec!["example.com".to_string(), "api.example.com".to_string()],
409            tcp: vec![TcpListenerConfig {
410                port: 5432,
411                protocol_hint: Some("postgresql".to_string()),
412                tls: false,
413                proxy_protocol: false,
414            }],
415            udp: vec![UdpListenerConfig {
416                port: 27015,
417                protocol_hint: Some("source-engine".to_string()),
418                session_timeout: Some(Duration::from_secs(120)),
419            }],
420            udp_session_timeout: Duration::from_secs(90),
421            trusted_proxy_cidrs: vec![],
422            cloudflare_trust: CloudflareTrust::Off,
423        };
424        assert_eq!(config.http_addr, "127.0.0.1:8080");
425        assert_eq!(config.https_addr, "127.0.0.1:8443");
426        assert_eq!(config.acme_email, Some("admin@example.com".to_string()));
427        assert_eq!(config.cert_storage_path, "/tmp/certs");
428        assert!(config.acme_enabled);
429        assert!(config.acme_staging);
430        assert!(config.acme_directory_url.is_none());
431        assert_eq!(config.auto_provision_domains.len(), 2);
432        assert_eq!(config.tcp.len(), 1);
433        assert_eq!(config.tcp[0].port, 5432);
434        assert_eq!(config.udp.len(), 1);
435        assert_eq!(config.udp[0].port, 27015);
436        assert_eq!(config.udp_session_timeout, Duration::from_secs(90));
437    }
438
439    #[test]
440    fn test_pingora_proxy_config_alias() {
441        // Ensure the backwards-compatible alias works
442        let _config: PingoraProxyConfig = ZLayerProxyConfig::default();
443    }
444
445    #[test]
446    fn test_acme_directory_production() {
447        let config = ZLayerProxyConfig {
448            acme_staging: false,
449            acme_directory_url: None,
450            ..Default::default()
451        };
452        assert_eq!(
453            config.acme_directory(),
454            "https://acme-v02.api.letsencrypt.org/directory"
455        );
456    }
457
458    #[test]
459    fn test_acme_directory_staging() {
460        let config = ZLayerProxyConfig {
461            acme_staging: true,
462            acme_directory_url: None,
463            ..Default::default()
464        };
465        assert_eq!(
466            config.acme_directory(),
467            "https://acme-staging-v02.api.letsencrypt.org/directory"
468        );
469    }
470
471    #[test]
472    fn test_acme_directory_custom() {
473        let custom_url = "https://acme.zerossl.com/v2/DV90";
474        let config = ZLayerProxyConfig {
475            acme_staging: true, // Should be ignored when custom URL is set
476            acme_directory_url: Some(custom_url.to_string()),
477            ..Default::default()
478        };
479        assert_eq!(config.acme_directory(), custom_url);
480    }
481
482    #[test]
483    fn test_discover_certificates_empty_dir() {
484        let dir = tempfile::tempdir().unwrap();
485        let certs = discover_certificates(&dir.path().to_path_buf());
486        assert!(certs.is_empty());
487    }
488
489    #[test]
490    fn test_discover_certificates_with_certs() {
491        let dir = tempfile::tempdir().unwrap();
492
493        // Create a valid cert/key pair
494        std::fs::write(dir.path().join("example.com.crt"), "cert content").unwrap();
495        std::fs::write(dir.path().join("example.com.key"), "key content").unwrap();
496
497        // Create another cert/key pair
498        std::fs::write(dir.path().join("api.example.com.crt"), "cert content 2").unwrap();
499        std::fs::write(dir.path().join("api.example.com.key"), "key content 2").unwrap();
500
501        let certs = discover_certificates(&dir.path().to_path_buf());
502        assert_eq!(certs.len(), 2);
503
504        let domains: Vec<&str> = certs.iter().map(|c| c.domain.as_str()).collect();
505        assert!(domains.contains(&"example.com"));
506        assert!(domains.contains(&"api.example.com"));
507    }
508
509    #[test]
510    fn test_discover_certificates_missing_key() {
511        let dir = tempfile::tempdir().unwrap();
512
513        // Create a cert without corresponding key
514        std::fs::write(dir.path().join("orphan.com.crt"), "cert content").unwrap();
515
516        let certs = discover_certificates(&dir.path().to_path_buf());
517        assert!(certs.is_empty()); // Should not find any since key is missing
518    }
519
520    #[test]
521    fn test_discover_certificates_nonexistent_dir() {
522        let path = PathBuf::from("/nonexistent/path/that/does/not/exist");
523        let certs = discover_certificates(&path);
524        assert!(certs.is_empty());
525    }
526
527    #[test]
528    fn test_discovered_cert_paths() {
529        let dir = tempfile::tempdir().unwrap();
530
531        std::fs::write(dir.path().join("test.example.com.crt"), "cert").unwrap();
532        std::fs::write(dir.path().join("test.example.com.key"), "key").unwrap();
533
534        let certs = discover_certificates(&dir.path().to_path_buf());
535        assert_eq!(certs.len(), 1);
536
537        let cert = &certs[0];
538        assert_eq!(cert.domain, "test.example.com");
539        assert!(cert.cert_path.ends_with("test.example.com.crt"));
540        assert!(cert.key_path.ends_with("test.example.com.key"));
541    }
542}