pub mod cf_ip_list;
pub mod config;
pub mod error;
pub mod network_policy;
pub mod server;
pub mod service;
pub mod tls;
pub mod trust;
pub mod tunnel;
pub mod acme;
pub mod lb;
pub mod routes;
pub mod sni_resolver;
pub mod stream;
use std::path::PathBuf;
use std::time::Duration;
pub use config::{
HeaderConfig, PoolConfig, ProxyConfig, ServerConfig, TimeoutConfig, TlsConfig, TlsVersion,
};
pub use error::{ProxyError, Result};
pub use network_policy::NetworkPolicyChecker;
pub use server::ProxyServer;
pub use service::{empty_body, full_body, BoxBody, ReverseProxyService};
pub use tls::{create_tls_acceptor, TlsServerConfig};
pub use tunnel::{
is_upgrade_request, is_upgrade_response, is_websocket_upgrade, proxy_tunnel, proxy_upgrade,
};
pub use lb::{
Backend, BackendGroup, BackendGroupSnapshot, BackendSnapshot, ConnectionGuard, HealthStatus,
LbStrategy, LoadBalancer,
};
pub use acme::{CertManager, CertMetadata};
pub use routes::{ResolvedService, RouteEntry, ServiceRegistry};
pub use sni_resolver::SniCertResolver;
pub use stream::{
BackendHealth as StreamBackendHealth, StreamRegistry, StreamService, TcpListenerConfig,
TcpStreamService, UdpListenerConfig, UdpStreamService, DEFAULT_UDP_SESSION_TIMEOUT,
};
fn default_udp_session_timeout() -> Duration {
stream::DEFAULT_UDP_SESSION_TIMEOUT
}
#[derive(Debug, Clone, Default)]
pub enum CloudflareTrust {
#[default]
Off,
Static,
AutoRefresh {
interval: std::time::Duration,
},
}
#[derive(Debug, Clone)]
pub struct ZLayerProxyConfig {
pub http_addr: String,
pub https_addr: String,
pub acme_email: Option<String>,
pub cert_storage_path: String,
pub acme_enabled: bool,
pub acme_staging: bool,
pub acme_directory_url: Option<String>,
pub auto_provision_domains: Vec<String>,
pub tcp: Vec<stream::TcpListenerConfig>,
pub udp: Vec<stream::UdpListenerConfig>,
pub udp_session_timeout: Duration,
pub trusted_proxy_cidrs: Vec<ipnet::IpNet>,
pub cloudflare_trust: CloudflareTrust,
}
impl Default for ZLayerProxyConfig {
fn default() -> Self {
Self {
http_addr: "0.0.0.0:80".to_string(),
https_addr: "0.0.0.0:443".to_string(),
acme_email: None,
cert_storage_path: zlayer_paths::ZLayerDirs::system_default()
.certs()
.to_string_lossy()
.into_owned(),
acme_enabled: false,
acme_staging: false,
acme_directory_url: None,
auto_provision_domains: vec![],
tcp: vec![],
udp: vec![],
udp_session_timeout: default_udp_session_timeout(),
trusted_proxy_cidrs: vec![
"127.0.0.0/8"
.parse()
.expect("hardcoded loopback CIDR is valid"),
"::1/128"
.parse()
.expect("hardcoded IPv6 loopback CIDR is valid"),
],
cloudflare_trust: CloudflareTrust::default(),
}
}
}
impl ZLayerProxyConfig {
#[must_use]
pub fn acme_directory(&self) -> &str {
match &self.acme_directory_url {
Some(url) => url.as_str(),
None if self.acme_staging => "https://acme-staging-v02.api.letsencrypt.org/directory",
None => "https://acme-v02.api.letsencrypt.org/directory",
}
}
}
pub type PingoraProxyConfig = ZLayerProxyConfig;
#[derive(Debug, thiserror::Error)]
pub enum ProxyStartError {
#[error("Certificate manager error: {0}")]
CertManager(String),
#[error("Server creation error: {0}")]
ServerCreation(String),
#[error("TLS setup error: {0}")]
TlsSetup(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
pub struct DiscoveredCert {
pub domain: String,
pub cert_path: PathBuf,
pub key_path: PathBuf,
}
pub fn discover_certificates(storage_path: &PathBuf) -> Vec<DiscoveredCert> {
let mut certs = Vec::new();
let entries = match std::fs::read_dir(storage_path) {
Ok(entries) => entries,
Err(e) => {
tracing::warn!(
path = %storage_path.display(),
error = %e,
"Failed to read certificate storage directory"
);
return certs;
}
};
for entry in entries.flatten() {
let path = entry.path();
if let Some(extension) = path.extension() {
if extension == "crt" {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let key_path = storage_path.join(format!("{stem}.key"));
if key_path.exists() {
tracing::debug!(
domain = %stem,
cert_path = %path.display(),
key_path = %key_path.display(),
"Discovered certificate"
);
certs.push(DiscoveredCert {
domain: stem.to_string(),
cert_path: path.clone(),
key_path,
});
} else {
tracing::warn!(
domain = %stem,
cert_path = %path.display(),
"Certificate found but missing corresponding key file"
);
}
}
}
}
}
certs
}
pub async fn load_existing_certs_into_resolver(
cert_manager: &CertManager,
sni_resolver: &SniCertResolver,
) -> std::result::Result<usize, ProxyStartError> {
let storage_path = cert_manager.storage_path().clone();
let discovered = discover_certificates(&storage_path);
let mut loaded = 0;
for cert_info in discovered {
let cert_pem = match tokio::fs::read_to_string(&cert_info.cert_path).await {
Ok(content) => content,
Err(e) => {
tracing::warn!(
domain = %cert_info.domain,
path = %cert_info.cert_path.display(),
error = %e,
"Failed to read certificate file"
);
continue;
}
};
let key_pem = match tokio::fs::read_to_string(&cert_info.key_path).await {
Ok(content) => content,
Err(e) => {
tracing::warn!(
domain = %cert_info.domain,
path = %cert_info.key_path.display(),
error = %e,
"Failed to read key file"
);
continue;
}
};
match sni_resolver.load_cert(&cert_info.domain, &cert_pem, &key_pem) {
Ok(()) => {
tracing::info!(
domain = %cert_info.domain,
"Loaded certificate into SNI resolver"
);
loaded += 1;
}
Err(e) => {
tracing::warn!(
domain = %cert_info.domain,
error = %e,
"Failed to load certificate into SNI resolver"
);
}
}
}
Ok(loaded)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_proxy_config_default() {
let config = ZLayerProxyConfig::default();
assert_eq!(config.http_addr, "0.0.0.0:80");
assert_eq!(config.https_addr, "0.0.0.0:443");
assert!(config.acme_email.is_none());
assert_eq!(
config.cert_storage_path,
zlayer_paths::ZLayerDirs::system_default()
.certs()
.to_string_lossy()
);
assert!(!config.acme_enabled);
assert!(!config.acme_staging);
assert!(config.acme_directory_url.is_none());
assert!(config.auto_provision_domains.is_empty());
assert!(config.tcp.is_empty());
assert!(config.udp.is_empty());
assert_eq!(config.udp_session_timeout, DEFAULT_UDP_SESSION_TIMEOUT);
}
#[test]
fn test_proxy_config_custom() {
let config = ZLayerProxyConfig {
http_addr: "127.0.0.1:8080".to_string(),
https_addr: "127.0.0.1:8443".to_string(),
acme_email: Some("admin@example.com".to_string()),
cert_storage_path: "/tmp/certs".to_string(),
acme_enabled: true,
acme_staging: true,
acme_directory_url: None,
auto_provision_domains: vec!["example.com".to_string(), "api.example.com".to_string()],
tcp: vec![TcpListenerConfig {
port: 5432,
protocol_hint: Some("postgresql".to_string()),
tls: false,
proxy_protocol: false,
}],
udp: vec![UdpListenerConfig {
port: 27015,
protocol_hint: Some("source-engine".to_string()),
session_timeout: Some(Duration::from_secs(120)),
}],
udp_session_timeout: Duration::from_secs(90),
trusted_proxy_cidrs: vec![],
cloudflare_trust: CloudflareTrust::Off,
};
assert_eq!(config.http_addr, "127.0.0.1:8080");
assert_eq!(config.https_addr, "127.0.0.1:8443");
assert_eq!(config.acme_email, Some("admin@example.com".to_string()));
assert_eq!(config.cert_storage_path, "/tmp/certs");
assert!(config.acme_enabled);
assert!(config.acme_staging);
assert!(config.acme_directory_url.is_none());
assert_eq!(config.auto_provision_domains.len(), 2);
assert_eq!(config.tcp.len(), 1);
assert_eq!(config.tcp[0].port, 5432);
assert_eq!(config.udp.len(), 1);
assert_eq!(config.udp[0].port, 27015);
assert_eq!(config.udp_session_timeout, Duration::from_secs(90));
}
#[test]
fn test_pingora_proxy_config_alias() {
let _config: PingoraProxyConfig = ZLayerProxyConfig::default();
}
#[test]
fn test_acme_directory_production() {
let config = ZLayerProxyConfig {
acme_staging: false,
acme_directory_url: None,
..Default::default()
};
assert_eq!(
config.acme_directory(),
"https://acme-v02.api.letsencrypt.org/directory"
);
}
#[test]
fn test_acme_directory_staging() {
let config = ZLayerProxyConfig {
acme_staging: true,
acme_directory_url: None,
..Default::default()
};
assert_eq!(
config.acme_directory(),
"https://acme-staging-v02.api.letsencrypt.org/directory"
);
}
#[test]
fn test_acme_directory_custom() {
let custom_url = "https://acme.zerossl.com/v2/DV90";
let config = ZLayerProxyConfig {
acme_staging: true, acme_directory_url: Some(custom_url.to_string()),
..Default::default()
};
assert_eq!(config.acme_directory(), custom_url);
}
#[test]
fn test_discover_certificates_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let certs = discover_certificates(&dir.path().to_path_buf());
assert!(certs.is_empty());
}
#[test]
fn test_discover_certificates_with_certs() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("example.com.crt"), "cert content").unwrap();
std::fs::write(dir.path().join("example.com.key"), "key content").unwrap();
std::fs::write(dir.path().join("api.example.com.crt"), "cert content 2").unwrap();
std::fs::write(dir.path().join("api.example.com.key"), "key content 2").unwrap();
let certs = discover_certificates(&dir.path().to_path_buf());
assert_eq!(certs.len(), 2);
let domains: Vec<&str> = certs.iter().map(|c| c.domain.as_str()).collect();
assert!(domains.contains(&"example.com"));
assert!(domains.contains(&"api.example.com"));
}
#[test]
fn test_discover_certificates_missing_key() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("orphan.com.crt"), "cert content").unwrap();
let certs = discover_certificates(&dir.path().to_path_buf());
assert!(certs.is_empty()); }
#[test]
fn test_discover_certificates_nonexistent_dir() {
let path = PathBuf::from("/nonexistent/path/that/does/not/exist");
let certs = discover_certificates(&path);
assert!(certs.is_empty());
}
#[test]
fn test_discovered_cert_paths() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("test.example.com.crt"), "cert").unwrap();
std::fs::write(dir.path().join("test.example.com.key"), "key").unwrap();
let certs = discover_certificates(&dir.path().to_path_buf());
assert_eq!(certs.len(), 1);
let cert = &certs[0];
assert_eq!(cert.domain, "test.example.com");
assert!(cert.cert_path.ends_with("test.example.com.crt"));
assert!(cert.key_path.ends_with("test.example.com.key"));
}
}