Skip to main content

cratefield_runtime_native/
server.rs

1//! The native server (issue #19): one axum router on a `TcpListener`,
2//! with client-IP sanitization outside everything else, the in-process
3//! cron scheduler, and graceful shutdown on SIGTERM/SIGINT (what
4//! `docker stop` sends).
5
6use 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/// Logs the push wiring report once at cold start (issue #191), the twin of
26/// the Cloudflare runtime's `check_push_wiring_once`. Names and verdicts
27/// only: the report never carries a value, so no secret reaches the logs
28/// through it.
29///
30/// A half-wired transport is `error!` in production and `warn!` below it — a
31/// venture that meant to enable FCM and mistyped one variable must not boot
32/// into a state where every Android send silently answers `NotConfigured`,
33/// while a developer wiring a transport one variable at a time must still be
34/// able to boot. `fz doctor` is what refuses a deploy.
35///
36/// Nothing is logged when the venture passed its own adapter: `push_wiring`
37/// answers `None`, so no environment is read and no transport the venture
38/// deliberately overrode is reported on.
39#[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
62/// `LISTEN_ADDR` default: loopback only. A deployment open to the world
63/// must say so explicitly (`LISTEN_ADDR=0.0.0.0:8080`, as the compose
64/// example does) — the same fail-closed instinct as the empty
65/// trusted-proxy list.
66pub(crate) const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:8080";
67
68#[derive(Debug, Error)]
69pub enum ServeError {
70    /// `LISTEN_ADDR` is not a socket address.
71    #[error("LISTEN_ADDR {addr:?} is not a valid socket address")]
72    ListenAddr { addr: String },
73    /// The listener could not be bound.
74    #[error("failed to bind {addr}: {source}")]
75    Bind {
76        addr: String,
77        #[source]
78        source: std::io::Error,
79    },
80    /// A `CRONS` entry is not a valid cron expression; the process
81    /// refuses to start rather than silently skipping scheduled work
82    /// (wrangler rejects an invalid cron at deploy time for the same
83    /// reason).
84    #[error("invalid CRONS entry")]
85    Cron(#[from] CronError),
86    /// The serving loop failed.
87    #[error("server failed: {0}")]
88    Serve(#[source] std::io::Error),
89}
90
91/// Serves the harness: builds the router once, wraps it in the client-IP
92/// middleware, starts the cron scheduler for the `CRONS` config, and
93/// serves until SIGTERM or SIGINT.
94///
95/// # Errors
96///
97/// [`ServeError`] when `LISTEN_ADDR` cannot be parsed or bound, a cron
98/// expression is invalid, or the accept loop fails.
99///
100/// # Errors: shutdown semantics
101///
102/// Graceful shutdown waits for in-flight requests only. Deferred work
103/// (`Defer`/`tokio::spawn`) is not awaited on shutdown — a task killed
104/// mid-flight simply ends, like a Worker that stops getting slices
105/// after its 30 s `wait_until` budget. Handlers that must finish commit
106/// before responding.
107pub 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
120/// Serves the harness on an already-bound listener: builds the router
121/// once, wraps it in the client-IP middleware (trust list and `CRONS`
122/// from `std::env`), and serves until SIGTERM or SIGINT. `serve` is this
123/// plus `LISTEN_ADDR` resolution and binding; tests and ventures with
124/// their own socket management (fd passing, port 0) call this directly.
125///
126/// # Errors
127///
128/// [`ServeError`] when a `CRONS` entry is invalid or the accept loop
129/// fails. Shutdown semantics as on [`serve`].
130pub 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    // Scheduled work: every expression gets its own task, fanned out to
143    // every module's `scheduled(ctx, cron)` — the same fan-out
144    // `serve_scheduled` performs per Workers cron trigger.
145    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        // Outside the IP layer: a request for a host this deployment does
159        // not answer to is refused before anything reads its headers.
160        .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
180/// `CRONS`: comma-separated cron expressions (UTC, wrangler's `[triggers]
181/// crons` moved into config). Default: no scheduled work.
182fn 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/// The trusted-proxy header list, resolved once per process and shared
204/// by every request (deployment config, not request state).
205#[derive(Clone)]
206struct IpTrust {
207    trusted: Vec<String>,
208}
209
210/// The hosts this deployment answers to, resolved once per process
211/// (issue #129).
212#[derive(Clone)]
213struct HostTrust {
214    /// Lowercased, port-less hosts. Empty means "answer to anything",
215    /// which only happens below production.
216    allowed: Arc<Vec<String>>,
217    /// Whether an unknown host is refused. Production only: local
218    /// development and the test suite reach the server by loopback
219    /// address, and refusing those by default would make the safe
220    /// deployment the one nobody can run.
221    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
256/// Host middleware (issue #129): a native process answers whatever `Host`
257/// a caller sends, and `Host` is what ADR 0008's database-per-tenant
258/// resolution will key off once phase three lands. An unvalidated one is a
259/// link-forgery and cache-poisoning vector now and the cross-tenant vector
260/// then, so a production deployment refuses a host it does not serve.
261///
262/// `x-forwarded-host` is deliberately not consulted: it is not in the
263/// `TRUSTED_PROXY_HEADERS` contract #131 established, and a second,
264/// parallel header-trust mechanism is exactly what that contract exists to
265/// prevent. A proxy that rewrites the host must rewrite `Host` itself.
266async 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        // HTTP/1.1 requires it and HTTP/2 supplies `:authority`; a
283        // request with neither cannot be resolved to a venture.
284        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
292/// The refusal: `421 Misdirected Request` is precisely this condition —
293/// the connection reached a server that does not answer for the host.
294fn 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
308/// Client-IP middleware: resolve the address (trusted headers first,
309/// else the TCP peer), strip every forwarding header, and set the
310/// verdict into the header core's `client_ip` reads first. Runs outside
311/// the harness's own layers, so the request scope's `ip_hash` and every
312/// module's rate-limit key see the sanitized view.
313async 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
327/// Resolves when the process should stop: SIGINT (Ctrl-C) or, on Unix,
328/// SIGTERM (what `docker stop` and systemd send). Public so a venture
329/// that assembles its own serving loop gets the same semantics.
330///
331/// # Panics
332///
333/// Only if the signal handlers cannot be installed (the process
334/// environment disallows it); serving cannot continue meaningfully
335/// without shutdown signals.
336pub 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    // ------------------------------------------ host isolation (issue #129)
388
389    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        // The adversarial half: the other tenant's host, and a host that
412        // merely looks like ours, are both refused. `Host` is what ADR
413        // 0008's per-tenant resolution will read, so answering for a
414        // neighbour's host is the cross-tenant vector this closes.
415        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        // And the mirror image holds for the other venture, so neither
429        // deployment would serve the other's traffic.
430        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        // Below production the server answers anything: local development
458        // and the test suite reach it by whatever name is convenient, and
459        // refusing those would make the safe deployment unrunnable.
460        assert!(!HostTrust::resolve(&tenant_a(), &MapConfig::default()).enforce);
461
462        // The deployment's ENV is what decides, not the compiled default
463        // — the same rule #143 established for readiness.
464        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        // Fail-closed must not mean fail-useless: with nothing to compare
472        // against, refusing every request would take the deployment down
473        // for a configuration mistake it can report instead.
474        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}