From 2c41968bcc9276ee4a72c33db6d4cf3dd9ef35cb Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Sat, 14 Nov 2026 02:28:45 +0530 Subject: [PATCH 1/2] Report Fastly and Cloudflare platform geo through ctx.geo() Fastly reads geo_lookup for the client address and Cloudflare reads the request's cf metadata. Both are always selected and pass their outcome through dispatch_admitted_with_geo, so public extensions still cannot replace it. Platform values that fail validation, such as Cloudflare's XX and T1 placeholders, report missing metadata rather than an invalid assertion. GeoSource and GeoTrustBasis gain platform variants and become non_exhaustive; fastly and cloudflare are reserved provider IDs. --- .../src/request.rs | 78 ++++++++++++++- .../tests/contract.rs | 50 +++++++++- crates/edgezero-adapter-fastly/src/request.rs | 85 ++++++++++++++-- .../edgezero-adapter-fastly/tests/contract.rs | 11 ++- crates/edgezero-core/src/geo.rs | 99 ++++++++++++++++++- 5 files changed, 303 insertions(+), 20 deletions(-) diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 58835f92..5de61572 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -11,12 +11,13 @@ use std::{io, task::Poll}; #[cfg(feature = "test-utils")] use bytes::Bytes; use edgezero_core::OutboundScopeOwner; -use edgezero_core::app::{App, StoreMetadata}; +use edgezero_core::app::{App, FrameworkErrorDetail, StoreMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStoreHandle, ConfigStoreOpenFailure}; use edgezero_core::context::RuntimeVariables; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; +use edgezero_core::geo::{GeoOutcome, GeoSource}; use edgezero_core::http::{HeaderValue, Method as CoreMethod, Request, Uri, request_builder}; use edgezero_core::ingress::{ CapturedTarget, HeaderFidelity, InboundOrigin, IngressBeginOutcome, IngressDispatchOutcome, @@ -41,7 +42,8 @@ use futures_util::stream::poll_fn; use futures_util::stream::{LocalBoxStream, unfold}; use futures_util::{StreamExt as _, TryStreamExt as _}; use worker::{ - Context, Delay, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, + Cf, Context, Delay, Env, Error as WorkerError, Method, Request as CfRequest, + Response as CfResponse, }; use crate::config_store::CloudflareConfigStore; @@ -506,6 +508,7 @@ where core_request, Stores::default(), request_start, + GeoOutcome::NoSelection, move || Ok(Some(source)), Ok, )) @@ -540,6 +543,7 @@ where core_request, Stores::default(), request_start, + GeoOutcome::NoSelection, move || make_source().map(Some), deliver, )) @@ -566,6 +570,7 @@ pub async fn dispatch_ingress_source_error_for_test( core_request, Stores::default(), request_start, + GeoOutcome::NoSelection, || -> Result>>, EdgeError> { Err(EdgeError::internal(anyhow::anyhow!( "cloudflare source construction failed" @@ -599,6 +604,7 @@ pub(crate) async fn dispatch_with_handles( CoreMethod::from_bytes(req.inner().method().as_bytes()).unwrap_or(CoreMethod::GET); let context = Arc::new(ctx); let runtime = CloudflareEgressRuntime::new(signal, Arc::clone(&context)); + let geo = request_geo(&req); let head_request = match into_core_request_head(&req, env, context, app.monotonic_clock()) { Ok(request) => request, Err(error) => { @@ -613,6 +619,7 @@ pub(crate) async fn dispatch_with_handles( head_request, stores, request_start, + geo, move || cloudflare_body_stream(req), move |egress| from_egress_response(egress, runtime), ) @@ -624,6 +631,7 @@ async fn dispatch_ingress_stream_and Result @@ -679,7 +687,17 @@ where *head_request.body_mut() = source.map_or_else(Body::empty, |stream| { cloudflare_deadline_body(stream, prepared.read_deadline(), prepared.monotonic_clock()) }); - dispatch_core_request(app, head_request, stores, prepared, deliver).await + dispatch_core_request(app, head_request, stores, prepared, geo, deliver).await +} + +/// Cloudflare's own geolocation from the request's `cf` metadata. +fn request_geo(req: &CfRequest) -> GeoOutcome { + let cf = req.cf(); + GeoOutcome::platform( + GeoSource::Cloudflare, + cf.and_then(Cf::country).as_deref(), + cf.and_then(Cf::region_code).as_deref(), + ) } /// Dispatch with per-id store registries built from baked metadata. @@ -827,6 +845,7 @@ async fn dispatch_core_request( mut core_request: Request, mut stores: Stores, prepared: PreparedIngress, + geo: GeoOutcome, deliver: Deliver, ) -> Result where @@ -849,7 +868,13 @@ where if let Some(registry) = secret_registry { core_request.extensions_mut().insert(registry); } - let response = Box::pin(app.dispatch_admitted(prepared, core_request)).await; + let response = Box::pin(app.dispatch_admitted_with_geo( + prepared, + core_request, + FrameworkErrorDetail::Default, + geo, + )) + .await; deliver(response).map_err(|err| edge_error_to_worker(&err)) } @@ -998,6 +1023,7 @@ mod tests { use async_stream::stream; use edgezero_core::app::Hooks; use edgezero_core::context::{BodyKind, RequestContext}; + use edgezero_core::geo::GeoStatus; use edgezero_core::http::{Response, StatusCode, response_builder}; use edgezero_core::ingress::{AdmissionDecision, IngressGrant}; #[cfg(feature = "test-utils")] @@ -1875,6 +1901,49 @@ mod tests { assert!(RETAINED_UPLOAD.with(|upload_ref| upload_ref.borrow().upgrade().is_none())); } + /// Rebuilds a request whose JS object carries the given incoming `cf` metadata. + fn request_with_cf(fields: &[(&str, &str)]) -> CfRequest { + let inner = Clone::clone(native_request(Method::Get, "/", None).inner()); + let cf = Object::new(); + for (key, value) in fields { + Reflect::set(&cf, &JsValue::from_str(key), &JsValue::from_str(value)) + .expect("cf field"); + } + Reflect::set(&inner, &JsValue::from_str("cf"), &cf).expect("cf"); + CfRequest::from(inner) + } + + #[wasm_bindgen_test] + fn cf_metadata_becomes_cloudflare_geo() { + let platform = + |country, region| GeoOutcome::platform(GeoSource::Cloudflare, country, region); + let us_ca = request_with_cf(&[("country", "US"), ("regionCode", "CA")]); + assert_eq!(request_geo(&us_ca), platform(Some("US"), Some("CA"))); + assert!(matches!( + request_geo(&us_ca), + GeoOutcome::Selected { + status: GeoStatus::Located { .. }, + .. + } + )); + let country_only = request_with_cf(&[("country", "GB")]); + assert_eq!(request_geo(&country_only), platform(Some("GB"), None)); + // Cloudflare reports unknown and Tor traffic with placeholder countries. + let tor = request_with_cf(&[("country", "T1")]); + assert_eq!(request_geo(&tor), platform(Some("T1"), None)); + assert!(matches!( + request_geo(&tor), + GeoOutcome::Selected { + status: GeoStatus::Unavailable(_), + .. + } + )); + assert_eq!( + request_geo(&native_request(Method::Get, "/", None)), + platform(None, None) + ); + } + fn native_request(method: Method, path: &str, body: Option<&[u8]>) -> CfRequest { let mut init = worker::RequestInit::new(); init.with_method(method); @@ -1914,6 +1983,7 @@ mod tests { head, Stores::default(), app.monotonic_now(), + GeoOutcome::NoSelection, move || cloudflare_body_stream(req), Ok, ) diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 48786e7a..b41d76a3 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -59,7 +59,7 @@ mod tests { }; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; - use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoStatus}; + use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoSource, GeoStatus}; use edgezero_core::http::{Method, Response, StatusCode, response_builder}; use edgezero_core::outbound::{ HttpClient, OutboundCollectedSlot, OutboundRequest, OutboundSlotResult, OutboundTime, @@ -216,9 +216,15 @@ mod tests { } } + /// Requests built in the test runtime carry no `cf` object, so Cloudflare geo is + /// selected but unavailable. + fn platform_geo() -> GeoOutcome { + GeoOutcome::platform(GeoSource::Cloudflare, None, None) + } + fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { - assert_eq!(ctx.geo(), &GeoOutcome::NoSelection); + assert_eq!(ctx.geo(), &platform_geo()); assert_eq!(ctx.extensions().get::(), Some(&forged_geo())); let body = Body::text(ctx.uri().to_string()); let response = response_builder() @@ -408,6 +414,46 @@ mod tests { assert_eq!(bytes.as_slice(), b"echo"); } + #[wasm_bindgen_test] + async fn dispatch_exposes_cf_metadata_through_ctx_geo() { + async fn geo(ctx: RequestContext) -> Result { + let body = Body::text(format!("{:?}", ctx.geo())); + response_builder() + .status(StatusCode::OK) + .body(body) + .map_err(EdgeError::internal) + } + let app = App::new(RouterService::builder().get("/geo", geo).build()); + let inner = Clone::clone(cf_request(CfMethod::Get, "/geo", None).inner()); + let cf = Object::new(); + for (key, value) in [("country", "US"), ("regionCode", "CA")] { + Reflect::set(&cf, &JsValue::from_str(key), &JsValue::from_str(value)) + .expect("cf field"); + } + Reflect::set(&inner, &JsValue::from_str("cf"), &cf).expect("cf"); + let (env, ctx, _timers) = test_env_ctx(); + + let mut response = CloudflareService::new(&app) + .dispatch(CfRequest::from(inner), env, ctx) + .await + .expect("cf response"); + + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let expected = GeoOutcome::platform(GeoSource::Cloudflare, Some("US"), Some("CA")); + assert!(matches!( + &expected, + GeoOutcome::Selected { + status: GeoStatus::Located { .. }, + .. + } + )); + assert_eq!( + response.text().await.expect("text"), + format!("{expected:?}"), + "cf metadata wins over the request's CloudFront headers" + ); + } + #[wasm_bindgen_test] async fn dispatch_runs_router_and_returns_response() { let app = build_test_app(); diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 214d85e9..afccc3f3 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -3,15 +3,17 @@ use std::fmt::Display; #[cfg(all(feature = "test-utils", target_arch = "wasm32"))] use std::io::Cursor; use std::io::Read; +use std::net::IpAddr; use std::str::from_utf8; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use std::task::Poll; use bytes::Bytes; -use edgezero_core::app::{App, StoreMetadata, StoresMetadata}; +use edgezero_core::app::{App, FrameworkErrorDetail, StoreMetadata, StoresMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStoreHandle, ConfigStoreOpenFailure}; use edgezero_core::error::EdgeError; +use edgezero_core::geo::{GeoOutcome, GeoSource}; use edgezero_core::http::header::HOST; use edgezero_core::http::{ Extensions, HeaderValue, Method, Request, Response, Uri, request_builder, @@ -29,6 +31,7 @@ use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; +use fastly::geo::{Geo, geo_lookup}; use fastly::{Error as FastlyError, Request as FastlyRequest}; use futures::executor; use futures_util::stream; @@ -402,6 +405,7 @@ fn dispatch_core_request( mut core_request: Request, stores: Stores, prepared: PreparedIngress, + geo: GeoOutcome, ) -> DispatchOutcome { // Hard-cutoff: legacy bare handles are no longer // inserted into request extensions. `with_config_handle` @@ -425,7 +429,12 @@ fn dispatch_core_request( let entered = prepared.request_start(); let method = prepared.request_method().clone(); match executor::block_on(drive_callback( - Box::pin(app.dispatch_admitted(prepared, core_request)), + Box::pin(app.dispatch_admitted_with_geo( + prepared, + core_request, + FrameworkErrorDetail::Default, + geo, + )), clock, entered, )) { @@ -464,6 +473,7 @@ where Prepare: FnOnce(&mut FastlyRequest, &mut Extensions), { let ingress = capture_request_ingress(&req); + let geo = request_geo(&req); let mut scratch = apply_request_prepare(&mut req, prepare); scratch.remove::(); let synthesized_headers = scratch.remove::(); @@ -475,9 +485,10 @@ where ) })?; head_request.extensions_mut().extend(scratch); - let outcome = dispatch_ingress_reader(app, head_request, stores, request_start, move || { - Ok(req.try_take_body()) - }); + let outcome = + dispatch_ingress_reader(app, head_request, stores, request_start, geo, move || { + Ok(req.try_take_body()) + }); Ok(match (outcome, synthesized_headers) { (DispatchOutcome::Detached(envelope), Some(headers)) => { DispatchOutcome::Detached(envelope.with_synthesized_response_headers(headers)) @@ -494,6 +505,7 @@ fn dispatch_ingress_reader( mut head_request: Request, stores: Stores, request_start: MonotonicInstant, + geo: GeoOutcome, make_source: MakeSource, ) -> DispatchOutcome where @@ -539,7 +551,22 @@ where source, Some((prepared.read_deadline(), prepared.monotonic_clock())), ); - dispatch_core_request(app, head_request, stores, prepared) + dispatch_core_request(app, head_request, stores, prepared, geo) +} + +/// Fastly's own geolocation for the downstream client address. `Geo::region` is +/// already the subdivision part of the ISO 3166-2 code (`CA`, not `US-CA`). +fn request_geo(req: &FastlyRequest) -> GeoOutcome { + client_geo(req.get_client_ip_addr()) +} + +fn client_geo(client_ip: Option) -> GeoOutcome { + let geo = client_ip.and_then(geo_lookup); + GeoOutcome::platform( + GeoSource::Fastly, + geo.as_ref().map(Geo::country_code), + geo.as_ref().and_then(Geo::region), + ) } /// Dispatch with per-id store registries built from baked metadata and closed lifecycle hooks. @@ -952,6 +979,7 @@ where core_request, Stores::default(), request_start, + GeoOutcome::NoSelection, move || Ok(Some(source)), ) .into_envelope() @@ -987,6 +1015,7 @@ where core_request, Stores::default(), request_start, + GeoOutcome::NoSelection, move || make_source().map(Some), ); match outcome { @@ -1020,6 +1049,7 @@ pub fn dispatch_ingress_source_error_for_test( core_request, Stores::default(), request_start, + GeoOutcome::NoSelection, || -> Result>>, EdgeError> { Err(EdgeError::internal(anyhow::anyhow!( "fastly source construction failed" @@ -1639,6 +1669,49 @@ mod synthesis_tests { fn resolve_secret_handle_builds_handle_when_required_true_matches_require_secrets() { let _handle = resolve_secret_handle(true); } + + #[cfg(target_arch = "wasm32")] + #[test] + fn client_geo_reads_fastly_country_and_region() { + use edgezero_core::geo::{ + GeoOutcome, GeoSource, GeoStatus, GeoUnavailableReason, SubdivisionCode, + }; + + // Viceroy answers loopback lookups with its default San Francisco record. + let loopback = client_geo(Some("127.0.0.1".parse().unwrap())); + assert_eq!( + loopback, + GeoOutcome::platform(GeoSource::Fastly, Some("US"), Some("CA")) + ); + let GeoOutcome::Selected { + provider, + status: GeoStatus::Located { location, .. }, + } = loopback + else { + panic!("loopback must be located: {loopback:?}"); + }; + assert_eq!(provider.as_str(), "fastly"); + assert_eq!(location.country().as_str(), "US"); + assert_eq!( + location.subdivision().map(SubdivisionCode::as_str), + Some("CA") + ); + + let unavailable = GeoOutcome::platform(GeoSource::Fastly, None, None); + assert!(matches!( + &unavailable, + GeoOutcome::Selected { + status: GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata), + .. + } + )); + assert_eq!(client_geo(None), unavailable, "no client address"); + assert_eq!( + client_geo(Some("192.0.2.1".parse().unwrap())), + unavailable, + "address without geolocation data" + ); + } } #[cfg(test)] diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index a1921b6e..24b0cf4c 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -42,7 +42,7 @@ mod tests { }; use edgezero_core::context::{BodyKind, RequestContext}; use edgezero_core::error::EdgeError; - use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoStatus}; + use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoSource, GeoStatus}; use edgezero_core::http::{HeaderMap, Method, Response, StatusCode, response_builder}; use edgezero_core::ingress::{AdmissionDecision, IngressGrant}; use edgezero_core::outbound::{ @@ -148,9 +148,14 @@ mod tests { } } + /// Test requests have no downstream client address, so Fastly geo is selected but unavailable. + fn platform_geo() -> GeoOutcome { + GeoOutcome::platform(GeoSource::Fastly, None, None) + } + fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { - assert_eq!(ctx.geo(), &GeoOutcome::NoSelection); + assert_eq!(ctx.geo(), &platform_geo()); assert_eq!(ctx.extensions().get::(), Some(&forged_geo())); let body = Body::text(ctx.uri().to_string()); let response = response_builder() @@ -601,7 +606,7 @@ mod tests { let request_events = Arc::clone(&handler_events); async move { request_events.lock().expect("events").push("handler"); - assert_eq!(ctx.geo(), &GeoOutcome::NoSelection); + assert_eq!(ctx.geo(), &platform_geo()); assert_eq!(ctx.extensions().get::(), Some(&forged_geo())); let value = ctx .extensions() diff --git a/crates/edgezero-core/src/geo.rs b/crates/edgezero-core/src/geo.rs index 6d6007c0..e1cd5726 100644 --- a/crates/edgezero-core/src/geo.rs +++ b/crates/edgezero-core/src/geo.rs @@ -64,7 +64,8 @@ impl GeoProviderId { /// Validate an application provider label matching `[a-z][a-z0-9._-]{0,63}`. /// /// # Errors - /// Returns a bounded category for invalid labels or the reserved `cloudfront` ID. + /// Returns a bounded category for invalid labels or the reserved `cloudfront`, `fastly` + /// and `cloudflare` IDs. #[inline] pub fn new(value: &str) -> Result { if value.is_empty() @@ -76,7 +77,7 @@ impl GeoProviderId { { return Err(GeoValueError::InvalidProviderId); } - if value == "cloudfront" { + if matches!(value, "cloudfront" | "fastly" | "cloudflare") { return Err(GeoValueError::ReservedProviderId); } Ok(Self(value.to_owned())) @@ -313,16 +314,22 @@ pub enum GeoUnavailableReason { /// The source of a located assertion, not a distribution identity attestation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum GeoSource { CloudFront, + Cloudflare, + Fastly, } /// The declared infrastructure protection used for source admission. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum GeoTrustBasis { /// Operator-declared credential-gated, network-isolated ALB path. /// This does not prove the external listener or network configuration. DeclaredProtectedAlb, + /// Request metadata the hosting platform attaches itself; no client header is read. + PlatformMetadata, } /// Framework-owned status for a selected provider. @@ -350,6 +357,40 @@ pub enum GeoOutcome { }, } +impl GeoOutcome { + /// Outcome for geography an edge platform attaches to the request itself. + /// + /// `subdivision` is the ISO 3166-2 code without its country prefix. Platform + /// metadata is not client-supplied, so a value that fails validation, such as + /// Cloudflare's `XX` (unknown) or `T1` (Tor), means the platform has no usable + /// geography rather than a forged assertion: an absent or unusable country is + /// missing metadata, and an unusable subdivision leaves a country-only result. + #[inline] + #[must_use] + pub fn platform(source: GeoSource, country: Option<&str>, subdivision: Option<&str>) -> Self { + let provider = GeoProviderId( + match source { + GeoSource::CloudFront => "cloudfront", + GeoSource::Cloudflare => "cloudflare", + GeoSource::Fastly => "fastly", + } + .to_owned(), + ); + let status = match country.and_then(|raw| CountryCode::new(raw).ok()) { + None => GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata), + Some(country_code) => GeoStatus::Located { + location: GeoLocation::new( + country_code, + subdivision.and_then(|raw| SubdivisionCode::new(raw).ok()), + ), + source, + trust_basis: GeoTrustBasis::PlatformMetadata, + }, + }; + Self::Selected { provider, status } + } +} + #[cfg(test)] mod tests { use super::*; @@ -461,11 +502,59 @@ mod tests { Err(GeoValueError::InvalidProviderId) ); } + for value in ["cloudfront", "fastly", "cloudflare"] { + assert_eq!( + GeoProviderId::new(value), + Err(GeoValueError::ReservedProviderId) + ); + } + assert_eq!(GeoProviderId::cloudfront().as_str(), "cloudfront"); + } + + #[test] + fn platform_outcome_treats_unusable_values_as_missing() { + let status = |outcome: GeoOutcome| match outcome { + GeoOutcome::Selected { provider, status } => { + assert_eq!(provider.as_str(), "fastly"); + status + } + GeoOutcome::NoSelection => panic!("platform geo is always selected"), + }; + let located = |country: &str, subdivision: Option<&str>| GeoStatus::Located { + location: GeoLocation::new( + CountryCode::new(country).unwrap(), + subdivision.map(|value| SubdivisionCode::new(value).unwrap()), + ), + source: GeoSource::Fastly, + trust_basis: GeoTrustBasis::PlatformMetadata, + }; + let platform = |country, subdivision| { + status(GeoOutcome::platform( + GeoSource::Fastly, + country, + subdivision, + )) + }; + assert_eq!(platform(Some("us"), Some("ca")), located("US", Some("CA"))); + assert_eq!(platform(Some("US"), None), located("US", None)); assert_eq!( - GeoProviderId::new("cloudfront"), - Err(GeoValueError::ReservedProviderId) + platform(None, None), + GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata) ); - assert_eq!(GeoProviderId::cloudfront().as_str(), "cloudfront"); + // Platform placeholders are unavailable geography, never a forged assertion. + for (country, subdivision) in [(Some("XX"), None), (Some("T1"), Some("CA"))] { + assert_eq!( + platform(country, subdivision), + GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata) + ); + } + assert_eq!(platform(Some("US"), Some("US-CA")), located("US", None)); + let GeoOutcome::Selected { provider, .. } = + GeoOutcome::platform(GeoSource::Cloudflare, None, None) + else { + panic!("selected") + }; + assert_eq!(provider.as_str(), "cloudflare"); } #[test] From 7ef4766db1e41517082befc81588505e289c0f31 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Sat, 14 Nov 2026 02:28:45 +0530 Subject: [PATCH 2/2] Bind Axum geo from the manifest and EDGEZERO__ADAPTER__GEO settings [adapters.axum.geo] carries provider, alb_peer_cidrs and origin_credential_header_name defaults, each overridden by the matching EDGEZERO__ADAPTER__GEO__* setting. Precedence is with_geo, then settings, then the manifest, then off, and misconfiguration fails startup instead of falling back. The dev run_app reads settings only. geo_capability() stays explicit-only and geo_capability_for::() reports the runner's view. --- .../edgezero-adapter-axum/src/dev_server.rs | 15 +- crates/edgezero-adapter-axum/src/geo.rs | 236 +++++++++++++++++- .../edgezero-adapter-axum/src/run_options.rs | 61 ++++- .../tests/fixtures/production_host.rs | 30 ++- .../tests/production_hosting.rs | 67 +++++ crates/edgezero-core/src/manifest.rs | 130 +++++++++- docs/guide/adapters/axum.md | 3 +- docs/guide/configuration.md | 22 ++ docs/guide/native-geo.md | 42 +++- 9 files changed, 580 insertions(+), 26 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 8d5ba1e6..ab66ebee 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -27,7 +27,8 @@ use std::time::Duration; use crate::diagnostics::{NativeDiagnosticsHandle, NativeSession}; pub use crate::run_options::AxumRunOptions; use crate::run_options::{ - JsonBodyLimit, config_path, failure, native_limit_setting, native_settings, shutdown_grace, + JsonBodyLimit, config_path, failure, geo_setting, native_limit_setting, native_settings, + shutdown_grace, }; use crate::trusted_proxy::TrustedProxyPolicy; use anyhow::Context as _; @@ -50,6 +51,7 @@ use edgezero_core::config_store::{ConfigStoreError, ConfigStoreHandle, ConfigSto use edgezero_core::env_config::EnvConfig; use edgezero_core::key_value_store::KvHandle; use edgezero_core::logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET, resolve_logging_level}; +use edgezero_core::manifest::BakedManifest; #[cfg(test)] use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; @@ -64,7 +66,7 @@ use tokio::time::{Instant as NativeInstant, sleep_until}; use crate::config_store::AxumConfigStore; use crate::config_store_limits::ConfigStoreLimits; use crate::connection::{ConnectionExit, serve_http1_with_shutdown}; -use crate::geo::NativeGeo; +use crate::geo::{self, NativeGeo}; use crate::ingress_config::AxumIngressConfig; use crate::key_value_store::PersistentKvStore; use crate::native_stores::{self, NativeSourcePlan, NativeStoreOverrides, NativeStoreSource}; @@ -1040,7 +1042,10 @@ where A: Hooks, F: FnOnce() -> anyhow::Result<()>, { - let (env, json_body_limit, native) = capture_dev_environment(vars_os())?; + let (env, json_body_limit, mut native) = capture_dev_environment(vars_os())?; + // The dev server ignores the manifest table: its deployment-only values are + // usually absent locally. Settings alone opt a dev run into geo. + native.geo = geo::resolve(None, &env, &BakedManifest::Absent)?; if !A::owns_logging() { app_logger(&env) .init() @@ -1096,7 +1101,7 @@ where ["adapter", "trusted_proxy_cidrs"] => Some("TRUSTED_PROXY_CIDRS"), ["adapter", "forwarding_header_family"] => Some("FORWARDING_HEADER_FAMILY"), ["logging", "request_records"] => Some("LOGGING__REQUEST_RECORDS"), - _ => None, + _ => geo_setting(&path), } }) }); @@ -1295,7 +1300,7 @@ where &'startup PreparedStores, ) -> LocalStartupFuture<'startup>, { - let geo = options.native_geo()?; + let geo = options.native_geo(&A::manifest())?; let native = NativeHosting { diagnostics: options.diagnostics(), policy: options.trusted_proxy_policy(), diff --git a/crates/edgezero-adapter-axum/src/geo.rs b/crates/edgezero-adapter-axum/src/geo.rs index 4d3f8655..5e82dfd9 100644 --- a/crates/edgezero-adapter-axum/src/geo.rs +++ b/crates/edgezero-adapter-axum/src/geo.rs @@ -4,7 +4,9 @@ //! is not proof of an ALB credential rule or a particular `CloudFront` distribution. //! See the native Geo guide for the required network, header and cache controls. //! -//! Off is valid and is the default, with no ambient Geo selector: +//! Off is valid and is the default. Without an explicit selection the runner reads +//! `EDGEZERO__ADAPTER__GEO__*` settings, then the app's `[adapters.axum.geo]` manifest table. +//! An explicit selection overrides both: //! ``` //! use edgezero_adapter_axum::dev_server::AxumRunOptions; //! use edgezero_adapter_axum::geo::{NativeGeoCapability, NativeGeoSelection}; @@ -106,11 +108,13 @@ use std::fmt; use std::net::SocketAddr; use std::sync::Arc; +use edgezero_core::env_config::EnvConfig; use edgezero_core::geo::{ GeoAssertion, GeoInput, GeoOutcome, GeoProvider, GeoProviderId, GeoResolution, GeoSource, GeoStatus, GeoTrustBasis, GeoUnavailableReason, }; use edgezero_core::http::{HeaderMap, HeaderName}; +use edgezero_core::manifest::{BakedManifest, ManifestAdapterGeo, ManifestGeoProvider}; use crate::peer_networks::PeerNetworks; @@ -125,6 +129,8 @@ pub enum NativeGeoConfigError { UnavailableCloudFront, #[error("native_geo: reserved supplied provider identity")] ReservedProviderId, + #[error("native_geo: invalid provider selection")] + InvalidSelection, } /// An operator declaration of a credential-gated, isolated CloudFront/ALB path. @@ -240,6 +246,76 @@ pub enum NativeGeoCapability { }, } +/// Resolves the startup selection: an explicit [`AxumRunOptions::with_geo`] wins, +/// then `EDGEZERO__ADAPTER__GEO__*` settings, then the baked `[adapters.axum.geo]` +/// table. With none of them geo stays off. +/// +/// [`AxumRunOptions::with_geo`]: crate::run_options::AxumRunOptions::with_geo +pub(crate) fn resolve( + explicit: Option<&NativeGeoSelection>, + env: &EnvConfig, + manifest: &BakedManifest, +) -> Result { + match explicit { + Some(selection) => NativeGeo::validate(selection), + None => NativeGeo::validate(&configured_selection(env, manifest_geo(manifest))?), + } +} + +fn manifest_geo(baked: &BakedManifest) -> Option<&ManifestAdapterGeo> { + // An absent or malformed manifest selects nothing; app building rejects malformed. + let BakedManifest::Present(manifest) = baked else { + return None; + }; + let (_name, adapter) = manifest.adapter_entry("axum")?; + adapter.geo.as_ref() +} + +fn configured_selection( + env: &EnvConfig, + manifest: Option<&ManifestAdapterGeo>, +) -> Result { + let env_provider = env.get(&["adapter", "geo", "provider"]); + let env_networks = env.get(&["adapter", "geo", "alb_peer_cidrs"]); + let env_header = env.get(&["adapter", "geo", "origin_credential_header_name"]); + let provider = match env_provider { + Some("cloudfront") => Some(ManifestGeoProvider::CloudFront), + Some("off") => Some(ManifestGeoProvider::Off), + Some(_) => return Err(NativeGeoConfigError::InvalidSelection), + None => manifest.and_then(|geo| geo.provider), + }; + match provider { + Some(ManifestGeoProvider::CloudFront) => { + let networks: Vec<&str> = match env_networks { + Some(raw) => raw.split(',').map(str::trim).collect(), + None => manifest + .and_then(|geo| geo.alb_peer_cidrs.as_deref()) + .unwrap_or_default() + .iter() + .map(String::as_str) + .collect(), + }; + let header = env_header + .or_else(|| manifest?.origin_credential_header_name.as_deref()) + .ok_or(NativeGeoConfigError::InvalidCredentialHeader)?; + let policy = ProtectedCloudFrontAlb::new(networks, header)?; + Ok(NativeGeoSelection::CloudFront { policy }) + } + // Policy without a selected provider is a misconfiguration, not a silent + // off. A manifest CloudFront default may still be overridden to off. + _ if env_networks.is_some() + || env_header.is_some() + || (provider.is_none() + && manifest.is_some_and(|geo| { + geo.alb_peer_cidrs.is_some() || geo.origin_credential_header_name.is_some() + })) => + { + Err(NativeGeoConfigError::InvalidSelection) + } + _ => Ok(NativeGeoSelection::Off), + } +} + /// Validated runner-owned composition, never installed in public extensions. #[derive(Clone, Default)] pub(crate) enum NativeGeo { @@ -359,8 +435,12 @@ fn assertion(headers: &HeaderMap, name: &'static str) -> GeoAsse mod tests { use super::*; use crate::run_options::AxumRunOptions; + use edgezero_core::app::Hooks; + use edgezero_core::error::EdgeError; use edgezero_core::geo::{CountryCode, GeoError, GeoLocation}; use edgezero_core::http::HeaderValue; + use edgezero_core::manifest::Manifest; + use edgezero_core::router::RouterService; use std::iter::empty; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -368,6 +448,160 @@ mod tests { ProtectedCloudFrontAlb::new(["127.0.0.1/32"], "x-origin-verify-fixture").unwrap() } + fn resolved( + explicit: Option<&NativeGeoSelection>, + vars: &[(&str, &str)], + manifest: &BakedManifest, + ) -> Result { + let env = EnvConfig::from_vars(vars.iter().copied()); + resolve(explicit, &env, manifest).map(|geo| geo.capability()) + } + + fn baked(geo: &'static str) -> BakedManifest { + let json: &'static str = + Box::leak(format!(r#"{{"adapters":{{"axum":{{"geo":{geo}}}}}}}"#).into_boxed_str()); + let baked = Manifest::from_baked_json(json); + assert!(matches!(baked, BakedManifest::Present(_)), "{json}"); + baked + } + + #[test] + fn geo_settings_resolve_explicit_then_env_then_manifest() { + const CIDRS: &str = "EDGEZERO__ADAPTER__GEO__ALB_PEER_CIDRS"; + const HEADER: &str = "EDGEZERO__ADAPTER__GEO__ORIGIN_CREDENTIAL_HEADER_NAME"; + const PROVIDER: &str = "EDGEZERO__ADAPTER__GEO__PROVIDER"; + let none = BakedManifest::Absent; + let cloudfront = baked( + r#"{"provider":"cloudfront","alb_peer_cidrs":["10.0.0.0/24"],"origin_credential_header_name":"x-origin-verify"}"#, + ); + let header_only = + baked(r#"{"provider":"cloudfront","origin_credential_header_name":"x-origin-verify"}"#); + let off = Ok(NativeGeoCapability::Off); + + // Nothing selected stays off; an explicit selection beats settings and manifest. + assert_eq!(resolved(None, &[], &none), off); + assert_eq!( + resolved( + Some(&NativeGeoSelection::Off), + &[(PROVIDER, "cloudfront")], + &cloudfront + ), + off + ); + // Settings override the manifest provider and fill deployment-only values. + assert_eq!(resolved(None, &[(PROVIDER, "off")], &cloudfront), off); + assert_eq!( + resolved(None, &[], &header_only), + Err(NativeGeoConfigError::InvalidProtectedPolicy), + "manifest without networks needs the setting" + ); + assert_eq!( + resolved( + None, + &[(PROVIDER, "cloudfront"), (CIDRS, "10.0.0.0/24")], + &none + ), + Err(NativeGeoConfigError::InvalidCredentialHeader) + ); + assert_eq!( + resolved( + None, + &[ + (CIDRS, "10.0.0.0/24, 10.0.1.0/24"), + (HEADER, "x-forwarded-for") + ], + &header_only + ), + Err(NativeGeoConfigError::InvalidCredentialHeader), + "setting header replaces the manifest header and is validated" + ); + // Misconfiguration never falls back to off. + for (vars, manifest) in [ + (vec![(PROVIDER, "maxmind")], &none), + (vec![(CIDRS, "10.0.0.0/24")], &none), + ( + vec![(PROVIDER, "off"), (HEADER, "x-origin-verify")], + &cloudfront, + ), + ] { + assert_eq!( + resolved(None, &vars, manifest), + Err(NativeGeoConfigError::InvalidSelection), + "{vars:?}" + ); + } + assert_eq!( + resolved(None, &[], &baked(r#"{"alb_peer_cidrs":["10.0.0.0/24"]}"#)), + Err(NativeGeoConfigError::InvalidSelection) + ); + assert_eq!(resolved(None, &[], &baked(r#"{"provider":"off"}"#)), off); + + let selected = [ + resolved(None, &[], &cloudfront), + resolved(None, &[(CIDRS, "10.0.0.0/24, 10.0.1.0/24")], &header_only), + resolved( + None, + &[ + (PROVIDER, "cloudfront"), + (CIDRS, "10.0.0.0/24"), + (HEADER, "x-origin-verify"), + ], + &none, + ), + ]; + for result in selected { + #[cfg(feature = "geo-cloudfront")] + assert!( + matches!(&result, Ok(NativeGeoCapability::Selected { provider, .. }) if provider == &GeoProviderId::cloudfront()), + "{result:?}" + ); + #[cfg(not(feature = "geo-cloudfront"))] + assert_eq!(result, Err(NativeGeoConfigError::UnavailableCloudFront)); + } + } + + #[test] + fn geo_capability_for_applies_the_app_manifest() { + struct GeoApp; + #[expect( + clippy::missing_trait_methods, + reason = "fixture retains unused Hooks defaults" + )] + impl Hooks for GeoApp { + fn routes() -> Result { + Ok(RouterService::builder().build()) + } + fn manifest() -> BakedManifest { + Manifest::from_baked_json( + r#"{"adapters":{"axum":{"geo":{"provider":"cloudfront","alb_peer_cidrs":["10.0.0.0/24"],"origin_credential_header_name":"x-origin-verify"}}}}"#, + ) + } + } + let options = AxumRunOptions::new("127.0.0.1:8080".parse().unwrap()).unwrap(); + assert_eq!(options.geo_capability(), Ok(NativeGeoCapability::Off)); + #[cfg(feature = "geo-cloudfront")] + assert!(matches!( + options.geo_capability_for::(), + Ok(NativeGeoCapability::Selected { .. }) + )); + #[cfg(not(feature = "geo-cloudfront"))] + assert_eq!( + options.geo_capability_for::(), + Err(NativeGeoConfigError::UnavailableCloudFront) + ); + // Settings feed only the runner view; the explicit-only view never errors on them. + let partial = + AxumRunOptions::from_vars([("EDGEZERO__ADAPTER__GEO__ALB_PEER_CIDRS", "10.0.1.0/24")]) + .unwrap(); + assert_eq!(partial.geo_capability(), Ok(NativeGeoCapability::Off)); + let disabled = + AxumRunOptions::from_vars([("EDGEZERO__ADAPTER__GEO__PROVIDER", "off")]).unwrap(); + assert_eq!( + disabled.geo_capability_for::(), + Ok(NativeGeoCapability::Off) + ); + } + #[test] fn geo_protected_policy_reuses_network_bounds() { assert_eq!( diff --git a/crates/edgezero-adapter-axum/src/run_options.rs b/crates/edgezero-adapter-axum/src/run_options.rs index b821072b..3ceb3f54 100644 --- a/crates/edgezero-adapter-axum/src/run_options.rs +++ b/crates/edgezero-adapter-axum/src/run_options.rs @@ -8,14 +8,15 @@ use std::str::FromStr as _; use std::time::{Duration, Instant}; use edgezero_core::addr; -use edgezero_core::app::StoresMetadata; +use edgezero_core::app::{Hooks, StoresMetadata}; use edgezero_core::env_config::EnvConfig; +use edgezero_core::manifest::BakedManifest; use edgezero_store_aws::settings::{AgentAccessToken, AwsPreparationLimits}; use edgezero_store_celld::settings::{CelldAccessToken, CelldPreparationLimits}; use log::LevelFilter; use crate::diagnostics::NativeDiagnosticsHandle; -use crate::geo::{NativeGeo, NativeGeoCapability, NativeGeoConfigError, NativeGeoSelection}; +use crate::geo::{self, NativeGeo, NativeGeoCapability, NativeGeoConfigError, NativeGeoSelection}; use crate::native_bindings::{ NativeBindingsLimits, NativeConfigBinding, NativeKvBinding, NativePreparationPolicySources, NativeStoreBindings, NativeStoreBindingsInput, @@ -74,7 +75,8 @@ pub struct AxumRunOptions { data_dir: Option, diagnostics: NativeDiagnosticsHandle, env: EnvConfig, - geo: NativeGeoSelection, + /// `None` until [`Self::with_geo`]; the manifest and settings then decide. + geo: Option, grace: Duration, json_body_limit: JsonBodyLimit, level: LevelFilter, @@ -364,7 +366,7 @@ impl AxumRunOptions { data_dir: None, diagnostics: NativeDiagnosticsHandle::new(), env: EnvConfig::default(), - geo: NativeGeoSelection::Off, + geo: None, grace: Duration::from_secs(10), json_body_limit: JsonBodyLimit::DEFAULT, level: LevelFilter::Info, @@ -378,21 +380,46 @@ impl AxumRunOptions { #[must_use] #[inline] pub fn with_geo(mut self, selection: NativeGeoSelection) -> Self { - self.geo = selection; + self.geo = Some(selection); self } - /// Validate the finalized selection and capture its startup capability. + /// Validate an explicit [`Self::with_geo`] selection and capture its capability. + /// + /// Without one this reports `Off`; it does not read `EDGEZERO__ADAPTER__GEO__*` + /// settings or a manifest. Use [`Self::geo_capability_for`] for the selection the + /// runner will actually use. /// /// # Errors /// Rejects unavailable implementations and invalid protected-source policies. #[inline] pub fn geo_capability(&self) -> Result { - self.native_geo().map(|geo| geo.capability()) + geo::resolve( + self.geo.as_ref(), + &EnvConfig::default(), + &BakedManifest::Absent, + ) + .map(|geo| geo.capability()) + } + + /// Capture the capability the runner will use for `A`: an explicit selection, + /// then `EDGEZERO__ADAPTER__GEO__*` settings, then its baked `[adapters.axum.geo]`. + /// + /// # Errors + /// Rejects unavailable implementations and invalid protected-source policies. + #[inline] + pub fn geo_capability_for(&self) -> Result + where + A: Hooks, + { + self.native_geo(&A::manifest()).map(|geo| geo.capability()) } - pub(crate) fn native_geo(&self) -> Result { - NativeGeo::validate(&self.geo) + pub(crate) fn native_geo( + &self, + manifest: &BakedManifest, + ) -> Result { + geo::resolve(self.geo.as_ref(), &self.env, manifest) } pub(crate) fn request_records(&self) -> bool { @@ -754,10 +781,24 @@ fn fixed_setting(path: &[String]) -> Option<&'static str> { ["adapter", "json_body_limit_bytes"] => Some("JSON_BODY_LIMIT_BYTES"), ["logging", "level"] => Some("LOGGING__LEVEL"), ["store_bindings_file"] => Some("STORE_BINDINGS_FILE"), - _ => native_limit_setting(path), + _ => geo_setting(path).or_else(|| native_limit_setting(path)), } } +/// `EDGEZERO__ADAPTER__GEO__*` names, shared by the dev and production input checks. +pub(crate) fn geo_setting(path: &[String]) -> Option<&'static str> { + let segments: Vec<&str> = path.iter().map(String::as_str).collect(); + matches!( + segments.as_slice(), + [ + "adapter", + "geo", + "provider" | "alb_peer_cidrs" | "origin_credential_header_name" + ] + ) + .then_some("GEO") +} + pub(crate) fn native_limit_setting(path: &[String]) -> Option<&'static str> { let segments: Vec<&str> = path.iter().map(String::as_str).collect(); match segments.as_slice() { diff --git a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs index 8ae94263..60113a27 100644 --- a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs +++ b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs @@ -38,6 +38,7 @@ use edgezero_core::ingress::{ AdmissionDecision, CheckedEffectiveHost, CheckedHostSource, IngressGrant, }; use edgezero_core::key_value_store::KvHandle; +use edgezero_core::manifest::{BakedManifest, Manifest}; use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::params::PathParams; use edgezero_core::probe::{self, LifecyclePhase, LifecycleReader}; @@ -295,6 +296,17 @@ impl Hooks for FixtureApp { Ok(()) } + /// `geo-manifest` bakes `CloudFront` geo with the credential header; the ALB + /// networks come from `EDGEZERO__ADAPTER__GEO__ALB_PEER_CIDRS`. + fn manifest() -> BakedManifest { + if mode().starts_with("geo-manifest") { + return Manifest::from_baked_json( + r#"{"adapters":{"axum":{"geo":{"provider":"cloudfront","origin_credential_header_name":"x-origin-verify-fixture"}}}}"#, + ); + } + BakedManifest::Absent + } + fn owns_logging() -> bool { true } @@ -992,6 +1004,10 @@ async fn geo_observation(mut ctx: RequestContext) -> Result reason = "test-only initializer asserts root-free startup, capability capture and known-valid forged state" )] fn run_geo_fixture(selected: &str) -> anyhow::Result<()> { + if selected == "geo-manifest-dev" { + // The dev runner ignores the manifest table; only settings select geo. + return run_app::(); + } let address: SocketAddr = env::var("FIXTURE_BIND")?.parse()?; let peer_network = if selected.ends_with("untrusted") { "192.0.2.1/32" @@ -1017,9 +1033,17 @@ fn run_geo_fixture(selected: &str) -> anyhow::Result<()> { } else { NativeGeoSelection::CloudFront { policy } }; - let options = AxumRunOptions::new(address)?.with_geo(selection); - // Capture from the same finalized options, without bypassing the runner's validation. - let capability = options.geo_capability(); + let (options, capability) = if selected == "geo-manifest" { + // No `with_geo`: the baked manifest and EDGEZERO__ settings select geo. + let options = AxumRunOptions::from_env()?; + let capability = options.geo_capability_for::(); + (options, capability) + } else { + let options = AxumRunOptions::new(address)?.with_geo(selection); + // Capture from the same finalized options, without bypassing the runner's validation. + let capability = options.geo_capability(); + (options, capability) + }; let is_off = selected == "geo-off"; let (sender, receiver) = oneshot::channel(); run_app_with_options_and_initializer::( diff --git a/crates/edgezero-adapter-axum/tests/production_hosting.rs b/crates/edgezero-adapter-axum/tests/production_hosting.rs index c74a86c0..c5047200 100644 --- a/crates/edgezero-adapter-axum/tests/production_hosting.rs +++ b/crates/edgezero-adapter-axum/tests/production_hosting.rs @@ -2269,6 +2269,73 @@ mod tests { ); } + #[test] + #[cfg(feature = "geo-cloudfront")] + fn geo_hosting_binds_cloudfront_from_manifest_and_settings() { + let cwd = tempfile::tempdir().expect("unrelated cwd"); + let addr = address(); + let mut command = geo_command("geo-manifest", &cwd, addr); + command + .env("EDGEZERO__ADAPTER__HOST", addr.ip().to_string()) + .env("EDGEZERO__ADAPTER__PORT", addr.port().to_string()) + .env("EDGEZERO__ADAPTER__GEO__ALB_PEER_CIDRS", "127.0.0.1/32"); + let mut server = Server::spawn(command, addr); + server.ready(); + let value = geo_response( + &server, + "CloudFront-Viewer-Country: us\r\nCloudFront-Viewer-Country-Region: ca\r\nx-origin-verify-fixture: ORIGIN_CREDENTIAL_SENTINEL\r\n", + ); + assert_eq!(value["geo"]["status"], "located"); + assert_eq!(value["geo"]["provider"], "cloudfront"); + assert_eq!(value["geo"]["country"], "US"); + assert_eq!(value["geo"]["subdivision"], "CA"); + assert_eq!(value["geo"]["trust_basis"], "DeclaredProtectedAlb"); + finish_geo(&mut server, &cwd); + } + + #[test] + fn geo_dev_runner_ignores_manifest_and_reads_settings() { + let cwd = tempfile::tempdir().expect("unrelated cwd"); + let dev = |settings: &[(&str, &str)]| { + let addr = address(); + let mut command = geo_command("geo-manifest-dev", &cwd, addr); + command + .env("EDGEZERO__ADAPTER__HOST", addr.ip().to_string()) + .env("EDGEZERO__ADAPTER__PORT", addr.port().to_string()); + for (key, value) in settings { + command.env(key, value); + } + let mut server = Server::spawn(command, addr); + server.ready(); + server + }; + let request = "CloudFront-Viewer-Country: US\r\nCloudFront-Viewer-Country-Region: CA\r\n"; + + // The baked table lacks networks; the dev server still starts, with geo off. + let unselected = dev(&[]); + let value = geo_response(&unselected, request); + assert_eq!(value["geo"]["status"], "no_selection"); + drop(unselected); + + // Settings alone opt a dev run in. Without the built-in this fails startup instead. + #[cfg(feature = "geo-cloudfront")] + { + let selected = dev(&[ + ("EDGEZERO__ADAPTER__GEO__PROVIDER", "cloudfront"), + ("EDGEZERO__ADAPTER__GEO__ALB_PEER_CIDRS", "127.0.0.1/32"), + ( + "EDGEZERO__ADAPTER__GEO__ORIGIN_CREDENTIAL_HEADER_NAME", + "x-origin-verify-fixture", + ), + ]); + let located = geo_response(&selected, request); + assert_eq!(located["geo"]["status"], "located"); + assert_eq!(located["geo"]["provider"], "cloudfront"); + assert_eq!(located["geo"]["country"], "US"); + assert_eq!(located["geo"]["subdivision"], "CA"); + } + } + #[test] #[cfg(not(feature = "geo-cloudfront"))] fn geo_hosting_rejects_unavailable_cloudfront() { diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 90189e5f..ef7cbcb3 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -343,6 +343,7 @@ impl ManifestLoader { #[derive(Debug, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] #[validate(schema(function = "validate_manifest_adapter_keys_case_unique"))] +#[validate(schema(function = "validate_manifest_adapter_geo_placement"))] #[expect( clippy::partial_pub_fields, reason = "deserialized fields are pub for the public API; internal state is private" @@ -678,8 +679,13 @@ pub struct ManifestAdapter { #[serde(default)] #[validate(nested)] pub commands: ManifestAdapterCommands, - /// Catch-all for any sub-table other than the four canonical ones - /// (`adapter`, `build`, `commands`, `logging`). The pre-rewrite + /// `[adapters.axum.geo]`: native geo selection. Only Axum reads it; other + /// adapters use their platform's geo and reject the table. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub geo: Option, + /// Catch-all for any sub-table other than the canonical ones + /// (`adapter`, `build`, `commands`, `geo`, `logging`). The pre-rewrite /// `[adapters..stores.*]` tables land here and are rejected by /// `validate_manifest_adapter` with the migration-guide message. #[serde(flatten)] @@ -689,6 +695,35 @@ pub struct ManifestAdapter { pub logging: ManifestLoggingConfig, } +/// `[adapters.axum.geo]`. Each key is a default that the matching +/// `EDGEZERO__ADAPTER__GEO__*` variable overrides at startup, so deployment-only +/// values such as ALB networks can stay out of the compiled manifest. The +/// merged selection is validated before the server listens. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +#[validate(schema(function = "validate_manifest_adapter_geo"))] +pub struct ManifestAdapterGeo { + /// ALB peer networks admitted as the protected `CloudFront` source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alb_peer_cidrs: Option>, + /// Header the ALB strips after checking the origin credential. Never the credential value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_credential_header_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, +} + +/// Geo provider an Axum deployment selects. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum ManifestGeoProvider { + #[serde(rename = "cloudfront")] + CloudFront, + Off, +} + #[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter_definition"))] @@ -1445,6 +1480,37 @@ fn validate_manifest_adapter_definition( Ok(()) } +fn validate_manifest_adapter_geo(geo: &ManifestAdapterGeo) -> Result<(), ValidationError> { + if geo.provider == Some(ManifestGeoProvider::Off) + && (geo.alb_peer_cidrs.is_some() || geo.origin_credential_header_name.is_some()) + { + let mut error = ValidationError::new("geo_off_with_policy"); + error.message = Some( + "`[adapters.axum.geo]` with `provider = \"off\"` must not set source policy fields" + .into(), + ); + return Err(error); + } + Ok(()) +} + +fn validate_manifest_adapter_geo_placement(manifest: &Manifest) -> Result<(), ValidationError> { + for (name, adapter) in &manifest.adapters { + if adapter.geo.is_some() && !name.eq_ignore_ascii_case("axum") { + let mut error = ValidationError::new("geo_adapter_unsupported"); + error.message = Some( + format!( + "`[adapters.{name}.geo]` is not supported; only Axum selects geo, \ + other adapters use their platform's geo" + ) + .into(), + ); + return Err(error); + } + } + Ok(()) +} + /// Validates a single `[adapters.]` block. The portable manifest model /// has no per-adapter store / runtime tuning surface — all of that moved to /// `EDGEZERO__*` env vars. The pre-rewrite @@ -3404,6 +3470,66 @@ ids = ["default"] } } + #[test] + fn axum_geo_table_loads_partial_defaults() { + let loaded = ManifestLoader::try_load_from_str( + "[adapters.axum.geo]\nprovider = \"cloudfront\"\norigin_credential_header_name = \"x-origin-verify\"\n", + ) + .expect("axum geo table"); + let geo = loaded.manifest().adapters["axum"].geo.clone().expect("geo"); + assert_eq!(geo.provider, Some(ManifestGeoProvider::CloudFront)); + assert_eq!(geo.alb_peer_cidrs, None); + assert_eq!( + geo.origin_credential_header_name.as_deref(), + Some("x-origin-verify") + ); + // `app!` bakes the serialized manifest; the geo table must survive the round trip. + let json: &'static str = Box::leak( + serde_json::to_string(loaded.manifest()) + .expect("serialize") + .into_boxed_str(), + ); + let BakedManifest::Present(baked) = Manifest::from_baked_json(json) else { + panic!("baked manifest with geo must load: {json}"); + }; + assert_eq!(baked.adapters["axum"].geo.as_ref(), Some(&geo)); + assert!( + ManifestLoader::try_load_from_str("[adapters.axum]\n") + .expect("no geo") + .manifest() + .adapters["axum"] + .geo + .is_none() + ); + } + + #[test] + fn axum_geo_table_rejects_invalid_shapes() { + for (manifest, expected) in [ + ( + "[adapters.axum.geo]\nprovider = \"off\"\nalb_peer_cidrs = []\n", + "must not set source policy fields", + ), + ( + "[adapters.axum.geo]\nprovider = \"maxmind\"\n", + "unknown variant", + ), + ( + "[adapters.axum.geo]\nregion_header = \"x\"\n", + "unknown field", + ), + ( + "[adapters.fastly.geo]\nprovider = \"cloudfront\"\n", + "only Axum selects geo", + ), + ] { + let err = ManifestLoader::try_load_from_str(manifest) + .err() + .unwrap_or_else(|| panic!("must reject: {manifest}")); + assert!(err.to_string().contains(expected), "{manifest}: {err}"); + } + } + #[test] fn empty_manifest_has_no_config_store() { let mfest = ManifestLoader::load_from_str(""); diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index fb05ecab..2f9d7f5d 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -290,7 +290,8 @@ request identity, counters, and deployment examples. ## Native Geo providers -Geo is off by default and selected only through `AxumRunOptions::with_geo`. The opt-in +Geo is off by default. Select it with `[adapters.axum.geo]` in `edgezero.toml` and +`EDGEZERO__ADAPTER__GEO__*` settings, or in code with `AxumRunOptions::with_geo`. The opt-in `geo-cloudfront` feature adds a bounded local CloudFront parser, not AWS SDKs or store setup. Both built-in and supplied providers require a separate `ProtectedCloudFrontAlb` policy using the actual socket peer. Ordinary trusted-proxy configuration does not admit geographic assertions. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5d912df0..87005122 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -556,6 +556,28 @@ implicitly applying manifest defaults or changing global state. Managed Fastly/Axum loggers retain `edgezero::boot` errors and warnings at `Warn` even when ordinary runtime logging is `Off`. See [Logging Initialisation](/guide/adapters/overview#logging-initialisation). +### Geo (Axum only) + +`[adapters.axum.geo]` selects native geo so `RequestContext::geo()` works without application +code. Fastly and Cloudflare always report their platform's geo, and other adapters reject the +table. + +```toml +[adapters.axum.geo] +provider = "cloudfront" +origin_credential_header_name = "x-origin-verify-randomsuffix" +# alb_peer_cidrs usually comes from EDGEZERO__ADAPTER__GEO__ALB_PEER_CIDRS at deploy time +``` + +| Field | Runtime override | Description | +| ------------------------------- | ------------------------------------------------------- | ------------------------------------------- | +| `provider` | `EDGEZERO__ADAPTER__GEO__PROVIDER` | `cloudfront` or `off` | +| `alb_peer_cidrs` | `EDGEZERO__ADAPTER__GEO__ALB_PEER_CIDRS` (comma list) | ALB peer networks admitted as the source | +| `origin_credential_header_name` | `EDGEZERO__ADAPTER__GEO__ORIGIN_CREDENTIAL_HEADER_NAME` | Header the ALB strips; never the credential | + +Each runtime setting overrides its manifest key. See the [native Geo guide](./native-geo.md#select-from-the-manifest-and-settings) +for precedence and validation. + ## Full Example ```toml diff --git a/docs/guide/native-geo.md b/docs/guide/native-geo.md index 7a1b1f05..aaec8b00 100644 --- a/docs/guide/native-geo.md +++ b/docs/guide/native-geo.md @@ -1,13 +1,47 @@ # Native Geo providers Native Axum hosting can resolve CloudFront country and subdivision assertions once per admitted -request. Geo is off by default. There is no database, reader-IP lookup, remote call, fallback -provider, CLI option, environment selector, or manifest selector. +request. Geo is off by default. Select it from the manifest and `EDGEZERO__` settings, or in +Rust. There is no database, reader-IP lookup, remote call, fallback provider, or CLI option. Geography is an estimate, not proof of residence, identity, consent, or permission. Applications own jurisdiction recognition and policy. A missing subdivision stays unknown, and a syntax-valid subdivision is not necessarily assigned or recognized. +## Select from the manifest and settings + +The runner reads `[adapters.axum.geo]` from the app's baked `edgezero.toml`, so an application +reads `RequestContext::geo()` without its own startup code: + +```toml +[adapters.axum.geo] +provider = "cloudfront" +origin_credential_header_name = "x-origin-verify-randomsuffix" +``` + +Each key is a default. The matching setting overrides it when the server starts, which keeps +deployment-only values such as ALB networks out of the compiled manifest: + +```sh +EDGEZERO__ADAPTER__GEO__ALB_PEER_CIDRS=10.42.1.0/24,10.42.2.0/24 +# EDGEZERO__ADAPTER__GEO__PROVIDER=off turns a baked selection off for one deployment +# EDGEZERO__ADAPTER__GEO__ORIGIN_CREDENTIAL_HEADER_NAME=x-origin-verify-other +``` + +Precedence, highest first: `AxumRunOptions::with_geo`, then the settings, then the manifest, +then off. Settings come from the options the runner receives: `from_env` and +`run_production_app` read them from the process, and options built with `new` carry none, but +the manifest still applies. The development `run_app` reads only the settings and ignores the +manifest table, because its deployment-only values are usually absent locally. +`geo_capability_for::()` reports the selection the runner will use. `geo_capability()` +reports only an explicit `with_geo` selection. + +The merged values pass the same validation as the Rust policy below, before the server listens. +Misconfiguration never falls back to off. An unknown provider, network or header settings with +no provider selected, settings-supplied policy fields with `provider = "off"`, and a CloudFront +selection missing networks or a header all fail startup. In the manifest, `provider = "off"` +with policy fields fails to load. + ## Select a provider in Rust The public selection API lives in `edgezero_adapter_axum::geo` with the `axum` feature. @@ -24,8 +58,8 @@ or declared stores. Existing declared stores keep their own startup requirements ### Off -Every options constructor defaults to `Off`, including `from_env` and `from_vars`. Selection -is always programmatic, even when other hosting settings come from the environment. +Without a manifest table or settings, every options constructor resolves to `Off`, including +`from_env` and `from_vars`. An explicit `Off` here overrides both. ```rust use edgezero_adapter_axum::dev_server::AxumRunOptions;