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