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::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(crate) 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 problem_type_base: Arc<str>,
226}
227
228impl HostTrust {
229 fn resolve(venture: &cratefield_core::Venture, config: &dyn Config) -> Self {
230 let mut allowed = venture_hosts(venture);
231 for host in trusted_hosts(config) {
232 if !allowed.contains(&host) {
233 allowed.push(host);
234 }
235 }
236 let enforce = cratefield_core::deployed_env(venture.env, config)
237 == cratefield_core::VentureEnv::Production;
238 if enforce && allowed.is_empty() {
239 tracing::warn!(
240 "no host to answer to: the venture declares no domain and TRUSTED_HOSTS is \
241 unset, so every request would be refused — serving all hosts instead \
242 (issue #129)"
243 );
244 }
245 Self {
246 allowed: Arc::new(allowed),
247 enforce,
248 problem_type_base: venture.problem_type_base().into(),
249 }
250 }
251
252 fn answers_to(&self, host: &str) -> bool {
253 if self.allowed.is_empty() {
254 return true;
255 }
256 let host = crate::ports::host_without_port(host);
257 self.allowed.iter().any(|known| known == &host) || is_loopback_host(&host)
258 }
259}
260
261async fn host_layer(State(trust): State<HostTrust>, request: Request, next: Next) -> Response {
272 if !trust.enforce {
273 return next.run(request).await;
274 }
275 let host = request
276 .headers()
277 .get(axum::http::header::HOST)
278 .and_then(|value| value.to_str().ok())
279 .map(str::to_owned)
280 .or_else(|| {
281 request
282 .uri()
283 .authority()
284 .map(|authority| authority.as_str().to_owned())
285 });
286 let Some(host) = host else {
287 return refuse_host(None, &trust.problem_type_base);
290 };
291 if !trust.answers_to(&host) {
292 return refuse_host(Some(&host), &trust.problem_type_base);
293 }
294 next.run(request).await
295}
296
297fn refuse_host(host: Option<&str>, problem_type_base: &str) -> Response {
300 if let Some(host) = host {
301 tracing::warn!(
302 host = crate::ports::host_without_port(host),
303 "refused a request for a host this deployment does not serve"
304 );
305 } else {
306 tracing::warn!("refused a request that carried no host");
307 }
308 let problem = cratefield_core::Problem::new(&cratefield_core::SLUGS.not_found)
309 .with_detail("this deployment does not serve that host");
310 let mut response = problem.into_response_with_base(problem_type_base);
311 *response.status_mut() = axum::http::StatusCode::MISDIRECTED_REQUEST;
312 response
313}
314
315async fn client_ip_layer(
321 State(trust): State<IpTrust>,
322 mut request: Request,
323 next: Next,
324) -> Response {
325 let peer = request
326 .extensions()
327 .get::<ConnectInfo<SocketAddr>>()
328 .map(|ConnectInfo(addr)| addr.ip());
329 let resolved = client_ip(request.headers(), &trust.trusted, peer);
330 normalize_headers(request.headers_mut(), resolved);
331 next.run(request).await
332}
333
334pub async fn shutdown_signal() {
344 let ctrl_c = tokio::signal::ctrl_c();
345 #[cfg(unix)]
346 {
347 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
348 .expect("install SIGTERM handler");
349 tokio::select! {
350 _ = ctrl_c => {},
351 _ = sigterm.recv() => {},
352 }
353 }
354 #[cfg(not(unix))]
355 {
356 if ctrl_c.await.is_err() {
357 tracing::error!("ctrl_c handler failed; exiting");
358 }
359 }
360 tracing::info!("shutdown signal received; draining in-flight requests");
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use cratefield_core::MapConfig;
367
368 #[test]
369 fn crons_default_to_empty_and_split_on_commas() {
370 assert!(cron_expressions(&MapConfig::default()).is_empty());
371 let cfg = MapConfig::from_pairs([("CRONS", "*/5 * * * * , 0 0 * * *,,")]);
372 assert_eq!(cron_expressions(&cfg), ["*/5 * * * *", "0 0 * * *"]);
373 }
374
375 #[test]
376 fn listen_addr_defaults_to_loopback() {
377 let addr = listen_addr(&MapConfig::default()).expect("default parses");
378 assert_eq!(addr.to_string(), "127.0.0.1:8080");
379
380 let cfg = MapConfig::from_pairs([("LISTEN_ADDR", "0.0.0.0:9999")]);
381 let addr = listen_addr(&cfg).expect("parses");
382 assert_eq!(addr.to_string(), "0.0.0.0:9999");
383 }
384
385 #[test]
386 fn bad_listen_addr_is_an_error() {
387 let cfg = MapConfig::from_pairs([("LISTEN_ADDR", "not-an-addr")]);
388 assert!(matches!(
389 listen_addr(&cfg),
390 Err(ServeError::ListenAddr { .. })
391 ));
392 }
393
394 use cratefield_core::{Venture, VentureEnv};
397
398 fn tenant_a() -> Venture {
399 Venture::new("tenant-a", "tenant-a.example").public_url("https://tenant-a.example")
400 }
401
402 fn tenant_b() -> Venture {
403 Venture::new("tenant-b", "tenant-b.example").public_url("https://tenant-b.example")
404 }
405
406 fn production() -> MapConfig {
407 MapConfig::from_pairs([("ENV", "production")])
408 }
409
410 #[test]
411 fn a_production_deployment_answers_only_for_its_own_venture() {
412 let trust = HostTrust::resolve(&tenant_a(), &production());
413 assert!(trust.enforce);
414 assert!(trust.answers_to("tenant-a.example"));
415 assert!(trust.answers_to("api.tenant-a.example"));
416 assert!(trust.answers_to("TENANT-A.example:8443"), "case and port");
417
418 for foreign in [
423 "tenant-b.example",
424 "api.tenant-b.example",
425 "tenant-a.example.evil.test",
426 "eviltenant-a.example",
427 "evil.test",
428 ] {
429 assert!(
430 !trust.answers_to(foreign),
431 "{foreign} must not be answered for"
432 );
433 }
434
435 let other = HostTrust::resolve(&tenant_b(), &production());
438 assert!(other.answers_to("tenant-b.example"));
439 assert!(!other.answers_to("tenant-a.example"));
440 }
441
442 #[test]
443 fn extra_hosts_are_additive_and_never_widen_to_a_neighbour() {
444 let config = MapConfig::from_pairs([
445 ("ENV", "production"),
446 ("TRUSTED_HOSTS", "cdn.tenant-a.example"),
447 ]);
448 let trust = HostTrust::resolve(&tenant_a(), &config);
449 assert!(trust.answers_to("cdn.tenant-a.example"));
450 assert!(trust.answers_to("tenant-a.example"));
451 assert!(!trust.answers_to("tenant-b.example"));
452 }
453
454 #[test]
455 fn loopback_stays_reachable_so_probes_and_local_work_keep_working() {
456 let trust = HostTrust::resolve(&tenant_a(), &production());
457 for local in ["127.0.0.1:8080", "localhost:8080", "[::1]:8080"] {
458 assert!(trust.answers_to(local), "{local} is how a probe arrives");
459 }
460 }
461
462 #[test]
463 fn enforcement_is_production_only_and_follows_the_deployment() {
464 assert!(!HostTrust::resolve(&tenant_a(), &MapConfig::default()).enforce);
468
469 assert!(HostTrust::resolve(&tenant_a(), &production()).enforce);
472 let compiled = Venture::new("tenant-a", "tenant-a.example").env(VentureEnv::Production);
473 assert!(HostTrust::resolve(&compiled, &MapConfig::default()).enforce);
474 }
475
476 #[test]
477 fn a_venture_with_no_domain_serves_rather_than_refusing_everything() {
478 let anonymous = Venture::new("anon", "");
482 let trust = HostTrust::resolve(&anonymous, &production());
483 assert!(trust.allowed.is_empty());
484 assert!(trust.answers_to("anything.example"));
485 }
486}