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#[cfg(test)]
58use zlayer_paths::ZLayerDirs;
59
60pub 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
73pub use lb::{
75 Backend, BackendGroup, BackendGroupSnapshot, BackendSnapshot, ConnectionGuard, HealthStatus,
76 LbStrategy, LoadBalancer,
77};
78
79pub use acme::{CertManager, CertMetadata};
81pub use routes::{endpoint_lb_key, ResolvedService, RouteEntry, ServiceRegistry};
82pub use sni_resolver::SniCertResolver;
83
84pub use stream::{
86 BackendHealth as StreamBackendHealth, StreamRegistry, StreamService, TcpListenerConfig,
87 TcpStreamService, UdpListenerConfig, UdpStreamService, DEFAULT_UDP_SESSION_TIMEOUT,
88};
89
90fn default_udp_session_timeout() -> Duration {
96 stream::DEFAULT_UDP_SESSION_TIMEOUT
97}
98
99#[derive(Debug, Clone, Default)]
107pub enum CloudflareTrust {
108 #[default]
111 Off,
112 Static,
114 AutoRefresh {
117 interval: std::time::Duration,
119 },
120}
121
122#[derive(Debug, Clone)]
127pub struct ZLayerProxyConfig {
128 pub http_addr: String,
130 pub https_addr: String,
132 pub acme_email: Option<String>,
134 pub cert_storage_path: String,
136 pub acme_enabled: bool,
138 pub acme_staging: bool,
140 pub acme_directory_url: Option<String>,
142 pub auto_provision_domains: Vec<String>,
144 pub tcp: Vec<stream::TcpListenerConfig>,
146 pub udp: Vec<stream::UdpListenerConfig>,
148 pub udp_session_timeout: Duration,
150 pub trusted_proxy_cidrs: Vec<ipnet::IpNet>,
156 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 #[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
206pub type PingoraProxyConfig = ZLayerProxyConfig;
208
209#[derive(Debug, thiserror::Error)]
211pub enum ProxyStartError {
212 #[error("Certificate manager error: {0}")]
214 CertManager(String),
215 #[error("Server creation error: {0}")]
217 ServerCreation(String),
218 #[error("TLS setup error: {0}")]
220 TlsSetup(String),
221 #[error("IO error: {0}")]
223 Io(#[from] std::io::Error),
224}
225
226#[derive(Debug, Clone)]
228pub struct DiscoveredCert {
229 pub domain: String,
231 pub cert_path: PathBuf,
233 pub key_path: PathBuf,
235}
236
237pub fn discover_certificates(storage_path: &PathBuf) -> Vec<DiscoveredCert> {
250 let mut certs = Vec::new();
251
252 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 if let Some(extension) = path.extension() {
270 if extension == "crt" {
271 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 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
303pub 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 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 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 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, 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 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 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 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()); }
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}