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::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    /// The venture's problem base (issue #557): the refusal is built
223    /// outside the harness router, so no layer stands behind it to name
224    /// the venture.
225    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
261/// Host middleware (issue #129): a native process answers whatever `Host`
262/// a caller sends, and `Host` is what ADR 0008's database-per-tenant
263/// resolution will key off once phase three lands. An unvalidated one is a
264/// link-forgery and cache-poisoning vector now and the cross-tenant vector
265/// then, so a production deployment refuses a host it does not serve.
266///
267/// `x-forwarded-host` is deliberately not consulted: it is not in the
268/// `TRUSTED_PROXY_HEADERS` contract #131 established, and a second,
269/// parallel header-trust mechanism is exactly what that contract exists to
270/// prevent. A proxy that rewrites the host must rewrite `Host` itself.
271async 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        // HTTP/1.1 requires it and HTTP/2 supplies `:authority`; a
288        // request with neither cannot be resolved to a venture.
289        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
297/// The refusal: `421 Misdirected Request` is precisely this condition —
298/// the connection reached a server that does not answer for the host.
299fn 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
315/// Client-IP middleware: resolve the address (trusted headers first,
316/// else the TCP peer), strip every forwarding header, and set the
317/// verdict into the header core's `client_ip` reads first. Runs outside
318/// the harness's own layers, so the request scope's `ip_hash` and every
319/// module's rate-limit key see the sanitized view.
320async 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
334/// Resolves when the process should stop: SIGINT (Ctrl-C) or, on Unix,
335/// SIGTERM (what `docker stop` and systemd send). Public so a venture
336/// that assembles its own serving loop gets the same semantics.
337///
338/// # Panics
339///
340/// Only if the signal handlers cannot be installed (the process
341/// environment disallows it); serving cannot continue meaningfully
342/// without shutdown signals.
343pub 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    // ------------------------------------------ host isolation (issue #129)
395
396    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        // The adversarial half: the other tenant's host, and a host that
419        // merely looks like ours, are both refused. `Host` is what ADR
420        // 0008's per-tenant resolution will read, so answering for a
421        // neighbour's host is the cross-tenant vector this closes.
422        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        // And the mirror image holds for the other venture, so neither
436        // deployment would serve the other's traffic.
437        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        // Below production the server answers anything: local development
465        // and the test suite reach it by whatever name is convenient, and
466        // refusing those would make the safe deployment unrunnable.
467        assert!(!HostTrust::resolve(&tenant_a(), &MapConfig::default()).enforce);
468
469        // The deployment's ENV is what decides, not the compiled default
470        // — the same rule #143 established for readiness.
471        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        // Fail-closed must not mean fail-useless: with nothing to compare
479        // against, refusing every request would take the deployment down
480        // for a configuration mistake it can report instead.
481        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}