diff --git a/.cargo/config.toml b/.cargo/config.toml index cc11c8e77d..f366d4683f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-headers-geo -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-headers-geo -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-headers-geo -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-headers-geo -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # Feature-on counterpart of `test-fastly`. `test-fastly` does NOT pass # --all-features, so without this the reusable-sandbox code is linted by # `clippy-fastly` but its tests never execute. @@ -40,8 +40,8 @@ test-fastly-reuse = "test -p trusted-server-adapter-fastly --target wasm32-wasip # run with the Axum adapter in CI. build-axum = "build -p trusted-server-adapter-axum" check-axum = "check -p trusted-server-adapter-axum" -clippy-axum = "clippy -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-loading-cache-51degrees -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb --all-targets --all-features -- -D warnings" -test-axum = "test -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-loading-cache-51degrees -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb" +clippy-axum = "clippy -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-headers-geo -p trusted-server-loading-cache-51degrees -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb --all-targets --all-features -- -D warnings" +test-axum = "test -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy -p trusted-server-cmp-osano -p trusted-server-modules -p trusted-server-identity-lockr -p trusted-server-audience-permutive -p trusted-server-cmp-sourcepoint -p trusted-server-cmp-didomi -p trusted-server-cmp-inmobi -p trusted-server-ads-txt-sellers -p trusted-server-headers-geo -p trusted-server-loading-cache-51degrees -p trusted-server-tag-google-tag -p trusted-server-tag-google-tag-manager -p trusted-server-testing-testlight -p trusted-server-framework-nextjs -p trusted-server-ad-tag-google -p trusted-server-bot-protection-datadome -p trusted-server-ad-server-mock -p trusted-server-auction-aps -p trusted-server-auction-prebid -p trusted-server-auction-prebid-server -p trusted-server-auction-protocol-openrtb" # --- Cloudflare adapter (native host + wasm32-unknown-unknown) --- # Build/check target the WASM runtime (requires the `cloudflare` feature); diff --git a/AGENTS.md b/AGENTS.md index 064ad08bd1..91a946fabd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ crates/ fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device module) edgecookie/ # vendor Edge Cookie module crates (built-in HMAC module is in core) geo/ # vendor geo module crates (host geo is injected by the adapter) + headers/ # response header module crates (geo, which writes the x-geo-* headers) permission-signal/ # permission signal module crates, one per scheme (gpc, gpp, tcf, us-privacy), and core links none trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline diff --git a/CHANGELOG.md b/CHANGELOG.md index a5da44056c..aef0036319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking for crates outside core:** a geo module is a `GeoModule`, called with the request's module call as a device module is (`locate(call)`), so it sees the request's evidence, the settings and the services. `RuntimeServicesBuilder::geo`, `RuntimeServices::with_geo` and `IntegrationRegistrationBuilder::with_geo_module` take an `Arc`, and `RuntimeServices::geo()`, `IntegrationRegistry::geo_module()`, the `IntegrationRegistration::geo_module` field and `build_geo_module` return or hold one. A module that resolves from the client address alone keeps implementing `PlatformGeo` and is offered through `PlatformGeoModule::new(name, module)`, which hands the platform lookup the address the call carries. Core's lookups on the Edge Cookie and auction paths build the call from the request. +- **Breaking:** no response carries an `x-geo-*` header, `x-geo-info-available` included, unless the deployment selects `headers.geo` (`[headers] modules = ["geo"]`) and names it in a `[[serve]]` entry. That serve middleware writes the location already resolved for the request on the responses the entry covers, and no adapter calls a geo module or writes the headers when it finishes a response. For crates outside core, `GeoInfo::set_response_headers` and the `HEADER_X_GEO_*` constants are gone from core. +- The Axum dev server and Spin, which have no geo lookup, refuse `[geo] module = "platform"` at startup naming the setting. The selection located no request there and still counted as a geo module, so a deployment running an Edge Cookie module was never asked to set `[geo] assume_single_jurisdiction`. The configuration and Cloudflare guides say that Cloudflare places a visitor in a region only from the `cf-region-code` request header. - A module declares the caches it loads through, by name, and a deployment selects a loading cache for each in its own `[cache.]` table, the way `[cache.robots-txt]` selects one for robots.txt. A `[cache.]` table naming a cache no linked module declares is refused by `ts config validate` and when the deployment starts, naming the ones it does, and a module declaring `robots-txt` or a name another module declares is refused. `IntegrationBuilder::with_cache_features` declares them, `CacheSettings` has a new public field `declared`, `CacheSettings::loading_caches` returns a `Vec` in place of an array, listing core's own and the declared ones, and `LoadingCaches::insert` takes any name. - **Breaking for crates outside core:** the cookie constants `COOKIE_TS_EC`, `COOKIE_TS_EC_RESOLVED`, `COOKIE_TS_EC_PULL_COMPLETE`, `COOKIE_TS_EIDS` and `COOKIE_TS_TESTER` are `COOKIE_EC`, `COOKIE_EC_RESOLVED`, `COOKIE_EC_PULL_COMPLETE`, `COOKIE_EIDS` and `COOKIE_TESTER`, holding the entry's name alone, and a cookie is named through the request's public prefix with `services.public_prefix().cookie_name(COOKIE_EC)`. A module's response header naming a cookie under the public prefix is refused as one under `ts-` was. - A robots.txt module's answer is held by the loading cache of the one loading cache module the build links, or of the module `[cache.robots-txt] module` names, briefly in memory and in the host's shared store outside it, so a module is asked again only when no copy young enough is held, rather than by every request. With no loading cache module linked, or several and none named, nothing is held and every request for the file asks each module, as every request did when no adapter gave the file a key-value store. The last answer is kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store on Cloudflare and Spin, and Axum, which has no key-value store, keeps none. diff --git a/Cargo.lock b/Cargo.lock index a4f9804cdd..d53e3bd58e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7063,6 +7063,21 @@ dependencies = [ "trusted-server-core", ] +[[package]] +name = "trusted-server-headers-geo" +version = "0.1.0" +dependencies = [ + "async-trait", + "edgezero-core", + "error-stack", + "futures", + "http", + "log", + "serde", + "trusted-server-core", + "validator", +] + [[package]] name = "trusted-server-identity-lockr" version = "0.1.0" @@ -7178,6 +7193,7 @@ dependencies = [ "trusted-server-cmp-sourcepoint", "trusted-server-core", "trusted-server-framework-nextjs", + "trusted-server-headers-geo", "trusted-server-identity-lockr", "trusted-server-tag-google-tag", "trusted-server-tag-google-tag-manager", diff --git a/Cargo.toml b/Cargo.toml index 004a0eb9e4..768a958e7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/cmp/didomi", "crates/cmp/inmobi", "crates/ads-txt/sellers", + "crates/headers/geo", "crates/loading-cache/51degrees", "crates/tag/google-tag", "crates/tag/google-tag-manager", @@ -169,6 +170,7 @@ trusted-server-cmp-sourcepoint = { path = "crates/cmp/sourcepoint" } trusted-server-cmp-didomi = { path = "crates/cmp/didomi" } trusted-server-cmp-inmobi = { path = "crates/cmp/inmobi" } trusted-server-ads-txt-sellers = { path = "crates/ads-txt/sellers" } +trusted-server-headers-geo = { path = "crates/headers/geo" } trusted-server-loading-cache-51degrees = { path = "crates/loading-cache/51degrees" } trusted-server-tag-google-tag = { path = "crates/tag/google-tag" } trusted-server-tag-google-tag-manager = { path = "crates/tag/google-tag-manager" } diff --git a/crates/auction/prebid-server/src/lib.rs b/crates/auction/prebid-server/src/lib.rs index 7505cb71a5..2c6e77fd42 100644 --- a/crates/auction/prebid-server/src/lib.rs +++ b/crates/auction/prebid-server/src/lib.rs @@ -2854,7 +2854,9 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(backend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new("noop", Arc::new(NoopGeo)), + )) .client_info(ClientInfo { client_ip: None, tls_protocol: None, diff --git a/crates/cmp/didomi/src/lib.rs b/crates/cmp/didomi/src/lib.rs index 08483c93ba..c9dd6c1372 100644 --- a/crates/cmp/didomi/src/lib.rs +++ b/crates/cmp/didomi/src/lib.rs @@ -459,9 +459,24 @@ impl DidomiIntegration { && matches!(backend, DidomiBackend::Sdk) && is_notice_loader(&parts.method, consent_path) { + // The geo module is called as every geo call is, with the + // request as evidence, its services and its client. + let host = parts + .headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let scheme = parts.uri.scheme_str().unwrap_or("https"); + let request = trusted_server_core::module_context::ModuleRequest::new( + &parts.method, + host, + scheme, + parts.uri.path(), + ) + .with_query(parts.uri.query().unwrap_or_default()); let geo = match services .geo() - .lookup(services.client_info().client_ip, services) + .locate_request(request, Some(&parts.headers), None, services) .await { Ok(Some(geo)) => match normalize_didomi_geo(&geo) { @@ -711,7 +726,12 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(StubGeo(geo_result))) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new( + "stub", + Arc::new(StubGeo(geo_result)), + ), + )) .client_info(ClientInfo { client_ip: Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), ..ClientInfo::default() diff --git a/crates/headers/geo/Cargo.toml b/crates/headers/geo/Cargo.toml new file mode 100644 index 0000000000..934162612f --- /dev/null +++ b/crates/headers/geo/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "trusted-server-headers-geo" +description = "Writes the reader's location as the x-geo-* response headers, from the location the request already resolved, as a serve middleware." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +error-stack = { workspace = true } +http = { workspace = true } +log = { workspace = true } +serde = { workspace = true } +trusted-server-core = { workspace = true } +validator = { workspace = true } + +[dev-dependencies] +async-trait = { workspace = true } +edgezero-core = { workspace = true } +futures = { workspace = true } +trusted-server-core = { workspace = true, features = ["test-utils"] } + +# The visible owner of this crate. The x-geo-* headers are Trusted Server's +# own, so the Trusted Server maintainers own it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "project owned" diff --git a/crates/headers/geo/src/fixtures/geo-headers.settings.toml b/crates/headers/geo/src/fixtures/geo-headers.settings.toml new file mode 100644 index 0000000000..29989b6758 --- /dev/null +++ b/crates/headers/geo/src/fixtures/geo-headers.settings.toml @@ -0,0 +1,6 @@ +[headers] +modules = ["geo"] + +[[serve]] +media_type = "text/html" +middleware = ["headers.geo"] diff --git a/crates/headers/geo/src/lib.rs b/crates/headers/geo/src/lib.rs new file mode 100644 index 0000000000..6c0c912682 --- /dev/null +++ b/crates/headers/geo/src/lib.rs @@ -0,0 +1,612 @@ +//! The reader's location as the `x-geo-*` response headers. +//! +//! The location is the one the request already resolved through its geo +//! module, which the module call hands this middleware's preparation, so no +//! geo module is asked again. The headers go on the responses a `[[serve]]` +//! entry naming the middleware covers, and on no other response. + +use std::any::Any; +use std::sync::Arc; + +use error_stack::Report; +use http::{HeaderName, HeaderValue}; +use serde::Deserialize; +use validator::{Validate, ValidationErrors}; + +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::geo::GeoInfo; +use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; +use trusted_server_core::middleware::{ + Middleware, MiddlewareAction, MiddlewareContext, MiddlewarePhase, Preparation, ResponseHeader, + is_dispatched_media_type, +}; +use trusted_server_core::module_context::{FromModuleContext as _, ModuleCall}; +use trusted_server_core::settings::{IntegrationConfig, Settings}; + +/// The id the module's registration carries. +const INTEGRATION_ID: &str = "headers_geo"; + +/// The name `[headers] modules` selects the module by and a `[[serve]]` +/// entry names its middleware by, from this crate's folder. +pub const MODULE: &str = "headers.geo"; + +const HEADER_X_GEO_CITY: HeaderName = HeaderName::from_static("x-geo-city"); +const HEADER_X_GEO_CONTINENT: HeaderName = HeaderName::from_static("x-geo-continent"); +const HEADER_X_GEO_COORDINATES: HeaderName = HeaderName::from_static("x-geo-coordinates"); +const HEADER_X_GEO_COUNTRY: HeaderName = HeaderName::from_static("x-geo-country"); +const HEADER_X_GEO_INFO_AVAILABLE: HeaderName = HeaderName::from_static("x-geo-info-available"); +const HEADER_X_GEO_METRO_CODE: HeaderName = HeaderName::from_static("x-geo-metro-code"); +const HEADER_X_GEO_REGION: HeaderName = HeaderName::from_static("x-geo-region"); + +/// The module's settings, of which there are none, so a key in its table is +/// refused rather than ignored. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GeoHeadersConfig {} + +impl Validate for GeoHeadersConfig { + fn validate(&self) -> Result<(), ValidationErrors> { + Ok(()) + } +} + +impl IntegrationConfig for GeoHeadersConfig {} + +/// Refuses a document that selects the module and names its middleware in no +/// `[[serve]]` entry, where the headers would never be written. +fn require_an_entry(settings: &Settings) -> Result<(), Report> { + let placed = settings + .serve + .entries() + .iter() + .any(|entry| entry.middleware.iter().any(|name| name == MODULE)); + if placed { + return Ok(()); + } + Err(Report::new(TrustedServerError::Settings { + message: format!( + "[headers] selects `geo`, but no [[serve]] entry names `{MODULE}`, so the headers \ + would never be written. Add `{MODULE}` to the [[serve]] entry that covers the \ + responses that are to carry them" + ), + })) +} + +/// Validates the module's table and reports whether a section selects the +/// module. +/// +/// # Errors +/// +/// Returns an error when the table holds a key, or when no `[[serve]]` entry +/// names the middleware. +pub fn validate(settings: &Settings) -> Result> { + if settings + .module_config::(MODULE)? + .is_none() + { + return Ok(false); + } + require_an_entry(settings)?; + Ok(true) +} + +/// Registers the middleware when a section selects the module. +/// +/// # Errors +/// +/// Returns the errors [`validate`] returns. +pub fn register( + settings: &Settings, +) -> Result, Report> { + if !validate(settings)? { + return Ok(None); + } + Ok(Some( + IntegrationRegistration::builder(INTEGRATION_ID) + .without_js() + .with_middleware(Arc::new(GeoHeaders)) + .build(), + )) +} + +/// The builder a deployment installs to offer this module. +#[must_use] +pub fn builder() -> IntegrationBuilder { + IntegrationBuilder::new(INTEGRATION_ID, env!("CARGO_PKG_NAME"), register, validate) + .with_module_name(MODULE) +} + +/// The middleware, which writes the location the request resolved. +#[derive(Debug)] +pub struct GeoHeaders; + +impl Middleware for GeoHeaders { + fn middleware_id(&self) -> &'static str { + MODULE + } + + /// Every media type an entry may cover, because the middleware writes + /// headers and leaves the body alone. + fn handles_media_type(&self, media_type: &str) -> bool { + is_dispatched_media_type(media_type) + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Serve] + } + + /// Takes the location from the module call, which is none when the + /// request resolved none or the geo module's permissions withhold it. + fn prepare<'a>( + &'a self, + call: ModuleCall<'a>, + _context: &'a MiddlewareContext<'a>, + ) -> Option> { + let location = <&GeoInfo>::from_module_context(&call).ok().cloned(); + Some(Box::pin(async move { + Ok(Box::new(location) as Box) + })) + } + + fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { + let location = context + .take_prepared::>(MODULE) + .unwrap_or_else(|| { + // Nothing prepared means the preparation did not run, which no + // request can cause. + log::error!("the location was not prepared, so the response says none is known"); + None + }); + MiddlewareAction { + response_headers: geo_headers(location.as_ref()), + ..MiddlewareAction::pass() + } + } +} + +/// The headers that carry `location`, or `x-geo-info-available: false` alone +/// when there is none. +fn geo_headers(location: Option<&GeoInfo>) -> Vec { + let Some(geo) = location else { + return vec![( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("false"), + )]; + }; + let mut headers = Vec::new(); + push_header(&mut headers, HEADER_X_GEO_CITY, &geo.city); + push_header(&mut headers, HEADER_X_GEO_COUNTRY, &geo.country); + push_header(&mut headers, HEADER_X_GEO_CONTINENT, &geo.continent); + push_header( + &mut headers, + HEADER_X_GEO_COORDINATES, + &geo.coordinates_string(), + ); + if geo.has_metro_code() { + push_header( + &mut headers, + HEADER_X_GEO_METRO_CODE, + &geo.metro_code.to_string(), + ); + } + if let Some(region) = &geo.region { + push_header(&mut headers, HEADER_X_GEO_REGION, region); + } + headers.push(( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("true"), + )); + headers +} + +/// Adds `value` under `name`, or skips it with a warning when a header cannot +/// carry it. +fn push_header(headers: &mut Vec, name: HeaderName, value: &str) { + match HeaderValue::from_str(value) { + Ok(value) => headers.push((name, value)), + Err(_) => log::warn!("Skipping invalid geo header value for {}", name.as_str()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::atomic::{AtomicUsize, Ordering}; + + use edgezero_core::body::Body as EdgeBody; + use futures::executor::block_on; + use http::{Method, Request, header}; + use trusted_server_core::auction::compile_auction_plan; + use trusted_server_core::auction::orchestrator::AuctionOrchestrator; + use trusted_server_core::cache_policy::EdgeCacheHeader; + use trusted_server_core::ec::EcContext; + use trusted_server_core::html_processor::test_support::page_settings; + use trusted_server_core::integrations::{IntegrationDocumentState, IntegrationRegistry}; + use trusted_server_core::middleware::test_support::context; + use trusted_server_core::middleware::{HTML_MEDIA_TYPE, MiddlewareChain, Prepared}; + use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; + use trusted_server_core::permissions::{Permission, PermissionSet}; + use trusted_server_core::platform::test_support::{ + StubHttpClient, build_services_with_http_client, + }; + use trusted_server_core::platform::{ + GeoModule, PlatformError, PlatformHttpClient, RuntimeServices, + }; + use trusted_server_core::publisher::{ + AppContext, AuctionDispatch, buffer_publisher_response_async, handle_publisher_request, + }; + + /// The settings of a deployment that selects the module and places it on + /// every page. + const PLACED: &str = include_str!("fixtures/geo-headers.settings.toml"); + + fn located() -> GeoInfo { + GeoInfo { + city: "San Francisco".to_owned(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 37.7749, + longitude: -122.4194, + metro_code: 807, + region: Some("CA".to_owned()), + asn: None, + } + } + + /// The headers as text, in the order they are written. + fn as_text(headers: &[ResponseHeader]) -> Vec<(&str, &str)> { + headers + .iter() + .map(|(name, value)| { + ( + name.as_str(), + value.to_str().expect("should be a text header value"), + ) + }) + .collect() + } + + /// What the middleware writes for a request whose module context is + /// `module_context`, prepared and planned as the serve phase runs it. + fn written_for(module_context: &ModuleContext<'_>) -> Vec { + let chain = MiddlewareChain::new( + MiddlewarePhase::Serve, + HTML_MEDIA_TYPE, + vec![Arc::new(GeoHeaders)], + ); + let document_state = IntegrationDocumentState::default(); + let prepared = Prepared::default(); + let context = MiddlewareContext { + prepared: &prepared, + ..context(MiddlewarePhase::Serve, &document_state) + }; + block_on(chain.prepare(module_context, &context)).expect("should prepare"); + chain.plan(&context).expect("should plan").response_headers + } + + fn request_view(method: &Method) -> ModuleRequest<'_> { + ModuleRequest::new(method, "publisher.example.com", "https", "/article") + } + + /// A geo module that answers one location and counts the calls it is + /// asked. + struct CountingGeo { + calls: Arc, + } + + #[async_trait::async_trait(?Send)] + impl GeoModule for CountingGeo { + fn id(&self) -> &'static str { + "testing.counting-geo" + } + + async fn locate( + &self, + _call: ModuleCall<'_>, + ) -> Result, Report> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(Some(located())) + } + } + + fn page_request() -> Request { + Request::builder() + .method(Method::GET) + .uri("https://publisher.example.com/article") + .header(header::HOST, "publisher.example.com") + .body(EdgeBody::empty()) + .expect("should build the page request") + } + + /// The page a reader of `settings` receives from an origin that answers + /// with HTML, and how many times the geo module was asked for it. + fn page_for(settings: &Settings) -> (http::Response, usize) { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + let calls = Arc::new(AtomicUsize::new(0)); + let services: RuntimeServices = + build_services_with_http_client(Arc::clone(&stub) as Arc) + .with_geo(Arc::new(CountingGeo { + calls: Arc::clone(&calls), + })); + let registry = IntegrationRegistry::with_registrations(settings, &[builder()]) + .expect("should build the registry"); + let plan = compile_auction_plan(settings).expect("should compile the auction plan"); + let orchestrator = AuctionOrchestrator::from_plan(Arc::new(plan), None); + let request = page_request(); + + let response = block_on(async { + let mut ec_context = + EcContext::read_from_request_resolving_geo(settings, &request, &services) + .await + .expect("should read the request's state"); + let publisher = handle_publisher_request( + AppContext { + settings, + integration_registry: ®istry, + }, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + request, + EdgeCacheHeader::SurrogateControl, + ) + .await + .expect("should fetch the page"); + buffer_publisher_response_async( + publisher, + &Method::GET, + settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should finish the page") + }); + (response, calls.load(Ordering::SeqCst)) + } + + /// The `x-geo-*` headers a response carries, sorted by name. + fn geo_headers_on(response: &http::Response) -> Vec<(String, String)> { + let mut headers: Vec<(String, String)> = response + .headers() + .iter() + .filter(|(name, _)| name.as_str().starts_with("x-geo-")) + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value.to_str().expect("should be text").to_owned(), + ) + }) + .collect(); + headers.sort(); + headers + } + + #[test] + fn module_constant_is_the_crate_folder() { + assert_eq!( + MODULE, + trusted_server_core::module_name!(), + "should be named by the folder this crate lives in" + ); + } + + #[test] + fn an_unselected_module_registers_nothing() { + let settings = page_settings(""); + + assert!(register(&settings).expect("should read").is_none()); + assert!(!validate(&settings).expect("should read")); + } + + #[test] + fn a_document_selecting_the_module_and_naming_it_in_no_serve_entry_is_refused() { + let settings = page_settings("[headers]\nmodules = [\"geo\"]\n"); + + let refused = validate(&settings).expect_err("should refuse a module no entry places"); + assert!( + format!("{refused:?}").contains("no [[serve]] entry names `headers.geo`"), + "{refused:?}" + ); + assert!(register(&settings).is_err(), "should refuse to register"); + } + + #[test] + fn the_serve_entry_naming_the_module_runs_its_middleware() { + let settings = page_settings(PLACED); + let registry = IntegrationRegistry::with_registrations(&settings, &[builder()]) + .expect("should build the registry"); + + assert_eq!( + registry + .middleware_chain( + &settings.serve, + MiddlewarePhase::Serve, + HTML_MEDIA_TYPE, + "/article" + ) + .ids(), + [MODULE], + "should run on a page the entry covers" + ); + } + + #[test] + fn the_middleware_runs_in_the_serve_phase_on_every_media_type_an_entry_may_cover() { + assert_eq!(GeoHeaders.phases(), [MiddlewarePhase::Serve]); + for media_type in [ + "text/html", + "text/plain", + "application/json", + "application/javascript", + ] { + assert!( + GeoHeaders.handles_media_type(media_type), + "should work on {media_type}" + ); + } + assert!(!GeoHeaders.handles_media_type("image/png")); + } + + #[test] + fn the_location_in_the_module_call_is_written_as_the_headers() { + let method = Method::GET; + let geo = located(); + let module_context = + ModuleContext::new(request_view(&method)).with_geo(&geo, PermissionSet::none()); + + assert_eq!( + as_text(&written_for(&module_context)), + [ + ("x-geo-city", "San Francisco"), + ("x-geo-country", "US"), + ("x-geo-continent", "NorthAmerica"), + ("x-geo-coordinates", "37.7749,-122.4194"), + ("x-geo-metro-code", "807"), + ("x-geo-region", "CA"), + ("x-geo-info-available", "true"), + ], + "should write every part of the location the request resolved" + ); + } + + #[test] + fn a_metro_code_of_zero_and_no_region_are_not_written() { + let method = Method::GET; + let geo = GeoInfo { + metro_code: 0, + region: None, + ..located() + }; + let module_context = + ModuleContext::new(request_view(&method)).with_geo(&geo, PermissionSet::none()); + + let headers = written_for(&module_context); + let written = as_text(&headers); + + assert!( + written + .iter() + .all(|(name, _)| *name != "x-geo-metro-code" && *name != "x-geo-region"), + "should leave out what the answer does not have: {written:?}" + ); + assert!( + written.contains(&("x-geo-info-available", "true")), + "should still say a location is known: {written:?}" + ); + } + + #[test] + fn a_value_a_header_cannot_carry_is_skipped() { + let method = Method::GET; + let geo = GeoInfo { + city: "Line\nbreak".to_owned(), + ..located() + }; + let module_context = + ModuleContext::new(request_view(&method)).with_geo(&geo, PermissionSet::none()); + + let headers = written_for(&module_context); + let written = as_text(&headers); + + assert!( + written.iter().all(|(name, _)| *name != "x-geo-city"), + "should skip the city a header cannot carry: {written:?}" + ); + assert!( + written.contains(&("x-geo-country", "US")), + "should write the rest: {written:?}" + ); + } + + #[test] + fn no_location_says_none_is_available() { + let method = Method::GET; + let module_context = ModuleContext::new(request_view(&method)); + + assert_eq!( + as_text(&written_for(&module_context)), + [("x-geo-info-available", "false")], + "should say no location is known and write nothing else" + ); + } + + #[test] + fn a_location_whose_use_needs_a_permission_the_middleware_does_not_declare_is_not_written() { + let method = Method::GET; + let geo = located(); + let module_context = ModuleContext::new(request_view(&method)) + .with_geo(&geo, PermissionSet::none().with(Permission::SelectBasicAds)); + + assert_eq!( + as_text(&written_for(&module_context)), + [("x-geo-info-available", "false")], + "should write no location the geo module's permissions withhold" + ); + } + + #[test] + fn nothing_prepared_says_no_location_is_known() { + let document_state = IntegrationDocumentState::default(); + + let action = GeoHeaders.create(&context(MiddlewarePhase::Serve, &document_state)); + + assert_eq!( + as_text(&action.response_headers), + [("x-geo-info-available", "false")], + "should say no location is known when nothing was prepared" + ); + } + + #[test] + fn a_page_carries_the_location_the_request_resolved_and_the_geo_module_is_asked_once() { + let settings = page_settings(PLACED); + + let (response, calls) = page_for(&settings); + + assert_eq!( + geo_headers_on(&response), + [ + ("x-geo-city", "San Francisco"), + ("x-geo-continent", "NorthAmerica"), + ("x-geo-coordinates", "37.7749,-122.4194"), + ("x-geo-country", "US"), + ("x-geo-info-available", "true"), + ("x-geo-metro-code", "807"), + ("x-geo-region", "CA"), + ] + .map(|(name, value)| (name.to_owned(), value.to_owned())), + "should write the location the request's state resolved on the page" + ); + assert_eq!( + calls, 1, + "should ask the geo module once, for the request's state, and not again for the headers" + ); + } + + #[test] + fn a_page_no_serve_entry_names_the_middleware_on_carries_no_geo_header() { + let settings = page_settings(""); + + let (response, calls) = page_for(&settings); + + assert!( + geo_headers_on(&response).is_empty(), + "should write no x-geo-* header: {:?}", + geo_headers_on(&response) + ); + assert_eq!(calls, 1, "should still resolve the request's location once"); + } +} diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index b425817d9a..a16c39965a 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -179,7 +179,9 @@ fn services_builder( .kv_store(kv) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new("noop", Arc::new(NoopGeo)), + )) .client_info(ClientInfo::default()) } diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 2b4a9e0787..2e6e6a9698 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -51,7 +51,8 @@ use trusted_server_core::middleware::{ }; use trusted_server_core::module_context::{ModuleCall, ModuleRequest, Withheld}; use trusted_server_core::platform::{ - GeoInfo, PlatformError, PlatformGeo, PlatformResponse, RuntimeServices, ServerKeyPrefix, + GeoInfo, PlatformError, PlatformGeo, PlatformGeoModule, PlatformResponse, RuntimeServices, + ServerKeyPrefix, }; use trusted_server_core::prefix::{PrefixModule, PublicPrefix}; use trusted_server_core::robots_txt::{Contribution, RobotsTxtContributor}; @@ -683,9 +684,16 @@ impl SeamProbeProxy { req: Request, services: &RuntimeServices, ) -> Result, Report> { + // The geo module is called as every geo call is, with the request + // as evidence, its services and its client. let country = match services .geo() - .lookup(services.client_info().client_ip, services) + .locate_request( + ModuleRequest::of(&req, services.client_info()), + Some(req.headers()), + None, + services, + ) .await { Ok(geo) => geo.map(|info| info.country), @@ -814,8 +822,13 @@ pub fn register( .with_middleware(Arc::new(SeamProbeMiddleware::new(config.country.clone()))) .with_middleware(Arc::new(SeamProbeReaderMiddleware)); if config.declares_geo { - registration = registration - .with_geo_module(module_name(), Arc::new(SeamProbeGeo::new(config.country))); + registration = registration.with_geo_module( + module_name(), + Arc::new(PlatformGeoModule::new( + module_name(), + Arc::new(SeamProbeGeo::new(config.country)), + )), + ); } { // Identity, device, the prefixes and robots.txt rules are declared @@ -1162,7 +1175,10 @@ mod tests { .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(StubBackend)) .http_client(Arc::new(UnavailableHttpClient)) - .geo(Arc::new(DisabledGeo)) + .geo(Arc::new(PlatformGeoModule::new( + "none", + Arc::new(DisabledGeo), + ))) .client_info(ClientInfo::default()) .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) .build() diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 03bf633cb7..a431044d37 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -216,6 +216,11 @@ fn build_state_with_registrations_and_services( .and_then(RuntimeServices::resolved_ec_module) .or_else(|| registry.ec_module()), )?; + // Services a caller supplies carry a geo lookup of their own for + // `platform` to select, and this host's own resolves nothing. + if services.is_none() { + refuse_platform_geo(&settings)?; + } let permission_signal_modules = trusted_server_core::permission_signal::build_permission_signal_modules( &settings, @@ -244,6 +249,25 @@ fn build_state_with_registrations_and_services( })) } +/// Refuses `[geo] module = "platform"`, because this host has no geo lookup. +/// +/// The selection would locate no request and still count as a geo module, +/// so a deployment running an Edge Cookie module would never be asked to +/// acknowledge single-jurisdiction operation. +fn refuse_platform_geo(settings: &Settings) -> Result<(), Report> { + if settings.geo.module.as_deref() != Some("platform") { + return Ok(()); + } + Err(Report::new(TrustedServerError::Configuration { + message: "`[geo] module = \"platform\"` selects the host's own geo lookup, and the \ + Axum dev server has no geo lookup, so no request would be located. Select \ + a geo module a linked crate offers, or leave `[geo] module` unset, which \ + needs `[geo] assume_single_jurisdiction = true` when an Edge Cookie module \ + is configured" + .to_owned(), + })) +} + // --------------------------------------------------------------------------- // Per-request RuntimeServices // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index afeb310356..81336a7c28 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -3,9 +3,8 @@ use std::sync::Arc; use async_trait::async_trait; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderValue, Response}; +use edgezero_core::http::Response; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::{ ROUTE_NAMESPACE, sanitize_trusted_client_ip_headers, strip_deployment_headers, }; @@ -53,9 +52,8 @@ impl Middleware for SanitizeRequestMiddleware { /// Response-finalization middleware: injects all standard TS response headers. /// -/// Geo lookup is unavailable in the Axum dev server — `X-Geo-Info-Available: false` -/// is always emitted. The Fastly adapter's `X-TS-ENV` is skipped because its -/// env var is not set in a local dev context. +/// The Fastly adapter's `X-TS-ENV` is skipped because its env var is not set +/// in a local dev context. /// /// Registered directly inside [`SanitizeRequestMiddleware`], so that every /// outgoing response carries a consistent set of headers. @@ -89,16 +87,10 @@ impl Middleware for FinalizeResponseMiddleware { /// Applies standard Trusted Server response headers to the given response. /// -/// Unlike the Fastly variant, geo is always unavailable so `X-Geo-Info-Available: false` -/// is unconditionally emitted. Fastly-specific headers are omitted. -/// Operator-configured `settings.response_headers` are applied last (with the -/// shared cookie cache-privacy hardening) and can override any managed header. +/// Fastly-specific headers are omitted. Operator-configured +/// `settings.response_headers` are applied with the shared cookie +/// cache-privacy hardening and can override any header already written. pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) { - response.headers_mut().insert( - HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static("false"), - ); - // Cookie-bearing responses stay private to shared caches and operator // headers cannot re-enable caching for uncacheable per-user payloads. trusted_server_core::response_privacy::apply_response_headers_with_cache_privacy( @@ -175,37 +167,21 @@ mod tests { } #[test] - fn sets_geo_unavailable_header() { + fn finalizing_writes_no_geo_header() { let settings = settings_with_response_headers(vec![]); let mut response = empty_response(); apply_finalize_headers(&settings, &mut response); - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("false"), - "should set X-Geo-Info-Available: false" - ); - } - - #[test] - fn operator_response_headers_override_geo_header() { - let settings = - settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); - let mut response = empty_response(); - - apply_finalize_headers(&settings, &mut response); - - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("operator-override"), - "should override the managed geo header with the operator-configured value" + let geo_headers: Vec<&str> = response + .headers() + .keys() + .map(edgezero_core::http::HeaderName::as_str) + .filter(|name| name.starts_with("x-geo-")) + .collect(); + assert!( + geo_headers.is_empty(), + "should write no x-geo-* header, which a serve middleware writes: {geo_headers:?}" ); } diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 2052385107..f44ce10ccd 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -284,8 +284,15 @@ async fn tsjs_route_matching_hash_uses_s_maxage_fallback() { // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn finalize_middleware_sets_geo_unavailable_header() { - let mut svc = make_service(); +async fn finalize_middleware_writes_the_operators_headers_and_no_geo_header() { + let mut settings = test_settings(); + settings + .response_headers + .insert("X-Finalized".to_owned(), "1".to_owned()); + let mut svc = EdgeZeroAxumService::new( + TrustedServerApp::routes_with_settings(settings) + .expect("should build router from test settings"), + ); let req = Request::builder() .method("POST") @@ -304,10 +311,20 @@ async fn finalize_middleware_sets_geo_unavailable_header() { assert_eq!( resp.headers() - .get("x-geo-info-available") + .get("x-finalized") .and_then(|v| v.to_str().ok()), - Some("false"), - "finalize middleware should set X-Geo-Info-Available: false on every response" + Some("1"), + "the finalize middleware should write the operator's headers on every response" + ); + let geo_headers: Vec<&str> = resp + .headers() + .keys() + .map(axum::http::HeaderName::as_str) + .filter(|name| name.starts_with("x-geo-")) + .collect(); + assert!( + geo_headers.is_empty(), + "the finalize middleware should write no x-geo-* header: {geo_headers:?}" ); } @@ -700,6 +717,23 @@ fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { ); } +/// `[geo] module = "platform"` selects the host's own geo lookup, which the +/// Axum dev server does not have, so startup refuses it rather than come up +/// locating no request with the single-jurisdiction check skipped. +#[test] +fn selecting_the_platform_geo_lookup_fails_at_startup() { + let error = + TrustedServerApp::routes_with_settings(test_settings_with(r#"module = "platform""#)) + .err() + .expect("should refuse the host's geo lookup on a host that has none"); + let message = error.to_string(); + + assert!( + message.contains(r#"`[geo] module = "platform"`"#) && message.contains("has no geo lookup"), + "should name the setting and say the host has no geo lookup: {message}" + ); +} + /// Builds a registration claiming an id a stock module already owns. fn duplicate_lockr_registration( _settings: &trusted_server_core::settings::Settings, diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 36fe55a023..937cbf7c07 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -26,8 +26,8 @@ use trusted_server_core::integrations::{ use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, - UnavailableHttpClient, UnavailableKvStore, + PlatformConfigStore, PlatformError, PlatformGeoModule, PlatformSecretStore, RuntimeServices, + StoreId, StoreName, UnavailableHttpClient, UnavailableKvStore, }; use trusted_server_core::settings::Settings; use trusted_server_core::tsjs::tsjs_script_src; @@ -1604,7 +1604,10 @@ fn stub_services() -> RuntimeServices { .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(StubBackend)) .http_client(Arc::new(UnavailableHttpClient)) - .geo(Arc::new(DisabledGeo)) + .geo(Arc::new(PlatformGeoModule::new( + "none", + Arc::new(DisabledGeo), + ))) .client_info(ClientInfo::default()) .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) .build() diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 93584b19a7..646ce54742 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -3,9 +3,8 @@ use std::sync::Arc; use async_trait::async_trait; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderValue, Response}; +use edgezero_core::http::Response; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::{ ROUTE_NAMESPACE, sanitize_trusted_client_ip_headers, strip_deployment_headers, }; @@ -53,10 +52,6 @@ impl Middleware for SanitizeRequestMiddleware { /// Response-finalization middleware: injects all standard TS response headers. /// -/// Geo availability is determined by the presence of the `cf-ipcountry` header -/// (injected by the Cloudflare Workers runtime). On the native host target the -/// header is absent, so `X-Geo-Info-Available: false` is emitted. -/// /// Registered directly inside [`SanitizeRequestMiddleware`], so that every /// outgoing response carries a consistent set of headers. pub struct FinalizeResponseMiddleware { @@ -74,19 +69,11 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let geo_available = ctx - .request() - .headers() - .get("cf-ipcountry") - .and_then(|v| v.to_str().ok()) - .filter(|s| !s.is_empty() && *s != "XX") - .is_some(); - let path = ctx.request().uri().path().to_owned(); let mut response = next.run(ctx).await?; // Before finalizing, so a header finalizing writes is kept. strip_deployment_headers(&path, ROUTE_NAMESPACE, &mut response); - apply_finalize_headers(&self.settings, geo_available, &mut response); + apply_finalize_headers(&self.settings, &mut response); Ok(response) } } @@ -97,20 +84,10 @@ impl Middleware for FinalizeResponseMiddleware { /// Applies standard Trusted Server response headers to the given response. /// -/// `geo_available` controls `X-Geo-Info-Available`; pass `true` when -/// `cf-ipcountry` was present and non-`XX` in the incoming request. -/// Operator-configured `settings.response_headers` are applied last (with the -/// shared cookie cache-privacy hardening) and can override any managed header. -pub(crate) fn apply_finalize_headers( - settings: &Settings, - geo_available: bool, - response: &mut Response, -) { - response.headers_mut().insert( - HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static(if geo_available { "true" } else { "false" }), - ); - +/// Operator-configured `settings.response_headers` are applied with the +/// shared cookie cache-privacy hardening and can override any header already +/// written. +pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) { // Cloudflare is a real shared cache: cookie-bearing responses must stay // private and operator headers must not re-enable caching for uncacheable // per-user payloads. @@ -190,54 +167,21 @@ mod tests { } #[test] - fn sets_geo_available_false_when_no_country_header() { - let settings = settings_with_response_headers(vec![]); - let mut response = empty_response(); - - apply_finalize_headers(&settings, false, &mut response); - - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("false"), - "should set X-Geo-Info-Available: false when geo is unavailable" - ); - } - - #[test] - fn sets_geo_available_true_when_country_header_present() { + fn finalizing_writes_no_geo_header() { let settings = settings_with_response_headers(vec![]); let mut response = empty_response(); - apply_finalize_headers(&settings, true, &mut response); + apply_finalize_headers(&settings, &mut response); - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("true"), - "should set X-Geo-Info-Available: true when cf-ipcountry is present" - ); - } - - #[test] - fn operator_response_headers_override_geo_header() { - let settings = - settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); - let mut response = empty_response(); - - apply_finalize_headers(&settings, false, &mut response); - - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("operator-override"), - "should override the managed geo header with the operator-configured value" + let geo_headers: Vec<&str> = response + .headers() + .keys() + .map(edgezero_core::http::HeaderName::as_str) + .filter(|name| name.starts_with("x-geo-")) + .collect(); + assert!( + geo_headers.is_empty(), + "should write no x-geo-* header, which a serve middleware writes: {geo_headers:?}" ); } @@ -246,7 +190,7 @@ mod tests { let settings = settings_with_response_headers(vec![("X-Custom-Header", "custom-value")]); let mut response = empty_response(); - apply_finalize_headers(&settings, false, &mut response); + apply_finalize_headers(&settings, &mut response); assert_eq!( response diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 069b8b53ed..6b705551d0 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -20,7 +20,13 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// serves the startup error router on every route (and its route table is the /// fallback-only set). fn test_router() -> RouterService { - let settings = Settings::from_toml( + TrustedServerApp::routes_with_settings(test_settings()) + .expect("should build router from test settings") +} + +/// The settings [`test_router`] is built from. +fn test_settings() -> Settings { + Settings::from_toml( r#" [publisher] domain = "test-publisher.example.com" @@ -40,12 +46,31 @@ fn test_router() -> RouterService { ) .expect("should parse route test settings") // A loaded domain carries its policy. - .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) +} +/// A router whose settings add one operator header, which only +/// `FinalizeResponseMiddleware` writes, so a response carrying it passed +/// through that middleware. +fn router_writing_an_operator_header() -> RouterService { + let mut settings = test_settings(); + settings + .response_headers + .insert("X-Finalized".to_owned(), "1".to_owned()); TrustedServerApp::routes_with_settings(settings) .expect("should build router from test settings") } +/// The `x-geo-*` headers on `response`, by name. +fn geo_header_names(response: &Response) -> Vec { + response + .headers() + .keys() + .map(|name| name.as_str().to_owned()) + .filter(|name| name.starts_with("x-geo-")) + .collect() +} + /// Return the set of (METHOD, path) pairs explicitly registered on the router. fn registered_routes() -> Vec<(String, String)> { test_router() @@ -111,10 +136,11 @@ fn routes_build_without_panic() { // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn finalize_middleware_injects_geo_header() { - // The X-Geo-Info-Available header is injected by FinalizeResponseMiddleware. - // Its absence on any response means the middleware was not wired. - let router = test_router(); +async fn finalize_middleware_writes_the_operators_headers_and_no_geo_header() { + // The operator's header is written by FinalizeResponseMiddleware alone, so + // its absence on a response means the middleware was not wired. No + // x-geo-* header is written there, because a serve middleware writes them. + let router = router_writing_an_operator_header(); let req = request_builder() .method("GET") @@ -124,9 +150,17 @@ async fn finalize_middleware_injects_geo_header() { let resp = route(router, req).await; + assert_eq!( + resp.headers() + .get("x-finalized") + .and_then(|value| value.to_str().ok()), + Some("1"), + "FinalizeResponseMiddleware must write the operator's headers on every response" + ); assert!( - resp.headers().contains_key("x-geo-info-available"), - "FinalizeResponseMiddleware must inject X-Geo-Info-Available on every response" + geo_header_names(&resp).is_empty(), + "no adapter writes an x-geo-* header: {:?}", + geo_header_names(&resp) ); } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index a14254e13e..c678a08e6a 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -76,9 +76,9 @@ //! When [`build_state`] fails, [`startup_error_router`] returns a minimal router //! that responds to all routes with the startup error. This router does **not** //! attach middleware. Startup-error responses may still receive entry-point -//! finalization (geo and TS headers) when settings can be reloaded via +//! finalization (TS headers) when settings can be reloaded via //! [`load_settings_from_config_store`]; if settings loading itself fails, they -//! are returned without geo or TS headers. +//! are returned without TS headers. use std::sync::Arc; @@ -537,15 +537,9 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime services.with_prefixes(prefixes) } -/// Builds the services graph handed to a module on a response-side finalize -/// path. -/// -/// The finalize paths run after the handler, where the per-request services -/// built by [`build_per_request_services`] are already out of scope, but a geo -/// module still needs real platform services to resolve a location. The -/// middleware builds this once and clones it per request with -/// [`RuntimeServices::with_client_info`], and the entry point rebuilds it per -/// call. +/// Builds the services graph handed to a module the entry point calls outside +/// the router, which is the device module, before the per-request services +/// built by [`build_per_request_services`] exist. /// /// The settings are ones the runtime loader loaded, so they carry the /// permission policy their domain names, which every services graph holds. @@ -554,7 +548,7 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime /// /// When the settings carry no permission policy, which only settings built /// outside the runtime loader can lack. -pub(crate) fn build_finalize_services( +pub(crate) fn build_entry_point_services( settings: &Settings, kv_store: Arc, loading_caches: Arc, @@ -1610,18 +1604,8 @@ impl TrustedServerApp { } fn routes_for_state(state: &Arc) -> RouterService { - let mut router = RouterService::builder().middleware( - FinalizeResponseMiddleware::new( - Arc::clone(&state.settings), - build_geo_module(&state.settings, Arc::new(FastlyPlatformGeo)), - build_finalize_services( - &state.settings, - Arc::clone(&state.default_kv_store), - Arc::clone(&state.loading_caches), - ), - ) - .with_registry(Arc::clone(&state.registry)), - ); + let mut router = RouterService::builder() + .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))); let fallback_handler = fallback_route_handler(Arc::clone(state)); @@ -1712,7 +1696,6 @@ mod tests { use error_stack::Report; use futures::executor::block_on; - use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::error::TrustedServerError; use trusted_server_core::integrations::{ @@ -2933,7 +2916,7 @@ mod tests { assert!( response .headers() - .get(HEADER_X_GEO_INFO_AVAILABLE) + .get(crate::middleware::HEADER_X_TS_FINALIZED) .is_none(), "router-level 405 bypasses FinalizeResponseMiddleware; lib.rs entry-point covers this" ); @@ -3064,7 +3047,12 @@ mod tests { .kv_store(Arc::new(NoopKvStore) as Arc) .backend(Arc::new(FixedBackend)) .http_client(Arc::new(StreamingHttpClient)) - .geo(Arc::new(crate::platform::FastlyPlatformGeo)) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new( + "platform", + Arc::new(crate::platform::FastlyPlatformGeo), + ), + )) .client_info(ClientInfo::default()) .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) .build() @@ -3234,7 +3222,7 @@ mod tests { .body(Body::empty()) .expect("should build request"); let mut response = block_on(super::dispatch_fallback(&state, &services, request)); - crate::middleware::apply_finalize_headers(&state.settings, None, &mut response); + crate::middleware::apply_finalize_headers(&state.settings, &mut response); response }; let tag = |response: &Response| { diff --git a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs index 0ed286e6b4..37f49d62a2 100644 --- a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs @@ -8,23 +8,25 @@ //! outcomes on the dev server. use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use edgezero_core::body::Body; -use edgezero_core::http::{HeaderMap, Method, Response, request_builder}; +use edgezero_core::http::{HeaderMap, HeaderName, Method, Response, request_builder}; use edgezero_core::router::RouterService; use error_stack::Report; use futures::executor::block_on; use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; -use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; +use trusted_server_core::module_context::{ModuleCall, ModuleContext, ModuleRequest}; +use trusted_server_core::platform::{GeoInfo, GeoModule, PlatformError}; use trusted_server_core::settings::Settings; use trusted_server_core::tsjs::tsjs_script_src; use trusted_server_core::tsjs_bundle::{JsModulePart, compile_time_parts}; use trusted_server_integration_seam_probe as seam_probe; use super::{ - AppState, TrustedServerApp, build_finalize_services, build_state_from_settings, + AppState, TrustedServerApp, build_entry_point_services, build_state_from_settings, build_state_with_registrations, register_integrations, }; @@ -210,6 +212,98 @@ fn proxy_route_reports_the_modules_geo_and_that_the_preparer_ran() { ); } +/// The name a section and `[geo] module` select the counting geo module by. +const COUNTING_GEO: &str = "testing.counting-geo"; + +/// How many times the counting geo module has been asked. +static COUNTED_GEO_CALLS: AtomicUsize = AtomicUsize::new(0); + +/// A geo module that answers one location for every request and counts the +/// calls it is asked. +struct CountingGeo; + +#[async_trait::async_trait(?Send)] +impl GeoModule for CountingGeo { + fn id(&self) -> &'static str { + COUNTING_GEO + } + + async fn locate( + &self, + _call: ModuleCall<'_>, + ) -> Result, Report> { + COUNTED_GEO_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(Some(GeoInfo { + city: "Paris".to_owned(), + country: "FR".to_owned(), + continent: "Europe".to_owned(), + latitude: 48.8566, + longitude: 2.3522, + metro_code: 0, + region: Some("IDF".to_owned()), + asn: None, + })) + } +} + +fn register_counting_geo( + settings: &Settings, +) -> Result, Report> { + Ok(settings.selects_module(COUNTING_GEO).then(|| { + IntegrationRegistration::builder("counting_geo") + .without_js() + .with_geo_module(COUNTING_GEO, Arc::new(CountingGeo)) + .build() + })) +} + +fn validate_counting_geo(settings: &Settings) -> Result> { + Ok(settings.selects_module(COUNTING_GEO)) +} + +/// A response this adapter finishes carries no `x-geo-*` header, even where +/// the request resolved a location, and the geo module is asked once, for +/// the request's state, and not again when the response is finished. +#[test] +fn a_finished_response_carries_no_geo_header_and_the_geo_module_is_asked_once() { + let settings = settings_with(&format!( + r#" + [geo] + module = "{COUNTING_GEO}" + + [testing] + modules = ["counting-geo"] + {HMAC_BLOCK} + "# + )); + let counting_geo = IntegrationBuilder::new( + "counting_geo", + "counting-geo-test", + register_counting_geo, + validate_counting_geo, + ) + .with_module_name(COUNTING_GEO); + let router = router_with(settings, &[counting_geo]); + + let response = get(&router, "/some-page", None); + + let geo_headers: Vec<&str> = response + .headers() + .keys() + .map(HeaderName::as_str) + .filter(|name| name.starts_with("x-geo-")) + .collect(); + assert!( + geo_headers.is_empty(), + "should write no x-geo-* header when finishing a response: {geo_headers:?}" + ); + assert_eq!( + COUNTED_GEO_CALLS.load(Ordering::SeqCst), + 1, + "should ask the geo module once, for the request's state" + ); +} + /// With `refuse_all` selected every response this adapter finalizes says /// not to index, the response of another route included. The file itself is /// the publisher path's, made by the `robots_txt` middleware the entry @@ -982,7 +1076,7 @@ fn ec_and_device_selectors_naming_a_module_resolve_the_modules_it_declares() { "the Edge Cookie module should be the one the module declared" ); - let services = build_finalize_services( + let services = build_entry_point_services( &state.settings, Arc::clone(&state.default_kv_store), Arc::default(), @@ -1030,7 +1124,7 @@ fn device_selector_naming_a_module_classifies_a_request_with_it() { )), &[seam_probe::builder()], ); - let services = build_finalize_services( + let services = build_entry_point_services( &selected.settings, Arc::clone(&selected.default_kv_store), Arc::default(), @@ -1051,7 +1145,7 @@ fn device_selector_naming_a_module_classifies_a_request_with_it() { settings_with(&format!("{HMAC_BLOCK}{PROBE_BLOCK}")), &[seam_probe::builder()], ); - let services = build_finalize_services( + let services = build_entry_point_services( &unset.settings, Arc::clone(&unset.default_kv_store), Arc::default(), diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 7a62e0e88e..3d31f34fe8 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -32,8 +32,7 @@ use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; -use trusted_server_core::platform::build_geo_module; -use trusted_server_core::platform::{ClientInfo, RuntimeServices, UnavailableKvStore}; +use trusted_server_core::platform::{RuntimeServices, UnavailableKvStore}; use trusted_server_core::prefix::Prefixes; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; @@ -56,12 +55,12 @@ mod tinybird; mod value_cache; use crate::app::{ - AppState, EcFinalizeState, RuntimeStoreConfig, TrustedServerApp, build_finalize_services, + AppState, EcFinalizeState, RuntimeStoreConfig, TrustedServerApp, build_entry_point_services, load_settings_from_config_store, }; use crate::ec_kv::FastlyEcKvStore; -use crate::middleware::{HEADER_X_TS_FINALIZED, apply_finalize_headers, geo_allowed_for_response}; -use crate::platform::{FastlyPlatformGeo, client_info_from_request}; +use crate::middleware::{HEADER_X_TS_FINALIZED, apply_finalize_headers}; +use crate::platform::client_info_from_request; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; use crate::sandbox::{RetainedApp, Sandbox, SandboxCounters, ServeMode, StartupDiagnostics}; /// Re-exported so a crate registering an integration names the type it hands @@ -598,7 +597,6 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re // accessors only return real values on the client request, so store them in // request extensions for build_per_request_services and EC bot classification. let client_info = client_info_from_request(&req, resolved_client_ip); - let client_ip = client_info.client_ip; // Strip and re-inject the TLS JA4 and HTTP/2 signals from the // authoritative Fastly SDK values, under the same trust model, so the @@ -630,7 +628,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re Some(settings) => { // The entry point is synchronous host code, so it drives the async // module seam at the same boundary it already drives the router. - let services = entry_point_finalize_services(settings, &app_state, entry_request(&req)); + let services = entry_point_services(settings, &app_state, entry_request(&req)); let registered = app_state .as_deref() .and_then(|state| state.registry.device_module()); @@ -649,9 +647,6 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. That preserves duplicate header values such // as multiple Set-Cookie headers. - // Kept for the finishing steps' module calls, which run after the - // request has been handed to the router. - let kept = EntryRequest::of(&req); let mut response = match into_core_request(req) { Ok(mut core_req) => { core_req.extensions_mut().insert(config_store); @@ -678,22 +673,10 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re if !take_finalize_sentinel(&mut response) { if let Some(settings) = settings_snapshot.as_deref() { - futures::executor::block_on(apply_entry_point_finalize_headers( - settings, - &mut response, - client_ip, - entry_point_finalize_services(settings, &app_state, kept.module_request()), - )); + apply_finalize_headers(settings, &mut response); } else { match load_settings_from_config_store(&runtime_stores) { - Ok(settings) => { - futures::executor::block_on(apply_entry_point_finalize_headers( - &settings, - &mut response, - client_ip, - entry_point_finalize_services(&settings, &app_state, kept.module_request()), - )); - } + Ok(settings) => apply_finalize_headers(&settings, &mut response), Err(e) => { log::warn!("entry-point finalize skipped: failed to reload settings: {e:?}"); } @@ -798,48 +781,23 @@ fn take_finalize_sentinel(response: &mut HttpResponse) -> bool { /// The prefixes come from the selected prefix module where the state was /// built. A deployment whose state failed to build has no registry to name /// one, so core's own stand. -fn entry_point_finalize_services( +fn entry_point_services( settings: &Settings, app_state: &Option>, request: ModuleRequest<'_>, ) -> RuntimeServices { match app_state { - Some(state) => build_finalize_services( + Some(state) => build_entry_point_services( settings, Arc::clone(&state.default_kv_store), Arc::clone(&state.loading_caches), ) .with_prefixes(state.registry.prefixes(settings, request)), - None => build_finalize_services(settings, Arc::new(UnavailableKvStore), Arc::default()) + None => build_entry_point_services(settings, Arc::new(UnavailableKvStore), Arc::default()) .with_prefixes(Prefixes::trusted_server(settings)), } } -async fn apply_entry_point_finalize_headers( - settings: &Settings, - response: &mut HttpResponse, - client_ip: Option, - services: RuntimeServices, -) { - // Route through the [geo] module selector, so a deployment that opts - // out of geolocation makes no host geo call on the entry-point finalize - // path either. - let geo = build_geo_module(settings, Arc::new(FastlyPlatformGeo)); - let geo_info = if geo_allowed_for_response(response) { - let services = services.with_client_info(ClientInfo { - client_ip, - ..ClientInfo::default() - }); - geo.lookup(client_ip, &services).await.unwrap_or_else(|e| { - log::warn!("entry-point geo lookup failed: {e}"); - None - }) - } else { - None - }; - apply_finalize_headers(settings, geo_info.as_ref(), response); -} - async fn apply_edgezero_ec_finalize( settings: &Settings, ec_state: &mut EcFinalizeState, @@ -1163,33 +1121,6 @@ fn entry_scheme(req: &FastlyRequest) -> &'static str { } } -/// What the client's request resolved to, owned, for the module calls that -/// run after the request itself has been handed to the router. -struct EntryRequest { - method: fastly::http::Method, - host: String, - scheme: &'static str, - path: String, - query: String, -} - -impl EntryRequest { - fn of(req: &FastlyRequest) -> Self { - Self { - method: req.get_method().clone(), - host: request_host(req).unwrap_or_default().to_owned(), - scheme: entry_scheme(req), - path: req.get_path().to_owned(), - query: req.get_query_str().unwrap_or_default().to_owned(), - } - } - - fn module_request(&self) -> ModuleRequest<'_> { - ModuleRequest::new(&self.method, &self.host, self.scheme, &self.path) - .with_query(&self.query) - } -} - #[cfg(test)] mod tests { use super::*; @@ -1280,7 +1211,7 @@ mod tests { fn the_device_module_the_registry_resolved_classifies_the_request() { let settings = test_settings(); let services = - build_finalize_services(&settings, Arc::new(UnavailableKvStore), Arc::default()); + build_entry_point_services(&settings, Arc::new(UnavailableKvStore), Arc::default()); let registered: Arc = Arc::new(MarkedDeviceModule); let signals = futures::executor::block_on(derive_device_signals( @@ -1302,7 +1233,7 @@ mod tests { fn with_no_registered_module_the_built_in_module_classifies_the_request() { let settings = test_settings(); let services = - build_finalize_services(&settings, Arc::new(UnavailableKvStore), Arc::default()); + build_entry_point_services(&settings, Arc::new(UnavailableKvStore), Arc::default()); let req = page_request(); let signals = @@ -1521,21 +1452,6 @@ mod tests { ); } - #[test] - fn a_kept_entry_request_reads_a_host_that_is_not_utf8_as_no_host() { - let kept = EntryRequest::of(&request_with_host_not_utf8()); - - assert_eq!( - kept.host, "", - "a kept request should carry no host where Host is not UTF-8" - ); - assert_eq!( - kept.module_request().host(), - "", - "the module request a kept request makes should hand on no host" - ); - } - #[test] fn a_malformed_selector_is_answered_500() { let response = refusal_response(&config_selection::Refusal::MalformedSelector( @@ -1793,29 +1709,24 @@ mod tests { } #[test] - fn entry_point_finalize_skips_geo_lookup_for_401() { + fn entry_point_finalize_writes_no_geo_header() { let settings = test_settings(); let mut response = response_builder() - .status(edgezero_core::http::StatusCode::UNAUTHORIZED) + .status(edgezero_core::http::StatusCode::METHOD_NOT_ALLOWED) .body(EdgeBody::empty()) .expect("should build response"); - // The predicate is what stops the lookup, so assert on it directly: - // a `true` here would send the client IP to the geo module on a - // response that never authenticated. - assert!( - !geo_allowed_for_response(&response), - "should skip entry-point geo lookup for 401 responses" - ); - apply_finalize_headers(&settings, None, &mut response); + apply_finalize_headers(&settings, &mut response); - assert_eq!( - response - .headers() - .get(trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE) - .and_then(|v| v.to_str().ok()), - Some("false"), - "401 responses should still carry geo-unavailable headers" + let geo_headers: Vec<&str> = response + .headers() + .keys() + .map(edgezero_core::http::HeaderName::as_str) + .filter(|name| name.starts_with("x-geo-")) + .collect(); + assert!( + geo_headers.is_empty(), + "a response the router did not finish should carry no x-geo-* header: {geo_headers:?}" ); } diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 5c25ba5da0..a147390081 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -1,27 +1,18 @@ //! Middleware implementations for the `EdgeZero` entry point. //! //! Provides the middleware the `EdgeZero` entry point registers, -//! [`FinalizeResponseMiddleware`], which looks the reader's location up and -//! injects the standard TS headers. +//! [`FinalizeResponseMiddleware`], which injects the standard TS headers. use std::sync::Arc; use async_trait::async_trait; -use edgezero_adapter_fastly::context::FastlyRequestContext; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderValue, Response, StatusCode}; +use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::response::IntoResponse; -use trusted_server_core::constants::{ - ENV_FASTLY_IS_STAGING, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, -}; -use trusted_server_core::geo::GeoInfo; +use trusted_server_core::constants::{ENV_FASTLY_IS_STAGING, HEADER_X_TS_ENV}; use trusted_server_core::http_util::{ROUTE_NAMESPACE, strip_deployment_headers}; -use trusted_server_core::integrations::IntegrationRegistry; -use trusted_server_core::module_context::ModuleRequest; -use trusted_server_core::platform::{ClientInfo, PlatformGeo, RuntimeServices}; -use trusted_server_core::prefix::Prefixes; use trusted_server_core::settings::Settings; pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; @@ -30,7 +21,7 @@ pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; // FinalizeResponseMiddleware // --------------------------------------------------------------------------- -/// Outermost middleware: performs geo lookup and injects all standard TS response headers. +/// Outermost middleware, which injects all standard TS response headers. /// /// Registered first in the middleware chain so that it wraps the handler. This /// guarantees every registered-route response carries a consistent set of headers. @@ -43,72 +34,24 @@ pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; /// # Header precedence /// /// Headers are written in this order (last write wins): -/// 1. Geo headers (or `X-Geo-Info-Available: false` when geo is unavailable) -/// 2. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` -/// 3. Operator-configured `settings.response_headers` (can override any managed header) +/// 1. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` +/// 2. Operator-configured `settings.response_headers` (can override any managed header) pub struct FinalizeResponseMiddleware { settings: Arc, - geo: Arc, - /// The services a geo module is given for the response-side lookup. - /// Built once with the application and cloned per request with that - /// request's client metadata applied. - services: RuntimeServices, - /// The registry whose selected prefix module names the publisher's - /// prefixes, or `None` where no application state was built. - registry: Option>, } impl FinalizeResponseMiddleware { - /// Creates a new [`FinalizeResponseMiddleware`] with the given settings, - /// geo lookup service, and the services graph handed to the geo module. - pub fn new( - settings: Arc, - geo: Arc, - services: RuntimeServices, - ) -> Self { - Self { - settings, - geo, - services, - registry: None, - } - } - - /// The registry whose selected prefix module names each request's - /// publisher prefixes. + /// Creates a new [`FinalizeResponseMiddleware`] with the given settings. #[must_use] - pub fn with_registry(mut self, registry: Arc) -> Self { - self.registry = Some(registry); - self + pub fn new(settings: Arc) -> Self { + Self { settings } } } #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let client_ip = ctx.request().extensions().get::().map_or_else( - || FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip), - |info| info.client_ip, - ); let path = ctx.request().uri().path().to_owned(); - // The publisher this request resolved to names the keys the geo - // module's answers are held under, so the services carry its prefixes, - // which are core's own where no registry names a prefix module. - let client = ctx - .request() - .extensions() - .get::() - .cloned() - .unwrap_or_default(); - let services = match &self.registry { - Some(registry) => self.services.clone().with_prefixes( - registry.prefixes(&self.settings, ModuleRequest::of(ctx.request(), &client)), - ), - None => self - .services - .clone() - .with_prefixes(Prefixes::trusted_server(&self.settings)), - }; let mut response = match next.run(ctx).await { Ok(r) => r, @@ -118,25 +61,9 @@ impl Middleware for FinalizeResponseMiddleware { } }; - let geo_info = if geo_allowed_for_response(&response) { - let services = services.clone().with_client_info(ClientInfo { - client_ip, - ..ClientInfo::default() - }); - self.geo - .lookup(client_ip, &services) - .await - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }) - } else { - None - }; - // Before finalizing, so a header finalizing writes is kept. strip_deployment_headers(&path, ROUTE_NAMESPACE, &mut response); - apply_finalize_headers(&self.settings, geo_info.as_ref(), &mut response); + apply_finalize_headers(&self.settings, &mut response); response .headers_mut() .insert(HEADER_X_TS_FINALIZED, HeaderValue::from_static("1")); @@ -145,35 +72,6 @@ impl Middleware for FinalizeResponseMiddleware { } } -// --------------------------------------------------------------------------- -// Shared geo resolution helper -// --------------------------------------------------------------------------- - -/// Resolves geo for a response, skipping the lookup for 401 responses. -/// -/// Returns `false` for authentication rejections (401), so the caller skips the -/// lookup entirely rather than doing unnecessary work and exposing geo data to -/// unauthenticated callers. Every other response is allowed a lookup. -/// -/// This is a predicate rather than a wrapper that runs the lookup itself -/// because [`PlatformGeo::lookup`] is asynchronous, and the decision is the -/// only part worth sharing between the two callers. -/// -/// Used by both [`FinalizeResponseMiddleware`] and the entry-point finalization -/// in `lib.rs` so the 401-skip rule is defined in one place. -/// -/// # Parity note -/// -/// Before legacy cleanup, the Fastly-native path skipped geo only for this -/// server's own auth challenges; origin-forwarded 401s still received geo -/// headers there. The `EdgeZero` path skips geo for **all** 401s by status. This -/// is intentionally more conservative: geo data is not sent to any -/// unauthenticated caller regardless of whether the 401 originated from this -/// server or the upstream origin. -pub(crate) fn geo_allowed_for_response(response: &Response) -> bool { - response.status() != StatusCode::UNAUTHORIZED -} - // --------------------------------------------------------------------------- // apply_finalize_headers — extracted for unit testing // --------------------------------------------------------------------------- @@ -183,29 +81,16 @@ pub(crate) fn geo_allowed_for_response(response: &Response) -> bool { /// Operates on [`Response`] from `edgezero_core::http`. /// /// Header write order (last write wins): -/// 1. Geo headers (`x-geo-*`) — or `X-Geo-Info-Available: false` when absent -/// 2. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` -/// 3. Set-Cookie cache privacy — strip surrogate cache headers and downgrade -/// `Cache-Control` to `private, max-age=0` on cookie-bearing responses -/// 4. `settings.response_headers` — operator-configured overrides, except the +/// 1. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` +/// 2. Set-Cookie cache privacy, which strips surrogate cache headers and +/// downgrades `Cache-Control` to `private, max-age=0` on cookie-bearing +/// responses +/// 3. `settings.response_headers`, the operator's overrides, except that the /// cache-controlling headers are skipped on uncacheable (`private`/`no-store`) /// responses so operators cannot re-enable shared caching for per-user payloads -/// 5. `X-Robots-Tag: noindex, nofollow` when `[robots-txt] modules` selects +/// 4. `X-Robots-Tag: noindex, nofollow` when `[robots-txt] modules` selects /// `refuse_all` -pub(crate) fn apply_finalize_headers( - settings: &Settings, - geo_info: Option<&GeoInfo>, - response: &mut Response, -) { - if let Some(geo) = geo_info { - geo.set_response_headers(response); - } else { - response.headers_mut().insert( - HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static("false"), - ); - } - +pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) { if std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { response .headers_mut() @@ -245,18 +130,14 @@ mod tests { use super::*; use std::collections::HashMap; - use std::net::IpAddr; - use std::sync::{Arc, Mutex}; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; - use edgezero_core::http::{HeaderName, Method, StatusCode, request_builder, response_builder}; + use edgezero_core::http::{HeaderName, Method, request_builder, response_builder}; use edgezero_core::middleware::Next; use edgezero_core::params::PathParams; - use error_stack::Report; use futures::executor::block_on; - use trusted_server_core::platform::{ClientInfo, PlatformError, PlatformGeo}; use trusted_server_core::response_privacy::apply_inactive_ad_stack_browser_cache_policy; fn empty_response() -> Response { @@ -274,38 +155,6 @@ mod tests { RequestContext::new(req, PathParams::new(HashMap::new())) } - struct FixedGeo(Option); - - #[async_trait::async_trait(?Send)] - impl PlatformGeo for FixedGeo { - async fn lookup( - &self, - _: Option, - _services: &trusted_server_core::platform::RuntimeServices, - ) -> Result, Report> { - Ok(self.0.clone()) - } - } - - struct RecordingGeo { - lookups: Arc>>>, - } - - #[async_trait::async_trait(?Send)] - impl PlatformGeo for RecordingGeo { - async fn lookup( - &self, - client_ip: Option, - _services: &trusted_server_core::platform::RuntimeServices, - ) -> Result, Report> { - self.lookups - .lock() - .expect("should lock recorded geo lookups") - .push(client_ip); - Ok(None) - } - } - fn test_settings() -> Settings { Settings::from_toml( r#" @@ -342,39 +191,49 @@ mod tests { s } + /// The `x-geo-*` headers on `response`, by name. + fn geo_header_names(response: &Response) -> Vec { + response + .headers() + .keys() + .map(HeaderName::as_str) + .filter(|name| name.starts_with("x-geo-")) + .map(str::to_owned) + .collect() + } + + fn finalize_middleware(settings: Settings) -> FinalizeResponseMiddleware { + FinalizeResponseMiddleware::new(Arc::new(settings)) + } + #[test] fn operator_response_headers_override_earlier_headers() { - let settings = - settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); - let mut response = empty_response(); + let settings = settings_with_response_headers(vec![("X-Example", "operator-override")]); + let mut response = response_with_headers(&[("x-example", "origin")]); - // No geo_info → would set "false"; operator header should win instead. - apply_finalize_headers(&settings, None, &mut response); + apply_finalize_headers(&settings, &mut response); assert_eq!( response .headers() - .get("x-geo-info-available") + .get("x-example") .and_then(|v| v.to_str().ok()), Some("operator-override"), - "should override the managed geo header with the operator-configured value" + "should replace a header already on the response with the operator-configured value" ); } #[test] - fn sets_geo_unavailable_header_when_no_geo_info() { + fn finalizing_writes_no_geo_header() { let settings = settings_with_response_headers(vec![]); let mut response = empty_response(); - apply_finalize_headers(&settings, None, &mut response); + apply_finalize_headers(&settings, &mut response); - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("false"), - "should set X-Geo-Info-Available: false when no geo info is available" + assert!( + geo_header_names(&response).is_empty(), + "should write no x-geo-* header, which a serve middleware writes: {:?}", + geo_header_names(&response) ); } @@ -408,7 +267,7 @@ mod tests { ) .expect("should answer"); - apply_finalize_headers(&settings, None, &mut response); + apply_finalize_headers(&settings, &mut response); let policy = response .headers() @@ -440,7 +299,7 @@ mod tests { ("surrogate-control", "max-age=600"), ]); - apply_finalize_headers(&settings, None, &mut response); + apply_finalize_headers(&settings, &mut response); assert_eq!( response @@ -468,7 +327,7 @@ mod tests { ]); let mut response = response_with_headers(&[("cache-control", "private, max-age=0")]); - apply_finalize_headers(&settings, None, &mut response); + apply_finalize_headers(&settings, &mut response); assert_eq!( response @@ -592,136 +451,13 @@ mod tests { ); } - /// The services graph the finalize middleware hands its geo module, - /// built the same way the router builds it so the tests exercise the - /// production shape rather than a stand-in. - fn test_finalize_services() -> RuntimeServices { - crate::app::build_finalize_services( - &test_settings(), - Arc::new(trusted_server_core::platform::UnavailableKvStore), - Arc::default(), - ) - } - // --------------------------------------------------------------------------- // FinalizeResponseMiddleware::handle tests // --------------------------------------------------------------------------- #[test] - fn finalize_handle_uses_client_info_ip_for_geo_lookup() { - let reader_ip = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7)); - let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); - let settings = settings_with_response_headers(vec![]); - let lookups = Arc::new(Mutex::new(Vec::new())); - let middleware = FinalizeResponseMiddleware::new( - Arc::new(settings), - Arc::new(RecordingGeo { - lookups: Arc::clone(&lookups), - }), - test_finalize_services(), - ); - let mut ctx = empty_ctx(); - ctx.request_mut().extensions_mut().insert(ClientInfo { - client_ip: Some(reader_ip), - ..ClientInfo::default() - }); - FastlyRequestContext::insert( - ctx.request_mut(), - FastlyRequestContext { - client_ip: Some(peer_ip), - }, - ); - let handler = - Arc::new( - |_ctx: RequestContext| async move { Ok::(empty_response()) }, - ); - - block_on(middleware.handle(ctx, Next::new(&[], &*handler))).expect("should succeed"); - - assert_eq!( - *lookups.lock().expect("should lock recorded geo lookups"), - vec![Some(reader_ip)], - "should look up geo using the resolved ClientInfo IP" - ); - } - - #[test] - fn finalize_handle_preserves_none_from_present_client_info() { - let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); - let settings = settings_with_response_headers(vec![]); - let lookups = Arc::new(Mutex::new(Vec::new())); - let middleware = FinalizeResponseMiddleware::new( - Arc::new(settings), - Arc::new(RecordingGeo { - lookups: Arc::clone(&lookups), - }), - test_finalize_services(), - ); - let mut ctx = empty_ctx(); - ctx.request_mut() - .extensions_mut() - .insert(ClientInfo::default()); - FastlyRequestContext::insert( - ctx.request_mut(), - FastlyRequestContext { - client_ip: Some(peer_ip), - }, - ); - let handler = - Arc::new( - |_ctx: RequestContext| async move { Ok::(empty_response()) }, - ); - - block_on(middleware.handle(ctx, Next::new(&[], &*handler))).expect("should succeed"); - - assert_eq!( - *lookups.lock().expect("should lock recorded geo lookups"), - vec![None], - "should preserve no IP from the authoritative ClientInfo" - ); - } - - #[test] - fn finalize_handle_uses_fastly_context_ip_when_client_info_is_absent() { - let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); - let settings = settings_with_response_headers(vec![]); - let lookups = Arc::new(Mutex::new(Vec::new())); - let middleware = FinalizeResponseMiddleware::new( - Arc::new(settings), - Arc::new(RecordingGeo { - lookups: Arc::clone(&lookups), - }), - test_finalize_services(), - ); - let mut ctx = empty_ctx(); - FastlyRequestContext::insert( - ctx.request_mut(), - FastlyRequestContext { - client_ip: Some(peer_ip), - }, - ); - let handler = - Arc::new( - |_ctx: RequestContext| async move { Ok::(empty_response()) }, - ); - - block_on(middleware.handle(ctx, Next::new(&[], &*handler))).expect("should succeed"); - - assert_eq!( - *lookups.lock().expect("should lock recorded geo lookups"), - vec![Some(peer_ip)], - "should fall back to the Fastly request context IP" - ); - } - - #[test] - fn finalize_handle_injects_geo_unavailable_on_ok_response() { - let settings = settings_with_response_headers(vec![]); - let middleware = FinalizeResponseMiddleware::new( - Arc::new(settings), - Arc::new(FixedGeo(None)), - test_finalize_services(), - ); + fn finalize_handle_writes_no_geo_header() { + let middleware = finalize_middleware(settings_with_response_headers(vec![])); let handler = Arc::new( |_ctx: RequestContext| async move { Ok::(empty_response()) }, @@ -730,24 +466,16 @@ mod tests { let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) .expect("should succeed"); - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("false"), - "should set X-Geo-Info-Available: false when geo returns None" + assert!( + geo_header_names(&response).is_empty(), + "should write no x-geo-* header, which a serve middleware writes: {:?}", + geo_header_names(&response) ); } #[test] fn finalize_handle_marks_response_as_finalized() { - let settings = settings_with_response_headers(vec![]); - let middleware = FinalizeResponseMiddleware::new( - Arc::new(settings), - Arc::new(FixedGeo(None)), - test_finalize_services(), - ); + let middleware = finalize_middleware(settings_with_response_headers(vec![])); let handler = Arc::new( |_ctx: RequestContext| async move { Ok::(empty_response()) }, @@ -768,12 +496,8 @@ mod tests { #[test] fn finalize_handle_absorbs_handler_error_and_injects_headers() { - let settings = settings_with_response_headers(vec![]); - let middleware = FinalizeResponseMiddleware::new( - Arc::new(settings), - Arc::new(FixedGeo(None)), - test_finalize_services(), - ); + let middleware = + finalize_middleware(settings_with_response_headers(vec![("X-Operator", "kept")])); let handler = Arc::new(|_ctx: RequestContext| async move { Err::(EdgeError::service_unavailable("test error")) }); @@ -785,54 +509,13 @@ mod tests { response.status().is_server_error(), "should produce a server-error status for absorbed handler error" ); - assert!( - response.headers().get("x-geo-info-available").is_some(), - "absorbed error response should still carry geo header" - ); - } - - #[test] - #[allow(clippy::panic)] - fn finalize_handle_skips_geo_lookup_for_401() { - struct PanicGeo; - #[async_trait::async_trait(?Send)] - impl PlatformGeo for PanicGeo { - async fn lookup( - &self, - _: Option, - _services: &trusted_server_core::platform::RuntimeServices, - ) -> Result, Report> { - panic!("should not call geo for 401 responses") - } - } - - let settings = settings_with_response_headers(vec![]); - let middleware = FinalizeResponseMiddleware::new( - Arc::new(settings), - Arc::new(PanicGeo), - test_finalize_services(), - ); - let handler = Arc::new(|_ctx: RequestContext| async move { - let mut resp = empty_response(); - *resp.status_mut() = StatusCode::UNAUTHORIZED; - Ok::(resp) - }); - - let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) - .expect("should succeed without calling geo"); - - assert_eq!( - response.status(), - StatusCode::UNAUTHORIZED, - "should preserve 401 status" - ); assert_eq!( response .headers() - .get("x-geo-info-available") + .get("x-operator") .and_then(|v| v.to_str().ok()), - Some("false"), - "should set geo-unavailable header without calling geo for 401" + Some("kept"), + "absorbed error response should still carry the operator's headers" ); } @@ -842,12 +525,7 @@ mod tests { // Set-Cookie headers. The old dispatch_with_config_handle path silently // collapsed them because fastly::Response uses set_header (last-wins). // This test verifies the EdgeZero middleware chain is header-transparent. - let settings = settings_with_response_headers(vec![]); - let middleware = FinalizeResponseMiddleware::new( - Arc::new(settings), - Arc::new(FixedGeo(None)), - test_finalize_services(), - ); + let middleware = finalize_middleware(settings_with_response_headers(vec![])); let handler = Arc::new(|_ctx: RequestContext| async move { let resp = response_builder() .header("set-cookie", "session=abc; Path=/; HttpOnly") @@ -890,11 +568,7 @@ mod tests { /// What the finalize middleware makes of that response for a request to /// `path`. fn finalized(path: &str, settings: Settings) -> Response { - let middleware = FinalizeResponseMiddleware::new( - Arc::new(settings), - Arc::new(FixedGeo(None)), - test_finalize_services(), - ); + let middleware = finalize_middleware(settings); let handler = Arc::new(|_ctx: RequestContext| async move { Ok::(response_naming_its_node()) }); diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index bec4bad6fb..5f3f51a613 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -416,7 +416,9 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(backend) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new("noop", Arc::new(NoopGeo)), + )) .client_info(ClientInfo::default()) .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) .build() diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 7bf19e8554..20a4bdf121 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -245,6 +245,11 @@ fn build_state_with_registrations_and_services( .and_then(RuntimeServices::resolved_ec_module) .or_else(|| registry.ec_module()), )?; + // Services a caller supplies carry a geo lookup of their own for + // `platform` to select, and this host's own resolves nothing. + if services.is_none() { + refuse_platform_geo(&settings)?; + } let permission_signal_modules = trusted_server_core::permission_signal::build_permission_signal_modules( &settings, @@ -274,6 +279,25 @@ fn build_state_with_registrations_and_services( })) } +/// Refuses `[geo] module = "platform"`, because this host has no geo lookup. +/// +/// The selection would locate no request and still count as a geo module, +/// so a deployment running an Edge Cookie module would never be asked to +/// acknowledge single-jurisdiction operation. +fn refuse_platform_geo(settings: &Settings) -> Result<(), Report> { + if settings.geo.module.as_deref() != Some("platform") { + return Ok(()); + } + Err(Report::new(TrustedServerError::Configuration { + message: "`[geo] module = \"platform\"` selects the host's own geo lookup, and \ + Spin has no geo lookup, so no request would be located. Select a geo \ + module a linked crate offers, or leave `[geo] module` unset, which needs \ + `[geo] assume_single_jurisdiction = true` when an Edge Cookie module is \ + configured" + .to_owned(), + })) +} + /// Set once startup has logged that this adapter sends no auction telemetry. /// The state is built for every request, so the flag keeps the warning to /// one for each instance. diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 547e8eb035..0fb3ba134f 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -3,9 +3,8 @@ use std::sync::Arc; use async_trait::async_trait; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderValue, Response}; +use edgezero_core::http::Response; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::{ ROUTE_NAMESPACE, sanitize_trusted_client_ip_headers, strip_deployment_headers, }; @@ -53,9 +52,6 @@ impl Middleware for SanitizeRequestMiddleware { /// Response-finalization middleware: injects all standard TS response headers. /// -/// Spin does not expose geo headers to the application, so -/// `X-Geo-Info-Available: false` is emitted for every response. -/// /// Registered directly inside [`SanitizeRequestMiddleware`], so that every /// outgoing response carries a consistent set of headers. pub struct FinalizeResponseMiddleware { @@ -73,13 +69,11 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let geo_available = false; - let path = ctx.request().uri().path().to_owned(); let mut response = next.run(ctx).await?; // Before finalizing, so a header finalizing writes is kept. strip_deployment_headers(&path, ROUTE_NAMESPACE, &mut response); - apply_finalize_headers(&self.settings, geo_available, &mut response); + apply_finalize_headers(&self.settings, &mut response); Ok(response) } } @@ -122,20 +116,10 @@ impl Middleware for NormalizeMiddleware { /// Applies standard Trusted Server response headers to the given response. /// -/// `geo_available` controls `X-Geo-Info-Available`. Spin passes `false` -/// because it has no geo headers. Operator-configured -/// `settings.response_headers` are applied last (with the shared cookie -/// cache-privacy hardening) and can override any managed header. -pub(crate) fn apply_finalize_headers( - settings: &Settings, - geo_available: bool, - response: &mut Response, -) { - response.headers_mut().insert( - HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static(if geo_available { "true" } else { "false" }), - ); - +/// Operator-configured `settings.response_headers` are applied with the +/// shared cookie cache-privacy hardening and can override any header already +/// written. +pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) { // Cookie-bearing responses stay private to shared caches and operator // headers cannot re-enable caching for uncacheable per-user payloads. trusted_server_core::response_privacy::apply_response_headers_with_cache_privacy( @@ -214,54 +198,21 @@ mod tests { } #[test] - fn sets_geo_available_false_when_spin_has_no_geo() { - let settings = settings_with_response_headers(vec![]); - let mut response = empty_response(); - - apply_finalize_headers(&settings, false, &mut response); - - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("false"), - "should set X-Geo-Info-Available: false when geo is unavailable" - ); - } - - #[test] - fn sets_geo_available_true_when_requested_by_helper() { + fn finalizing_writes_no_geo_header() { let settings = settings_with_response_headers(vec![]); let mut response = empty_response(); - apply_finalize_headers(&settings, true, &mut response); + apply_finalize_headers(&settings, &mut response); - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("true"), - "should set X-Geo-Info-Available: true when requested" - ); - } - - #[test] - fn operator_response_headers_override_geo_header() { - let settings = - settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); - let mut response = empty_response(); - - apply_finalize_headers(&settings, false, &mut response); - - assert_eq!( - response - .headers() - .get("x-geo-info-available") - .and_then(|v| v.to_str().ok()), - Some("operator-override"), - "should override the managed geo header with the operator-configured value" + let geo_headers: Vec<&str> = response + .headers() + .keys() + .map(edgezero_core::http::HeaderName::as_str) + .filter(|name| name.starts_with("x-geo-")) + .collect(); + assert!( + geo_headers.is_empty(), + "should write no x-geo-* header, which a serve middleware writes: {geo_headers:?}" ); } @@ -270,7 +221,7 @@ mod tests { let settings = settings_with_response_headers(vec![("X-Custom-Header", "custom-value")]); let mut response = empty_response(); - apply_finalize_headers(&settings, false, &mut response); + apply_finalize_headers(&settings, &mut response); assert_eq!( response diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 146e871bcb..37857bac90 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -1098,15 +1098,7 @@ mod tests { services.client_info().client_ip.is_none(), "should not set client IP without Spin request context" ); - assert!( - services - .geo() - .lookup(None, &test_services()) - .await - .expect("should not fail") - .is_none(), - "should use null geo" - ); + assert!(services.geo().id() == "none", "should use null geo"); assert!( services .config_store() diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 4345de4766..f40392a6f8 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -19,7 +19,13 @@ use trusted_server_core::settings::Settings; /// every route into a startup error page (and its route table into the /// fallback-only set). fn test_router() -> RouterService { - let settings = Settings::from_toml( + TrustedServerApp::routes_with_settings(test_settings()) + .expect("should build router from test settings") +} + +/// The settings [`test_router`] is built from. +fn test_settings() -> Settings { + Settings::from_toml( r#" [publisher] domain = "test-publisher.example.com" @@ -39,12 +45,31 @@ fn test_router() -> RouterService { ) .expect("should parse route test settings") // A loaded domain carries its policy. - .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) +} +/// A router whose settings add one operator header, which only +/// `FinalizeResponseMiddleware` writes, so a response carrying it passed +/// through that middleware. +fn router_writing_an_operator_header() -> RouterService { + let mut settings = test_settings(); + settings + .response_headers + .insert("X-Finalized".to_owned(), "1".to_owned()); TrustedServerApp::routes_with_settings(settings) .expect("should build router from test settings") } +/// The `x-geo-*` headers on `response`, by name. +fn geo_header_names(response: &Response) -> Vec { + response + .headers() + .keys() + .map(|name| name.as_str().to_owned()) + .filter(|name| name.starts_with("x-geo-")) + .collect() +} + async fn route(router: RouterService, req: Request) -> Response { router.oneshot(req).await.expect("should route request") } @@ -137,10 +162,11 @@ async fn health_route_returns_ok() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn finalize_middleware_injects_geo_header() { - // The X-Geo-Info-Available header is injected by FinalizeResponseMiddleware. - // Its absence on any response means the middleware was not wired. - let router = test_router(); +async fn finalize_middleware_writes_the_operators_headers_and_no_geo_header() { + // The operator's header is written by FinalizeResponseMiddleware alone, so + // its absence on a response means the middleware was not wired. No + // x-geo-* header is written there, because a serve middleware writes them. + let router = router_writing_an_operator_header(); let req = request_builder() .method("GET") @@ -150,17 +176,25 @@ async fn finalize_middleware_injects_geo_header() { let resp = route(router, req).await; + assert_eq!( + resp.headers() + .get("x-finalized") + .and_then(|value| value.to_str().ok()), + Some("1"), + "FinalizeResponseMiddleware must write the operator's headers on every response" + ); assert!( - resp.headers().contains_key("x-geo-info-available"), - "FinalizeResponseMiddleware must inject X-Geo-Info-Available on every response" + geo_header_names(&resp).is_empty(), + "no adapter writes an x-geo-* header: {:?}", + geo_header_names(&resp) ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn the_middleware_chain_runs_for_a_routed_request() { - // Asserts that the finalizing middleware ran (X-Geo-Info-Available present) - // and that the route is actually reached (status != 404). - let router = test_router(); + // Asserts that the finalizing middleware ran (the operator's header + // present) and that the route is actually reached (status != 404). + let router = router_writing_an_operator_header(); let req = request_builder() .method("POST") @@ -172,8 +206,8 @@ async fn the_middleware_chain_runs_for_a_routed_request() { let resp = route(router, req).await; assert!( - resp.headers().contains_key("x-geo-info-available"), - "middleware chain must inject X-Geo-Info-Available" + resp.headers().contains_key("x-finalized"), + "middleware chain must write the operator's headers" ); assert_ne!( resp.status().as_u16(), @@ -1111,6 +1145,25 @@ fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { ); } +/// `[geo] module = "platform"` selects the host's own geo lookup, which Spin +/// does not have, so startup refuses it rather than come up locating no +/// request with the single-jurisdiction check skipped. +#[test] +fn selecting_the_platform_geo_lookup_fails_at_startup() { + let mut settings = test_settings(); + settings.geo.module = Some("platform".to_owned()); + + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("should refuse the host's geo lookup on a host that has none"); + let message = error.to_string(); + + assert!( + message.contains(r#"`[geo] module = "platform"`"#) && message.contains("has no geo lookup"), + "should name the setting and say the host has no geo lookup: {message}" + ); +} + /// Regression test: a Next.js navigation with a pending auction must buffer to /// the structural body close. The Flight payload carries a literal ``, so /// a parser-blind seam would inject bids early and split the RSC data. diff --git a/crates/trusted-server-cli/src/commands/keys.rs b/crates/trusted-server-cli/src/commands/keys.rs index 9c3dc465aa..e613d3d77a 100644 --- a/crates/trusted-server-cli/src/commands/keys.rs +++ b/crates/trusted-server-cli/src/commands/keys.rs @@ -18,8 +18,8 @@ use error_stack::Report; use trusted_server_core::permissions::PermissionPolicy; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, - UnavailableHttpClient, UnavailableKvStore, + PlatformConfigStore, PlatformError, PlatformGeoModule, PlatformSecretStore, RuntimeServices, + StoreId, StoreName, UnavailableHttpClient, UnavailableKvStore, }; use trusted_server_core::request_signing::rotation::KeyRotationManager; use trusted_server_core::request_signing::{kid_is_creatable, kid_is_well_formed}; @@ -294,7 +294,10 @@ fn services(runner: Arc, stores: &StoreArgs) -> RuntimeService .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(NoBackend)) .http_client(Arc::new(UnavailableHttpClient)) - .geo(Arc::new(DisabledGeo)) + .geo(Arc::new(PlatformGeoModule::new( + "none", + Arc::new(DisabledGeo), + ))) .client_info(ClientInfo::default()) // The commands decide no request's permissions, and every services // graph carries a policy, so they carry the one that grants nothing. diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 7602c0caae..2a8c7392b0 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -333,10 +333,16 @@ pub async fn handle_auction( } let eids = resolve_auction_eids(&auction_kv_snapshot, registry, ec_context); - // Look up geo for device info. + // Look up geo for device info, through the module call a page's request + // makes, so the geo module sees the request as it does on a page. let geo = services .geo() - .lookup(services.client_info().client_ip, services) + .locate_request( + crate::module_context::ModuleRequest::of(&http_req, services.client_info()), + Some(http_req.headers()), + Some(settings), + services, + ) .await .unwrap_or_else(|e| { log::warn!("geo lookup failed: {e}"); @@ -708,7 +714,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .auction_telemetry_sink(telemetry_sink) .client_info(ClientInfo::default()) .build() @@ -1152,7 +1161,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::clone(&stub) as Arc) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build(); let consent = ConsentContext { diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 35fb9bd7ce..5e1924b890 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -6793,7 +6793,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) @@ -6859,7 +6862,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) @@ -6919,7 +6925,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) @@ -7007,7 +7016,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) @@ -7062,7 +7074,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index c1f26c152d..9aa5a5dd9b 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -29,13 +29,6 @@ pub const HEADER_X_TS_JS_ASSET_PROXY: HeaderName = HeaderName::from_static("x-ts pub const HEADER_X_CONSENT_ADVERTISING: HeaderName = HeaderName::from_static("x-consent-advertising"); pub const HEADER_X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for"); -pub const HEADER_X_GEO_CITY: HeaderName = HeaderName::from_static("x-geo-city"); -pub const HEADER_X_GEO_CONTINENT: HeaderName = HeaderName::from_static("x-geo-continent"); -pub const HEADER_X_GEO_COORDINATES: HeaderName = HeaderName::from_static("x-geo-coordinates"); -pub const HEADER_X_GEO_COUNTRY: HeaderName = HeaderName::from_static("x-geo-country"); -pub const HEADER_X_GEO_INFO_AVAILABLE: HeaderName = HeaderName::from_static("x-geo-info-available"); -pub const HEADER_X_GEO_METRO_CODE: HeaderName = HeaderName::from_static("x-geo-metro-code"); -pub const HEADER_X_GEO_REGION: HeaderName = HeaderName::from_static("x-geo-region"); pub const HEADER_X_SUBJECT_ID: HeaderName = HeaderName::from_static("x-subject-id"); pub const HEADER_X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id"); pub const HEADER_X_COMPRESS_HINT: HeaderName = HeaderName::from_static("x-compress-hint"); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index f26ce865cf..0b46d3bb45 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -392,9 +392,18 @@ impl EcContext { req: &Request, services: &RuntimeServices, ) -> Result> { + // The geo module is called as the device module is, with the + // request's evidence, its settings and its services, so a module + // that answers location and device from one service can answer both + // from one call. let lookup = services .geo() - .lookup(services.client_info().client_ip, services) + .locate_request( + ModuleRequest::of(req, services.client_info()), + Some(req.headers()), + Some(settings), + services, + ) .await; let geo_info = match &lookup { Ok(info) => info.clone(), @@ -1971,7 +1980,10 @@ pub(crate) mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(crate::platform::test_support::NoopBackend)) .http_client(Arc::new(crate::platform::test_support::NoopHttpClient)) - .geo(Arc::new(ConfigReadingGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "config-reading", + Arc::new(ConfigReadingGeo), + ))) .client_info(crate::platform::ClientInfo::default()) .build(); @@ -1986,6 +1998,62 @@ pub(crate) mod tests { ); } + /// A geo module that answers from the request's User-Agent, which only a + /// module called with the request's evidence can read, the way a device + /// module is called. + #[derive(Debug)] + struct UserAgentReadingGeo; + + #[async_trait::async_trait(?Send)] + impl crate::platform::GeoModule for UserAgentReadingGeo { + fn id(&self) -> &'static str { + "testing.user-agent-geo" + } + + async fn locate( + &self, + call: crate::module_context::ModuleCall<'_>, + ) -> Result, Report> { + use crate::module_context::FromModuleContext; + + let evidence = <&dyn crate::evidence::RequestInfo>::from_module_context(&call) + .map_err(|_| Report::new(crate::platform::PlatformError::Geo))?; + let country = match evidence.header("user-agent") { + Some(agent) if agent.contains("Kiosk/1.0") => "FR", + _ => "ZZ", + }; + Ok(Some(GeoInfo { + country: country.to_owned(), + ..non_regulated_geo() + })) + } + } + + #[tokio::test] + async fn a_geo_module_is_called_with_the_request_as_evidence() { + let settings = create_test_settings(); + let req = create_test_request(&[("user-agent", "Mozilla/5.0 Kiosk/1.0")]); + let services = crate::platform::test_support::test_services_builder() + .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) + .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(crate::platform::test_support::NoopBackend)) + .http_client(Arc::new(crate::platform::test_support::NoopHttpClient)) + .geo(Arc::new(UserAgentReadingGeo)) + .client_info(crate::platform::ClientInfo::default()) + .build(); + + let ec = EcContext::read_from_request_resolving_geo(&settings, &req, &services) + .await + .expect("should read EC context"); + + assert_eq!( + ec.geo_info().map(|info| info.country.as_str()), + Some("FR"), + "the country can only be FR if the module read the User-Agent from the call's evidence" + ); + } + /// A module that derives its identifier from a value it reads out of the /// config store at generate time, which is the whole point of handing modules /// the platform services. Nothing about the value is known when the diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs index 9bb2456b3a..fea625016d 100644 --- a/crates/trusted-server-core/src/ec/resolve.rs +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -1349,7 +1349,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .permission_signal_modules(modules.into()) .build() diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 22a37ad2c0..3576e7478e 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -1,216 +1,7 @@ -//! Geographic location utilities for the trusted server. +//! The reader's location, the platform-neutral [`GeoInfo`] that a geo module +//! resolves for a request, re-exported from [`crate::platform`]. //! -//! This module provides response-header injection for the platform-neutral -//! [`GeoInfo`] type. -//! -//! The [`GeoInfo`] data type is defined in [`crate::platform`] as platform- -//! neutral data; this module re-exports it and adds helper methods for HTTP -//! response header injection. - -use edgezero_core::body::Body as EdgeBody; -use http::{HeaderValue, Response}; +//! Core writes no part of it into a response. A serve middleware that a +//! deployment places does that, from the location the request resolved. pub use crate::platform::GeoInfo; - -use crate::constants::{ - HEADER_X_GEO_CITY, HEADER_X_GEO_CONTINENT, HEADER_X_GEO_COORDINATES, HEADER_X_GEO_COUNTRY, - HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_GEO_METRO_CODE, HEADER_X_GEO_REGION, -}; - -impl GeoInfo { - /// Sets geo information headers on the response. - /// - /// Adds `x-geo-city`, `x-geo-country`, `x-geo-continent`, `x-geo-coordinates`, - /// `x-geo-metro-code`, `x-geo-region` (when available), and - /// `x-geo-info-available: true` to the given response. - pub fn set_response_headers(&self, response: &mut Response) { - let headers = response.headers_mut(); - - insert_geo_header(headers, HEADER_X_GEO_CITY, &self.city); - insert_geo_header(headers, HEADER_X_GEO_COUNTRY, &self.country); - insert_geo_header(headers, HEADER_X_GEO_CONTINENT, &self.continent); - insert_geo_header( - headers, - HEADER_X_GEO_COORDINATES, - &self.coordinates_string(), - ); - if self.has_metro_code() { - let metro_code = self.metro_code.to_string(); - insert_geo_header(headers, HEADER_X_GEO_METRO_CODE, &metro_code); - } - if let Some(ref region) = self.region { - insert_geo_header(headers, HEADER_X_GEO_REGION, region); - } - headers.insert( - HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static("true"), - ); - } -} - -fn insert_geo_header(headers: &mut http::HeaderMap, name: http::header::HeaderName, value: &str) { - match HeaderValue::from_str(value) { - Ok(header_value) => { - headers.insert(name, header_value); - } - Err(_) => { - log::warn!("Skipping invalid geo header value for {}", name.as_str()); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use edgezero_core::body::Body as EdgeBody; - use http::Response as HttpResponse; - - fn sample_geo_info() -> GeoInfo { - GeoInfo { - city: "San Francisco".to_string(), - country: "US".to_string(), - continent: "NorthAmerica".to_string(), - latitude: 37.7749, - longitude: -122.4194, - metro_code: 807, - region: Some("CA".to_string()), - asn: None, - } - } - - fn build_response() -> HttpResponse { - HttpResponse::builder() - .status(http::StatusCode::OK) - .body(EdgeBody::empty()) - .expect("should build response") - } - - #[test] - fn set_response_headers_sets_all_geo_headers() { - let geo = sample_geo_info(); - let mut response = build_response(); - - geo.set_response_headers(&mut response); - - assert_eq!( - response - .headers() - .get(HEADER_X_GEO_CITY) - .expect("should have city header") - .to_str() - .expect("should be valid str"), - "San Francisco", - "should set city header" - ); - assert_eq!( - response - .headers() - .get(HEADER_X_GEO_COUNTRY) - .expect("should have country header") - .to_str() - .expect("should be valid str"), - "US", - "should set country header" - ); - assert_eq!( - response - .headers() - .get(HEADER_X_GEO_CONTINENT) - .expect("should have continent header") - .to_str() - .expect("should be valid str"), - "NorthAmerica", - "should set continent header" - ); - assert_eq!( - response - .headers() - .get(HEADER_X_GEO_COORDINATES) - .expect("should have coordinates header") - .to_str() - .expect("should be valid str"), - "37.7749,-122.4194", - "should set coordinates header" - ); - assert_eq!( - response - .headers() - .get(HEADER_X_GEO_METRO_CODE) - .expect("should have metro code header") - .to_str() - .expect("should be valid str"), - "807", - "should set metro code header" - ); - assert_eq!( - response - .headers() - .get(HEADER_X_GEO_REGION) - .expect("should have region header") - .to_str() - .expect("should be valid str"), - "CA", - "should set region header" - ); - assert_eq!( - response - .headers() - .get(HEADER_X_GEO_INFO_AVAILABLE) - .expect("should have info available header") - .to_str() - .expect("should be valid str"), - "true", - "should set geo info available to true" - ); - } - - #[test] - fn set_response_headers_omits_metro_code_when_zero() { - let geo = GeoInfo { - metro_code: 0, - ..sample_geo_info() - }; - let mut response = build_response(); - - geo.set_response_headers(&mut response); - - assert!( - response.headers().get(HEADER_X_GEO_METRO_CODE).is_none(), - "should not set metro code header when metro_code is 0" - ); - assert!( - response.headers().get(HEADER_X_GEO_CITY).is_some(), - "should still set city header" - ); - } - - #[test] - fn set_response_headers_omits_region_when_none() { - let geo = GeoInfo { - region: None, - ..sample_geo_info() - }; - let mut response = build_response(); - - geo.set_response_headers(&mut response); - - assert!( - response.headers().get(HEADER_X_GEO_REGION).is_none(), - "should not set region header when region is None" - ); - assert!( - response.headers().get(HEADER_X_GEO_CITY).is_some(), - "should still set city header" - ); - assert_eq!( - response - .headers() - .get(HEADER_X_GEO_INFO_AVAILABLE) - .expect("should have info available header") - .to_str() - .expect("should be valid str"), - "true", - "should still set geo info available to true" - ); - } -} diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index d9f53923dd..528abffe55 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -20,7 +20,9 @@ use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; use crate::middleware::{Middleware, MiddlewareChain, MiddlewarePhase, PhaseEntries}; use crate::module_context::{ModuleCall, ModuleContext, ModuleRequest, ResolvedRequest}; -use crate::platform::{DisabledGeo, PlatformGeo, PlatformIdentity, RuntimeServices}; +use crate::platform::{ + DisabledGeo, GeoModule, PlatformGeoModule, PlatformIdentity, RuntimeServices, +}; use crate::prefix::{PrefixModule, Prefixes, SelectedPrefixModule}; use crate::robots_txt::{ALLOW_ALL, REFUSE_ALL, RobotsTxtContributor}; use crate::settings::Settings; @@ -655,7 +657,7 @@ pub struct IntegrationRegistration { /// /// Declaring one does not make it active, because the module is only asked /// to resolve location when `[geo] module` names it. - pub geo_module: Option<(&'static str, Arc)>, + pub geo_module: Option<(&'static str, Arc)>, /// Edge Cookie module this registration supplies, with the name /// `[ec] module` selects it by. /// @@ -764,7 +766,7 @@ impl IntegrationRegistrationBuilder { /// `[geo] module` names it, and a module the selector does not choose is /// logged as a warning when the registry is built. #[must_use] - pub fn with_geo_module(mut self, name: &'static str, module: Arc) -> Self { + pub fn with_geo_module(mut self, name: &'static str, module: Arc) -> Self { self.registration.geo_module = Some((name, module)); self } @@ -914,7 +916,7 @@ struct IntegrationRegistryInner { // Geo modules the running registrations supply, each with the name // `[geo] module` selects it by, in registration order. Declaring one does // not activate it. - geo_modules: Vec<(&'static str, Arc)>, + geo_modules: Vec<(&'static str, Arc)>, // Edge Cookie modules the running registrations supply, each with its // name, in registration order. `[ec] module` picks at most one of them. ec_modules: Vec<(&'static str, Arc)>, @@ -933,7 +935,7 @@ struct IntegrationRegistryInner { // The module `[geo] module` resolved to, or `None` when the selector // is `platform` and the adapter's own host lookup stands. Unset and `none` // both resolve the disabled module. - geo_module: Option>, + geo_module: Option>, ec_module: Option>, device_module: Option>, // The prefix modules the running registrations supply, each with its @@ -1371,13 +1373,16 @@ fn check_robots_txt_selection( fn resolve_geo_module( settings: &Settings, inner: &IntegrationRegistryInner, -) -> Result>, Report> { +) -> Result>, Report> { let selector = settings.geo.module.as_deref(); let resolved = match selector { // Unset resolves nothing and makes no host geo call, so a default // deployment is not tied to any host geo service. `none` spells the // same choice explicitly. - None | Some(GEO_MODULE_NONE) => Some(Arc::new(DisabledGeo) as Arc), + None | Some(GEO_MODULE_NONE) => Some(Arc::new(PlatformGeoModule::new( + GEO_MODULE_NONE, + Arc::new(DisabledGeo), + )) as Arc), // `platform` opts in to the adapter's own host lookup, so the registry // supplies nothing and the adapter's module stands. Some(GEO_MODULE_PLATFORM) => None, @@ -2037,7 +2042,7 @@ impl IntegrationRegistry { /// is `platform` and the adapter's own host lookup stands. Unset and `none` /// both resolve the disabled module. #[must_use] - pub fn geo_module(&self) -> Option> { + pub fn geo_module(&self) -> Option> { self.inner.geo_module.clone() } @@ -6082,12 +6087,34 @@ mod tests { const GEO_PROBE_MODULE: &str = "testing.geo-probe"; /// Builds a `geo-probe` registration that declares a geo module. + /// Calls a geo module as a request from no address does: with the + /// services and no client, so a platform lookup is asked about nothing. + async fn locate_without_a_client( + module: &dyn GeoModule, + ) -> Result, Report> { + let services = noop_services(); + let method = http::Method::GET; + let context = crate::module_context::ModuleContext::new( + crate::module_context::ModuleRequest::new(&method, "publisher.example", "https", "/"), + ) + .with_services(&services); + module + .locate(context.call(module.id(), module.required_permissions())) + .await + } + fn geo_probe_registration( _settings: &Settings, ) -> Result, Report> { Ok(Some( IntegrationRegistration::builder("geo-probe") - .with_geo_module(GEO_PROBE_MODULE, Arc::new(FixedCountryGeo)) + .with_geo_module( + GEO_PROBE_MODULE, + Arc::new(PlatformGeoModule::new( + GEO_PROBE_MODULE, + Arc::new(FixedCountryGeo), + )), + ) .build(), )) } @@ -6397,8 +6424,7 @@ mod tests { let module = registry .geo_module() .expect("should resolve the module's geo module"); - let resolved = module - .lookup(None, &noop_services()) + let resolved = locate_without_a_client(&*module) .await .expect("should look up without failing") .expect("should resolve a location"); @@ -6419,8 +6445,7 @@ mod tests { .geo_module() .expect("should resolve the disabled geo module"); assert!( - module - .lookup(None, &noop_services()) + locate_without_a_client(&*module) .await .expect("should look up without failing") .is_none(), @@ -6447,8 +6472,7 @@ mod tests { .geo_module() .expect("an unset selector should resolve the disabled module, not nothing"); assert!( - module - .lookup(None, &noop_services()) + locate_without_a_client(&*module) .await .expect("should look up without failing") .is_none(), @@ -6664,7 +6688,13 @@ mod tests { ) -> Result, Report> { Ok(Some( IntegrationRegistration::builder("example-vendor") - .with_geo_module("geo.example", Arc::new(FixedCountryGeo)) + .with_geo_module( + "geo.example", + Arc::new(PlatformGeoModule::new( + "geo.example", + Arc::new(FixedCountryGeo), + )), + ) .with_device_module("device.example", Arc::new(ExampleDevice)) .with_ec_module("edgecookie.example", Arc::new(ExampleEc)) .build(), @@ -6837,7 +6867,13 @@ mod tests { ) -> Result, Report> { Ok(Some( IntegrationRegistration::builder("geo-second") - .with_geo_module(GEO_PROBE_MODULE, Arc::new(FixedCountryGeo)) + .with_geo_module( + GEO_PROBE_MODULE, + Arc::new(PlatformGeoModule::new( + GEO_PROBE_MODULE, + Arc::new(FixedCountryGeo), + )), + ) .build(), )) } diff --git a/crates/trusted-server-core/src/platform/geo.rs b/crates/trusted-server-core/src/platform/geo.rs index c8077215dd..af41c67c90 100644 --- a/crates/trusted-server-core/src/platform/geo.rs +++ b/crates/trusted-server-core/src/platform/geo.rs @@ -1,8 +1,14 @@ use std::net::IpAddr; +use std::sync::Arc; use error_stack::Report; +use http::HeaderMap; -use super::{GeoInfo, PlatformError, PlatformGeo, RuntimeServices}; +use super::{ClientInfo, GeoInfo, GeoModule, PlatformError, PlatformGeo, RuntimeServices}; +use crate::evidence::BorrowedRequestInfo; +use crate::module_context::{FromModuleContext, ModuleCall, ModuleContext, ModuleRequest}; +use crate::permissions::PermissionSet; +use crate::settings::Settings; /// A geo module that resolves nothing. /// @@ -28,3 +34,254 @@ impl PlatformGeo for DisabledGeo { Ok(None) } } + +/// A [`PlatformGeo`] called as a [`GeoModule`]: the client address comes from +/// the call's client metadata and the services from the call, and the +/// platform lookup answers. A call carrying no services answers nothing. +pub struct PlatformGeoModule { + name: &'static str, + platform: Arc, +} + +impl PlatformGeoModule { + /// Calls `platform` as the module named `name`. + #[must_use] + pub fn new(name: &'static str, platform: Arc) -> Self { + Self { name, platform } + } +} + +impl std::fmt::Debug for PlatformGeoModule { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlatformGeoModule") + .field("name", &self.name) + .finish_non_exhaustive() + } +} + +#[async_trait::async_trait(?Send)] +impl GeoModule for PlatformGeoModule { + fn id(&self) -> &'static str { + self.name + } + + async fn locate(&self, call: ModuleCall<'_>) -> Result, Report> { + let Ok(services) = <&RuntimeServices>::from_module_context(&call) else { + return Ok(None); + }; + let client_ip = <&ClientInfo>::from_module_context(&call) + .ok() + .and_then(|client| client.client_ip); + self.platform.lookup(client_ip, services).await + } + + fn required_permissions(&self) -> PermissionSet { + self.platform.required_permissions() + } +} + +impl dyn GeoModule + '_ { + /// Calls this module for `request`, with the call every geo call carries: + /// the request as evidence, from `headers` and the client address + /// `services` holds, the `settings` when the caller has them, the + /// services and the client. One place builds the call, so a module sees + /// the same evidence on every path that asks for a location. + /// + /// `headers` is `None` where the caller has no request headers, and a + /// module then sees the address alone. + /// + /// # Errors + /// + /// Whatever the module's [`GeoModule::locate`] returns. + pub async fn locate_request( + &self, + request: ModuleRequest<'_>, + headers: Option<&HeaderMap>, + settings: Option<&Settings>, + services: &RuntimeServices, + ) -> Result, Report> { + let client = services.client_info(); + let address = client + .client_ip + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let evidence = BorrowedRequestInfo::new(&address, headers) + .with_request_target(request.path(), request.query()); + let mut context = ModuleContext::new(request) + .with_evidence(&evidence) + .with_services(services) + .with_client(client); + if let Some(settings) = settings { + context = context.with_settings(settings); + } + self.locate(context.call(self.id(), self.required_permissions())) + .await + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + use std::sync::Mutex; + + use http::Method; + + use super::*; + use crate::module_context::{ModuleContext, ModuleRequest}; + use crate::permissions::PermissionSet; + use crate::platform::test_support::noop_services; + + /// A platform lookup that records the address it was asked about. + #[derive(Default)] + struct Recording { + asked: Mutex>>, + } + + #[async_trait::async_trait(?Send)] + impl PlatformGeo for Recording { + async fn lookup( + &self, + client_ip: Option, + _services: &RuntimeServices, + ) -> Result, Report> { + self.asked.lock().expect("lock").push(client_ip); + Ok(None) + } + } + + #[tokio::test] + async fn the_platform_lookup_is_given_the_address_the_call_carries() { + let recording = Arc::new(Recording::default()); + let module = + PlatformGeoModule::new("platform", Arc::clone(&recording) as Arc); + let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)); + let client = ClientInfo { + client_ip: Some(address), + ..ClientInfo::default() + }; + let services = noop_services().with_client_info(client.clone()); + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new( + &method, + "publisher.example", + "https", + "/", + )) + .with_services(&services) + .with_client(&client); + + let located = module + .locate(context.call(module.id(), PermissionSet::none())) + .await + .expect("the platform answers"); + + assert!(located.is_none(), "the recording platform answers nothing"); + assert_eq!(*recording.asked.lock().expect("lock"), vec![Some(address)]); + assert_eq!(module.id(), "platform"); + } + + #[tokio::test] + async fn a_call_without_services_asks_the_platform_nothing() { + let recording = Arc::new(Recording::default()); + let module = + PlatformGeoModule::new("platform", Arc::clone(&recording) as Arc); + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new( + &method, + "publisher.example", + "https", + "/", + )); + + let located = module + .locate(context.call(module.id(), PermissionSet::none())) + .await + .expect("no services is not an error"); + + assert!(located.is_none()); + assert!(recording.asked.lock().expect("lock").is_empty()); + } + + /// A module that answers from the evidence the call carries: the address + /// and the User-Agent header. + struct EvidenceReadingGeo; + + #[async_trait::async_trait(?Send)] + impl GeoModule for EvidenceReadingGeo { + fn id(&self) -> &'static str { + "testing.evidence-geo" + } + + async fn locate( + &self, + call: ModuleCall<'_>, + ) -> Result, Report> { + let evidence = <&dyn crate::evidence::RequestInfo>::from_module_context(&call) + .map_err(|_| Report::new(PlatformError::Geo))?; + let country = match evidence.header("user-agent") { + Some(agent) if agent.contains("Kiosk/1.0") => "FR", + _ => "ZZ", + }; + Ok(Some(GeoInfo { + city: String::new(), + country: country.to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some(evidence.client_ip().to_owned()), + asn: None, + })) + } + } + + #[tokio::test] + async fn locate_request_hands_the_module_the_headers_and_the_client_address() { + let module: Arc = Arc::new(EvidenceReadingGeo); + let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)); + let services = noop_services().with_client_info(ClientInfo { + client_ip: Some(address), + ..ClientInfo::default() + }); + let method = Method::GET; + let mut headers = HeaderMap::new(); + headers.insert( + "user-agent", + "Mozilla/5.0 Kiosk/1.0".parse().expect("header"), + ); + let request = ModuleRequest::new(&method, "publisher.example", "https", "/"); + + let located = module + .locate_request(request, Some(&headers), None, &services) + .await + .expect("the module answers") + .expect("the module resolves a location"); + + assert_eq!(located.country, "FR", "the module read the User-Agent"); + assert_eq!( + located.region.as_deref(), + Some("203.0.113.7"), + "the module read the client address" + ); + } + + #[tokio::test] + async fn locate_request_without_headers_still_carries_the_address() { + let module: Arc = Arc::new(EvidenceReadingGeo); + let services = noop_services().with_client_info(ClientInfo { + client_ip: Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), + ..ClientInfo::default() + }); + let method = Method::GET; + let request = ModuleRequest::new(&method, "publisher.example", "https", "/"); + + let located = module + .locate_request(request, None, None, &services) + .await + .expect("the module answers") + .expect("the module resolves a location"); + + assert_eq!(located.country, "ZZ"); + assert_eq!(located.region.as_deref(), Some("203.0.113.7")); + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index b462361a3c..a4eedb97f2 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -68,7 +68,7 @@ pub use backend_naming::{ }; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; pub use error::PlatformError; -pub use geo::DisabledGeo; +pub use geo::{DisabledGeo, PlatformGeoModule}; pub use http::{ PlatformCacheIntent, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, UnavailableHttpClient, @@ -99,7 +99,9 @@ pub use template_cache::{ TemplateCookieValue, TemplateEntry, TemplateMetadata, TemplateMetadataEncodeError, UnavailableTemplateCache, VaryHeaderValues, VarySpec, reader_url_surrogate_key, }; -pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; +pub use traits::{ + GeoModule, PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore, +}; pub use types::{ ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, StoreName, @@ -133,10 +135,10 @@ use crate::settings::Settings; pub fn build_geo_module( settings: &Settings, host_default: Arc, -) -> Arc { +) -> Arc { match settings.geo.module.as_deref() { - Some("platform") => host_default, - _ => Arc::new(DisabledGeo), + Some("platform") => Arc::new(PlatformGeoModule::new("platform", host_default)), + _ => Arc::new(PlatformGeoModule::new("none", Arc::new(DisabledGeo))), } } @@ -233,9 +235,15 @@ mod tests { #[tokio::test] async fn runtime_services_geo_lookup_returns_none_for_no_ip() { let services = noop_services(); - let result = services - .geo() - .lookup(services.client_info().client_ip, &noop_services()) + let method = ::http::Method::GET; + let context = crate::module_context::ModuleContext::new( + crate::module_context::ModuleRequest::new(&method, "publisher.example", "https", "/"), + ) + .with_services(&services) + .with_client(services.client_info()); + let module = services.geo(); + let result = module + .locate(context.call(module.id(), module.required_permissions())) .await .expect("should not fail for noop geo with no ip"); assert!(result.is_none(), "should return None when no IP is present"); @@ -246,16 +254,25 @@ mod tests { let settings = Settings::default(); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_module(&settings, Arc::clone(&host)); - assert!( - !Arc::ptr_eq(&host, &selected), + assert_eq!( + selected.id(), + "none", "default settings should not use the host geo" ); + let client = ClientInfo { + client_ip: Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), + ..ClientInfo::default() + }; + let services = noop_services().with_client_info(client.clone()); + let method = ::http::Method::GET; + let context = crate::module_context::ModuleContext::new( + crate::module_context::ModuleRequest::new(&method, "publisher.example", "https", "/"), + ) + .with_services(&services) + .with_client(&client); assert!( selected - .lookup( - Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), - &noop_services() - ) + .locate(context.call(selected.id(), selected.required_permissions())) .await .expect("disabled geo lookup should not fail") .is_none(), @@ -269,8 +286,9 @@ mod tests { settings.geo.module = Some("none".to_owned()); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_module(&settings, Arc::clone(&host)); - assert!( - !Arc::ptr_eq(&host, &selected), + assert_eq!( + selected.id(), + "none", "module none should not use the host geo" ); } @@ -281,8 +299,9 @@ mod tests { settings.geo.module = Some("platform".to_owned()); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_module(&settings, Arc::clone(&host)); - assert!( - Arc::ptr_eq(&host, &selected), + assert_eq!( + selected.id(), + "platform", "the platform selector should use the host geo" ); } diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 7f39e096d8..63e47920a1 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -964,7 +964,10 @@ pub fn build_services_with_config_and_secret( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build() } @@ -980,7 +983,10 @@ pub fn build_services_with_config_and_secret_and_client_ip( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: Some(client_ip), ..ClientInfo::default() @@ -1017,7 +1023,10 @@ pub fn build_services_with_config( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build() } @@ -1033,6 +1042,7 @@ pub fn noop_services() -> RuntimeServices { /// This is the only way to reach the lookup-failure path, because the seam is /// what turns an `Err` into the requires-signal floor. pub fn build_services_with_geo(geo: Arc) -> RuntimeServices { + let geo: Arc = Arc::new(super::PlatformGeoModule::new("test", geo)); test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) @@ -1059,7 +1069,10 @@ pub fn noop_services_with_device_module( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build() .with_device_module(device_module) @@ -1209,7 +1222,10 @@ pub fn services_with_ec_module_and_config_store( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), ..ClientInfo::default() @@ -1234,7 +1250,10 @@ fn noop_services_with_ec_module_and_ip( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip, ..ClientInfo::default() @@ -1255,7 +1274,10 @@ pub fn noop_services_with_telemetry_sink( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .auction_telemetry_sink(auction_telemetry_sink) .client_info(ClientInfo::default()) .build() @@ -1285,7 +1307,10 @@ pub fn build_services_with_http_client_and_client_ip( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: Some(client_ip), ..ClientInfo::default() @@ -1300,7 +1325,10 @@ pub fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: Some(ip), ..ClientInfo::default() @@ -1329,7 +1357,10 @@ pub fn build_services_with_backend_and_http_client( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(backend) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: None, tls_protocol: None, @@ -1351,7 +1382,10 @@ pub fn build_services_with_config_secret_and_http_client( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build() } @@ -1367,7 +1401,10 @@ pub fn build_services_with_secret_http_client_and_client_ip( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index 47d7d9cf57..f3f2ce7e69 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -180,3 +180,39 @@ pub trait PlatformGeo: Send + Sync { crate::permissions::PermissionSet::none() } } + +/// A geo module as core calls it, with the request's module call, which +/// carries the request's evidence, the settings and the services as a device +/// module's call does, so a module whose one service answers location and +/// device from the same request can answer both from one call. A type that +/// resolves from the client address alone implements [`PlatformGeo`] and is +/// called through [`PlatformGeoModule`](super::PlatformGeoModule). +/// +/// Core builds the call in one place, `locate_request` on `dyn GeoModule`, +/// so every path that asks for a location hands the module the same +/// evidence. No path asks again when a response is finished. +/// +/// Uses `#[async_trait(?Send)]` for the same reason as [`PlatformGeo`]. +#[async_trait::async_trait(?Send)] +pub trait GeoModule: Send + Sync { + /// The name the module is called as, for the call and the logs. + fn id(&self) -> &'static str; + + /// Resolves the request's location, `None` when the module has no data + /// for it, with the country as an ISO 3166-1 alpha-2 code and the region + /// as the ISO 3166-2 subdivision code without the country prefix, as + /// [`PlatformGeo::lookup`] gives them. + /// + /// # Errors + /// + /// Returns [`PlatformError::Geo`] when the lookup fails unexpectedly. + async fn locate( + &self, + call: crate::module_context::ModuleCall<'_>, + ) -> Result, Report>; + + /// The permissions this module's data use requires. Empty by default. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } +} diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 16fc73a608..4e8ba8db7d 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -6,7 +6,7 @@ use std::time::Duration; use crate::auction::telemetry::{AuctionTelemetrySink, NoopAuctionTelemetrySink}; use super::{ - PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, + GeoModule, PlatformBackend, PlatformConfigStore, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, }; use crate::ec::device::DeviceModule; @@ -200,7 +200,7 @@ pub struct RuntimeServices { /// Outbound HTTP client abstraction. pub(crate) http_client: Arc, /// Geographic information lookup. - pub(crate) geo: Arc, + pub(crate) geo: Arc, /// Auction telemetry sink. pub(crate) auction_telemetry_sink: Arc, /// Per-request client metadata extracted at the entry point. @@ -336,9 +336,9 @@ impl RuntimeServices { &*self.http_client } - /// Returns the platform geo lookup service. + /// Returns the geo module the request is located by. #[must_use] - pub fn geo(&self) -> &dyn PlatformGeo { + pub fn geo(&self) -> &dyn GeoModule { &*self.geo } @@ -475,7 +475,7 @@ impl RuntimeServices { /// the host's own lookup without the rest of the runtime services graph /// being rebuilt. #[must_use] - pub fn with_geo(self, geo: Arc) -> Self { + pub fn with_geo(self, geo: Arc) -> Self { Self { geo, ..self } } @@ -623,7 +623,7 @@ pub struct RuntimeServicesBuilder { template_assembler: Option>, backend: Option>, http_client: Option>, - geo: Option>, + geo: Option>, auction_telemetry_sink: Option>, client_info: Option, host_signals: Option>, @@ -726,7 +726,7 @@ impl RuntimeServicesBuilder { /// Set the geo lookup implementation. #[must_use] - pub fn geo(mut self, geo: Arc) -> Self { + pub fn geo(mut self, geo: Arc) -> Self { self.geo = Some(geo); self } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 0156904dab..c5c75b6c27 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -11667,7 +11667,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .template_cache(cache) .build() @@ -11699,7 +11702,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .template_cache(cache) .auction_telemetry_sink(telemetry_sink) @@ -12337,7 +12343,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: Some(READER_IP.parse().expect("should parse the reader's IP")), tls_protocol: Some("TLSv1.3".to_owned()), @@ -14939,7 +14948,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(Arc::clone(&stub) as Arc) - .geo(Arc::new(StubGeo(user.geo_marker))) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "stub", + Arc::new(StubGeo(user.geo_marker)), + ))) .client_info(ClientInfo::default()) .template_cache( Arc::clone(&cache) as Arc @@ -18981,7 +18993,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .auction_telemetry_sink(telemetry_sink) .client_info(ClientInfo::default()) .build() @@ -29816,7 +29831,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .auction_telemetry_sink(telemetry_sink) .client_info(ClientInfo::default()) .build() diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index 2f02b58964..6a2d8e1133 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -505,7 +505,10 @@ pub(crate) mod tests { .kv_store(Arc::new(MemoryKvStore::default())) .backend(Arc::new(crate::platform::test_support::NoopBackend)) .http_client(Arc::new(StubHttpClient::new())) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .client_info(crate::platform::ClientInfo { client_ip: Some(std::net::IpAddr::from([203, 0, 113, 7])), tls_ja4: Some("t13d1516h2_reader".to_owned()), @@ -1095,7 +1098,10 @@ pub(crate) mod tests { .kv_store(Arc::new(crate::platform::UnavailableKvStore)) .backend(Arc::new(crate::platform::test_support::NoopBackend)) .http_client(Arc::new(StubHttpClient::new())) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .value_cache(Arc::clone(&value_cache) as Arc) .loading_caches(Arc::new(crate::platform::LoadingCaches::every(Arc::new( MemoryLoadingCache::default(), diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 4b32db6dca..495f42a683 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -685,7 +685,10 @@ pub mod nextjs_auction { .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(FixtureStore)) .http_client(client) - .geo(Arc::new(FixtureGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "fixture", + Arc::new(FixtureGeo), + ))) .client_info(ClientInfo::default()) .permission_policy(crate::permissions::PermissionPolicy::sample()) .build() diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index 5cf84e24e5..86c027157a 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -12,6 +12,24 @@ pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { app_config_envelope(origin_port, |_| Ok(())) } +/// Builds the integration app-config envelope for the Axum dev server. +/// +/// The fixture selects the host's geo lookup, which Viceroy answers and the +/// Axum dev server, having none, refuses at startup. With `[geo] module` +/// unset and single-jurisdiction operation acknowledged, the dev server +/// locates no request, as its own lookup did. +pub fn axum_app_config_envelope(origin_port: u16) -> TestResult { + app_config_envelope(origin_port, |table| { + let geo = table_mut(table, "geo")?; + geo.remove("module"); + geo.insert( + "assume_single_jurisdiction".to_string(), + toml::Value::Boolean(true), + ); + Ok(()) + }) +} + /// Builds the integration app-config envelope after `customize` edits the /// parsed fixture. fn app_config_envelope( diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index 3a0e8f9b84..7a0264301b 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -1,4 +1,4 @@ -use crate::common::config::integration_app_config_envelope; +use crate::common::config::axum_app_config_envelope; use crate::common::runtime::{ RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port, }; @@ -60,7 +60,7 @@ impl RuntimeEnvironment for AxumDevServer { let binary = self.binary_path(); let port = super::find_available_port().unwrap_or(AXUM_DEFAULT_PORT); - let app_config = integration_app_config_envelope(origin_port())?; + let app_config = axum_app_config_envelope(origin_port())?; let store_name = default_config_store_name(); let config_key = default_config_key(); let config_variable = config_env_var(store_name.as_ref(), &config_key); diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 12caa94efe..4abe689d8b 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -2,7 +2,7 @@ //! //! Sends identical requests to all host-runnable adapters and asserts that: //! - Response status codes match -//! - Critical headers (X-Geo-Info-Available) match +//! - No adapter writes an `x-geo-*` header on its own routes //! //! Fastly parity is verified via cargo test-fastly + Viceroy in CI. @@ -660,7 +660,7 @@ async fn verify_signature_route_parity() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn geo_header_parity_on_all_responses() { +async fn no_adapter_writes_a_geo_header_on_its_own_routes() { let routes_to_check: &[(&str, &str, &str)] = &[ ("GET", "/.well-known/trusted-server.json", ""), ("POST", "/auction", r#"{"adUnits":[]}"#), @@ -684,36 +684,24 @@ async fn geo_header_parity_on_all_responses() { spin_post_headers(path, body).await }; - assert!( - axum_headers.contains_key("x-geo-info-available"), - "Axum: {method} {path} (status={axum_status}) must have X-Geo-Info-Available" - ); - assert!( - cf_headers.contains_key("x-geo-info-available"), - "Cloudflare: {method} {path} (status={cf_status}) must have X-Geo-Info-Available" - ); - let axum_geo = axum_headers - .get("x-geo-info-available") - .expect("should have x-geo-info-available after assert") - .to_str() - .expect("should be valid UTF-8"); - let cf_geo = cf_headers - .get("x-geo-info-available") - .expect("should have x-geo-info-available after assert") - .to_str() - .expect("should be valid UTF-8"); - assert_eq!( - axum_geo, cf_geo, - "{method} {path}: X-Geo-Info-Available value must match across adapters \ - (axum={axum_geo:?} cf={cf_geo:?})" - ); - // Spin hardcodes X-Geo-Info-Available: false (no geo headers available in the - // Spin runtime). Value comparison against axum/cf would lock in a known asymmetry, - // so presence is the gate here. - assert!( - spin_headers.contains_key("x-geo-info-available"), - "Spin: {method} {path} (status={spin_status}) must have X-Geo-Info-Available" - ); + // The x-geo-* headers are written by a serve middleware on the pages + // an entry names it for, and never on a route of the server's own. + for (adapter, status, headers) in [ + ("Axum", axum_status, &axum_headers), + ("Cloudflare", cf_status, &cf_headers), + ("Spin", spin_status, &spin_headers), + ] { + let geo_headers: Vec<&str> = headers + .keys() + .map(http::HeaderName::as_str) + .filter(|name| name.starts_with("x-geo-")) + .collect(); + assert!( + geo_headers.is_empty(), + "{adapter}: {method} {path} (status={status}) must carry no x-geo-* header: \ + {geo_headers:?}" + ); + } } } diff --git a/crates/trusted-server-modules/Cargo.toml b/crates/trusted-server-modules/Cargo.toml index 5520109c1e..0474c7f13a 100644 --- a/crates/trusted-server-modules/Cargo.toml +++ b/crates/trusted-server-modules/Cargo.toml @@ -29,6 +29,7 @@ trusted-server-cmp-osano = { workspace = true } trusted-server-cmp-sourcepoint = { workspace = true } trusted-server-core = { workspace = true } trusted-server-framework-nextjs = { workspace = true } +trusted-server-headers-geo = { workspace = true } trusted-server-identity-lockr = { workspace = true } trusted-server-tag-google-tag = { workspace = true } trusted-server-tag-google-tag-manager = { workspace = true } diff --git a/crates/trusted-server-modules/src/lib.rs b/crates/trusted-server-modules/src/lib.rs index 218ec1804f..d91b8317e3 100644 --- a/crates/trusted-server-modules/src/lib.rs +++ b/crates/trusted-server-modules/src/lib.rs @@ -43,6 +43,7 @@ pub fn builders() -> Vec { trusted_server_ad_tag_google::builder(), trusted_server_ad_tag_google::diagnostics::builder(), trusted_server_ads_txt_sellers::builder(), + trusted_server_headers_geo::builder(), // Implementations `[demand]` and `[ad-server]` can name, which no // section selects. trusted_server_auction_protocol_openrtb::builder(), @@ -112,6 +113,7 @@ pub fn middleware() -> Vec { use trusted_server_core::robots_txt::middleware as robots_txt; use trusted_server_core::service_version; use trusted_server_framework_nextjs as nextjs; + use trusted_server_headers_geo as geo_headers; use trusted_server_identity_lockr as lockr; use trusted_server_tag_google_tag as google_tag; use trusted_server_tag_google_tag_manager as google_tag_manager; @@ -155,6 +157,7 @@ pub fn middleware() -> Vec { media_type: ads_txt_sellers::MEDIA_TYPE, name: ads_txt_sellers::MODULE, }, + row(geo_headers::builder().id(), Serve, geo_headers::MODULE), // Core makes the file `[robots-txt]` describes under the module's // own name, on the file's path alone. StockMiddleware { @@ -216,6 +219,7 @@ mod tests { include_str!("../../bot-protection/datadome/src/fixtures/page-change.settings.toml"), include_str!("../../ad-tag/google/src/fixtures/page-change.settings.toml"), include_str!("../../ads-txt/sellers/src/fixtures/sellers.settings.toml"), + include_str!("../../headers/geo/src/fixtures/geo-headers.settings.toml"), ]; #[test] @@ -498,6 +502,7 @@ middleware = [\"robots_txt\"] "ad-tag.google", "ad-tag.google.diagnostics", "ads-txt.sellers", + "headers.geo", "auction-protocol.openrtb", "auction.prebid-server", "auction.aps", diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index f5910a88b0..d69ccf20df 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -56,7 +56,7 @@ Native Axum dev/test adapter (native binary): | Feature | Axum dev server | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | KV store | Unavailable — synthetic-ID and consent routes degrade gracefully | -| Geo lookup | Always returns `None` | +| Geo lookup | None, and `[geo] module = "platform"` is refused at startup | | Auction fan-out ordering | Requests run concurrently via `tokio::spawn`; `select` returns first-to-complete but does not replicate Fastly's priority-queue tie-breaking | ### trusted-server-adapter-spin diff --git a/docs/guide/cloudflare.md b/docs/guide/cloudflare.md index 3c29b7e49c..a4316227f0 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -84,5 +84,11 @@ resolved. See the [API reference](./api-reference) for route-level behavior. The Worker sends no [auction telemetry](./telemetry#adapter-support), and warns at startup when `[analytics]` selects a module. +With `[geo] module = "platform"` the Worker reads the location from the `cf-*` +request headers and the region from `cf-region-code` alone, which Cloudflare's +Add visitor location headers managed transform adds. Without that header a US +visitor is placed in no state, so the rules `permissions.yaml` gives a US +state do not apply to them. + Compare the [Fastly](./fastly), [Spin](./spin), and [Axum](./axum-dev) adapter journeys. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 58f92584fe..fb0e180fff 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -869,7 +869,7 @@ TRUSTED_SERVER__DEVICE__MODULE=builtin ## Geo Configuration -Selects how a client IP is resolved into geolocation (country, region, coordinates), mirroring the Edge Cookie module selection. The resolved country also feeds the [permission model](/guide/permission-model). +Selects the module that resolves the request into geolocation (country, region, coordinates), mirroring the Edge Cookie module selection. The module is called with the request as a device module is, so a module may resolve from more than the client address. The resolved country also feeds the [permission model](/guide/permission-model). ### `[geo]` @@ -883,6 +883,8 @@ module, so it sits directly in `[geo]`. No module is the default, so a default deployment is not tied to any host geo service. A module from a crate at `crates/geo/` is written `` or `geo.`, and the crate's own module has to be selected in the section of its type as well. Naming a module this deployment does not run fails at startup, with the geo modules it does run. A failed geo lookup at request time resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. +**What `platform` selects.** On Fastly it is Fastly's geo lookup of the client address. On Cloudflare the location is read from the `cf-*` request headers, and the region from `cf-region-code` alone, which Cloudflare's Add visitor location headers managed transform adds. Without that header a US visitor is placed in no state, so the rules `permissions.yaml` gives a US state, and the opt-out signals they read, do not apply. The Axum dev server and Spin have no geo lookup, and refuse `platform` at startup naming the setting. + **Example**: ```toml @@ -890,6 +892,23 @@ No module is the default, so a default deployment is not tied to any host geo se module = "platform" ``` +**Location headers.** No response carries an `x-geo-*` header unless the +deployment selects `headers.geo` and names it in a `[[serve]]` entry. On the +responses the entry covers, the middleware then writes the location already +resolved for the request, being `x-geo-city`, `x-geo-country`, +`x-geo-continent`, `x-geo-coordinates`, `x-geo-metro-code` and `x-geo-region` +where the answer has them, and `x-geo-info-available` as `true` or `false`. +Selecting the module without such an entry is refused when the settings load. + +```toml +[headers] +modules = ["geo"] + +[[serve]] +media_type = "text/html" +middleware = ["headers.geo"] +``` + **Environment Override**: ```bash @@ -2096,6 +2115,7 @@ generated configuration places it in. | `ad-tag.google.loader` | `[[serve]]` | Writes the loader tag into the head, after the bootstrap, when `write_loader` is set, for a reader whose permissions allow storage on the device | | `ad-tag.google.diagnostics` | `[[serve]]` | For a request that activated diagnostics, writes the bootstrap ahead of the script bundle and the tag that loads the diagnostics module straight after it | | `ads-txt.sellers` | `[[fetch]]` | Writes the lines `[ads-txt.sellers]` gives at the end of the publisher's `ads.txt`, on `text/plain` alone | +| `headers.geo` | `[[serve]]` | Writes the location the request resolved as the `x-geo-*` response headers, or `x-geo-info-available: false` where none is known. `[headers] modules = ["geo"]` selects it | | `robots_txt` | `[[fetch]]` | Makes the `robots.txt` that `[robots-txt]` describes, in place of whatever the origin answers, on `text/plain` and at `/robots.txt` alone | | `service_version` | `[[serve]]` | Writes `X-TS-Version`, the git version the build compiled in, and `X-TS-Service-Version`, the platform's version number of the service, when each is known. Core supplies it, so no section selects it | diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index d7d980dfd2..f6bcfbba1a 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -230,7 +230,9 @@ headers before forwarding the request. ### Verify before trusting it -Geolocation response headers are the observable signal. From a client whose +Geolocation response headers are the observable signal, on a page the +`headers.geo` middleware is placed on (see +[Geo Configuration](/guide/configuration#geo-configuration)). From a client whose real location differs from the fronting POP, compare a request through the front door against one sent directly to the Compute service, and confirm `x-geo-city` and `x-geo-coordinates` agree. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 8a7c10dd20..84175a3a63 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -48,7 +48,7 @@ use trusted_server_core::middleware::{ }; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, - PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, + PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformGeoModule, PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableHttpClient, UnavailableKvStore, }; @@ -188,7 +188,7 @@ pub fn runtime_services() -> RuntimeServices { .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(FixtureBackend)) .http_client(Arc::new(UnavailableHttpClient)) - .geo(Arc::new(FixtureGeo)) + .geo(Arc::new(PlatformGeoModule::new("fixture", Arc::new(FixtureGeo)))) .client_info(ClientInfo::default()) .build() } @@ -464,21 +464,21 @@ this repository that leaves the declaration out. The build function returns an `IntegrationRegistration`, built with the builder every integration uses. -| Declaration | What it does | -| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `.with_proxy(...)` | Routes the paths the proxy declares | -| `.with_request_filter(...)` | Inspects a request and can turn it back before it reaches the origin | -| `.with_middleware(...)` | Offers a page change that an entry may place by the middleware's name | -| `.with_bundle_tag_attribute(name, value)` | Puts an attribute on the script bundle's tag, for a browser module that reads a setting from it | -| `.with_js_module(CarriedJsModule { source, sha256 })` | Carries the integration's own browser script, built outside `trusted-server-js` | -| `.with_deferred_js()` | Serves the script as its own `