use std::net::SocketAddr;
use std::sync::Arc;
use axum::extract::{ConnectInfo, Request, State};
use axum::middleware::{Next, from_fn_with_state};
use axum::response::Response;
use cratefield_core::{Config, Harness};
use thiserror::Error;
use tokio::net::TcpListener;
use crate::config::EnvConfig;
use crate::cron::{CronError, spawn_cron_scheduler};
use crate::ports::{
client_ip, is_loopback_host, normalize_headers, trusted_hosts, trusted_proxy_headers,
venture_hosts,
};
use crate::runtime::{Native, clone_ports};
use crate::tracing_setup::install_tracing;
#[cfg(feature = "push")]
fn log_push_wiring(harness: &Harness, runtime: &Native, config: &dyn Config) {
use cratefield_push_wiring::WiringSeverity;
let Some(wiring) = runtime.push_wiring() else {
return;
};
tracing::info!("{}", wiring.summary());
let deployed = cratefield_core::deployed_env(harness.venture().env, config);
match wiring.severity(deployed) {
WiringSeverity::Ok => {}
WiringSeverity::Warning => {
for problem in wiring.problems() {
tracing::warn!("{problem}");
}
}
WiringSeverity::Error => {
for problem in wiring.problems() {
tracing::error!("{problem}");
}
}
}
}
pub(crate) const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:8080";
#[derive(Debug, Error)]
pub enum ServeError {
#[error("LISTEN_ADDR {addr:?} is not a valid socket address")]
ListenAddr { addr: String },
#[error("failed to bind {addr}: {source}")]
Bind {
addr: String,
#[source]
source: std::io::Error,
},
#[error("invalid CRONS entry")]
Cron(#[from] CronError),
#[error("server failed: {0}")]
Serve(#[source] std::io::Error),
}
pub async fn serve(harness: Arc<Harness>, runtime: Native) -> Result<(), ServeError> {
install_tracing();
let config = Arc::new(EnvConfig);
let addr = listen_addr(&*config)?;
let listener = TcpListener::bind(addr)
.await
.map_err(|source| ServeError::Bind {
addr: addr.to_string(),
source,
})?;
serve_on(harness, runtime, listener).await
}
pub async fn serve_on(
harness: Arc<Harness>,
runtime: Native,
listener: TcpListener,
) -> Result<(), ServeError> {
install_tracing();
let config = Arc::new(EnvConfig);
let ports = runtime.ports();
#[cfg(feature = "push")]
log_push_wiring(&harness, &runtime, &*config);
let crons = cron_expressions(&*config);
if !crons.is_empty() {
spawn_cron_scheduler(&harness, &ports, &crons)?;
}
let app = harness
.router(clone_ports(&ports))
.layer(from_fn_with_state(
IpTrust {
trusted: trusted_proxy_headers(&*config),
},
client_ip_layer,
))
.layer(from_fn_with_state(
HostTrust::resolve(harness.venture(), &*config),
host_layer,
));
let addr = listener
.local_addr()
.map(|addr| addr.to_string())
.unwrap_or_default();
tracing::info!(%addr, "cratefield native runtime listening");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.map_err(ServeError::Serve)
}
fn cron_expressions(config: &dyn Config) -> Vec<String> {
config
.get("CRONS")
.map(|raw| {
raw.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn listen_addr(config: &dyn Config) -> Result<SocketAddr, ServeError> {
let raw = config
.get("LISTEN_ADDR")
.unwrap_or_else(|| DEFAULT_LISTEN_ADDR.to_owned());
raw.parse::<SocketAddr>()
.map_err(|_| ServeError::ListenAddr { addr: raw })
}
#[derive(Clone)]
struct IpTrust {
trusted: Vec<String>,
}
#[derive(Clone)]
struct HostTrust {
allowed: Arc<Vec<String>>,
enforce: bool,
problem_type_base: Arc<str>,
}
impl HostTrust {
fn resolve(venture: &cratefield_core::Venture, config: &dyn Config) -> Self {
let mut allowed = venture_hosts(venture);
for host in trusted_hosts(config) {
if !allowed.contains(&host) {
allowed.push(host);
}
}
let enforce = cratefield_core::deployed_env(venture.env, config)
== cratefield_core::VentureEnv::Production;
if enforce && allowed.is_empty() {
tracing::warn!(
"no host to answer to: the venture declares no domain and TRUSTED_HOSTS is \
unset, so every request would be refused — serving all hosts instead \
(issue #129)"
);
}
Self {
allowed: Arc::new(allowed),
enforce,
problem_type_base: venture.problem_type_base().into(),
}
}
fn answers_to(&self, host: &str) -> bool {
if self.allowed.is_empty() {
return true;
}
let host = crate::ports::host_without_port(host);
self.allowed.iter().any(|known| known == &host) || is_loopback_host(&host)
}
}
async fn host_layer(State(trust): State<HostTrust>, request: Request, next: Next) -> Response {
if !trust.enforce {
return next.run(request).await;
}
let host = request
.headers()
.get(axum::http::header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
.or_else(|| {
request
.uri()
.authority()
.map(|authority| authority.as_str().to_owned())
});
let Some(host) = host else {
return refuse_host(None, &trust.problem_type_base);
};
if !trust.answers_to(&host) {
return refuse_host(Some(&host), &trust.problem_type_base);
}
next.run(request).await
}
fn refuse_host(host: Option<&str>, problem_type_base: &str) -> Response {
if let Some(host) = host {
tracing::warn!(
host = crate::ports::host_without_port(host),
"refused a request for a host this deployment does not serve"
);
} else {
tracing::warn!("refused a request that carried no host");
}
let problem = cratefield_core::Problem::new(&cratefield_core::SLUGS.not_found)
.with_detail("this deployment does not serve that host");
let mut response = problem.into_response_with_base(problem_type_base);
*response.status_mut() = axum::http::StatusCode::MISDIRECTED_REQUEST;
response
}
async fn client_ip_layer(
State(trust): State<IpTrust>,
mut request: Request,
next: Next,
) -> Response {
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.ip());
let resolved = client_ip(request.headers(), &trust.trusted, peer);
normalize_headers(request.headers_mut(), resolved);
next.run(request).await
}
pub async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler");
tokio::select! {
_ = ctrl_c => {},
_ = sigterm.recv() => {},
}
}
#[cfg(not(unix))]
{
if ctrl_c.await.is_err() {
tracing::error!("ctrl_c handler failed; exiting");
}
}
tracing::info!("shutdown signal received; draining in-flight requests");
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_core::MapConfig;
#[test]
fn crons_default_to_empty_and_split_on_commas() {
assert!(cron_expressions(&MapConfig::default()).is_empty());
let cfg = MapConfig::from_pairs([("CRONS", "*/5 * * * * , 0 0 * * *,,")]);
assert_eq!(cron_expressions(&cfg), ["*/5 * * * *", "0 0 * * *"]);
}
#[test]
fn listen_addr_defaults_to_loopback() {
let addr = listen_addr(&MapConfig::default()).expect("default parses");
assert_eq!(addr.to_string(), "127.0.0.1:8080");
let cfg = MapConfig::from_pairs([("LISTEN_ADDR", "0.0.0.0:9999")]);
let addr = listen_addr(&cfg).expect("parses");
assert_eq!(addr.to_string(), "0.0.0.0:9999");
}
#[test]
fn bad_listen_addr_is_an_error() {
let cfg = MapConfig::from_pairs([("LISTEN_ADDR", "not-an-addr")]);
assert!(matches!(
listen_addr(&cfg),
Err(ServeError::ListenAddr { .. })
));
}
use cratefield_core::{Venture, VentureEnv};
fn tenant_a() -> Venture {
Venture::new("tenant-a", "tenant-a.example").public_url("https://tenant-a.example")
}
fn tenant_b() -> Venture {
Venture::new("tenant-b", "tenant-b.example").public_url("https://tenant-b.example")
}
fn production() -> MapConfig {
MapConfig::from_pairs([("ENV", "production")])
}
#[test]
fn a_production_deployment_answers_only_for_its_own_venture() {
let trust = HostTrust::resolve(&tenant_a(), &production());
assert!(trust.enforce);
assert!(trust.answers_to("tenant-a.example"));
assert!(trust.answers_to("api.tenant-a.example"));
assert!(trust.answers_to("TENANT-A.example:8443"), "case and port");
for foreign in [
"tenant-b.example",
"api.tenant-b.example",
"tenant-a.example.evil.test",
"eviltenant-a.example",
"evil.test",
] {
assert!(
!trust.answers_to(foreign),
"{foreign} must not be answered for"
);
}
let other = HostTrust::resolve(&tenant_b(), &production());
assert!(other.answers_to("tenant-b.example"));
assert!(!other.answers_to("tenant-a.example"));
}
#[test]
fn extra_hosts_are_additive_and_never_widen_to_a_neighbour() {
let config = MapConfig::from_pairs([
("ENV", "production"),
("TRUSTED_HOSTS", "cdn.tenant-a.example"),
]);
let trust = HostTrust::resolve(&tenant_a(), &config);
assert!(trust.answers_to("cdn.tenant-a.example"));
assert!(trust.answers_to("tenant-a.example"));
assert!(!trust.answers_to("tenant-b.example"));
}
#[test]
fn loopback_stays_reachable_so_probes_and_local_work_keep_working() {
let trust = HostTrust::resolve(&tenant_a(), &production());
for local in ["127.0.0.1:8080", "localhost:8080", "[::1]:8080"] {
assert!(trust.answers_to(local), "{local} is how a probe arrives");
}
}
#[test]
fn enforcement_is_production_only_and_follows_the_deployment() {
assert!(!HostTrust::resolve(&tenant_a(), &MapConfig::default()).enforce);
assert!(HostTrust::resolve(&tenant_a(), &production()).enforce);
let compiled = Venture::new("tenant-a", "tenant-a.example").env(VentureEnv::Production);
assert!(HostTrust::resolve(&compiled, &MapConfig::default()).enforce);
}
#[test]
fn a_venture_with_no_domain_serves_rather_than_refusing_everything() {
let anonymous = Venture::new("anon", "");
let trust = HostTrust::resolve(&anonymous, &production());
assert!(trust.allowed.is_empty());
assert!(trust.answers_to("anything.example"));
}
}