use std::net::SocketAddr;
use std::sync::Arc;
use postrust_proxy::config::{load_from_database, load_from_file, ProxyConfig};
use postrust_proxy::health::HealthChecker;
use postrust_proxy::ratelimit::RateLimiter;
use postrust_proxy::vendored::ProxyService;
use sqlx::postgres::PgPoolOptions;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "postrust_proxy=info".into()),
)
.init();
let config_path = std::env::args()
.nth(1)
.or_else(|| std::env::var("POSTRUST_PROXY_CONFIG").ok())
.ok_or("usage: postrust-proxy <config.toml> (or set POSTRUST_PROXY_CONFIG)")?;
let config: ProxyConfig = load_from_file(&config_path).await?;
let h2_port = config.server.http2_port;
let http_host = config.server.http_host.clone();
let https_host = config.server.https_host.clone();
let https_port = config.server.https_port;
let cert_file = config.tls.cert_file.clone();
let key_file = config.tls.key_file.clone();
let addr: SocketAddr = format!("{}:{}", config.server.http_host, config.server.http_port)
.parse()
.map_err(|e| format!("bad listen address in {config_path}: {e}"))?;
let database_url = std::env::var("DATABASE_URL").ok();
let pool = PgPoolOptions::new().connect_lazy(
database_url
.as_deref()
.unwrap_or("postgres://postgres:postgres@127.0.0.1:5432/postgres"),
)?;
let cancel = CancellationToken::new();
#[cfg(feature = "acme")]
let cancel_for_acme = cancel.clone();
let mut config = config;
if config.server.database_config && database_url.is_some() {
let (routes, upstreams) = load_from_database(&pool).await?;
tracing::info!(
routes = routes.len(),
upstreams = upstreams.len(),
"loaded configuration from the database"
);
merge_from_database(&mut config, routes, upstreams)?;
}
let config = config;
let https = wants_https(&config);
let cert_store = match (&database_url, https.needs_store()) {
(Some(_), true) => Some(Arc::new(
postrust_proxy::tls::CertificateStore::new(pool.clone(), &config.tls.cert_dir).await?,
)),
_ => None,
};
#[cfg(feature = "acme")]
let acme_worker = if config.tls.acme_enabled {
match &cert_store {
Some(store) => Some(
start_acme_worker(
&config,
pool.clone(),
store.clone(),
cancel_for_acme.clone(),
)
.await?,
),
None => {
tracing::warn!(
"tls.acme_enabled is set but DATABASE_URL is not; the ACME worker needs a database for the account, challenges and certificates, so it will not start"
);
None
}
}
} else {
None
};
let rate_limiter = Arc::new(RateLimiter::new(config.rate_limit.clone()));
let health_checker = Arc::new(HealthChecker::new());
let service = Arc::new(ProxyService::new(
Arc::new(RwLock::new(config)),
health_checker,
rate_limiter,
));
service.load_config().await;
{
let cancel = cancel.clone();
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
cancel.cancel();
});
}
let h2_addr: Option<SocketAddr> = match h2_port {
Some(port) => Some(
format!("{http_host}:{port}")
.parse()
.map_err(|e| format!("bad http2 listen address in {config_path}: {e}"))?,
),
None => None,
};
let static_key = match (&cert_file, &key_file) {
(Some(cert), Some(key)) => {
let cert_pem = tokio::fs::read(cert)
.await
.map_err(|e| format!("could not read {cert}: {e}"))?;
let key_pem = tokio::fs::read(key)
.await
.map_err(|e| format!("could not read {key}: {e}"))?;
Some(postrust_proxy::tls::SniCertResolver::certified_key(
&cert_pem, &key_pem,
)?)
}
(None, None) => None,
_ => return Err("tls.cert_file and tls.key_file must be set together".into()),
};
let tls = if https.enabled() {
let resolver = Arc::new(postrust_proxy::tls::SniCertResolver::new(static_key));
if let Some(store) = &cert_store {
match resolver.refresh(store).await {
Ok(0) => {
tracing::info!("No stored certificates yet; serving the configured pair only")
}
Ok(_) => {}
Err(e) => tracing::warn!("Could not load stored certificates: {e}"),
}
spawn_certificate_refresh(resolver.clone(), store.clone(), cancel.clone());
}
let tls_config = postrust_proxy::tls::build_server_config_with_resolver(resolver);
let https_addr: SocketAddr = format!("{https_host}:{https_port}")
.parse()
.map_err(|e| format!("bad https listen address in {config_path}: {e}"))?;
let tls_service = service.clone();
let tls_cancel = cancel.clone();
Some(tokio::spawn(async move {
tls_service
.serve_https(https_addr, tls_config, tls_cancel)
.await
}))
} else {
None
};
match h2_addr {
Some(h2_addr) => {
let h2_service = service.clone();
let h2_cancel = cancel.clone();
let h2 = tokio::spawn(async move { h2_service.serve_h2c(h2_addr, h2_cancel).await });
let main = service.serve_http(addr, cancel).await;
h2.await
.map_err(|e| format!("http2 listener panicked: {e}"))??;
main?;
}
None => service.serve_http(addr, cancel).await?,
}
if let Some(tls) = tls {
tls.await
.map_err(|e| format!("https listener panicked: {e}"))??;
}
#[cfg(feature = "acme")]
if let Some(worker) = acme_worker {
worker
.await
.map_err(|e| format!("ACME worker panicked: {e}"))?;
}
Ok(())
}
#[cfg(feature = "acme")]
async fn start_acme_worker(
config: &ProxyConfig,
pool: sqlx::PgPool,
cert_store: Arc<postrust_proxy::tls::CertificateStore>,
cancel: CancellationToken,
) -> Result<tokio::task::JoinHandle<()>, Box<dyn std::error::Error>> {
use postrust_proxy::tls::{AcmeIssuer, LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING};
let directory = config.tls.acme_directory_url.clone().unwrap_or_else(|| {
if config.tls.acme_staging {
LETS_ENCRYPT_STAGING.to_string()
} else {
LETS_ENCRYPT_PRODUCTION.to_string()
}
});
let mut issuer = AcmeIssuer::new(
directory,
config.tls.acme_email.clone(),
pool.clone(),
cert_store,
);
if let Some(root) = &config.tls.acme_root_pem {
issuer = issuer.with_root_certificate(root);
}
Ok(tokio::spawn(postrust_proxy::saas::ssl::run(
pool,
Arc::new(issuer),
cancel,
)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Https {
static_pair: bool,
stored: bool,
}
impl Https {
fn enabled(self) -> bool {
self.static_pair || self.stored
}
fn needs_store(self) -> bool {
self.stored
}
}
fn wants_https(config: &ProxyConfig) -> Https {
Https {
static_pair: config.tls.cert_file.is_some() && config.tls.key_file.is_some(),
stored: config.tls.acme_enabled || config.server.https_enabled,
}
}
fn spawn_certificate_refresh(
resolver: Arc<postrust_proxy::tls::SniCertResolver>,
store: Arc<postrust_proxy::tls::CertificateStore>,
cancel: CancellationToken,
) {
const EVERY: std::time::Duration = std::time::Duration::from_secs(60);
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel.cancelled() => return,
_ = tokio::time::sleep(EVERY) => {}
}
if let Err(e) = resolver.refresh(&store).await {
tracing::warn!("Certificate refresh failed: {e}");
}
}
});
}
fn merge_from_database(
config: &mut ProxyConfig,
routes: Vec<postrust_proxy::config::Route>,
upstreams: Vec<postrust_proxy::config::Upstream>,
) -> Result<(), String> {
for upstream in &upstreams {
if config.upstreams.iter().any(|u| u.name == upstream.name) {
return Err(format!(
"upstream {:?} is declared in both the config file and the database",
upstream.name
));
}
}
for route in &routes {
if config.routes.iter().any(|r| r.name == route.name) {
return Err(format!(
"route {:?} is declared in both the config file and the database",
route.name
));
}
}
config.upstreams.extend(upstreams);
config.routes.extend(routes);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> ProxyConfig {
ProxyConfig::default()
}
#[test]
fn a_plain_proxy_does_not_serve_https() {
let https = wants_https(&config());
assert!(!https.enabled());
assert!(!https.needs_store());
}
#[test]
fn a_static_pair_serves_https_without_a_store() {
let mut config = config();
config.tls.cert_file = Some("/etc/postrust/fullchain.pem".into());
config.tls.key_file = Some("/etc/postrust/privkey.pem".into());
let https = wants_https(&config);
assert!(https.enabled());
assert!(!https.needs_store());
}
#[test]
fn half_a_pair_is_not_a_pair() {
let mut config = config();
config.tls.cert_file = Some("/etc/postrust/fullchain.pem".into());
assert!(!wants_https(&config).static_pair);
}
#[test]
fn acme_serves_https_from_the_store_alone() {
let mut config = config();
config.tls.acme_enabled = true;
let https = wants_https(&config);
assert!(https.enabled());
assert!(https.needs_store());
assert!(!https.static_pair);
}
#[test]
fn https_enabled_is_read() {
let mut config = config();
config.server.https_enabled = true;
let https = wants_https(&config);
assert!(https.enabled());
assert!(https.needs_store());
}
}