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-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] 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;