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