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