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}