Skip to main content

boatramp_server/
serve_pipeline.rs

1//! The request-serving pipeline: host-based routing (`serve_by_host`), the
2//! by-name admin route, preview and ACME domain-challenge serving, access
3//! control (auth gate, basic-auth, rate-limit), request-context building, and
4//! the resolve -> entry -> static/handler/proxy dispatch that streams the
5//! chosen response. Pulls the shared response helpers and backends in via
6//! `use super::*`.
7
8use super::*;
9use boatramp_core::project::ProjectRef;
10
11/// A request's network identity, threaded into the serving pipeline for
12/// access control: the socket peer plus the shared rate limiter.
13struct Visitor<'a> {
14    peer: IpAddr,
15    limiter: &'a dyn RateLimitStore,
16}
17
18/// Serve under the explicit by-name admin/testing route `/_sites/<site>/...`.
19/// The catch-all captures `<site>` or `<site>/<path...>`. Accepts any method so a
20/// proxy rewrite can forward non-`GET` requests. This route is not host-routed and
21/// does not serve a root-mounted site — for that, use host routing (see the
22/// addressing docs).
23pub(super) async fn serve_sites(
24    State(deploy): State<DeployStore>,
25    Extension(limiter): Extension<Arc<dyn RateLimitStore>>,
26    Extension(handlers): Extension<Arc<HandlerRuntime>>,
27    ConnectInfo(peer): ConnectInfo<SocketAddr>,
28    request: Request,
29) -> Response {
30    let raw = request.uri().path();
31    let rest = raw
32        .strip_prefix("/_sites/")
33        .unwrap_or("")
34        .trim_start_matches('/');
35    let (site, path) = rest.split_once('/').unwrap_or((rest, ""));
36    if site.is_empty() {
37        return not_found();
38    }
39    let (site, request_path) = (site.to_string(), format!("/{path}"));
40    let visitor = Visitor {
41        peer: peer.ip(),
42        limiter: limiter.as_ref(),
43    };
44    // The explicit `/_sites/<name>/` admin/testing route is not host-routed, so
45    // transport/canonical redirects don't apply. It addresses sites by bare name,
46    // a default-project admin convenience.
47    serve_request(
48        &deploy,
49        ProjectRef::DEFAULT.as_str(),
50        &site,
51        &request_path,
52        request,
53        &visitor,
54        &handlers,
55        false,
56    )
57    .await
58}
59
60/// The root-key-signed bootstrap-TLS identity attestation (base64url
61/// `COSE_Sign1`), carried as an extension for [`serve_bootstrap_identity`].
62#[derive(Clone)]
63pub(super) struct BootstrapAttestation(pub(super) Option<String>);
64
65/// `GET /.well-known/boatramp-bootstrap-identity` — serve the root-key attestation
66/// of this node's `--tls rpk` control-plane TLS key. Public + unauthenticated: a
67/// signed statement that reveals nothing (the TLS public key is already presented
68/// in the handshake). A client pinning only the root key verifies it (root
69/// signature + validity), extracts the attested TLS key, and pins it. `404` when
70/// no attestation is set (not `--tls rpk`, or a verify-only node with no issuer).
71pub(super) async fn serve_bootstrap_identity(
72    Extension(att): Extension<BootstrapAttestation>,
73) -> Response {
74    match att.0 {
75        Some(a) => (
76            StatusCode::OK,
77            [(header::CONTENT_TYPE, "application/octet-stream")],
78            a,
79        )
80            .into_response(),
81        None => not_found(),
82    }
83}
84
85/// Serve a pending **HTTP domain-ownership challenge** from the edge, *before*
86/// host routing — the fix for the verify-before-attach chicken-and-egg. A host
87/// pointed at this server but not yet attached to any site (so it would
88/// otherwise fall through to `default_site` and 404 its own challenge) fetches
89/// its token here, letting `domain verify` succeed with no prior deploy. Returns
90/// the token for a matching `(Host, token)` pending HTTP challenge, else `404`.
91///
92/// Gated by the `domain_verify_self_serve` posture knob (on by default). It only
93/// ever echoes back a random token to the very host that owns the pending
94/// challenge, so it leaks nothing and needs no auth (like an ACME http-01
95/// challenge). Mounted on both the main router and the `:80` redirect router, so
96/// the plain-HTTP probe is answered directly instead of 308-redirected to an
97/// HTTPS endpoint that may have no cert yet.
98pub(super) async fn serve_domain_challenge(
99    State(deploy): State<DeployStore>,
100    Extension(posture): Extension<boatramp_core::security::SecurityPosture>,
101    Path(token): Path<String>,
102    headers: HeaderMap,
103) -> Response {
104    if !posture.domain_verify_self_serve {
105        return not_found();
106    }
107    let Some(host) = headers
108        .get(header::HOST)
109        .and_then(|value| value.to_str().ok())
110        .map(strip_port)
111    else {
112        return not_found();
113    };
114    match deploy
115        .find_pending_http_challenge(host, &token, now_unix())
116        .await
117    {
118        Ok(Some(v)) => (
119            StatusCode::OK,
120            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
121            v.token,
122        )
123            .into_response(),
124        Ok(None) => not_found(),
125        Err(err) => deploy_error_response(err),
126    }
127}
128
129/// The serve fast path (hot-path bypass): the dependencies the axum router's Extension
130/// layers hold, cloned so [`RouterHandler`](crate::http_serve) can call
131/// [`serve_by_host_inner`] directly for a plain site GET/HEAD — skipping the axum
132/// Router + middleware future-composition tax (~15–20% of per-core CPU, profiled) — while
133/// every security check still runs inside `serve_by_host_inner`. Non-eligible requests
134/// fall through to the full router unchanged.
135// An opaque handle held by the serve loops (`RouterHandler`); `pub` because the public
136// `router_with_fast` returns it, but its fields are `pub(crate)` so it stays opaque to
137// external crates — they only pass it back into a serve loop.
138#[derive(Clone)]
139pub struct FastServe {
140    pub(crate) deploy: DeployStore,
141    pub(crate) limiter: Arc<dyn RateLimitStore>,
142    pub(crate) handlers: Arc<HandlerRuntime>,
143    pub(crate) daemon: Arc<DaemonRuntime>,
144    pub(crate) implicit: ImplicitRouting,
145    pub(crate) preview_auth: Auth,
146    /// The operator security posture and listener TLS flag the router carries as
147    /// per-request extensions (`Extension(posture)` / `Extension(served_over_tls)`). The
148    /// site path reads them from the request — the SSRF gate on a gateway-upstream proxy
149    /// ([`proxy::request_posture`](crate::proxy)) and the scheme/HSTS derivation
150    /// ([`serve_request`]) — so the bypass must re-insert them to stay byte-identical to
151    /// the router (a permissive fleet would otherwise wrongly refuse a private upstream,
152    /// and a TLS listener would derive `http`).
153    pub(crate) posture: boatramp_core::security::SecurityPosture,
154    pub(crate) served_over_tls: bool,
155    /// When serving a TLS listener paired with an HTTP/3 endpoint, the `Alt-Svc` value the
156    /// router's [`advertise_http3`](crate::advertise_http3) layer would add — re-applied by
157    /// [`dispatch`](Self::dispatch) so a bypassed response still advertises h3. `None` on
158    /// the plaintext path (no h3) and on TLS without http3.
159    pub(crate) alt_svc: Option<axum::http::HeaderValue>,
160}
161
162impl FastServe {
163    /// Advertise a paired HTTP/3 listener on `port`: an eligible bypassed response gains the
164    /// same `Alt-Svc` header the router's [`advertise_http3`](crate::advertise_http3) layer
165    /// adds. Call it (with the same port) exactly where `advertise_http3` wraps the router, so
166    /// the two paths stay byte-identical. TLS + http3 only.
167    #[cfg(feature = "http3")]
168    pub fn advertise_http3(mut self, port: u16) -> Self {
169        self.alt_svc = axum::http::HeaderValue::from_str(&crate::http3::alt_svc_value(port)).ok();
170        self
171    }
172
173    /// Whether `request` may take the fast path: a plain site GET/HEAD the router would
174    /// route to its `serve_by_host` fallback anyway. **Conservative** — anything the
175    /// router matches explicitly (any `/api`, `/_*`, `/healthz`, `/readyz`, `/mcp`,
176    /// `/.well-known`) or that the console owns falls through to the full router.
177    /// Excluding too much only forgoes the speedup; excluding too little is the exact
178    /// bug the differential test guards against.
179    pub(crate) fn eligible(&self, request: &Request) -> bool {
180        if !matches!(
181            *request.method(),
182            axum::http::Method::GET | axum::http::Method::HEAD
183        ) {
184            return false;
185        }
186        let path = request.uri().path();
187        let reserved = path == "/api"
188            || path.starts_with("/api/")
189            || path.starts_with("/_") // /_deploy /_sites /_webhooks (path-form; host-form previews still serve)
190            || path.starts_with("/.well-known/")
191            || path == "/healthz"
192            || path == "/readyz"
193            || path == "/mcp"
194            || path.starts_with("/mcp/");
195        if reserved {
196            return false;
197        }
198        // The console owns a runtime-configured host+path — never serve it as site
199        // content. This is the *same* predicate the console middleware uses (host from the
200        // `Host` header only, exactly as it resolves it), so the two can't disagree.
201        #[cfg(feature = "console")]
202        {
203            let effective = self.daemon.effective();
204            let host = request
205                .headers()
206                .get(header::HOST)
207                .and_then(|v| v.to_str().ok())
208                .map(crate::strip_port)
209                .unwrap_or("");
210            if crate::console::would_intercept(&effective, host, path) {
211                return false;
212            }
213        }
214        true
215    }
216
217    /// Serve an already-[`eligible`](Self::eligible) `request` directly, applying the same
218    /// request-id + access-log/metrics guarantees the `access_log` middleware would (via the
219    /// shared [`assign_request_id`](crate::assign_request_id) /
220    /// [`AccessLogCtx`](crate::AccessLogCtx)). Auth, rate-limiting, host-routing, previews,
221    /// and the DV gate all run inside [`serve_by_host_inner`], so the bypass can never skip
222    /// them. The caller MUST have checked [`eligible`](Self::eligible) first — that gate is
223    /// what keeps the bypass from stealing a request the router would match explicitly.
224    pub(crate) async fn dispatch(&self, mut request: Request, peer: SocketAddr) -> Response {
225        debug_assert!(
226            self.eligible(&request),
227            "FastServe::dispatch called on an ineligible request — caller must gate on eligible()"
228        );
229        // A HEAD is served like its GET but with the body dropped. The router path relies on
230        // axum stripping the HEAD body before the response reaches the codec; the h1 codec
231        // also suppresses it, but the h2 codec does not — so the bypass strips it here to be
232        // correct (and byte-identical to the router) over every protocol. Captured before the
233        // request is consumed below.
234        let is_head = *request.method() == axum::http::Method::HEAD;
235        let request_id = crate::assign_request_id(&mut request);
236        // Re-insert the per-request extensions the router's tower layers would add and the
237        // site path reads: the security posture (the gateway-upstream SSRF gate) and the
238        // listener's TLS flag (scheme + HSTS). `RequestId` (WASM handlers) and `ConnectInfo`
239        // (peer) are already present — from `assign_request_id` above and `RouterHandler`.
240        {
241            let ext = request.extensions_mut();
242            ext.insert(self.posture);
243            ext.insert(crate::ServedOverTls(self.served_over_tls));
244        }
245        let log = crate::AccessLogCtx::capture(&request, request_id);
246        let response = serve_by_host_inner(
247            self.deploy.clone(),
248            self.limiter.clone(),
249            self.handlers.clone(),
250            self.daemon.clone(),
251            self.implicit,
252            self.preview_auth.clone(),
253            peer,
254            request,
255        )
256        .await;
257        // Drop the body for HEAD (headers stand in for the GET response), matching the
258        // router; the codec recomputes framing from the now-empty body identically.
259        let mut response = if is_head {
260            let (parts, _body) = response.into_parts();
261            Response::from_parts(parts, axum::body::Body::empty())
262        } else {
263            response
264        };
265        // Advertise a paired h3 listener, matching the outermost `advertise_http3` router
266        // layer on a TLS+http3 listener (`None`, so a no-op, on every other path).
267        if let Some(alt_svc) = &self.alt_svc {
268            response
269                .headers_mut()
270                .insert(axum::http::header::ALT_SVC, alt_svc.clone());
271        }
272        match log {
273            Some(ctx) => ctx.finish(response),
274            None => response,
275        }
276    }
277}
278
279/// axum extractor wrapper over [`serve_by_host_inner`] — the router's site fallback.
280/// The plain-arg inner is called directly by the serve hot-path bypass (stage 2), so the
281/// two paths share one serving implementation.
282#[allow(clippy::too_many_arguments)] // axum extractors, not a real parameter list
283pub(super) async fn serve_by_host(
284    State(deploy): State<DeployStore>,
285    Extension(limiter): Extension<Arc<dyn RateLimitStore>>,
286    Extension(handlers): Extension<Arc<HandlerRuntime>>,
287    Extension(daemon): Extension<Arc<DaemonRuntime>>,
288    Extension(implicit): Extension<ImplicitRouting>,
289    Extension(preview_auth): Extension<Auth>,
290    ConnectInfo(peer): ConnectInfo<SocketAddr>,
291    request: Request,
292) -> Response {
293    serve_by_host_inner(
294        deploy,
295        limiter,
296        handlers,
297        daemon,
298        implicit,
299        preview_auth,
300        peer,
301        request,
302    )
303    .await
304}
305
306/// Host-routed static + reverse-proxy serving: resolves the site from the request host,
307/// applies preview auth + rate-limiting (`Visitor`) + virtualhost routing, then serves.
308/// Plain-arg form so both the axum router (via [`serve_by_host`]) and the hot-path bypass
309/// call one implementation — the bypass can never diverge on the security-relevant checks
310/// because they live here, not in the middleware. (Serve hot-path bypass, stage 2.)
311#[allow(clippy::too_many_arguments)]
312pub(crate) async fn serve_by_host_inner(
313    deploy: DeployStore,
314    limiter: Arc<dyn RateLimitStore>,
315    handlers: Arc<HandlerRuntime>,
316    daemon: Arc<DaemonRuntime>,
317    implicit: ImplicitRouting,
318    preview_auth: Auth,
319    peer: SocketAddr,
320    request: Request,
321) -> Response {
322    // Catch-all site + preview protection are read live from the daemon-config
323    // runtime, so `config set default_site …` / `protect_previews …` take effect
324    // without a restart.
325    let effective = daemon.effective();
326    let preview_policy = PreviewPolicy {
327        protect: effective.protect_previews,
328    };
329    // Resolve the request host from the `Host` header (HTTP/1.1) or, when that is
330    // absent, the URI authority. HTTP/2 sends the host as the `:authority`
331    // pseudo-header, which hyper places in the request URI rather than a `Host`
332    // header — so without this fallback every H2 request has no host and 404s.
333    let Some(host) = request
334        .headers()
335        .get(header::HOST)
336        .and_then(|value| value.to_str().ok())
337        .or_else(|| request.uri().host())
338        .map(strip_port)
339        .map(str::to_string)
340    else {
341        return not_found();
342    };
343    let request_path = request.uri().path().to_string();
344    // Wildcard preview host form `<id>.deploy.<site-host>`: the deploy id rides
345    // as a subdomain (an unguessable content-hash capability, like the path form
346    // `<site-host>/_deploy/<id>/…`). The remaining host resolves the site, and
347    // the deployment is served with a preview-scoped binding identity. Falls
348    // through to normal virtualhost routing when the host isn't a preview host.
349    if let Some((id_prefix, site_host)) = parse_deploy_host(&host) {
350        if let Some(blocked) =
351            preview_auth_gate(preview_policy, &preview_auth, request.headers()).await
352        {
353            return blocked;
354        }
355        return serve_host_preview(
356            &deploy,
357            &handlers,
358            peer.ip(),
359            &request_path,
360            request,
361            id_prefix,
362            site_host,
363        )
364        .await;
365    }
366    match deploy.resolve_site_by_host(&host).await {
367        Ok(Some(owner)) => {
368            let visitor = Visitor {
369                peer: peer.ip(),
370                limiter: limiter.as_ref(),
371            };
372            // Stage 0: stash the routed domain's tenant context tag in the request extensions so
373            // the handler-dispatch path can resolve a domain-sourced in-site tenant scope without
374            // re-reading the routing index. Only set when the domain carries one.
375            #[cfg(feature = "handlers")]
376            let mut request = request;
377            #[cfg(feature = "handlers")]
378            if let Some(context) = owner.context.clone() {
379                request
380                    .extensions_mut()
381                    .insert(crate::DomainContext(context));
382            }
383            // Host-routed: transport/canonical redirects + HSTS apply.
384            serve_request(
385                &deploy,
386                &owner.project,
387                &owner.site,
388                &request_path,
389                request,
390                &visitor,
391                &handlers,
392                true,
393            )
394            .await
395        }
396        // Unmatched host — no verified, attached virtualhost. Resolution order:
397        //   (0) mandatory verification — a **non-local** host that isn't verified
398        //       gets the "verification pending" page (421), never a fallback;
399        //   (A) implicit first-label routing — `<site>.host` names a served site;
400        //   the configured catch-all `default_site` (explicit operator intent).
401        // (A) runs only when `implicit` is on (dev / single-tenant / a loopback
402        // bind). There is deliberately no implicit *sole-site* auto-default: an
403        // operator makes a site the catch-all explicitly with `default_site`.
404        Ok(None) => {
405            // (0) Strict gate (DV-2): a non-local public host that matched no
406            // verified virtualhost is refused with the holding page — so
407            // `default_site`/implicit never silently serve an unverified host.
408            // Local hosts (localhost/*.localhost/*.local/IPs) and a fleet with the
409            // gate off (`[security] require_domain_verification = false`, or an
410            // admin `domain add --unverified` that attached the host above) pass.
411            if effective.posture.require_domain_verification && !is_local_host(&host, implicit.0) {
412                return verification_pending_page(&deploy, &host).await;
413            }
414            // (A) First host label naming a served site: `blog.localhost` → `blog`.
415            if implicit.0 {
416                let label = host.split('.').next().unwrap_or("");
417                if !label.is_empty()
418                    && matches!(
419                        deploy.current_id(ProjectRef::DEFAULT, label).await,
420                        Ok(Some(_))
421                    )
422                {
423                    let visitor = Visitor {
424                        peer: peer.ip(),
425                        limiter: limiter.as_ref(),
426                    };
427                    // Implicit first-label routing is a default-project convenience.
428                    return serve_request(
429                        &deploy,
430                        ProjectRef::DEFAULT.as_str(),
431                        label,
432                        &request_path,
433                        request,
434                        &visitor,
435                        &handlers,
436                        true,
437                    )
438                    .await;
439                }
440            }
441            match effective.default_site.as_deref() {
442                Some(site) => {
443                    let visitor = Visitor {
444                        peer: peer.ip(),
445                        limiter: limiter.as_ref(),
446                    };
447                    // The operator's catch-all `default_site` is a default-project site.
448                    serve_request(
449                        &deploy,
450                        ProjectRef::DEFAULT.as_str(),
451                        site,
452                        &request_path,
453                        request,
454                        &visitor,
455                        &handlers,
456                        true,
457                    )
458                    .await
459                }
460                None => not_found(),
461            }
462        }
463        Err(err) => deploy_error_response(err),
464    }
465}
466
467/// Serve a wildcard-host preview: resolve `id_prefix` to a full deployment id
468/// and `site_host` to a site, then run the deployment with a **preview-scoped**
469/// binding identity (like [`serve_preview`], but reached by subdomain). Handlers
470/// run only when the host resolves to a real site; otherwise the preview serves
471/// static content only. No visitor access control — the unguessable id is the
472/// capability (consistent with the path-form preview).
473#[allow(clippy::too_many_arguments)]
474async fn serve_host_preview(
475    deploy: &DeployStore,
476    handlers: &HandlerRuntime,
477    peer: IpAddr,
478    request_path: &str,
479    request: Request,
480    id_prefix: &str,
481    site_host: &str,
482) -> Response {
483    let id = match deploy.resolve_manifest_id(id_prefix).await {
484        Ok(Some(id)) => id,
485        Ok(None) => return not_found(),
486        Err(err) => return deploy_error_response(err),
487    };
488    let owner = match deploy.resolve_site_by_host(site_host).await {
489        Ok(owner) => owner,
490        Err(err) => return deploy_error_response(err),
491    };
492    let project = owner.as_ref().map(|o| o.project.clone());
493    let site = owner.map(|o| o.site);
494    let site_config = match (&project, &site) {
495        (Some(project), Some(site)) => {
496            match deploy.get_site_config(ProjectRef::new(project), site).await {
497                Ok(config) => config,
498                Err(err) => return deploy_error_response(err),
499            }
500        }
501        _ => None,
502    };
503    match deploy.get_manifest(&id).await {
504        Ok(Some(manifest)) => {
505            serve_resolved(
506                deploy,
507                &manifest,
508                request_path,
509                request,
510                peer,
511                project.as_deref(),
512                site.as_deref(),
513                site_config.as_ref(),
514                handlers,
515                Some(&id),
516            )
517            .await
518        }
519        Ok(None) => not_found(),
520        Err(err) => deploy_error_response(err),
521    }
522}
523
524/// Run the serving pipeline for a resolved `site` and request path: apply the
525/// deploy config (redirects, rewrites/SPA, clean URLs, custom 404, headers,
526/// cache) via [`route::resolve`], then HTTP correctness (conditional `304`,
527/// `Range`/`206`, `ETag`).
528#[allow(clippy::too_many_arguments)]
529async fn serve_request(
530    deploy: &DeployStore,
531    project: &str,
532    site: &str,
533    request_path: &str,
534    request: Request,
535    visitor: &Visitor<'_>,
536    handlers: &HandlerRuntime,
537    host_routed: bool,
538) -> Response {
539    let project_ref = ProjectRef::new(project);
540    // Load the site config once (for access policy + client-IP resolution). Cached
541    // by content hash — the hot path skips the body read + JSON parse per request.
542    let site_config = match deploy.get_site_config_cached(project_ref, site).await {
543        Ok(config) => config,
544        Err(err) => return deploy_error_response(err),
545    };
546
547    // Transport redirects + HSTS. The effective scheme
548    // honors `X-Forwarded-Proto` **only from a configured trusted proxy**
549    // — otherwise a direct HTTP client could forge `…: https` to
550    // skip the HTTPS redirect. For an untrusted/direct peer the scheme is the
551    // listener's own (TLS ⇒ `https`, else `http`). Host-routed traffic only.
552    let listener_scheme = if request
553        .extensions()
554        .get::<ServedOverTls>()
555        .map(|s| s.0)
556        .unwrap_or(false)
557    {
558        "https"
559    } else {
560        "http"
561    };
562    let peer_trusted = site_config
563        .as_ref()
564        .map(|c| c.access.is_trusted_proxy(visitor.peer))
565        .unwrap_or(false);
566    let effective_scheme = if peer_trusted {
567        request
568            .headers()
569            .get("x-forwarded-proto")
570            .and_then(|v| v.to_str().ok())
571            .unwrap_or(listener_scheme)
572            .to_string()
573    } else {
574        listener_scheme.to_string()
575    };
576    // Captured before the request body is consumed, for on-the-fly compression.
577    #[cfg(feature = "compression")]
578    let accept_encoding = request
579        .headers()
580        .get(header::ACCEPT_ENCODING)
581        .and_then(|v| v.to_str().ok())
582        .map(str::to_string);
583    // Site-tier security response headers, applied (host-routed only) after the
584    // response is built: HSTS (HTTPS only), plus opt-in CSP / X-Frame-Options.
585    let mut security_headers: Vec<(HeaderName, String)> = Vec::new();
586    if host_routed {
587        if let Some(cfg) = site_config.as_ref() {
588            let host = request
589                .headers()
590                .get(header::HOST)
591                .and_then(|v| v.to_str().ok())
592                .map(strip_port)
593                .unwrap_or("");
594            let path_and_query = request
595                .uri()
596                .path_and_query()
597                .map(axum::http::uri::PathAndQuery::as_str)
598                .unwrap_or(request_path);
599            if let Some(target) = boatramp_core::config::transport_redirect(
600                &cfg.security,
601                &cfg.domains,
602                &effective_scheme,
603                host,
604                path_and_query,
605            ) {
606                return redirect_to(&target);
607            }
608            // HSTS only over HTTPS (it's meaningless / ignored over plain HTTP).
609            if effective_scheme == "https" {
610                if let Some(hsts) = cfg.security.hsts.as_ref() {
611                    security_headers.push((
612                        HeaderName::from_static("strict-transport-security"),
613                        hsts.header_value(),
614                    ));
615                }
616            }
617            // CSP + X-Frame-Options apply on either scheme, when configured.
618            if let Some(csp) = cfg.security.csp.as_deref() {
619                security_headers.push((header::CONTENT_SECURITY_POLICY, csp.to_string()));
620            }
621            if let Some(frame) = cfg.security.frame_options.as_deref() {
622                security_headers.push((header::X_FRAME_OPTIONS, frame.to_string()));
623            }
624        }
625    }
626
627    let access = site_config.as_ref().map(|c| &c.access);
628
629    // Resolve the real client IP, honoring X-Forwarded-For only from a
630    // configured trusted proxy.
631    let trusted = access.map(|a| a.trusted_proxies.as_slice()).unwrap_or(&[]);
632    let forwarded_for = request
633        .headers()
634        .get("x-forwarded-for")
635        .and_then(|value| value.to_str().ok());
636    let client_ip = boatramp_core::access::resolve_client_ip(visitor.peer, forwarded_for, trusted);
637
638    // Visitor access control (WAF → IP rules → rate limit → basic auth) runs
639    // before any content is read.
640    if let Some(access) = access {
641        if let Some(denied) = enforce_access(
642            access,
643            site,
644            request.headers(),
645            request_path,
646            client_ip,
647            visitor.limiter,
648        )
649        .await
650        {
651            return denied;
652        }
653    }
654
655    let manifest = match deploy.current_manifest(project_ref, site).await {
656        Ok(Some(manifest)) => manifest,
657        Ok(None) => return not_found(),
658        Err(err) => return deploy_error_response(err),
659    };
660    let mut response = serve_resolved(
661        deploy,
662        &manifest,
663        request_path,
664        request,
665        client_ip,
666        Some(project),
667        Some(site),
668        site_config.as_deref(),
669        handlers,
670        None,
671    )
672    .await;
673    // Site-tier security headers (HSTS / CSP / X-Frame-Options), computed above.
674    for (name, value) in security_headers {
675        if let Ok(value) = HeaderValue::from_str(&value) {
676            response.headers_mut().insert(name, value);
677        }
678    }
679    // On-the-fly compression (opt-in per site; covers dynamic + variant-less
680    // static responses). A no-op without the `compression` feature.
681    #[cfg(feature = "compression")]
682    let response = match site_config.as_ref() {
683        Some(cfg) if cfg.compression.enabled => maybe_compress(
684            response,
685            accept_encoding.as_deref(),
686            cfg.compression.min_size,
687        ),
688        _ => response,
689    };
690    response
691}
692
693/// When previews are protected, require a valid control-plane token. Returns
694/// `Some(401)` to block, `None` to allow. "Any valid token" (no scope needed).
695async fn preview_auth_gate(
696    policy: PreviewPolicy,
697    auth: &Auth,
698    headers: &HeaderMap,
699) -> Option<Response> {
700    if !policy.protect {
701        return None;
702    }
703    let bearer = headers
704        .get(header::AUTHORIZATION)
705        .and_then(|v| v.to_str().ok())
706        .and_then(|v| v.strip_prefix("Bearer "));
707    let ok = match bearer {
708        Some(token) => auth.verify_bearer(token).await,
709        None => false,
710    };
711    (!ok).then(|| {
712        (
713            StatusCode::UNAUTHORIZED,
714            "preview requires a valid bearer token\n",
715        )
716            .into_response()
717    })
718}
719
720/// A `301 Moved Permanently` to `target` (transport/canonical redirects).
721fn redirect_to(target: &str) -> Response {
722    match HeaderValue::from_str(target) {
723        Ok(location) => (
724            StatusCode::MOVED_PERMANENTLY,
725            [(header::LOCATION, location)],
726        )
727            .into_response(),
728        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
729    }
730}
731
732/// A standalone router for a plain `:80` listener that permanently redirects
733/// every request to its HTTPS equivalent. Bound alongside the HTTPS listener so
734/// plain-HTTP visitors are upgraded even when boatramp terminates TLS itself.
735///
736/// It does serve one thing directly rather than redirecting: the HTTP
737/// domain-ownership challenge (`/.well-known/boatramp-domain-verification/…`).
738/// That probe arrives on plain `:80` for a host that may have no cert yet, so a
739/// 308 to HTTPS would bounce it to an endpoint that can't answer — the token
740/// must be served here. (ACME's own challenges use ALPN-01/DNS-01, so there is
741/// no `/.well-known/acme-challenge` to serve.)
742pub fn http_redirect_router(
743    deploy: DeployStore,
744    posture: boatramp_core::security::SecurityPosture,
745) -> Router {
746    Router::new()
747        .route(
748            "/.well-known/boatramp-domain-verification/{token}",
749            get(serve_domain_challenge),
750        )
751        .fallback(redirect_http_to_https)
752        .with_state(deploy)
753        .layer(Extension(posture))
754}
755
756/// 308-redirect any request to `https://<host><path-and-query>` (308 preserves
757/// the method/body, unlike 301).
758async fn redirect_http_to_https(req: Request) -> Response {
759    let host = req
760        .headers()
761        .get(header::HOST)
762        .and_then(|v| v.to_str().ok())
763        .map(strip_port)
764        .unwrap_or("");
765    if host.is_empty() {
766        return (StatusCode::BAD_REQUEST, "missing Host header\n").into_response();
767    }
768    let path_and_query = req
769        .uri()
770        .path_and_query()
771        .map(axum::http::uri::PathAndQuery::as_str)
772        .unwrap_or("/");
773    match HeaderValue::from_str(&format!("https://{host}{path_and_query}")) {
774        Ok(location) => (
775            StatusCode::PERMANENT_REDIRECT,
776            [(header::LOCATION, location)],
777        )
778            .into_response(),
779        Err(_) => (StatusCode::BAD_REQUEST, "invalid host\n").into_response(),
780    }
781}
782
783/// Evaluate a site's [`AccessConfig`] against an already-resolved `client_ip`.
784/// Returns `Some(response)` to short-circuit (403/429/401), or `None` to allow.
785/// Order: WAF → IP rules → rate limit → basic auth. `async` because the
786/// cluster-wide rate-limit store does a KV round-trip.
787async fn enforce_access(
788    access: &AccessConfig,
789    site: &str,
790    req_headers: &HeaderMap,
791    path: &str,
792    client_ip: IpAddr,
793    limiter: &dyn RateLimitStore,
794) -> Option<Response> {
795    if !access.is_enforced() {
796        return None;
797    }
798    // WAF (user-agent rules + anomaly scoring) is the outermost filter: a blocked
799    // request shouldn't reach rate limiting or auth.
800    if access.waf.is_enabled() {
801        let header_str = |name| req_headers.get(name).and_then(|v| v.to_str().ok());
802        let waf_req = boatramp_core::waf::WafRequest {
803            user_agent: header_str(header::USER_AGENT),
804            accept: header_str(header::ACCEPT),
805            path,
806        };
807        if let boatramp_core::waf::WafVerdict::Block(reason) =
808            boatramp_core::waf::evaluate(&access.waf, &waf_req)
809        {
810            tracing::debug!(%client_ip, site, %reason, "request blocked by WAF");
811            return Some((StatusCode::FORBIDDEN, "forbidden\n").into_response());
812        }
813    }
814    if !access.ip.allows(client_ip) {
815        tracing::debug!(%client_ip, site, "request blocked by IP rules");
816        return Some((StatusCode::FORBIDDEN, "forbidden\n").into_response());
817    }
818    if let Some(limit) = &access.rate_limit {
819        if !limiter.check(site, client_ip, limit).await {
820            return Some(too_many_requests());
821        }
822    }
823    if let Some(basic) = &access.basic_auth {
824        if !verify_basic_auth(basic, req_headers) {
825            return Some(basic_auth_challenge(basic));
826        }
827    }
828    None
829}
830
831/// Verify an HTTP `Authorization: Basic` header against the site credentials.
832fn verify_basic_auth(basic: &BasicAuth, req_headers: &HeaderMap) -> bool {
833    use base64::Engine;
834    let Some(encoded) = req_headers
835        .get(header::AUTHORIZATION)
836        .and_then(|value| value.to_str().ok())
837        .and_then(|value| value.strip_prefix("Basic "))
838    else {
839        return false;
840    };
841    let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded.trim()) else {
842        return false;
843    };
844    let Ok(text) = String::from_utf8(decoded) else {
845        return false;
846    };
847    match text.split_once(':') {
848        Some((user, pass)) => basic.verify(user, pass),
849        None => false,
850    }
851}
852
853/// `401` with a `WWW-Authenticate: Basic` challenge.
854fn basic_auth_challenge(basic: &BasicAuth) -> Response {
855    let realm = basic.realm.replace(['"', '\\'], "");
856    let mut headers = HeaderMap::new();
857    if let Ok(value) = HeaderValue::from_str(&format!("Basic realm=\"{realm}\", charset=\"UTF-8\""))
858    {
859        headers.insert(header::WWW_AUTHENTICATE, value);
860    }
861    (
862        StatusCode::UNAUTHORIZED,
863        headers,
864        "authentication required\n",
865    )
866        .into_response()
867}
868
869/// `429 Too Many Requests` with a `Retry-After`.
870fn too_many_requests() -> Response {
871    let mut headers = HeaderMap::new();
872    headers.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
873    (
874        StatusCode::TOO_MANY_REQUESTS,
875        headers,
876        "rate limit exceeded\n",
877    )
878        .into_response()
879}
880
881/// Serve an immutable deployment by id under `/_deploy/<id>/...`. Like
882/// [`serve_sites`], a single catch-all captures `<id>` or `<id>/<path...>`, so
883/// `/_deploy/<id>`, `/_deploy/<id>/`, and `/_deploy/<id>/about` all route here.
884pub(super) async fn serve_preview(
885    State(deploy): State<DeployStore>,
886    Extension(handlers): Extension<Arc<HandlerRuntime>>,
887    Extension(daemon): Extension<Arc<DaemonRuntime>>,
888    Extension(preview_auth): Extension<Auth>,
889    ConnectInfo(peer): ConnectInfo<SocketAddr>,
890    request: Request,
891) -> Response {
892    let preview_policy = PreviewPolicy {
893        protect: daemon.effective().protect_previews,
894    };
895    if let Some(blocked) = preview_auth_gate(preview_policy, &preview_auth, request.headers()).await
896    {
897        return blocked;
898    }
899    let raw = request.uri().path();
900    let rest = raw
901        .strip_prefix("/_deploy/")
902        .unwrap_or("")
903        .trim_start_matches('/');
904    let (id, path) = rest.split_once('/').unwrap_or((rest, ""));
905    if id.is_empty() {
906        return not_found();
907    }
908    let (id, request_path) = (id.to_string(), format!("/{path}"));
909    // When the preview is reached via the *site's own hostname*
910    // (`site.example.com/_deploy/<id>/…`), resolve that site so handlers can run
911    // — with **preview-scoped** bindings (`Some(&id)` below) so they never touch
912    // the live site's kv/blob/sql. Reached via any other host
913    // (no site resolves), handlers stay off — the preview serves static only.
914    let site = request
915        .headers()
916        .get(header::HOST)
917        .and_then(|value| value.to_str().ok())
918        .map(strip_port);
919    let owner = match site {
920        Some(host) => match deploy.resolve_site_by_host(host).await {
921            Ok(owner) => owner,
922            Err(err) => return deploy_error_response(err),
923        },
924        None => None,
925    };
926    let project = owner.as_ref().map(|o| o.project.clone());
927    let site = owner.map(|o| o.site);
928    let site_config = match (&project, &site) {
929        (Some(project), Some(site)) => {
930            match deploy.get_site_config(ProjectRef::new(project), site).await {
931                Ok(config) => config,
932                Err(err) => return deploy_error_response(err),
933            }
934        }
935        _ => None,
936    };
937    match deploy.get_manifest(&id).await {
938        Ok(Some(manifest)) => {
939            serve_resolved(
940                &deploy,
941                &manifest,
942                &request_path,
943                request,
944                peer.ip(),
945                project.as_deref(),
946                site.as_deref(),
947                site_config.as_ref(),
948                &handlers,
949                Some(&id),
950            )
951            .await
952        }
953        Ok(None) => not_found(),
954        Err(err) => deploy_error_response(err),
955    }
956}
957
958/// Run the deploy-config routing pipeline against a resolved `manifest`, then
959/// stream the chosen entry (or proxy). `client_ip` is the resolved visitor
960/// address (for proxy `X-Forwarded-For`).
961/// Build the [`RequestContext`](boatramp_core::predicate::RequestContext) a
962/// conditional-routing `when` predicate reads from the live request. Only called
963/// when the deployment actually has conditional rules, so the non-conditional hot
964/// path never pays for it.
965fn build_request_context(request: &Request) -> boatramp_core::predicate::RequestContext {
966    use boatramp_core::predicate::RequestContext;
967    let headers = request.headers();
968    let mut hmap: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
969    for (name, value) in headers {
970        if let Ok(v) = value.to_str() {
971            hmap.entry(name.as_str().to_ascii_lowercase())
972                .and_modify(|e| {
973                    e.push_str(", ");
974                    e.push_str(v);
975                })
976                .or_insert_with(|| v.to_string());
977        }
978    }
979    let host = headers
980        .get(header::HOST)
981        .and_then(|h| h.to_str().ok())
982        .map(|h| h.split(':').next().unwrap_or(h).to_string())
983        .unwrap_or_default();
984    let cookies = headers
985        .get(header::COOKIE)
986        .and_then(|h| h.to_str().ok())
987        .map(parse_cookie_header)
988        .unwrap_or_default();
989    let query = request
990        .uri()
991        .query()
992        .map(parse_query_string)
993        .unwrap_or_default();
994    let accept_languages = headers
995        .get(header::ACCEPT_LANGUAGE)
996        .and_then(|h| h.to_str().ok())
997        .map(RequestContext::parse_accept_language)
998        .unwrap_or_default();
999    RequestContext {
1000        method: request.method().as_str().to_ascii_uppercase(),
1001        host,
1002        headers: hmap,
1003        cookies,
1004        query,
1005        accept_languages,
1006    }
1007}
1008
1009/// Parse a `Cookie` header into name→value pairs (first value wins).
1010pub(super) fn parse_cookie_header(raw: &str) -> std::collections::BTreeMap<String, String> {
1011    raw.split(';')
1012        .filter_map(|pair| pair.split_once('='))
1013        .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
1014        .fold(std::collections::BTreeMap::new(), |mut m, (k, v)| {
1015            m.entry(k).or_insert(v);
1016            m
1017        })
1018}
1019
1020/// Parse a URL query string into key→value pairs (first value wins), with
1021/// `application/x-www-form-urlencoded` decoding (`+` → space, `%XX` → byte) so a
1022/// condition compares against the real value.
1023pub(super) fn parse_query_string(raw: &str) -> std::collections::BTreeMap<String, String> {
1024    raw.split('&')
1025        .filter(|p| !p.is_empty())
1026        .map(|pair| match pair.split_once('=') {
1027            Some((k, v)) => (percent_decode(k), percent_decode(v)),
1028            None => (percent_decode(pair), String::new()),
1029        })
1030        .fold(std::collections::BTreeMap::new(), |mut m, (k, v)| {
1031            m.entry(k).or_insert(v);
1032            m
1033        })
1034}
1035
1036/// Decode a `application/x-www-form-urlencoded` component: `+` → space, `%XX` →
1037/// the byte, everything else verbatim. Invalid `%` escapes are left as-is;
1038/// non-UTF-8 results are lossily replaced.
1039fn percent_decode(s: &str) -> String {
1040    let bytes = s.as_bytes();
1041    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
1042    let mut i = 0;
1043    while i < bytes.len() {
1044        match bytes[i] {
1045            b'+' => {
1046                out.push(b' ');
1047                i += 1;
1048            }
1049            b'%' if i + 2 < bytes.len() => {
1050                let hi = (bytes[i + 1] as char).to_digit(16);
1051                let lo = (bytes[i + 2] as char).to_digit(16);
1052                match (hi, lo) {
1053                    (Some(h), Some(l)) => {
1054                        out.push((h * 16 + l) as u8);
1055                        i += 3;
1056                    }
1057                    _ => {
1058                        out.push(b'%');
1059                        i += 1;
1060                    }
1061                }
1062            }
1063            b => {
1064                out.push(b);
1065                i += 1;
1066            }
1067        }
1068    }
1069    String::from_utf8_lossy(&out).into_owned()
1070}
1071
1072/// Merge conditional-routing `Vary` header names into a response, so a per-visitor
1073/// (language/cookie/header) redirect or page is never shared across visitors by a
1074/// downstream cache. A no-op when `vary` is empty (the non-conditional case).
1075pub(super) fn apply_vary(mut response: Response, vary: &[String]) -> Response {
1076    if vary.is_empty() {
1077        return response;
1078    }
1079    let mut names: Vec<String> = response
1080        .headers()
1081        .get(header::VARY)
1082        .and_then(|v| v.to_str().ok())
1083        .map(|s| {
1084            s.split(',')
1085                .map(|p| p.trim().to_ascii_lowercase())
1086                .filter(|p| !p.is_empty())
1087                .collect()
1088        })
1089        .unwrap_or_default();
1090    for v in vary {
1091        if !names.iter().any(|n| n == v) {
1092            names.push(v.clone());
1093        }
1094    }
1095    if let Ok(hv) = HeaderValue::from_str(&names.join(", ")) {
1096        response.headers_mut().insert(header::VARY, hv);
1097    }
1098    response
1099}
1100
1101#[allow(clippy::too_many_arguments)]
1102#[cfg_attr(not(feature = "handlers"), allow(unused_variables))]
1103async fn serve_resolved(
1104    deploy: &DeployStore,
1105    manifest: &Manifest,
1106    request_path: &str,
1107    request: Request,
1108    client_ip: IpAddr,
1109    // The tenant project the resolved site belongs to; `None` mirrors `site:
1110    // None` (a preview reached via a non-resolving host, handlers off).
1111    project: Option<&str>,
1112    site: Option<&str>,
1113    site_config: Option<&SiteConfig>,
1114    handlers: &HandlerRuntime,
1115    // `Some(deploy_id)` when serving a by-id preview, so handler bindings get a
1116    // preview-scoped identity; `None` for live serving.
1117    preview: Option<&str>,
1118) -> Response {
1119    // Evaluate conditional (`when`) routing against the request. The request
1120    // context is built only when the deploy has conditional rules, and `vary`
1121    // carries the request dimensions those conditions read (applied to the
1122    // response below so a per-language/-cookie outcome isn't wrongly cached).
1123    let ctx = if manifest.config.redirects.iter().any(|r| r.when.is_some())
1124        || manifest.config.rewrites.iter().any(|r| r.when.is_some())
1125    {
1126        build_request_context(&request)
1127    } else {
1128        boatramp_core::predicate::RequestContext::default()
1129    };
1130    let route::ResolveResult { outcome, vary } =
1131        route::resolve_ctx(&manifest.config, &manifest.files, request_path, &ctx);
1132    // Routing precedence: redirects win over handlers, which
1133    // win over rewrites/static. A redirect short-circuits below; otherwise a
1134    // matching handler is dispatched in preference to the file/rewrite outcome.
1135    #[cfg(feature = "handlers")]
1136    if !matches!(outcome, Outcome::Redirect { .. }) {
1137        if let Some(site) = site {
1138            if let Some(handler) = route::match_handler(
1139                &manifest.config.handlers,
1140                request.method().as_str(),
1141                request_path,
1142            ) {
1143                return apply_vary(
1144                    dispatch_handler(
1145                        handlers,
1146                        deploy,
1147                        manifest,
1148                        // Handler dispatch only runs when `site` is Some, and the
1149                        // project is threaded alongside it; default-project fallback
1150                        // keeps a by-id preview reached via a non-resolving host safe.
1151                        project.unwrap_or(ProjectRef::DEFAULT.as_str()),
1152                        site,
1153                        request_path,
1154                        site_config,
1155                        handler,
1156                        request,
1157                        client_ip,
1158                        preview,
1159                    )
1160                    .await,
1161                    &vary,
1162                );
1163            }
1164            // No handler matched: a GET to a configured SSE stream route fans out
1165            // its messaging topics. Streams are GET-only.
1166            if request.method() == Method::GET {
1167                if let Some(stream) = manifest
1168                    .config
1169                    .streams
1170                    .iter()
1171                    .find(|s| route_matches(&s.route, request_path))
1172                {
1173                    if let (Some(inner), Some(site_handlers)) = (
1174                        handlers.inner.as_ref(),
1175                        site_config
1176                            .and_then(|c| c.handlers.as_ref())
1177                            .filter(|h| h.enabled),
1178                    ) {
1179                        // A `websocket` stream upgraded by the client is served
1180                        // bidirectionally (WebSocket fan-out); otherwise it's SSE.
1181                        // serve_ws_stream does the RFC 6455 handshake + takes over the
1182                        // connection via boatramp-http's upgrade seam (the request body
1183                        // isn't held across an await — WS carries none).
1184                        if stream.websocket && is_upgrade_request(request.headers()) {
1185                            return apply_vary(
1186                                serve_ws_stream(
1187                                    inner,
1188                                    site,
1189                                    site_handlers,
1190                                    stream,
1191                                    request,
1192                                    client_ip,
1193                                    preview,
1194                                )
1195                                .await,
1196                                &vary,
1197                            );
1198                        }
1199                        // Pull the only field needed from the request as an owned
1200                        // value: `&Request` is not `Send` (the body isn't `Sync`),
1201                        // so it must not be held across the dispatch await.
1202                        let after = request
1203                            .headers()
1204                            .get("last-event-id")
1205                            .and_then(|value| value.to_str().ok())
1206                            .map(str::to_string);
1207                        return apply_vary(
1208                            serve_stream(
1209                                inner,
1210                                site,
1211                                site_handlers,
1212                                stream,
1213                                after,
1214                                client_ip,
1215                                preview,
1216                            )
1217                            .await,
1218                            &vary,
1219                        );
1220                    }
1221                    // A stream route on a site with handlers disabled / no runtime
1222                    // is not served (deny by default).
1223                    return apply_vary(not_found(), &vary);
1224                }
1225            }
1226            // Sessions (duplex/resumable guest sessions): a `GET` opens the resumable outbound SSE
1227            // stream, a `POST` delivers an inbound frame that re-enters the guest. Matched after
1228            // handlers + streams (distinct route set); other methods are 405.
1229            #[cfg(feature = "session")]
1230            if let Some(session) = manifest
1231                .config
1232                .sessions
1233                .iter()
1234                .find(|s| route_matches(&s.route, request_path))
1235            {
1236                let enabled = site_config
1237                    .and_then(|c| c.handlers.as_ref())
1238                    .filter(|h| h.enabled);
1239                if let (Some(inner), Some(site_handlers)) = (handlers.inner.as_ref(), enabled) {
1240                    let project = project.unwrap_or(ProjectRef::DEFAULT.as_str());
1241                    return apply_vary(
1242                        match *request.method() {
1243                            Method::GET => {
1244                                crate::session_serve::serve_session_open(
1245                                    inner,
1246                                    site_handlers,
1247                                    project,
1248                                    site,
1249                                    session,
1250                                    request,
1251                                    client_ip,
1252                                    preview,
1253                                )
1254                                .await
1255                            }
1256                            Method::POST => {
1257                                crate::session_serve::dispatch_session_post(
1258                                    inner,
1259                                    deploy,
1260                                    manifest,
1261                                    site_handlers,
1262                                    project,
1263                                    site,
1264                                    session,
1265                                    request,
1266                                    client_ip,
1267                                    preview,
1268                                )
1269                                .await
1270                            }
1271                            _ => method_not_allowed(),
1272                        },
1273                        &vary,
1274                    );
1275                }
1276                // A session route on a site with handlers disabled / no runtime is not served.
1277                return apply_vary(not_found(), &vary);
1278            }
1279        }
1280    }
1281    // Gateway: an operator-declared route forwards to a private
1282    // upstream. Independent of the handlers feature; runs after redirects/
1283    // handlers and **wins over static files** (the operator declared it). Access
1284    // control already ran up front; only declared upstreams reach private addrs.
1285    if !matches!(outcome, Outcome::Redirect { .. }) {
1286        if let Some(gw) = site_config
1287            .and_then(|c| c.gateway.as_ref())
1288            .filter(|g| g.is_enabled())
1289        {
1290            if let Some(route) = gw.match_route(request_path) {
1291                return apply_vary(
1292                    match gw.upstreams.get(&route.upstream) {
1293                        Some(upstream) => {
1294                            // A compute-backed upstream resolves its pool live from
1295                            // the workload's healthy replica endpoints. Record
1296                            // the request as activity so the reconcile loop
1297                            // keeps the workload warm / wakes it, and only sleeps it
1298                            // once genuinely idle.
1299                            let (compute_backends, compute_regions) = match &upstream.compute {
1300                                Some(workload) => {
1301                                    // Resolve the compute upstream against the site's OWN
1302                                    // project (not `default`), so a non-default tenant's
1303                                    // replica state is found — closing the project-blind
1304                                    // resolution that 502'd / never woke it.
1305                                    let compute_project =
1306                                        project.unwrap_or(ProjectRef::DEFAULT.as_str());
1307                                    gateway::record_activity(workload);
1308                                    let mut pool =
1309                                        compute_endpoints(deploy, compute_project, workload).await;
1310                                    // Wake-from-zero: no live replica but one
1311                                    // is parked → nudge the reconcile loop to restore it
1312                                    // and hold this request until it's serving. The cold
1313                                    // start is invisible to the client; only a genuine
1314                                    // restore failure (timeout) falls through to 502.
1315                                    if pool.is_empty()
1316                                        && has_parked_replica(deploy, compute_project, workload)
1317                                            .await
1318                                    {
1319                                        gateway::wake_reconcile();
1320                                        pool = await_warm(
1321                                            deploy,
1322                                            compute_project,
1323                                            workload,
1324                                            COMPUTE_WAKE_TIMEOUT,
1325                                        )
1326                                        .await;
1327                                    }
1328                                    // FA-8: for a nearest-region pool, tag each replica
1329                                    // endpoint with its node's region (from placement).
1330                                    let regions = if upstream.lb
1331                                        == boatramp_core::gateway::LbPolicy::Nearest
1332                                    {
1333                                        Some(
1334                                            compute_endpoint_regions(
1335                                                deploy,
1336                                                compute_project,
1337                                                workload,
1338                                            )
1339                                            .await,
1340                                        )
1341                                    } else {
1342                                        None
1343                                    };
1344                                    (Some(pool), regions)
1345                                }
1346                                None => (None, None),
1347                            };
1348                            dispatch_gateway(
1349                                request,
1350                                site.unwrap_or(""),
1351                                &route.upstream,
1352                                upstream,
1353                                request_path,
1354                                client_ip,
1355                                compute_backends,
1356                                compute_regions,
1357                            )
1358                            .await
1359                        }
1360                        None => (
1361                            StatusCode::BAD_GATEWAY,
1362                            "gateway route references an unknown upstream\n",
1363                        )
1364                            .into_response(),
1365                    },
1366                    &vary,
1367                );
1368            }
1369        }
1370    }
1371    // Plaintext (not TLS) gates the zero-copy `sendfile` static body. The listener
1372    // stamps `ServedOverTls`; absent (an unusual direct call) defaults to plaintext,
1373    // which is still correct — the codec only `sendfile`s a bare TCP socket and reads
1374    // the file through the (encrypting) write half otherwise.
1375    let plaintext = !request
1376        .extensions()
1377        .get::<ServedOverTls>()
1378        .map(|s| s.0)
1379        .unwrap_or(false);
1380    let response = match outcome {
1381        Outcome::Redirect { location, status } => redirect(status, &location),
1382        Outcome::Proxy { url } => proxy(request, &url, &manifest.config, client_ip).await,
1383        Outcome::File {
1384            path: served,
1385            entry,
1386        } => {
1387            // Static content answers only GET/HEAD; other methods are 405.
1388            if !matches!(*request.method(), Method::GET | Method::HEAD) {
1389                return apply_vary(method_not_allowed(), &vary);
1390            }
1391            serve_entry(
1392                deploy,
1393                &manifest.config,
1394                request_path,
1395                &served,
1396                &entry,
1397                request.headers(),
1398                StatusCode::OK,
1399                plaintext,
1400            )
1401            .await
1402        }
1403        Outcome::NotFound { error } => match error {
1404            Some((served, entry)) => {
1405                serve_entry(
1406                    deploy,
1407                    &manifest.config,
1408                    request_path,
1409                    &served,
1410                    &entry,
1411                    request.headers(),
1412                    StatusCode::NOT_FOUND,
1413                    plaintext,
1414                )
1415                .await
1416            }
1417            None => not_found(),
1418        },
1419    };
1420    apply_vary(response, &vary)
1421}
1422
1423/// `405` for a non-`GET`/`HEAD` request to static content.
1424fn method_not_allowed() -> Response {
1425    let mut headers = HeaderMap::new();
1426    headers.insert(header::ALLOW, HeaderValue::from_static("GET, HEAD"));
1427    (
1428        StatusCode::METHOD_NOT_ALLOWED,
1429        headers,
1430        "method not allowed\n",
1431    )
1432        .into_response()
1433}
1434
1435/// A large static blob to serve zero-copy via `sendfile` — attached as a response
1436/// extension (survives `into_parts`), the [`http_serve`](crate::http_serve) bridge
1437/// turns it into a [`boatramp_http::Body::File`] the h1 codec moves kernel-to-kernel.
1438/// Only produced for plaintext connections; TLS keeps the memory-mapped path.
1439#[derive(Clone)]
1440pub(crate) struct SendfileSource {
1441    // `Arc` so this can ride an `http::Extensions` (which requires `Clone`).
1442    pub(crate) file: std::sync::Arc<std::fs::File>,
1443    pub(crate) offset: u64,
1444    pub(crate) len: u64,
1445}
1446
1447/// Smallest blob that `sendfile`s rather than serving from the in-memory small-blob
1448/// cache. There is a crossover: `sendfile` avoids the socket-buffer page-alloc/zero cost
1449/// (`clear_page`) but adds an `open` + the `sendfile` syscall dance per request, a losing
1450/// trade for a tiny cache-resident body at very high request rates. Measured on the EPYC
1451/// benchmark box, the knee is ~32 KiB (≤16 KiB the cache wins by ~10–18%; ≥64 KiB
1452/// `sendfile` wins by ~11% at 64 KiB, ~19% at 100 KiB, ~85% at 1 MiB). Default 64 KiB —
1453/// captures the clear medium+ wins while leaving small bodies and the marginal 32–64 KiB
1454/// range on the cache. Tune with `BOATRAMP_SENDFILE_MIN_KB` (floored at 4 KiB).
1455static SENDFILE_MIN_BYTES: std::sync::LazyLock<u64> = std::sync::LazyLock::new(|| {
1456    std::env::var("BOATRAMP_SENDFILE_MIN_KB")
1457        .ok()
1458        .and_then(|v| v.parse::<u64>().ok())
1459        .map_or(64 * 1024, |kb| kb.saturating_mul(1024).max(4 * 1024))
1460});
1461
1462/// Kill-switch for the zero-copy `sendfile` static path (default on). `BOATRAMP_SENDFILE=0`
1463/// (or `false`) forces the memory-mapped body path — a safety valve for the hot path and
1464/// the knob for a same-binary A/B of the two.
1465static SENDFILE_ENABLED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {
1466    std::env::var("BOATRAMP_SENDFILE")
1467        .map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
1468        .unwrap_or(true)
1469});
1470
1471/// Stream a resolved entry, applying conditional/range/headers. `base_status` is
1472/// `200` for a normal hit and `404` for a custom error document. `plaintext` is true
1473/// when the connection is not TLS — the gate for the zero-copy `sendfile` body.
1474#[allow(clippy::too_many_arguments)]
1475async fn serve_entry(
1476    deploy: &DeployStore,
1477    config: &DeployConfig,
1478    request_path: &str,
1479    served_path: &str,
1480    entry: &FileEntry,
1481    req_headers: &HeaderMap,
1482    base_status: StatusCode,
1483    plaintext: bool,
1484) -> Response {
1485    let is_range = base_status == StatusCode::OK && req_headers.contains_key(header::RANGE);
1486
1487    // Content-encoding negotiation. Range requests are served from the identity
1488    // representation (Range over a compressed variant is intentionally avoided).
1489    let chosen = if is_range {
1490        None
1491    } else {
1492        negotiate_encoding(entry, req_headers)
1493    };
1494    let (blob_hash, blob_size, encoding) = match chosen {
1495        Some((enc, variant)) => (variant.hash.as_str(), variant.size, Some(enc)),
1496        None => (entry.hash.as_str(), entry.size, None),
1497    };
1498    // ETag is per-representation (identity vs br vs gzip differ in bytes).
1499    let etag = format!("\"{blob_hash}\"");
1500
1501    // Conditional GET — content hash is a strong validator.
1502    if base_status == StatusCode::OK && if_none_match(req_headers, &etag) {
1503        let mut headers = response_headers(config, request_path, served_path, entry, &etag);
1504        set_content_encoding(&mut headers, encoding);
1505        return (StatusCode::NOT_MODIFIED, headers).into_response();
1506    }
1507
1508    // Range request (identity only).
1509    if is_range {
1510        if let Some(spec) = req_headers
1511            .get(header::RANGE)
1512            .and_then(|value| value.to_str().ok())
1513        {
1514            match parse_ranges(spec, entry.size) {
1515                // A single range → `206` with `Content-Range`, streamed.
1516                Some(ranges) if ranges.len() == 1 => {
1517                    let (offset, len) = ranges[0];
1518                    let object = match deploy.open_blob_range(&entry.hash, offset, Some(len)).await
1519                    {
1520                        Ok(object) => object,
1521                        Err(err) => return deploy_error_response(err),
1522                    };
1523                    let mut headers =
1524                        response_headers(config, request_path, served_path, entry, &etag);
1525                    set_header(&mut headers, header::CONTENT_LENGTH, &len.to_string());
1526                    set_header(
1527                        &mut headers,
1528                        header::CONTENT_RANGE,
1529                        &format!("bytes {}-{}/{}", offset, offset + len - 1, entry.size),
1530                    );
1531                    return (
1532                        StatusCode::PARTIAL_CONTENT,
1533                        headers,
1534                        Body::from_stream(object.body),
1535                    )
1536                        .into_response();
1537                }
1538                // Several ranges → `206 multipart/byteranges`, streamed.
1539                Some(ranges) if ranges.len() <= MAX_RANGES => {
1540                    return multipart_byteranges(
1541                        deploy,
1542                        config,
1543                        request_path,
1544                        served_path,
1545                        entry,
1546                        &etag,
1547                        &ranges,
1548                    )
1549                    .await;
1550                }
1551                // Too many ranges: ignore `Range`, serve the full `200` body.
1552                Some(_) => {}
1553                // Malformed / wholly unsatisfiable → `416`.
1554                None => {
1555                    let mut headers = HeaderMap::new();
1556                    set_header(
1557                        &mut headers,
1558                        header::CONTENT_RANGE,
1559                        &format!("bytes */{}", entry.size),
1560                    );
1561                    return (StatusCode::RANGE_NOT_SATISFIABLE, headers).into_response();
1562                }
1563            }
1564        }
1565    }
1566
1567    // Full body (identity or negotiated variant). Small blobs are served from the
1568    // in-memory body cache as one refcounted frame (no open/ReaderStream/disk read);
1569    // larger blobs stream straight from storage.
1570    let mut headers = response_headers(config, request_path, served_path, entry, &etag);
1571    set_header(&mut headers, header::CONTENT_LENGTH, &blob_size.to_string());
1572    set_content_encoding(&mut headers, encoding);
1573
1574    // Zero-copy `sendfile` fast-path: a large static blob on a local backend served
1575    // over a plaintext connection is moved kernel-to-kernel by the h1 codec (no
1576    // userspace copy — what nginx/caddy do). Hand an empty body + the file source as an
1577    // extension; the `http_serve` bridge turns it into a `Body::File`. TLS, remote
1578    // backends, and small (cached) blobs fall through to the mapped/cached/stream path,
1579    // byte-identical to before. HEAD is unaffected (the codec suppresses the body).
1580    if *SENDFILE_ENABLED && plaintext && blob_size > *SENDFILE_MIN_BYTES {
1581        if let Some(file) = deploy.blob_file(blob_hash) {
1582            let mut resp = (base_status, headers, axum::body::Body::empty()).into_response();
1583            resp.extensions_mut().insert(SendfileSource {
1584                file: std::sync::Arc::new(file),
1585                offset: 0,
1586                len: blob_size,
1587            });
1588            return resp;
1589        }
1590    }
1591
1592    match deploy.open_blob_cached(blob_hash, blob_size).await {
1593        // Cached (small) and Mapped (large, memory-mapped file) both serve one
1594        // borrowed frame as a content-length body.
1595        Ok(boatramp_core::deploy::BlobBody::Cached(bytes))
1596        | Ok(boatramp_core::deploy::BlobBody::Mapped(bytes)) => {
1597            (base_status, headers, Body::from(bytes)).into_response()
1598        }
1599        Ok(boatramp_core::deploy::BlobBody::Stream(object)) => {
1600            (base_status, headers, Body::from_stream(object.body)).into_response()
1601        }
1602        Err(err) => deploy_error_response(err),
1603    }
1604}