cratefield_runtime_native/
server.rs1use std::net::SocketAddr;
7use std::sync::Arc;
8
9use axum::extract::{ConnectInfo, Request, State};
10use axum::middleware::{Next, from_fn_with_state};
11use axum::response::{IntoResponse, Response};
12use cratefield_core::{Config, Harness};
13use thiserror::Error;
14use tokio::net::TcpListener;
15
16use crate::config::EnvConfig;
17use crate::cron::{CronError, spawn_cron_scheduler};
18use crate::ports::{
19 client_ip, is_loopback_host, normalize_headers, trusted_hosts, trusted_proxy_headers,
20 venture_hosts,
21};
22use crate::runtime::{Native, clone_ports};
23use crate::tracing_setup::install_tracing;
24
25#[cfg(feature = "push")]
40fn log_push_wiring(harness: &Harness, runtime: &Native, config: &dyn Config) {
41 use cratefield_push_wiring::WiringSeverity;
42 let Some(wiring) = runtime.push_wiring() else {
43 return;
44 };
45 tracing::info!("{}", wiring.summary());
46 let deployed = cratefield_core::deployed_env(harness.venture().env, config);
47 match wiring.severity(deployed) {
48 WiringSeverity::Ok => {}
49 WiringSeverity::Warning => {
50 for problem in wiring.problems() {
51 tracing::warn!("{problem}");
52 }
53 }
54 WiringSeverity::Error => {
55 for problem in wiring.problems() {
56 tracing::error!("{problem}");
57 }
58 }
59 }
60}
61
62pub const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:8080";
67
68#[derive(Debug, Error)]
69pub enum ServeError {
70 #[error("LISTEN_ADDR {addr:?} is not a valid socket address")]
72 ListenAddr { addr: String },
73 #[error("failed to bind {addr}: {source}")]
75 Bind {
76 addr: String,
77 #[source]
78 source: std::io::Error,
79 },
80 #[error("invalid CRONS entry")]
85 Cron(#[from] CronError),
86 #[error("server failed: {0}")]
88 Serve(#[source] std::io::Error),
89}
90
91pub async fn serve(harness: Arc<Harness>, runtime: Native) -> Result<(), ServeError> {
108 install_tracing();
109 let config = Arc::new(EnvConfig);
110 let addr = listen_addr(&*config)?;
111 let listener = TcpListener::bind(addr)
112 .await
113 .map_err(|source| ServeError::Bind {
114 addr: addr.to_string(),
115 source,
116 })?;
117 serve_on(harness, runtime, listener).await
118}
119
120pub async fn serve_on(
131 harness: Arc<Harness>,
132 runtime: Native,
133 listener: TcpListener,
134) -> Result<(), ServeError> {
135 install_tracing();
136
137 let config = Arc::new(EnvConfig);
138 let ports = runtime.ports();
139 #[cfg(feature = "push")]
140 log_push_wiring(&harness, &runtime, &*config);
141
142 let crons = cron_expressions(&*config);
146 if !crons.is_empty() {
147 spawn_cron_scheduler(&harness, &ports, &crons)?;
148 }
149
150 let app = harness
151 .router(clone_ports(&ports))
152 .layer(from_fn_with_state(
153 IpTrust {
154 trusted: trusted_proxy_headers(&*config),
155 },
156 client_ip_layer,
157 ))
158 .layer(from_fn_with_state(
161 HostTrust::resolve(harness.venture(), &*config),
162 host_layer,
163 ));
164
165 let addr = listener
166 .local_addr()
167 .map(|addr| addr.to_string())
168 .unwrap_or_default();
169 tracing::info!(%addr, "cratefield native runtime listening");
170
171 axum::serve(
172 listener,
173 app.into_make_service_with_connect_info::<SocketAddr>(),
174 )
175 .with_graceful_shutdown(shutdown_signal())
176 .await
177 .map_err(ServeError::Serve)
178}
179
180fn cron_expressions(config: &dyn Config) -> Vec<String> {
183 config
184 .get("CRONS")
185 .map(|raw| {
186 raw.split(',')
187 .map(str::trim)
188 .filter(|entry| !entry.is_empty())
189 .map(str::to_owned)
190 .collect::<Vec<_>>()
191 })
192 .unwrap_or_default()
193}
194
195fn listen_addr(config: &dyn Config) -> Result<SocketAddr, ServeError> {
196 let raw = config
197 .get("LISTEN_ADDR")
198 .unwrap_or_else(|| DEFAULT_LISTEN_ADDR.to_owned());
199 raw.parse::<SocketAddr>()
200 .map_err(|_| ServeError::ListenAddr { addr: raw })
201}
202
203#[derive(Clone)]
206struct IpTrust {
207 trusted: Vec<String>,
208}
209
210#[derive(Clone)]
213struct HostTrust {
214 allowed: Arc<Vec<String>>,
217 enforce: bool,
222}
223
224impl HostTrust {
225 fn resolve(venture: &cratefield_core::Venture, config: &dyn Config) -> Self {
226 let mut allowed = venture_hosts(venture);
227 for host in trusted_hosts(config) {
228 if !allowed.contains(&host) {
229 allowed.push(host);
230 }
231 }
232 let enforce = cratefield_core::deployed_env(venture.env, config)
233 == cratefield_core::VentureEnv::Production;
234 if enforce && allowed.is_empty() {
235 tracing::warn!(
236 "no host to answer to: the venture declares no domain and TRUSTED_HOSTS is \
237 unset, so every request would be refused — serving all hosts instead \
238 (issue #129)"
239 );
240 }
241 Self {
242 allowed: Arc::new(allowed),
243 enforce,
244 }
245 }
246
247 fn answers_to(&self, host: &str) -> bool {
248 if self.allowed.is_empty() {
249 return true;
250 }
251 let host = crate::ports::host_without_port(host);
252 self.allowed.iter().any(|known| known == &host) || is_loopback_host(&host)
253 }
254}
255
256async fn host_layer(State(trust): State<HostTrust>, request: Request, next: Next) -> Response {
267 if !trust.enforce {
268 return next.run(request).await;
269 }
270 let host = request
271 .headers()
272 .get(axum::http::header::HOST)
273 .and_then(|value| value.to_str().ok())
274 .map(str::to_owned)
275 .or_else(|| {
276 request
277 .uri()
278 .authority()
279 .map(|authority| authority.as_str().to_owned())
280 });
281 let Some(host) = host else {
282 return refuse_host(None);
285 };
286 if !trust.answers_to(&host) {
287 return refuse_host(Some(&host));
288 }
289 next.run(request).await
290}
291
292fn refuse_host(host: Option<&str>) -> Response {
295 if let Some(host) = host {
296 tracing::warn!(
297 host = crate::ports::host_without_port(host),
298 "refused a request for a host this deployment does not serve"
299 );
300 } else {
301 tracing::warn!("refused a request that carried no host");
302 }
303 let problem = cratefield_core::Problem::new(&cratefield_core::SLUGS.not_found)
304 .with_detail("this deployment does not serve that host");
305 (axum::http::StatusCode::MISDIRECTED_REQUEST, problem).into_response()
306}
307
308async fn client_ip_layer(
314 State(trust): State<IpTrust>,
315 mut request: Request,
316 next: Next,
317) -> Response {
318 let peer = request
319 .extensions()
320 .get::<ConnectInfo<SocketAddr>>()
321 .map(|ConnectInfo(addr)| addr.ip());
322 let resolved = client_ip(request.headers(), &trust.trusted, peer);
323 normalize_headers(request.headers_mut(), resolved);
324 next.run(request).await
325}
326
327pub async fn shutdown_signal() {
337 let ctrl_c = tokio::signal::ctrl_c();
338 #[cfg(unix)]
339 {
340 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
341 .expect("install SIGTERM handler");
342 tokio::select! {
343 _ = ctrl_c => {},
344 _ = sigterm.recv() => {},
345 }
346 }
347 #[cfg(not(unix))]
348 {
349 if ctrl_c.await.is_err() {
350 tracing::error!("ctrl_c handler failed; exiting");
351 }
352 }
353 tracing::info!("shutdown signal received; draining in-flight requests");
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use cratefield_core::MapConfig;
360
361 #[test]
362 fn crons_default_to_empty_and_split_on_commas() {
363 assert!(cron_expressions(&MapConfig::default()).is_empty());
364 let cfg = MapConfig::from_pairs([("CRONS", "*/5 * * * * , 0 0 * * *,,")]);
365 assert_eq!(cron_expressions(&cfg), ["*/5 * * * *", "0 0 * * *"]);
366 }
367
368 #[test]
369 fn listen_addr_defaults_to_loopback() {
370 let addr = listen_addr(&MapConfig::default()).expect("default parses");
371 assert_eq!(addr.to_string(), "127.0.0.1:8080");
372
373 let cfg = MapConfig::from_pairs([("LISTEN_ADDR", "0.0.0.0:9999")]);
374 let addr = listen_addr(&cfg).expect("parses");
375 assert_eq!(addr.to_string(), "0.0.0.0:9999");
376 }
377
378 #[test]
379 fn bad_listen_addr_is_an_error() {
380 let cfg = MapConfig::from_pairs([("LISTEN_ADDR", "not-an-addr")]);
381 assert!(matches!(
382 listen_addr(&cfg),
383 Err(ServeError::ListenAddr { .. })
384 ));
385 }
386
387 use cratefield_core::{Venture, VentureEnv};
390
391 fn tenant_a() -> Venture {
392 Venture::new("tenant-a", "tenant-a.example").public_url("https://tenant-a.example")
393 }
394
395 fn tenant_b() -> Venture {
396 Venture::new("tenant-b", "tenant-b.example").public_url("https://tenant-b.example")
397 }
398
399 fn production() -> MapConfig {
400 MapConfig::from_pairs([("ENV", "production")])
401 }
402
403 #[test]
404 fn a_production_deployment_answers_only_for_its_own_venture() {
405 let trust = HostTrust::resolve(&tenant_a(), &production());
406 assert!(trust.enforce);
407 assert!(trust.answers_to("tenant-a.example"));
408 assert!(trust.answers_to("api.tenant-a.example"));
409 assert!(trust.answers_to("TENANT-A.example:8443"), "case and port");
410
411 for foreign in [
416 "tenant-b.example",
417 "api.tenant-b.example",
418 "tenant-a.example.evil.test",
419 "eviltenant-a.example",
420 "evil.test",
421 ] {
422 assert!(
423 !trust.answers_to(foreign),
424 "{foreign} must not be answered for"
425 );
426 }
427
428 let other = HostTrust::resolve(&tenant_b(), &production());
431 assert!(other.answers_to("tenant-b.example"));
432 assert!(!other.answers_to("tenant-a.example"));
433 }
434
435 #[test]
436 fn extra_hosts_are_additive_and_never_widen_to_a_neighbour() {
437 let config = MapConfig::from_pairs([
438 ("ENV", "production"),
439 ("TRUSTED_HOSTS", "cdn.tenant-a.example"),
440 ]);
441 let trust = HostTrust::resolve(&tenant_a(), &config);
442 assert!(trust.answers_to("cdn.tenant-a.example"));
443 assert!(trust.answers_to("tenant-a.example"));
444 assert!(!trust.answers_to("tenant-b.example"));
445 }
446
447 #[test]
448 fn loopback_stays_reachable_so_probes_and_local_work_keep_working() {
449 let trust = HostTrust::resolve(&tenant_a(), &production());
450 for local in ["127.0.0.1:8080", "localhost:8080", "[::1]:8080"] {
451 assert!(trust.answers_to(local), "{local} is how a probe arrives");
452 }
453 }
454
455 #[test]
456 fn enforcement_is_production_only_and_follows_the_deployment() {
457 assert!(!HostTrust::resolve(&tenant_a(), &MapConfig::default()).enforce);
461
462 assert!(HostTrust::resolve(&tenant_a(), &production()).enforce);
465 let compiled = Venture::new("tenant-a", "tenant-a.example").env(VentureEnv::Production);
466 assert!(HostTrust::resolve(&compiled, &MapConfig::default()).enforce);
467 }
468
469 #[test]
470 fn a_venture_with_no_domain_serves_rather_than_refusing_everything() {
471 let anonymous = Venture::new("anon", "");
475 let trust = HostTrust::resolve(&anonymous, &production());
476 assert!(trust.allowed.is_empty());
477 assert!(trust.answers_to("anything.example"));
478 }
479}