1pub 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
49pub 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
61pub 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
74pub use lb::{
76 Backend, BackendGroup, BackendGroupSnapshot, BackendSnapshot, ConnectionGuard, HealthStatus,
77 LbStrategy, LoadBalancer,
78};
79
80pub use acme::{CertManager, CertMetadata};
82pub use routes::{endpoint_lb_key, ResolvedService, RouteEntry, ServiceRegistry};
83pub use sni_resolver::SniCertResolver;
84
85pub 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
92fn default_udp_session_timeout() -> Duration {
98 stream::DEFAULT_UDP_SESSION_TIMEOUT
99}
100
101#[derive(Debug, Clone, Default)]
109pub enum CloudflareTrust {
110 #[default]
113 Off,
114 Static,
116 AutoRefresh {
119 interval: std::time::Duration,
121 },
122}
123
124#[derive(Debug, Clone)]
129pub struct ZLayerProxyConfig {
130 pub http_addr: String,
132 pub https_addr: String,
134 pub acme_email: Option<String>,
136 pub cert_storage_path: String,
138 pub acme_enabled: bool,
140 pub acme_staging: bool,
142 pub acme_directory_url: Option<String>,
144 pub auto_provision_domains: Vec<String>,
146 pub tcp: Vec<stream::TcpListenerConfig>,
148 pub udp: Vec<stream::UdpListenerConfig>,
150 pub udp_session_timeout: Duration,
152 pub trusted_proxy_cidrs: Vec<ipnet::IpNet>,
158 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 #[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
208pub type PingoraProxyConfig = ZLayerProxyConfig;
210
211#[derive(Debug, thiserror::Error)]
213pub enum ProxyStartError {
214 #[error("Certificate manager error: {0}")]
216 CertManager(String),
217 #[error("Server creation error: {0}")]
219 ServerCreation(String),
220 #[error("TLS setup error: {0}")]
222 TlsSetup(String),
223 #[error("IO error: {0}")]
225 Io(#[from] std::io::Error),
226}
227
228#[derive(Debug, Clone)]
230pub struct DiscoveredCert {
231 pub domain: String,
233 pub cert_path: PathBuf,
235 pub key_path: PathBuf,
237}
238
239pub fn discover_certificates(storage_path: &PathBuf) -> Vec<DiscoveredCert> {
252 let mut certs = Vec::new();
253
254 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 if let Some(extension) = path.extension() {
272 if extension == "crt" {
273 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 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
305pub 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 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 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 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, 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 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 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 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()); }
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}