1pub 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
48pub 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
58pub 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
71pub use lb::{
73 Backend, BackendGroup, BackendGroupSnapshot, BackendSnapshot, ConnectionGuard, HealthStatus,
74 LbStrategy, LoadBalancer,
75};
76
77pub use acme::{CertManager, CertMetadata};
79pub use routes::{ResolvedService, RouteEntry, ServiceRegistry};
80pub use sni_resolver::SniCertResolver;
81
82pub use stream::{
84 BackendHealth as StreamBackendHealth, StreamRegistry, StreamService, TcpListenerConfig,
85 TcpStreamService, UdpListenerConfig, UdpStreamService, DEFAULT_UDP_SESSION_TIMEOUT,
86};
87
88fn default_udp_session_timeout() -> Duration {
94 stream::DEFAULT_UDP_SESSION_TIMEOUT
95}
96
97#[derive(Debug, Clone, Default)]
105pub enum CloudflareTrust {
106 #[default]
109 Off,
110 Static,
112 AutoRefresh {
115 interval: std::time::Duration,
117 },
118}
119
120#[derive(Debug, Clone)]
125pub struct ZLayerProxyConfig {
126 pub http_addr: String,
128 pub https_addr: String,
130 pub acme_email: Option<String>,
132 pub cert_storage_path: String,
134 pub acme_enabled: bool,
136 pub acme_staging: bool,
138 pub acme_directory_url: Option<String>,
140 pub auto_provision_domains: Vec<String>,
142 pub tcp: Vec<stream::TcpListenerConfig>,
144 pub udp: Vec<stream::UdpListenerConfig>,
146 pub udp_session_timeout: Duration,
148 pub trusted_proxy_cidrs: Vec<ipnet::IpNet>,
154 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 #[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
204pub type PingoraProxyConfig = ZLayerProxyConfig;
206
207#[derive(Debug, thiserror::Error)]
209pub enum ProxyStartError {
210 #[error("Certificate manager error: {0}")]
212 CertManager(String),
213 #[error("Server creation error: {0}")]
215 ServerCreation(String),
216 #[error("TLS setup error: {0}")]
218 TlsSetup(String),
219 #[error("IO error: {0}")]
221 Io(#[from] std::io::Error),
222}
223
224#[derive(Debug, Clone)]
226pub struct DiscoveredCert {
227 pub domain: String,
229 pub cert_path: PathBuf,
231 pub key_path: PathBuf,
233}
234
235pub fn discover_certificates(storage_path: &PathBuf) -> Vec<DiscoveredCert> {
248 let mut certs = Vec::new();
249
250 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 if let Some(extension) = path.extension() {
268 if extension == "crt" {
269 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 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
301pub 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 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 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 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, 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 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 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 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()); }
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}