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            // Host-routed: transport/canonical redirects + HSTS apply.
373            serve_request(
374                &deploy,
375                &owner.project,
376                &owner.site,
377                &request_path,
378                request,
379                &visitor,
380                &handlers,
381                true,
382            )
383            .await
384        }
385        // Unmatched host — no verified, attached virtualhost. Resolution order:
386        //   (0) mandatory verification — a **non-local** host that isn't verified
387        //       gets the "verification pending" page (421), never a fallback;
388        //   (A) implicit first-label routing — `<site>.host` names a served site;
389        //   the configured catch-all `default_site` (explicit operator intent).
390        // (A) runs only when `implicit` is on (dev / single-tenant / a loopback
391        // bind). There is deliberately no implicit *sole-site* auto-default: an
392        // operator makes a site the catch-all explicitly with `default_site`.
393        Ok(None) => {
394            // (0) Strict gate (DV-2): a non-local public host that matched no
395            // verified virtualhost is refused with the holding page — so
396            // `default_site`/implicit never silently serve an unverified host.
397            // Local hosts (localhost/*.localhost/*.local/IPs) and a fleet with the
398            // gate off (`[security] require_domain_verification = false`, or an
399            // admin `domain add --unverified` that attached the host above) pass.
400            if effective.posture.require_domain_verification && !is_local_host(&host, implicit.0) {
401                return verification_pending_page(&deploy, &host).await;
402            }
403            // (A) First host label naming a served site: `blog.localhost` → `blog`.
404            if implicit.0 {
405                let label = host.split('.').next().unwrap_or("");
406                if !label.is_empty()
407                    && matches!(
408                        deploy.current_id(ProjectRef::DEFAULT, label).await,
409                        Ok(Some(_))
410                    )
411                {
412                    let visitor = Visitor {
413                        peer: peer.ip(),
414                        limiter: limiter.as_ref(),
415                    };
416                    // Implicit first-label routing is a default-project convenience.
417                    return serve_request(
418                        &deploy,
419                        ProjectRef::DEFAULT.as_str(),
420                        label,
421                        &request_path,
422                        request,
423                        &visitor,
424                        &handlers,
425                        true,
426                    )
427                    .await;
428                }
429            }
430            match effective.default_site.as_deref() {
431                Some(site) => {
432                    let visitor = Visitor {
433                        peer: peer.ip(),
434                        limiter: limiter.as_ref(),
435                    };
436                    // The operator's catch-all `default_site` is a default-project site.
437                    serve_request(
438                        &deploy,
439                        ProjectRef::DEFAULT.as_str(),
440                        site,
441                        &request_path,
442                        request,
443                        &visitor,
444                        &handlers,
445                        true,
446                    )
447                    .await
448                }
449                None => not_found(),
450            }
451        }
452        Err(err) => deploy_error_response(err),
453    }
454}
455
456/// Serve a wildcard-host preview: resolve `id_prefix` to a full deployment id
457/// and `site_host` to a site, then run the deployment with a **preview-scoped**
458/// binding identity (like [`serve_preview`], but reached by subdomain). Handlers
459/// run only when the host resolves to a real site; otherwise the preview serves
460/// static content only. No visitor access control — the unguessable id is the
461/// capability (consistent with the path-form preview).
462#[allow(clippy::too_many_arguments)]
463async fn serve_host_preview(
464    deploy: &DeployStore,
465    handlers: &HandlerRuntime,
466    peer: IpAddr,
467    request_path: &str,
468    request: Request,
469    id_prefix: &str,
470    site_host: &str,
471) -> Response {
472    let id = match deploy.resolve_manifest_id(id_prefix).await {
473        Ok(Some(id)) => id,
474        Ok(None) => return not_found(),
475        Err(err) => return deploy_error_response(err),
476    };
477    let owner = match deploy.resolve_site_by_host(site_host).await {
478        Ok(owner) => owner,
479        Err(err) => return deploy_error_response(err),
480    };
481    let project = owner.as_ref().map(|o| o.project.clone());
482    let site = owner.map(|o| o.site);
483    let site_config = match (&project, &site) {
484        (Some(project), Some(site)) => {
485            match deploy.get_site_config(ProjectRef::new(project), site).await {
486                Ok(config) => config,
487                Err(err) => return deploy_error_response(err),
488            }
489        }
490        _ => None,
491    };
492    match deploy.get_manifest(&id).await {
493        Ok(Some(manifest)) => {
494            serve_resolved(
495                deploy,
496                &manifest,
497                request_path,
498                request,
499                peer,
500                project.as_deref(),
501                site.as_deref(),
502                site_config.as_ref(),
503                handlers,
504                Some(&id),
505            )
506            .await
507        }
508        Ok(None) => not_found(),
509        Err(err) => deploy_error_response(err),
510    }
511}
512
513/// Run the serving pipeline for a resolved `site` and request path: apply the
514/// deploy config (redirects, rewrites/SPA, clean URLs, custom 404, headers,
515/// cache) via [`route::resolve`], then HTTP correctness (conditional `304`,
516/// `Range`/`206`, `ETag`).
517#[allow(clippy::too_many_arguments)]
518async fn serve_request(
519    deploy: &DeployStore,
520    project: &str,
521    site: &str,
522    request_path: &str,
523    request: Request,
524    visitor: &Visitor<'_>,
525    handlers: &HandlerRuntime,
526    host_routed: bool,
527) -> Response {
528    let project_ref = ProjectRef::new(project);
529    // Load the site config once (for access policy + client-IP resolution). Cached
530    // by content hash — the hot path skips the body read + JSON parse per request.
531    let site_config = match deploy.get_site_config_cached(project_ref, site).await {
532        Ok(config) => config,
533        Err(err) => return deploy_error_response(err),
534    };
535
536    // Transport redirects + HSTS. The effective scheme
537    // honors `X-Forwarded-Proto` **only from a configured trusted proxy**
538    // — otherwise a direct HTTP client could forge `…: https` to
539    // skip the HTTPS redirect. For an untrusted/direct peer the scheme is the
540    // listener's own (TLS ⇒ `https`, else `http`). Host-routed traffic only.
541    let listener_scheme = if request
542        .extensions()
543        .get::<ServedOverTls>()
544        .map(|s| s.0)
545        .unwrap_or(false)
546    {
547        "https"
548    } else {
549        "http"
550    };
551    let peer_trusted = site_config
552        .as_ref()
553        .map(|c| c.access.is_trusted_proxy(visitor.peer))
554        .unwrap_or(false);
555    let effective_scheme = if peer_trusted {
556        request
557            .headers()
558            .get("x-forwarded-proto")
559            .and_then(|v| v.to_str().ok())
560            .unwrap_or(listener_scheme)
561            .to_string()
562    } else {
563        listener_scheme.to_string()
564    };
565    // Captured before the request body is consumed, for on-the-fly compression.
566    #[cfg(feature = "compression")]
567    let accept_encoding = request
568        .headers()
569        .get(header::ACCEPT_ENCODING)
570        .and_then(|v| v.to_str().ok())
571        .map(str::to_string);
572    // Site-tier security response headers, applied (host-routed only) after the
573    // response is built: HSTS (HTTPS only), plus opt-in CSP / X-Frame-Options.
574    let mut security_headers: Vec<(HeaderName, String)> = Vec::new();
575    if host_routed {
576        if let Some(cfg) = site_config.as_ref() {
577            let host = request
578                .headers()
579                .get(header::HOST)
580                .and_then(|v| v.to_str().ok())
581                .map(strip_port)
582                .unwrap_or("");
583            let path_and_query = request
584                .uri()
585                .path_and_query()
586                .map(axum::http::uri::PathAndQuery::as_str)
587                .unwrap_or(request_path);
588            if let Some(target) = boatramp_core::config::transport_redirect(
589                &cfg.security,
590                &cfg.domains,
591                &effective_scheme,
592                host,
593                path_and_query,
594            ) {
595                return redirect_to(&target);
596            }
597            // HSTS only over HTTPS (it's meaningless / ignored over plain HTTP).
598            if effective_scheme == "https" {
599                if let Some(hsts) = cfg.security.hsts.as_ref() {
600                    security_headers.push((
601                        HeaderName::from_static("strict-transport-security"),
602                        hsts.header_value(),
603                    ));
604                }
605            }
606            // CSP + X-Frame-Options apply on either scheme, when configured.
607            if let Some(csp) = cfg.security.csp.as_deref() {
608                security_headers.push((header::CONTENT_SECURITY_POLICY, csp.to_string()));
609            }
610            if let Some(frame) = cfg.security.frame_options.as_deref() {
611                security_headers.push((header::X_FRAME_OPTIONS, frame.to_string()));
612            }
613        }
614    }
615
616    let access = site_config.as_ref().map(|c| &c.access);
617
618    // Resolve the real client IP, honoring X-Forwarded-For only from a
619    // configured trusted proxy.
620    let trusted = access.map(|a| a.trusted_proxies.as_slice()).unwrap_or(&[]);
621    let forwarded_for = request
622        .headers()
623        .get("x-forwarded-for")
624        .and_then(|value| value.to_str().ok());
625    let client_ip = boatramp_core::access::resolve_client_ip(visitor.peer, forwarded_for, trusted);
626
627    // Visitor access control (WAF → IP rules → rate limit → basic auth) runs
628    // before any content is read.
629    if let Some(access) = access {
630        if let Some(denied) = enforce_access(
631            access,
632            site,
633            request.headers(),
634            request_path,
635            client_ip,
636            visitor.limiter,
637        )
638        .await
639        {
640            return denied;
641        }
642    }
643
644    let manifest = match deploy.current_manifest(project_ref, site).await {
645        Ok(Some(manifest)) => manifest,
646        Ok(None) => return not_found(),
647        Err(err) => return deploy_error_response(err),
648    };
649    let mut response = serve_resolved(
650        deploy,
651        &manifest,
652        request_path,
653        request,
654        client_ip,
655        Some(project),
656        Some(site),
657        site_config.as_deref(),
658        handlers,
659        None,
660    )
661    .await;
662    // Site-tier security headers (HSTS / CSP / X-Frame-Options), computed above.
663    for (name, value) in security_headers {
664        if let Ok(value) = HeaderValue::from_str(&value) {
665            response.headers_mut().insert(name, value);
666        }
667    }
668    // On-the-fly compression (opt-in per site; covers dynamic + variant-less
669    // static responses). A no-op without the `compression` feature.
670    #[cfg(feature = "compression")]
671    let response = match site_config.as_ref() {
672        Some(cfg) if cfg.compression.enabled => maybe_compress(
673            response,
674            accept_encoding.as_deref(),
675            cfg.compression.min_size,
676        ),
677        _ => response,
678    };
679    response
680}
681
682/// When previews are protected, require a valid control-plane token. Returns
683/// `Some(401)` to block, `None` to allow. "Any valid token" (no scope needed).
684async fn preview_auth_gate(
685    policy: PreviewPolicy,
686    auth: &Auth,
687    headers: &HeaderMap,
688) -> Option<Response> {
689    if !policy.protect {
690        return None;
691    }
692    let bearer = headers
693        .get(header::AUTHORIZATION)
694        .and_then(|v| v.to_str().ok())
695        .and_then(|v| v.strip_prefix("Bearer "));
696    let ok = match bearer {
697        Some(token) => auth.verify_bearer(token).await,
698        None => false,
699    };
700    (!ok).then(|| {
701        (
702            StatusCode::UNAUTHORIZED,
703            "preview requires a valid bearer token\n",
704        )
705            .into_response()
706    })
707}
708
709/// A `301 Moved Permanently` to `target` (transport/canonical redirects).
710fn redirect_to(target: &str) -> Response {
711    match HeaderValue::from_str(target) {
712        Ok(location) => (
713            StatusCode::MOVED_PERMANENTLY,
714            [(header::LOCATION, location)],
715        )
716            .into_response(),
717        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
718    }
719}
720
721/// A standalone router for a plain `:80` listener that permanently redirects
722/// every request to its HTTPS equivalent. Bound alongside the HTTPS listener so
723/// plain-HTTP visitors are upgraded even when boatramp terminates TLS itself.
724///
725/// It does serve one thing directly rather than redirecting: the HTTP
726/// domain-ownership challenge (`/.well-known/boatramp-domain-verification/…`).
727/// That probe arrives on plain `:80` for a host that may have no cert yet, so a
728/// 308 to HTTPS would bounce it to an endpoint that can't answer — the token
729/// must be served here. (ACME's own challenges use ALPN-01/DNS-01, so there is
730/// no `/.well-known/acme-challenge` to serve.)
731pub fn http_redirect_router(
732    deploy: DeployStore,
733    posture: boatramp_core::security::SecurityPosture,
734) -> Router {
735    Router::new()
736        .route(
737            "/.well-known/boatramp-domain-verification/{token}",
738            get(serve_domain_challenge),
739        )
740        .fallback(redirect_http_to_https)
741        .with_state(deploy)
742        .layer(Extension(posture))
743}
744
745/// 308-redirect any request to `https://<host><path-and-query>` (308 preserves
746/// the method/body, unlike 301).
747async fn redirect_http_to_https(req: Request) -> Response {
748    let host = req
749        .headers()
750        .get(header::HOST)
751        .and_then(|v| v.to_str().ok())
752        .map(strip_port)
753        .unwrap_or("");
754    if host.is_empty() {
755        return (StatusCode::BAD_REQUEST, "missing Host header\n").into_response();
756    }
757    let path_and_query = req
758        .uri()
759        .path_and_query()
760        .map(axum::http::uri::PathAndQuery::as_str)
761        .unwrap_or("/");
762    match HeaderValue::from_str(&format!("https://{host}{path_and_query}")) {
763        Ok(location) => (
764            StatusCode::PERMANENT_REDIRECT,
765            [(header::LOCATION, location)],
766        )
767            .into_response(),
768        Err(_) => (StatusCode::BAD_REQUEST, "invalid host\n").into_response(),
769    }
770}
771
772/// Evaluate a site's [`AccessConfig`] against an already-resolved `client_ip`.
773/// Returns `Some(response)` to short-circuit (403/429/401), or `None` to allow.
774/// Order: WAF → IP rules → rate limit → basic auth. `async` because the
775/// cluster-wide rate-limit store does a KV round-trip.
776async fn enforce_access(
777    access: &AccessConfig,
778    site: &str,
779    req_headers: &HeaderMap,
780    path: &str,
781    client_ip: IpAddr,
782    limiter: &dyn RateLimitStore,
783) -> Option<Response> {
784    if !access.is_enforced() {
785        return None;
786    }
787    // WAF (user-agent rules + anomaly scoring) is the outermost filter: a blocked
788    // request shouldn't reach rate limiting or auth.
789    if access.waf.is_enabled() {
790        let header_str = |name| req_headers.get(name).and_then(|v| v.to_str().ok());
791        let waf_req = boatramp_core::waf::WafRequest {
792            user_agent: header_str(header::USER_AGENT),
793            accept: header_str(header::ACCEPT),
794            path,
795        };
796        if let boatramp_core::waf::WafVerdict::Block(reason) =
797            boatramp_core::waf::evaluate(&access.waf, &waf_req)
798        {
799            tracing::debug!(%client_ip, site, %reason, "request blocked by WAF");
800            return Some((StatusCode::FORBIDDEN, "forbidden\n").into_response());
801        }
802    }
803    if !access.ip.allows(client_ip) {
804        tracing::debug!(%client_ip, site, "request blocked by IP rules");
805        return Some((StatusCode::FORBIDDEN, "forbidden\n").into_response());
806    }
807    if let Some(limit) = &access.rate_limit {
808        if !limiter.check(site, client_ip, limit).await {
809            return Some(too_many_requests());
810        }
811    }
812    if let Some(basic) = &access.basic_auth {
813        if !verify_basic_auth(basic, req_headers) {
814            return Some(basic_auth_challenge(basic));
815        }
816    }
817    None
818}
819
820/// Verify an HTTP `Authorization: Basic` header against the site credentials.
821fn verify_basic_auth(basic: &BasicAuth, req_headers: &HeaderMap) -> bool {
822    use base64::Engine;
823    let Some(encoded) = req_headers
824        .get(header::AUTHORIZATION)
825        .and_then(|value| value.to_str().ok())
826        .and_then(|value| value.strip_prefix("Basic "))
827    else {
828        return false;
829    };
830    let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded.trim()) else {
831        return false;
832    };
833    let Ok(text) = String::from_utf8(decoded) else {
834        return false;
835    };
836    match text.split_once(':') {
837        Some((user, pass)) => basic.verify(user, pass),
838        None => false,
839    }
840}
841
842/// `401` with a `WWW-Authenticate: Basic` challenge.
843fn basic_auth_challenge(basic: &BasicAuth) -> Response {
844    let realm = basic.realm.replace(['"', '\\'], "");
845    let mut headers = HeaderMap::new();
846    if let Ok(value) = HeaderValue::from_str(&format!("Basic realm=\"{realm}\", charset=\"UTF-8\""))
847    {
848        headers.insert(header::WWW_AUTHENTICATE, value);
849    }
850    (
851        StatusCode::UNAUTHORIZED,
852        headers,
853        "authentication required\n",
854    )
855        .into_response()
856}
857
858/// `429 Too Many Requests` with a `Retry-After`.
859fn too_many_requests() -> Response {
860    let mut headers = HeaderMap::new();
861    headers.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
862    (
863        StatusCode::TOO_MANY_REQUESTS,
864        headers,
865        "rate limit exceeded\n",
866    )
867        .into_response()
868}
869
870/// Serve an immutable deployment by id under `/_deploy/<id>/...`. Like
871/// [`serve_sites`], a single catch-all captures `<id>` or `<id>/<path...>`, so
872/// `/_deploy/<id>`, `/_deploy/<id>/`, and `/_deploy/<id>/about` all route here.
873pub(super) async fn serve_preview(
874    State(deploy): State<DeployStore>,
875    Extension(handlers): Extension<Arc<HandlerRuntime>>,
876    Extension(daemon): Extension<Arc<DaemonRuntime>>,
877    Extension(preview_auth): Extension<Auth>,
878    ConnectInfo(peer): ConnectInfo<SocketAddr>,
879    request: Request,
880) -> Response {
881    let preview_policy = PreviewPolicy {
882        protect: daemon.effective().protect_previews,
883    };
884    if let Some(blocked) = preview_auth_gate(preview_policy, &preview_auth, request.headers()).await
885    {
886        return blocked;
887    }
888    let raw = request.uri().path();
889    let rest = raw
890        .strip_prefix("/_deploy/")
891        .unwrap_or("")
892        .trim_start_matches('/');
893    let (id, path) = rest.split_once('/').unwrap_or((rest, ""));
894    if id.is_empty() {
895        return not_found();
896    }
897    let (id, request_path) = (id.to_string(), format!("/{path}"));
898    // When the preview is reached via the *site's own hostname*
899    // (`site.example.com/_deploy/<id>/…`), resolve that site so handlers can run
900    // — with **preview-scoped** bindings (`Some(&id)` below) so they never touch
901    // the live site's kv/blob/sql. Reached via any other host
902    // (no site resolves), handlers stay off — the preview serves static only.
903    let site = request
904        .headers()
905        .get(header::HOST)
906        .and_then(|value| value.to_str().ok())
907        .map(strip_port);
908    let owner = match site {
909        Some(host) => match deploy.resolve_site_by_host(host).await {
910            Ok(owner) => owner,
911            Err(err) => return deploy_error_response(err),
912        },
913        None => None,
914    };
915    let project = owner.as_ref().map(|o| o.project.clone());
916    let site = owner.map(|o| o.site);
917    let site_config = match (&project, &site) {
918        (Some(project), Some(site)) => {
919            match deploy.get_site_config(ProjectRef::new(project), site).await {
920                Ok(config) => config,
921                Err(err) => return deploy_error_response(err),
922            }
923        }
924        _ => None,
925    };
926    match deploy.get_manifest(&id).await {
927        Ok(Some(manifest)) => {
928            serve_resolved(
929                &deploy,
930                &manifest,
931                &request_path,
932                request,
933                peer.ip(),
934                project.as_deref(),
935                site.as_deref(),
936                site_config.as_ref(),
937                &handlers,
938                Some(&id),
939            )
940            .await
941        }
942        Ok(None) => not_found(),
943        Err(err) => deploy_error_response(err),
944    }
945}
946
947/// Run the deploy-config routing pipeline against a resolved `manifest`, then
948/// stream the chosen entry (or proxy). `client_ip` is the resolved visitor
949/// address (for proxy `X-Forwarded-For`).
950/// Build the [`RequestContext`](boatramp_core::predicate::RequestContext) a
951/// conditional-routing `when` predicate reads from the live request. Only called
952/// when the deployment actually has conditional rules, so the non-conditional hot
953/// path never pays for it.
954fn build_request_context(request: &Request) -> boatramp_core::predicate::RequestContext {
955    use boatramp_core::predicate::RequestContext;
956    let headers = request.headers();
957    let mut hmap: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
958    for (name, value) in headers {
959        if let Ok(v) = value.to_str() {
960            hmap.entry(name.as_str().to_ascii_lowercase())
961                .and_modify(|e| {
962                    e.push_str(", ");
963                    e.push_str(v);
964                })
965                .or_insert_with(|| v.to_string());
966        }
967    }
968    let host = headers
969        .get(header::HOST)
970        .and_then(|h| h.to_str().ok())
971        .map(|h| h.split(':').next().unwrap_or(h).to_string())
972        .unwrap_or_default();
973    let cookies = headers
974        .get(header::COOKIE)
975        .and_then(|h| h.to_str().ok())
976        .map(parse_cookie_header)
977        .unwrap_or_default();
978    let query = request
979        .uri()
980        .query()
981        .map(parse_query_string)
982        .unwrap_or_default();
983    let accept_languages = headers
984        .get(header::ACCEPT_LANGUAGE)
985        .and_then(|h| h.to_str().ok())
986        .map(RequestContext::parse_accept_language)
987        .unwrap_or_default();
988    RequestContext {
989        method: request.method().as_str().to_ascii_uppercase(),
990        host,
991        headers: hmap,
992        cookies,
993        query,
994        accept_languages,
995    }
996}
997
998/// Parse a `Cookie` header into name→value pairs (first value wins).
999pub(super) fn parse_cookie_header(raw: &str) -> std::collections::BTreeMap<String, String> {
1000    raw.split(';')
1001        .filter_map(|pair| pair.split_once('='))
1002        .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
1003        .fold(std::collections::BTreeMap::new(), |mut m, (k, v)| {
1004            m.entry(k).or_insert(v);
1005            m
1006        })
1007}
1008
1009/// Parse a URL query string into key→value pairs (first value wins), with
1010/// `application/x-www-form-urlencoded` decoding (`+` → space, `%XX` → byte) so a
1011/// condition compares against the real value.
1012pub(super) fn parse_query_string(raw: &str) -> std::collections::BTreeMap<String, String> {
1013    raw.split('&')
1014        .filter(|p| !p.is_empty())
1015        .map(|pair| match pair.split_once('=') {
1016            Some((k, v)) => (percent_decode(k), percent_decode(v)),
1017            None => (percent_decode(pair), String::new()),
1018        })
1019        .fold(std::collections::BTreeMap::new(), |mut m, (k, v)| {
1020            m.entry(k).or_insert(v);
1021            m
1022        })
1023}
1024
1025/// Decode a `application/x-www-form-urlencoded` component: `+` → space, `%XX` →
1026/// the byte, everything else verbatim. Invalid `%` escapes are left as-is;
1027/// non-UTF-8 results are lossily replaced.
1028fn percent_decode(s: &str) -> String {
1029    let bytes = s.as_bytes();
1030    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
1031    let mut i = 0;
1032    while i < bytes.len() {
1033        match bytes[i] {
1034            b'+' => {
1035                out.push(b' ');
1036                i += 1;
1037            }
1038            b'%' if i + 2 < bytes.len() => {
1039                let hi = (bytes[i + 1] as char).to_digit(16);
1040                let lo = (bytes[i + 2] as char).to_digit(16);
1041                match (hi, lo) {
1042                    (Some(h), Some(l)) => {
1043                        out.push((h * 16 + l) as u8);
1044                        i += 3;
1045                    }
1046                    _ => {
1047                        out.push(b'%');
1048                        i += 1;
1049                    }
1050                }
1051            }
1052            b => {
1053                out.push(b);
1054                i += 1;
1055            }
1056        }
1057    }
1058    String::from_utf8_lossy(&out).into_owned()
1059}
1060
1061/// Merge conditional-routing `Vary` header names into a response, so a per-visitor
1062/// (language/cookie/header) redirect or page is never shared across visitors by a
1063/// downstream cache. A no-op when `vary` is empty (the non-conditional case).
1064pub(super) fn apply_vary(mut response: Response, vary: &[String]) -> Response {
1065    if vary.is_empty() {
1066        return response;
1067    }
1068    let mut names: Vec<String> = response
1069        .headers()
1070        .get(header::VARY)
1071        .and_then(|v| v.to_str().ok())
1072        .map(|s| {
1073            s.split(',')
1074                .map(|p| p.trim().to_ascii_lowercase())
1075                .filter(|p| !p.is_empty())
1076                .collect()
1077        })
1078        .unwrap_or_default();
1079    for v in vary {
1080        if !names.iter().any(|n| n == v) {
1081            names.push(v.clone());
1082        }
1083    }
1084    if let Ok(hv) = HeaderValue::from_str(&names.join(", ")) {
1085        response.headers_mut().insert(header::VARY, hv);
1086    }
1087    response
1088}
1089
1090#[allow(clippy::too_many_arguments)]
1091#[cfg_attr(not(feature = "handlers"), allow(unused_variables))]
1092async fn serve_resolved(
1093    deploy: &DeployStore,
1094    manifest: &Manifest,
1095    request_path: &str,
1096    request: Request,
1097    client_ip: IpAddr,
1098    // The tenant project the resolved site belongs to; `None` mirrors `site:
1099    // None` (a preview reached via a non-resolving host, handlers off).
1100    project: Option<&str>,
1101    site: Option<&str>,
1102    site_config: Option<&SiteConfig>,
1103    handlers: &HandlerRuntime,
1104    // `Some(deploy_id)` when serving a by-id preview, so handler bindings get a
1105    // preview-scoped identity; `None` for live serving.
1106    preview: Option<&str>,
1107) -> Response {
1108    // Evaluate conditional (`when`) routing against the request. The request
1109    // context is built only when the deploy has conditional rules, and `vary`
1110    // carries the request dimensions those conditions read (applied to the
1111    // response below so a per-language/-cookie outcome isn't wrongly cached).
1112    let ctx = if manifest.config.redirects.iter().any(|r| r.when.is_some())
1113        || manifest.config.rewrites.iter().any(|r| r.when.is_some())
1114    {
1115        build_request_context(&request)
1116    } else {
1117        boatramp_core::predicate::RequestContext::default()
1118    };
1119    let route::ResolveResult { outcome, vary } =
1120        route::resolve_ctx(&manifest.config, &manifest.files, request_path, &ctx);
1121    // Routing precedence: redirects win over handlers, which
1122    // win over rewrites/static. A redirect short-circuits below; otherwise a
1123    // matching handler is dispatched in preference to the file/rewrite outcome.
1124    #[cfg(feature = "handlers")]
1125    if !matches!(outcome, Outcome::Redirect { .. }) {
1126        if let Some(site) = site {
1127            if let Some(handler) = route::match_handler(
1128                &manifest.config.handlers,
1129                request.method().as_str(),
1130                request_path,
1131            ) {
1132                return apply_vary(
1133                    dispatch_handler(
1134                        handlers,
1135                        deploy,
1136                        manifest,
1137                        // Handler dispatch only runs when `site` is Some, and the
1138                        // project is threaded alongside it; default-project fallback
1139                        // keeps a by-id preview reached via a non-resolving host safe.
1140                        project.unwrap_or(ProjectRef::DEFAULT.as_str()),
1141                        site,
1142                        request_path,
1143                        site_config,
1144                        handler,
1145                        request,
1146                        client_ip,
1147                        preview,
1148                    )
1149                    .await,
1150                    &vary,
1151                );
1152            }
1153            // No handler matched: a GET to a configured SSE stream route fans out
1154            // its messaging topics. Streams are GET-only.
1155            if request.method() == Method::GET {
1156                if let Some(stream) = manifest
1157                    .config
1158                    .streams
1159                    .iter()
1160                    .find(|s| route_matches(&s.route, request_path))
1161                {
1162                    if let (Some(inner), Some(site_handlers)) = (
1163                        handlers.inner.as_ref(),
1164                        site_config
1165                            .and_then(|c| c.handlers.as_ref())
1166                            .filter(|h| h.enabled),
1167                    ) {
1168                        // A `websocket` stream upgraded by the client is served
1169                        // bidirectionally (WebSocket fan-out); otherwise it's SSE.
1170                        // serve_ws_stream does the RFC 6455 handshake + takes over the
1171                        // connection via boatramp-http's upgrade seam (the request body
1172                        // isn't held across an await — WS carries none).
1173                        if stream.websocket && is_upgrade_request(request.headers()) {
1174                            return apply_vary(
1175                                serve_ws_stream(
1176                                    inner,
1177                                    site,
1178                                    site_handlers,
1179                                    stream,
1180                                    request,
1181                                    client_ip,
1182                                    preview,
1183                                )
1184                                .await,
1185                                &vary,
1186                            );
1187                        }
1188                        // Pull the only field needed from the request as an owned
1189                        // value: `&Request` is not `Send` (the body isn't `Sync`),
1190                        // so it must not be held across the dispatch await.
1191                        let after = request
1192                            .headers()
1193                            .get("last-event-id")
1194                            .and_then(|value| value.to_str().ok())
1195                            .map(str::to_string);
1196                        return apply_vary(
1197                            serve_stream(
1198                                inner,
1199                                site,
1200                                site_handlers,
1201                                stream,
1202                                after,
1203                                client_ip,
1204                                preview,
1205                            )
1206                            .await,
1207                            &vary,
1208                        );
1209                    }
1210                    // A stream route on a site with handlers disabled / no runtime
1211                    // is not served (deny by default).
1212                    return apply_vary(not_found(), &vary);
1213                }
1214            }
1215        }
1216    }
1217    // Gateway: an operator-declared route forwards to a private
1218    // upstream. Independent of the handlers feature; runs after redirects/
1219    // handlers and **wins over static files** (the operator declared it). Access
1220    // control already ran up front; only declared upstreams reach private addrs.
1221    if !matches!(outcome, Outcome::Redirect { .. }) {
1222        if let Some(gw) = site_config
1223            .and_then(|c| c.gateway.as_ref())
1224            .filter(|g| g.is_enabled())
1225        {
1226            if let Some(route) = gw.match_route(request_path) {
1227                return apply_vary(
1228                    match gw.upstreams.get(&route.upstream) {
1229                        Some(upstream) => {
1230                            // A compute-backed upstream resolves its pool live from
1231                            // the workload's healthy replica endpoints. Record
1232                            // the request as activity so the reconcile loop
1233                            // keeps the workload warm / wakes it, and only sleeps it
1234                            // once genuinely idle.
1235                            let (compute_backends, compute_regions) = match &upstream.compute {
1236                                Some(workload) => {
1237                                    // Resolve the compute upstream against the site's OWN
1238                                    // project (not `default`), so a non-default tenant's
1239                                    // replica state is found — closing the project-blind
1240                                    // resolution that 502'd / never woke it.
1241                                    let compute_project =
1242                                        project.unwrap_or(ProjectRef::DEFAULT.as_str());
1243                                    gateway::record_activity(workload);
1244                                    let mut pool =
1245                                        compute_endpoints(deploy, compute_project, workload).await;
1246                                    // Wake-from-zero: no live replica but one
1247                                    // is parked → nudge the reconcile loop to restore it
1248                                    // and hold this request until it's serving. The cold
1249                                    // start is invisible to the client; only a genuine
1250                                    // restore failure (timeout) falls through to 502.
1251                                    if pool.is_empty()
1252                                        && has_parked_replica(deploy, compute_project, workload)
1253                                            .await
1254                                    {
1255                                        gateway::wake_reconcile();
1256                                        pool = await_warm(
1257                                            deploy,
1258                                            compute_project,
1259                                            workload,
1260                                            COMPUTE_WAKE_TIMEOUT,
1261                                        )
1262                                        .await;
1263                                    }
1264                                    // FA-8: for a nearest-region pool, tag each replica
1265                                    // endpoint with its node's region (from placement).
1266                                    let regions = if upstream.lb
1267                                        == boatramp_core::gateway::LbPolicy::Nearest
1268                                    {
1269                                        Some(
1270                                            compute_endpoint_regions(
1271                                                deploy,
1272                                                compute_project,
1273                                                workload,
1274                                            )
1275                                            .await,
1276                                        )
1277                                    } else {
1278                                        None
1279                                    };
1280                                    (Some(pool), regions)
1281                                }
1282                                None => (None, None),
1283                            };
1284                            dispatch_gateway(
1285                                request,
1286                                site.unwrap_or(""),
1287                                &route.upstream,
1288                                upstream,
1289                                request_path,
1290                                client_ip,
1291                                compute_backends,
1292                                compute_regions,
1293                            )
1294                            .await
1295                        }
1296                        None => (
1297                            StatusCode::BAD_GATEWAY,
1298                            "gateway route references an unknown upstream\n",
1299                        )
1300                            .into_response(),
1301                    },
1302                    &vary,
1303                );
1304            }
1305        }
1306    }
1307    // Plaintext (not TLS) gates the zero-copy `sendfile` static body. The listener
1308    // stamps `ServedOverTls`; absent (an unusual direct call) defaults to plaintext,
1309    // which is still correct — the codec only `sendfile`s a bare TCP socket and reads
1310    // the file through the (encrypting) write half otherwise.
1311    let plaintext = !request
1312        .extensions()
1313        .get::<ServedOverTls>()
1314        .map(|s| s.0)
1315        .unwrap_or(false);
1316    let response = match outcome {
1317        Outcome::Redirect { location, status } => redirect(status, &location),
1318        Outcome::Proxy { url } => proxy(request, &url, &manifest.config, client_ip).await,
1319        Outcome::File {
1320            path: served,
1321            entry,
1322        } => {
1323            // Static content answers only GET/HEAD; other methods are 405.
1324            if !matches!(*request.method(), Method::GET | Method::HEAD) {
1325                return apply_vary(method_not_allowed(), &vary);
1326            }
1327            serve_entry(
1328                deploy,
1329                &manifest.config,
1330                request_path,
1331                &served,
1332                &entry,
1333                request.headers(),
1334                StatusCode::OK,
1335                plaintext,
1336            )
1337            .await
1338        }
1339        Outcome::NotFound { error } => match error {
1340            Some((served, entry)) => {
1341                serve_entry(
1342                    deploy,
1343                    &manifest.config,
1344                    request_path,
1345                    &served,
1346                    &entry,
1347                    request.headers(),
1348                    StatusCode::NOT_FOUND,
1349                    plaintext,
1350                )
1351                .await
1352            }
1353            None => not_found(),
1354        },
1355    };
1356    apply_vary(response, &vary)
1357}
1358
1359/// `405` for a non-`GET`/`HEAD` request to static content.
1360fn method_not_allowed() -> Response {
1361    let mut headers = HeaderMap::new();
1362    headers.insert(header::ALLOW, HeaderValue::from_static("GET, HEAD"));
1363    (
1364        StatusCode::METHOD_NOT_ALLOWED,
1365        headers,
1366        "method not allowed\n",
1367    )
1368        .into_response()
1369}
1370
1371/// A large static blob to serve zero-copy via `sendfile` — attached as a response
1372/// extension (survives `into_parts`), the [`http_serve`](crate::http_serve) bridge
1373/// turns it into a [`boatramp_http::Body::File`] the h1 codec moves kernel-to-kernel.
1374/// Only produced for plaintext connections; TLS keeps the memory-mapped path.
1375#[derive(Clone)]
1376pub(crate) struct SendfileSource {
1377    // `Arc` so this can ride an `http::Extensions` (which requires `Clone`).
1378    pub(crate) file: std::sync::Arc<std::fs::File>,
1379    pub(crate) offset: u64,
1380    pub(crate) len: u64,
1381}
1382
1383/// Smallest blob that `sendfile`s rather than serving from the in-memory small-blob
1384/// cache. There is a crossover: `sendfile` avoids the socket-buffer page-alloc/zero cost
1385/// (`clear_page`) but adds an `open` + the `sendfile` syscall dance per request, a losing
1386/// trade for a tiny cache-resident body at very high request rates. Measured on the EPYC
1387/// benchmark box, the knee is ~32 KiB (≤16 KiB the cache wins by ~10–18%; ≥64 KiB
1388/// `sendfile` wins by ~11% at 64 KiB, ~19% at 100 KiB, ~85% at 1 MiB). Default 64 KiB —
1389/// captures the clear medium+ wins while leaving small bodies and the marginal 32–64 KiB
1390/// range on the cache. Tune with `BOATRAMP_SENDFILE_MIN_KB` (floored at 4 KiB).
1391static SENDFILE_MIN_BYTES: std::sync::LazyLock<u64> = std::sync::LazyLock::new(|| {
1392    std::env::var("BOATRAMP_SENDFILE_MIN_KB")
1393        .ok()
1394        .and_then(|v| v.parse::<u64>().ok())
1395        .map_or(64 * 1024, |kb| kb.saturating_mul(1024).max(4 * 1024))
1396});
1397
1398/// Kill-switch for the zero-copy `sendfile` static path (default on). `BOATRAMP_SENDFILE=0`
1399/// (or `false`) forces the memory-mapped body path — a safety valve for the hot path and
1400/// the knob for a same-binary A/B of the two.
1401static SENDFILE_ENABLED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {
1402    std::env::var("BOATRAMP_SENDFILE")
1403        .map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
1404        .unwrap_or(true)
1405});
1406
1407/// Stream a resolved entry, applying conditional/range/headers. `base_status` is
1408/// `200` for a normal hit and `404` for a custom error document. `plaintext` is true
1409/// when the connection is not TLS — the gate for the zero-copy `sendfile` body.
1410#[allow(clippy::too_many_arguments)]
1411async fn serve_entry(
1412    deploy: &DeployStore,
1413    config: &DeployConfig,
1414    request_path: &str,
1415    served_path: &str,
1416    entry: &FileEntry,
1417    req_headers: &HeaderMap,
1418    base_status: StatusCode,
1419    plaintext: bool,
1420) -> Response {
1421    let is_range = base_status == StatusCode::OK && req_headers.contains_key(header::RANGE);
1422
1423    // Content-encoding negotiation. Range requests are served from the identity
1424    // representation (Range over a compressed variant is intentionally avoided).
1425    let chosen = if is_range {
1426        None
1427    } else {
1428        negotiate_encoding(entry, req_headers)
1429    };
1430    let (blob_hash, blob_size, encoding) = match chosen {
1431        Some((enc, variant)) => (variant.hash.as_str(), variant.size, Some(enc)),
1432        None => (entry.hash.as_str(), entry.size, None),
1433    };
1434    // ETag is per-representation (identity vs br vs gzip differ in bytes).
1435    let etag = format!("\"{blob_hash}\"");
1436
1437    // Conditional GET — content hash is a strong validator.
1438    if base_status == StatusCode::OK && if_none_match(req_headers, &etag) {
1439        let mut headers = response_headers(config, request_path, served_path, entry, &etag);
1440        set_content_encoding(&mut headers, encoding);
1441        return (StatusCode::NOT_MODIFIED, headers).into_response();
1442    }
1443
1444    // Range request (identity only).
1445    if is_range {
1446        if let Some(spec) = req_headers
1447            .get(header::RANGE)
1448            .and_then(|value| value.to_str().ok())
1449        {
1450            match parse_ranges(spec, entry.size) {
1451                // A single range → `206` with `Content-Range`, streamed.
1452                Some(ranges) if ranges.len() == 1 => {
1453                    let (offset, len) = ranges[0];
1454                    let object = match deploy.open_blob_range(&entry.hash, offset, Some(len)).await
1455                    {
1456                        Ok(object) => object,
1457                        Err(err) => return deploy_error_response(err),
1458                    };
1459                    let mut headers =
1460                        response_headers(config, request_path, served_path, entry, &etag);
1461                    set_header(&mut headers, header::CONTENT_LENGTH, &len.to_string());
1462                    set_header(
1463                        &mut headers,
1464                        header::CONTENT_RANGE,
1465                        &format!("bytes {}-{}/{}", offset, offset + len - 1, entry.size),
1466                    );
1467                    return (
1468                        StatusCode::PARTIAL_CONTENT,
1469                        headers,
1470                        Body::from_stream(object.body),
1471                    )
1472                        .into_response();
1473                }
1474                // Several ranges → `206 multipart/byteranges`, streamed.
1475                Some(ranges) if ranges.len() <= MAX_RANGES => {
1476                    return multipart_byteranges(
1477                        deploy,
1478                        config,
1479                        request_path,
1480                        served_path,
1481                        entry,
1482                        &etag,
1483                        &ranges,
1484                    )
1485                    .await;
1486                }
1487                // Too many ranges: ignore `Range`, serve the full `200` body.
1488                Some(_) => {}
1489                // Malformed / wholly unsatisfiable → `416`.
1490                None => {
1491                    let mut headers = HeaderMap::new();
1492                    set_header(
1493                        &mut headers,
1494                        header::CONTENT_RANGE,
1495                        &format!("bytes */{}", entry.size),
1496                    );
1497                    return (StatusCode::RANGE_NOT_SATISFIABLE, headers).into_response();
1498                }
1499            }
1500        }
1501    }
1502
1503    // Full body (identity or negotiated variant). Small blobs are served from the
1504    // in-memory body cache as one refcounted frame (no open/ReaderStream/disk read);
1505    // larger blobs stream straight from storage.
1506    let mut headers = response_headers(config, request_path, served_path, entry, &etag);
1507    set_header(&mut headers, header::CONTENT_LENGTH, &blob_size.to_string());
1508    set_content_encoding(&mut headers, encoding);
1509
1510    // Zero-copy `sendfile` fast-path: a large static blob on a local backend served
1511    // over a plaintext connection is moved kernel-to-kernel by the h1 codec (no
1512    // userspace copy — what nginx/caddy do). Hand an empty body + the file source as an
1513    // extension; the `http_serve` bridge turns it into a `Body::File`. TLS, remote
1514    // backends, and small (cached) blobs fall through to the mapped/cached/stream path,
1515    // byte-identical to before. HEAD is unaffected (the codec suppresses the body).
1516    if *SENDFILE_ENABLED && plaintext && blob_size > *SENDFILE_MIN_BYTES {
1517        if let Some(file) = deploy.blob_file(blob_hash) {
1518            let mut resp = (base_status, headers, axum::body::Body::empty()).into_response();
1519            resp.extensions_mut().insert(SendfileSource {
1520                file: std::sync::Arc::new(file),
1521                offset: 0,
1522                len: blob_size,
1523            });
1524            return resp;
1525        }
1526    }
1527
1528    match deploy.open_blob_cached(blob_hash, blob_size).await {
1529        // Cached (small) and Mapped (large, memory-mapped file) both serve one
1530        // borrowed frame as a content-length body.
1531        Ok(boatramp_core::deploy::BlobBody::Cached(bytes))
1532        | Ok(boatramp_core::deploy::BlobBody::Mapped(bytes)) => {
1533            (base_status, headers, Body::from(bytes)).into_response()
1534        }
1535        Ok(boatramp_core::deploy::BlobBody::Stream(object)) => {
1536            (base_status, headers, Body::from_stream(object.body)).into_response()
1537        }
1538        Err(err) => deploy_error_response(err),
1539    }
1540}