diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8eedc8fa60..2778d303ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -94,6 +94,12 @@ jobs: - name: Run Axum adapter tests run: cargo test-axum + - name: Test native Geo without CloudFront + run: cargo test --locked -p trusted-server-adapter-axum --features geo-maxmind + + - name: Test both native Geo implementations + run: cargo test --locked -p trusted-server-adapter-axum --features geo-maxmind,geo-cloudfront + - name: Test template build digest inputs (native) run: cargo test -p trusted-server-core --test template_build_digest diff --git a/Cargo.lock b/Cargo.lock index c8807a37d2..35e71f0151 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2011,6 +2011,7 @@ dependencies = [ "async-stream", "async-trait", "axum", + "base16ct 1.0.0", "bytes", "ctor", "edgezero-adapter", @@ -2027,11 +2028,13 @@ dependencies = [ "ipnet", "libc", "log", + "maxminddb", "mime", "redb", "reqwest 0.13.4", "serde", "serde_json", + "sha2 0.11.1", "simple_logger", "thiserror 2.0.18", "tokio", @@ -4188,6 +4191,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + [[package]] name = "is-terminal" version = "0.4.17" @@ -4571,6 +4580,18 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" +[[package]] +name = "maxminddb" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b43d2458d977b610b04a3858e6504f06133663d303a1a0606601b530bfc9bc6" +dependencies = [ + "ipnetwork", + "memchr", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "maybe-async" version = "0.2.11" diff --git a/Dockerfile b/Dockerfile index b3c2a0afab..4905dee20f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ FROM rust:1.98.1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288 COPY --from=node /usr/local/ /usr/local/ WORKDIR /src COPY . . -# AWS providers and CloudFront Geo are default-off. Forward them explicitly. +# AWS providers and native Geo implementations are default-off. Forward them explicitly. ARG NATIVE_FEATURES="" # Optional existing build metadata input; .git never enters the build context. ARG TRUSTED_SERVER__GIT_VERSION="" @@ -19,8 +19,8 @@ feature_ifs=$IFS IFS=, for feature in $NATIVE_FEATURES; do case "$feature" in - aws-appconfig-agent|aws-secrets-manager|aws-dynamodb-ec|geo-cloudfront) ;; - *) echo 'Only the declared native AWS store and CloudFront Geo features are supported' >&2; exit 1 ;; + aws-appconfig-agent|aws-secrets-manager|aws-dynamodb-ec|geo-cloudfront|geo-maxmind) ;; + *) echo 'Only the declared native AWS store and Geo features are supported' >&2; exit 1 ;; esac done IFS=$feature_ifs diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index d31db35391..b3c66b1b16 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -24,6 +24,7 @@ aws-appconfig-agent = ["edgezero-adapter-axum/aws-appconfig-agent"] aws-secrets-manager = ["edgezero-adapter-axum/aws-secrets-manager"] aws-dynamodb-ec = ["dep:aws-config", "dep:aws-sdk-dynamodb"] geo-cloudfront = ["edgezero-adapter-axum/geo-cloudfront"] +geo-maxmind = ["edgezero-adapter-axum/geo-maxmind"] [dependencies] async-trait = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/native_geo.rs b/crates/trusted-server-adapter-axum/src/native_geo.rs index 1599b959cc..f830b80553 100644 --- a/crates/trusted-server-adapter-axum/src/native_geo.rs +++ b/crates/trusted-server-adapter-axum/src/native_geo.rs @@ -2,12 +2,16 @@ //! //! EdgeZero does not load a Geo environment or store binding. This optional TS //! boot profile selects its Rust options before store preparation and listening. -//! It contains a credential removal name, never the expected origin credential. +//! Local databases use checked reader IPs and the existing trusted proxy policy. +//! Protected assertions contain a credential removal name, never its value. use std::fmt; use std::fs; +use std::path::PathBuf; -use edgezero_adapter_axum::geo::{NativeGeoSelection, ProtectedCloudFrontAlb}; +use edgezero_adapter_axum::geo::{ + MaxMindGeoConfig, NativeGeoSelection, ProtectedCloudFrontAlb, ReaderIpExpectation, +}; use serde::Deserialize; /// Path to the separately delivered TS native Geo boot profile. @@ -53,6 +57,18 @@ enum GeoSelection { alb_peer_cidrs: Vec, origin_credential_header_name: String, }, + #[serde(rename = "maxmind")] + MaxMind { + database_path: PathBuf, + reader_ip_expectation: ReaderExpectation, + }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum ReaderExpectation { + PeerAllowed, + ForwardedRequired, } /// Capture the application-owned selection once before starting the runner. @@ -85,11 +101,25 @@ fn parse_selection(bytes: &str) -> Result ProtectedCloudFrontAlb::new(alb_peer_cidrs, &origin_credential_header_name) .map(|policy| NativeGeoSelection::CloudFront { policy }) .map_err(|_| NativeGeoProfileError::InvalidPolicy), + GeoSelection::MaxMind { + database_path, + reader_ip_expectation, + } => Ok(NativeGeoSelection::MaxMind { + config: MaxMindGeoConfig::new( + database_path, + match reader_ip_expectation { + ReaderExpectation::PeerAllowed => ReaderIpExpectation::PeerAllowed, + ReaderExpectation::ForwardedRequired => ReaderIpExpectation::ForwardedRequired, + }, + ), + }), } } #[cfg(test)] mod tests { + use std::path::Path; + use edgezero_adapter_axum::dev_server::AxumRunOptions; use edgezero_adapter_axum::geo::NativeGeoCapability; @@ -102,6 +132,71 @@ alb_peer_cidrs = ["127.0.0.1/32"] origin_credential_header_name = "x-origin-fixture-private-name" "#; + const MAXMIND_PROFILE: &str = r#"version = 1 +[geo] +provider = "maxmind" +database_path = "/geo/GeoLite2-City.mmdb" +reader_ip_expectation = "forwarded_required" +"#; + + #[test] + fn maxmind_profile_selects_local_database_and_explicit_reader_expectation() { + for (name, expected) in [ + ("forwarded_required", ReaderIpExpectation::ForwardedRequired), + ("peer_allowed", ReaderIpExpectation::PeerAllowed), + ] { + let selection = parse_selection(&MAXMIND_PROFILE.replace("forwarded_required", name)) + .expect("should parse local database selection"); + let NativeGeoSelection::MaxMind { config } = selection else { + unreachable!("should select MaxMind"); + }; + assert_eq!(config.path, Path::new("/geo/GeoLite2-City.mmdb")); + assert_eq!(config.expectation, expected); + } + } + + #[test] + fn maxmind_profile_rejects_missing_or_unknown_expectation_and_foreign_policy_fields() { + for bytes in [ + MAXMIND_PROFILE.replace("reader_ip_expectation = \"forwarded_required\"", ""), + MAXMIND_PROFILE.replace("forwarded_required", "automatic"), + MAXMIND_PROFILE.replace("database_path = \"/geo/GeoLite2-City.mmdb\"", ""), + format!("{MAXMIND_PROFILE}alb_peer_cidrs = ['127.0.0.1/32']\n"), + format!("{MAXMIND_PROFILE}origin_credential_header_name = 'x-origin-test'\n"), + ] { + assert!(matches!( + parse_selection(&bytes), + Err(NativeGeoProfileError::InvalidProfile) + )); + } + } + + #[test] + fn maxmind_capability_validates_compiled_provider_without_opening_database() { + let selection = parse_selection(MAXMIND_PROFILE) + .expect("should parse selection without opening a database"); + let options = AxumRunOptions::new("127.0.0.1:8787".parse().expect("should parse address")) + .expect("should construct options") + .with_geo(selection); + #[cfg(all(feature = "geo-maxmind", unix))] + assert!(matches!( + options + .geo_capability() + .expect("should validate available provider"), + NativeGeoCapability::Selected { + input: edgezero_adapter_axum::geo::NativeGeoInputContract::ReaderIp { + expectation: ReaderIpExpectation::ForwardedRequired + }, + .. + } + )); + #[cfg(not(all(feature = "geo-maxmind", unix)))] + assert!( + options.geo_capability().is_err(), + "should reject unavailable MaxMind" + ); + } + #[test] fn absent_profile_retains_off() { temp_env::with_var_unset(PROFILE_FILE_ENV, || { diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/geoip/README.md b/crates/trusted-server-adapter-axum/tests/fixtures/geoip/README.md new file mode 100644 index 0000000000..c8f3e9b42a --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/geoip/README.md @@ -0,0 +1,13 @@ +# Synthetic GeoIP fixtures + +These original Apache-2.0 test fixtures come from the pinned EdgeZero revision +`fda87556f5aab160711eb5244c1d1065a6bb48d6`, under +`crates/edgezero-adapter-axum/tests/fixtures/geoip/`. They contain no production +GeoLite data. EdgeZero's `scripts/generate_geoip_fixtures.mjs` regenerates them. + +- `city.mmdb` maps loopback and 192.0.2.1 to US/CA, and 192.0.2.2 to country-only GB. +- `country.mmdb` exercises country-only projection. +- `ipv6.mmdb` maps 2001:db8::/32 to GB/ENG. + +Loopback's synthetic location makes accidental proxy-IP lookup observable. +Real operator datasets stay outside this repository. diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/geoip/city.mmdb b/crates/trusted-server-adapter-axum/tests/fixtures/geoip/city.mmdb new file mode 100644 index 0000000000..2e1065b7f2 Binary files /dev/null and b/crates/trusted-server-adapter-axum/tests/fixtures/geoip/city.mmdb differ diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/geoip/country.mmdb b/crates/trusted-server-adapter-axum/tests/fixtures/geoip/country.mmdb new file mode 100644 index 0000000000..5b32057fc1 Binary files /dev/null and b/crates/trusted-server-adapter-axum/tests/fixtures/geoip/country.mmdb differ diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/geoip/ipv6.mmdb b/crates/trusted-server-adapter-axum/tests/fixtures/geoip/ipv6.mmdb new file mode 100644 index 0000000000..59bae29601 Binary files /dev/null and b/crates/trusted-server-adapter-axum/tests/fixtures/geoip/ipv6.mmdb differ diff --git a/crates/trusted-server-adapter-axum/tests/startup.rs b/crates/trusted-server-adapter-axum/tests/startup.rs index 09f4b2144a..8d09cb1c8f 100644 --- a/crates/trusted-server-adapter-axum/tests/startup.rs +++ b/crates/trusted-server-adapter-axum/tests/startup.rs @@ -60,6 +60,16 @@ impl ServerFixture { missing_secret: bool, short_passphrase: bool, profile: Option<&str>, + ) { + self.spawn_configured_with_env(missing_secret, short_passphrase, profile, &[]); + } + + fn spawn_configured_with_env( + &mut self, + missing_secret: bool, + short_passphrase: bool, + profile: Option<&str>, + environment: &[(&str, &str)], ) { let mut command = Command::new(env!("CARGO_BIN_EXE_trusted-server-axum")); command @@ -87,6 +97,7 @@ impl ServerFixture { ) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + command.envs(environment.iter().copied()); if let Some(profile) = profile { let path = self.root.join("native-geo.toml"); fs::write(&path, profile).expect("should write synthetic Geo profile"); @@ -150,6 +161,12 @@ impl ServerFixture { !diagnostic.contains("native-fixture-passphrase"), "should not expose resolved secrets" ); + for sensitive in ["private-database-name", "invalid-private-database-content"] { + assert!( + !diagnostic.contains(sensitive), + "should not expose database inputs" + ); + } return; } assert!( @@ -161,7 +178,11 @@ impl ServerFixture { } fn wait_ready(&mut self) -> String { - let deadline = Instant::now() + Duration::from_secs(10); + self.wait_ready_with_timeout(Duration::from_secs(10)) + } + + fn wait_ready_with_timeout(&mut self, timeout: Duration) -> String { + let deadline = Instant::now() + timeout; loop { if let Ok(response) = self.get("/health") { assert!( @@ -437,6 +458,265 @@ fn nonmatching_real_socket_peer_cannot_supply_geo() { assert!(!response.to_ascii_lowercase().contains("x-geo-country:")); } +#[test] +#[cfg(not(all(feature = "geo-maxmind", unix)))] +fn compiled_out_maxmind_fails_before_store_preparation_and_listen() { + let mut server = ServerFixture::new(None); + server.spawn_configured(false, false, Some( + "version = 1\n[geo]\nprovider = 'maxmind'\ndatabase_path = '/missing.mmdb'\nreader_ip_expectation = 'forwarded_required'" + )); + server.assert_failed_before_listen("maxmind implementation unavailable"); +} + +#[cfg(all(feature = "geo-maxmind", unix))] +mod maxmind { + use std::path::Path; + + use super::*; + + const CITY: &[u8] = include_bytes!("fixtures/geoip/city.mmdb"); + const COUNTRY: &[u8] = include_bytes!("fixtures/geoip/country.mmdb"); + const IPV6: &[u8] = include_bytes!("fixtures/geoip/ipv6.mmdb"); + const TRUSTED_XFF: &[(&str, &str)] = &[ + ( + "EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", + "127.0.0.1/32,198.51.100.8/32", + ), + ("EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", "x-forwarded"), + ]; + + fn profile(database: &Path, expectation: &str) -> String { + let mut geo = toml::Table::new(); + geo.insert("provider".to_owned(), "maxmind".into()); + geo.insert( + "database_path".to_owned(), + database + .to_str() + .expect("should use UTF-8 test path") + .into(), + ); + geo.insert("reader_ip_expectation".to_owned(), expectation.into()); + let mut profile = toml::Table::new(); + profile.insert("version".to_owned(), 1.into()); + profile.insert("geo".to_owned(), geo.into()); + toml::to_string(&profile).expect("should serialize native Geo profile") + } + + fn spawn(bytes: &[u8], expectation: &str, environment: &[(&str, &str)]) -> ServerFixture { + let mut server = ServerFixture::new(Some(ENVELOPE)); + let path = server.root.join("test.mmdb"); + fs::write(&path, bytes).expect("should write synthetic database"); + let profile = profile(&path, expectation); + server.spawn_configured_with_env(false, false, Some(&profile), environment); + server.wait_ready(); + server + } + + fn assert_location(response: &str, country: &str, region: Option<&str>) { + let lower = response.to_ascii_lowercase(); + assert!(lower.contains("x-geo-info-available: true"), "{response}"); + assert!( + lower.contains(&format!( + "x-geo-country: {}\r\n", + country.to_ascii_lowercase() + )), + "{response}" + ); + if let Some(region) = region { + assert!( + lower.contains(&format!( + "x-geo-region: {}\r\n", + region.to_ascii_lowercase() + )), + "{response}" + ); + } else { + assert!(!lower.contains("x-geo-region:"), "{response}"); + } + for absent in [ + "x-geo-city:", + "x-geo-coordinates:", + "x-geo-continent:", + "x-geo-metro-code:", + ] { + assert!( + !lower.contains(absent), + "should not invent unavailable detail: {response}" + ); + } + } + + fn assert_unavailable(response: &str) { + let lower = response.to_ascii_lowercase(); + assert!(lower.contains("x-geo-info-available: false"), "{response}"); + assert!(!lower.contains("x-geo-country:"), "{response}"); + assert!(!lower.contains("x-geo-region:"), "{response}"); + } + + #[test] + fn direct_reader_uses_snapshot_and_ignores_geographic_and_untrusted_forwarding_headers() { + let server = spawn(CITY, "peer_allowed", &[]); + let headers = "CloudFront-Viewer-Country: GB\r\nCloudFront-Viewer-Country-Region: ENG\r\nX-Forwarded-For: 192.0.2.2\r\nFly-Client-IP: 192.0.2.2\r\n"; + assert_location( + &server + .get_with_headers("/health", headers) + .expect("should reach health"), + "US", + Some("CA"), + ); + fs::remove_file(server.root.join("test.mmdb")) + .expect("should remove source after readiness"); + assert_location( + &server + .get("/health") + .expect("should retain prepared snapshot"), + "US", + Some("CA"), + ); + } + + #[test] + fn forwarded_reader_uses_checked_chain_and_finalizes_error_routes() { + let server = spawn(CITY, "forwarded_required", TRUSTED_XFF); + // A deployment-controlled rightmost hop resembles proxy chains that append an app address. + // The forged leftmost country-only reader must not override the first untrusted reader. + let headers = "X-Forwarded-For: 192.0.2.2, 192.0.2.1, 198.51.100.8\r\nCloudFront-Viewer-Country: GB\r\n"; + for (path, status) in [ + ("/health", "200"), + ("/_ts/admin/ec", "401"), + ("/static/tsjs=0000000000000000", "404"), + ] { + let response = server + .get_with_headers(path, headers) + .expect("should reach managed route"); + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "{response}" + ); + assert_location(&response, "US", Some("CA")); + } + let country_only = server + .get_with_headers("/health", "X-Forwarded-For: 192.0.2.2\r\n") + .expect("should resolve a different reader"); + assert_location(&country_only, "GB", None); + for headers in [ + "", + "X-Forwarded-For: malformed\r\n", + "X-Forwarded-For: 127.0.0.1\r\n", + "CloudFront-Viewer-Country: US\r\nFly-Client-IP: 192.0.2.1\r\n", + "X-Forwarded-For: 203.0.113.1\r\n", + ] { + assert_unavailable( + &server + .get_with_headers("/health", headers) + .expect("should preserve health"), + ); + } + } + + #[test] + fn forwarded_reader_requires_actual_proxy_peer_trust() { + let server = spawn( + CITY, + "forwarded_required", + &[ + ("EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", "198.51.100.8/32"), + ("EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", "x-forwarded"), + ], + ); + assert_unavailable( + &server + .get_with_headers("/health", "X-Forwarded-For: 192.0.2.1, 198.51.100.8\r\n") + .expect("should ignore untrusted peer"), + ); + } + + #[test] + fn standard_forwarded_supports_ipv6_readers() { + let server = spawn( + IPV6, + "forwarded_required", + &[ + ("EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", "127.0.0.1/32"), + ("EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", "forwarded"), + ], + ); + assert_location( + &server + .get_with_headers( + "/health", + "Forwarded: for=\"[2001:db8::1234]\";proto=https\r\n", + ) + .expect("should resolve IPv6 reader"), + "GB", + Some("ENG"), + ); + } + + #[test] + fn country_database_never_supplies_a_subdivision() { + let server = spawn(COUNTRY, "peer_allowed", &[]); + assert_location( + &server.get("/health").expect("should use country snapshot"), + "US", + None, + ); + } + + #[test] + fn missing_or_corrupt_database_fails_before_listen() { + for corrupt in [false, true] { + let mut server = ServerFixture::new(Some(ENVELOPE)); + let path = server.root.join("private-database-name.mmdb"); + if corrupt { + fs::write(&path, b"invalid-private-database-content") + .expect("should write invalid database"); + } + server.spawn_configured(false, false, Some(&profile(&path, "peer_allowed"))); + server.assert_failed_before_listen(if corrupt { + "native_geo: invalid database" + } else { + "native_geo: database unavailable" + }); + } + } + + #[test] + #[ignore = "requires an operator GeoLite snapshot and explicit expected location"] + fn operator_geolite_snapshot_resolves_checked_reader() { + let database = std::env::var("TS_TEST_GEOLITE_DATABASE") + .expect("should supply TS_TEST_GEOLITE_DATABASE"); + let reader = std::env::var("TS_TEST_GEO_IP").expect("should supply TS_TEST_GEO_IP"); + let country = + std::env::var("TS_TEST_GEO_COUNTRY").expect("should supply TS_TEST_GEO_COUNTRY"); + let region = std::env::var("TS_TEST_GEO_REGION").ok(); + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn_configured_with_env( + false, + false, + Some(&profile(Path::new(&database), "forwarded_required")), + TRUSTED_XFF, + ); + // Production-size snapshots take longer to validate than synthetic fixtures. + server.wait_ready_with_timeout(Duration::from_secs(120)); + assert_location( + &server + .get_with_headers( + "/health", + &format!("X-Forwarded-For: {reader}, 198.51.100.8\r\n"), + ) + .expect("should resolve operator snapshot"), + &country, + region.as_deref(), + ); + assert_unavailable( + &server + .get("/health") + .expect("should not geolocate the proxy"), + ); + } +} + #[test] fn handwritten_store_metadata_matches_the_manifest() { let manifest = include_str!("../../../edgezero.toml"); diff --git a/docs/guide/native-rc-demo.md b/docs/guide/native-rc-demo.md index d86e0b17a1..adbb403f05 100644 --- a/docs/guide/native-rc-demo.md +++ b/docs/guide/native-rc-demo.md @@ -5,9 +5,9 @@ deployment. It starts from Trusted Server main `6ef07ae52bec3e695ec57fe090243b1a281c314f` and pins EdgeZero to `fda87556f5aab160711eb5244c1d1065a6bb48d6`. PRs #1410, #1411 and #1412, including their #777 module/configuration/permission stack, are excluded. -The newer EdgeZero revision includes optional local GeoIP support, but this -candidate retains its existing Off/CloudFront selections. MaxMind is not enabled -or configured by the dependency update. +Native Geo supports Off, a local GeoLite database, or protected CloudFront +assertions. Both built-in implementations are default-off and require an explicit +TS boot profile as well as their build feature. The [validation record](./native-rc-validation.md) lists executed checks, resolved failures and remaining limits. @@ -114,12 +114,128 @@ The file must contain only trusted shell assignments and be readable by UID 1000 secrets remain visible to Docker administrators. Remote Secrets Manager bindings avoid this local file-to-environment bridge. -## Optional protected native Geo +## Optional native Geo -The `geo-cloudfront` feature is default-off. Compile it explicitly, then select -it through a separately delivered TS boot profile. EdgeZero does not read a -Geo setting from store bindings, publisher AppConfig or an `EDGEZERO__GEO__*` -environment variable. +Compile `geo-maxmind` for local IP lookup, `geo-cloudfront` for protected +assertions, or both to make either available. Select exactly one through a +separately delivered TS boot profile. EdgeZero does not read a Geo setting from +store bindings, publisher AppConfig or an `EDGEZERO__GEO__*` environment variable. +Set `TS_NATIVE_GEO_PROFILE_FILE` to the profile path. No profile means Off. +An unreadable/invalid selected profile or compiled-out provider fails startup. + +### Local GeoLite lookup + +Local lookup requires no AWS, ALB, CloudFront, origin credential or geographic +request headers. This is native Unix hosting support, not a WASM provider. + +```toml +version = 1 + +[geo] +provider = "maxmind" +database_path = "/geo/GeoLite2-City.mmdb" +reader_ip_expectation = "forwarded_required" +``` + +The path must identify an extracted, regular MMDB file, not a gzip or tar archive. +Prefer an absolute path; relative paths resolve from the server's working directory. +Download and verify it outside the application, then mount it read-only and readable +by the runtime user, UID 10001 in the supplied image. The server has no downloader +and needs no MaxMind credentials. Do not commit or copy a production dataset into +this repository or its Docker build context. + +Supported metadata types are `GeoLite2-City` and `GeoLite2-Country`. +City supplies country and optional first-level subdivision through this API. +Country supplies country only. Neither supplies city or coordinates to TS. + +`reader_ip_expectation` is required: + +- `forwarded_required` uses only a client resolved by EdgeZero's checked + `Forwarded` or `X-Forwarded-For` handling. Use this behind reverse proxies. + Missing/untrusted/unresolved forwarding leaves Geo unavailable. It does not + fall back to geolocating the proxy. +- `peer_allowed` also permits the actual socket peer as the reader. Use it only + when direct reader connections are intended. + +For either forwarding header family, separately configure the actual trusted +proxy peers and trusted chain hops. For example, these are synthetic addresses, +not deployable provider ranges: + +```sh +export EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS=192.0.2.10/32,198.51.100.8/32 +export EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY=x-forwarded +export TS_NATIVE_GEO_PROFILE_FILE=/config/native-geo.toml +``` + +Use `forwarded` instead of `x-forwarded` for an ingress that owns the standard +`Forwarded` header. EdgeZero requires the actual TCP peer to be trusted, walks +the selected chain right-to-left across trusted hops, and stops at the first +untrusted address. Do not trust all private networks or a platform-wide range +merely to get a result. Network access controls must prevent unrelated callers +from impersonating the configured proxy peers. Raw geographic headers and +`Fly-Client-IP` do not override the selected local lookup. + +```mermaid +flowchart TD + Request[Incoming request] --> Ingress[EdgeZero checks socket peer and proxy chain] + Ingress --> Reader{Usable checked reader?} + Reader -->|Yes| Database[Look up immutable local MMDB snapshot] + Reader -->|No| Unavailable[Geo unavailable] + Database --> Outcome[RequestContext geo outcome] + Unavailable --> Outcome + Outcome --> TS[TS response headers and existing consent policy] +``` + +EdgeZero reads and validates the selected database before application initialization, +listening and readiness. Missing, corrupt or unsupported databases fail startup +without falling back to Off. Replacing/deleting the source file does not change a +running snapshot. Publish a complete replacement and restart each process to adopt +updates. Measure startup time and peak memory with your chosen snapshot before +deployment. The upstream pinned reader has a known unused-metadata validation +limitation, tracked in EdgeZero issue #438; it does not authenticate the dataset. + +Operators own GeoLite download/update, license and attribution requirements. +This product includes GeoLite Data created by MaxMind, available from +https://www.maxmind.com. + +#### Fly.io and other reverse proxies + +Fly's [request-header documentation](https://fly.io/docs/networking/request-headers/) +says its `X-Forwarded-For` ends with an app-assigned shared or dedicated IP. +For the generic chain walker to reach the reader, configure both the actual +socket-peer ranges and any deployment-controlled rightmost proxy hops, including +that app address where present. Use exact `/32` or `/128` addresses where possible. +Confirm the peer and chain on your deployment and retest when routing or assigned +addresses change. The docs do not establish a stable socket-peer allowlist, so +there is no built-in Fly CIDR default. + +Start with `forwarded_required` and `x-forwarded`. Test a normal request, a forged +leftmost XFF entry, missing forwarding, and access that bypasses the intended +ingress. Do not enable Geo by trusting every peer. If your network cannot provide +a narrow authenticated ingress boundary, put a controlled proxy in front of TS +that normalizes forwarding and restrict access to it. This branch does not add a +Fly header parser or claim a verified live Fly deployment. + +#### Test an operator snapshot locally + +The normal adapter tests use tiny synthetic databases and require no account or +download. An ignored test also exercises a real operator snapshot through the +production binary. Supply a known IP/location pair from your dataset: + +```sh +TS_TEST_GEOLITE_DATABASE=/absolute/path/to/GeoLite2-City.mmdb \ +TS_TEST_GEO_IP="" \ +TS_TEST_GEO_COUNTRY="" \ +TS_TEST_GEO_REGION="" \ +cargo test --locked -p trusted-server-adapter-axum --features geo-maxmind \ + --test startup operator_geolite_snapshot_resolves_checked_reader -- --ignored +``` + +Unset `TS_TEST_GEO_REGION` when the selected record has no subdivision. +The test uses a loopback trusted proxy and a synthetic app-address hop, requires +a forwarded reader and checks that a direct request stays unavailable. + +### Protected CloudFront assertions ```toml version = 1 @@ -174,11 +290,22 @@ docker build --build-arg TRUSTED_SERVER__GIT_VERSION="$(git rev-parse HEAD)" \ -t ts-edgezero431-local:providers . ``` -Only `aws-appconfig-agent`, `aws-secrets-manager` and `geo-cloudfront` are accepted -by this recipe. Add `geo-cloudfront` to the build argument for the protected -profile; compiling it alone does not select Geo. CLI features do not compile -server providers. There is no DynamoDB feature or EC -graph claim here. Build outputs include `/usr/local/share/ts-build-inputs.sha256` +The recipe accepts `aws-appconfig-agent`, `aws-secrets-manager`, +`aws-dynamodb-ec`, `geo-cloudfront` and `geo-maxmind`. Add `geo-maxmind` to +`NATIVE_FEATURES` for local lookup or `geo-cloudfront` for protected assertions. +Compiling a provider alone does not select Geo. CLI features do not compile +server providers. Mount the profile and extracted MMDB separately from the +image, for example: + +```sh +docker build --build-arg NATIVE_FEATURES=geo-maxmind -t ts-native-geo:local . +# Add these to the existing prepared-config/secret docker run command: +# --mount type=bind,src=/srv/ts/native-geo.toml,dst=/config/native-geo.toml,readonly +# --mount type=bind,src=/srv/ts/geo,dst=/geo,readonly +# -e TS_NATIVE_GEO_PROFILE_FILE=/config/native-geo.toml +``` + +The image's existing prepared configuration and secrets remain required. Build outputs include `/usr/local/share/ts-build-inputs.sha256` for comparing the exact copied build inputs with later working-tree changes. Digest-pinned bases do not make subsequent Debian security updates bit-reproducible. Build from a clean committed checkout when supplying the existing git-version diff --git a/docs/guide/native-rc-validation.md b/docs/guide/native-rc-validation.md index e4ab77b19d..9029c20d2d 100644 --- a/docs/guide/native-rc-validation.md +++ b/docs/guide/native-rc-validation.md @@ -1,5 +1,64 @@ # EdgeZero RC431 validation record +## Native MaxMind follow-up, 2026-11-04 + +Checked on Linux amd64 with Rust 1.98.1 and Node 24.12.0, in branch +`feat/native-maxmind-geo` over `45da2460e53a8c396fcfb2eb14020b81e047e966`. +The original worktree's unrelated uncommitted work was not copied or changed. +EdgeZero remains pinned to `fda87556f5aab160711eb5244c1d1065a6bb48d6`. + +The TS boot profile now selects Off, MaxMind or protected CloudFront assertions. +MaxMind stays default-off and uses the existing checked reader/proxy policy. +No Fly-specific parser, ALB requirement, downloader or remote lookup was added. + +| Check | Result | Local log | +| --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `cargo test --locked -p trusted-server-adapter-axum` | 96 passed, 2 ignored | `/tmp/ts-maxmind-matrix.log` | +| Same command with `--features geo-maxmind` | 101 passed, 3 ignored | Same matrix log | +| Same command with `--features geo-maxmind,geo-cloudfront` | 103 passed, 3 ignored | Same matrix log | +| `cargo clippy --locked -p trusted-server-adapter-axum --all-targets --features geo-maxmind,geo-cloudfront -- -D warnings` | Passed | Same matrix log | +| `cargo doc --locked -p trusted-server-adapter-axum --no-deps --features geo-maxmind,geo-cloudfront` | Passed | Same matrix log | +| Ignored `operator_geolite_snapshot_resolves_checked_reader` test, explicitly selected with operator dataset/location inputs | Three passes, covering country-only IPv4, GB/ENG and country-only IPv6 | `/tmp/ts-maxmind-real-test.log`, `/tmp/ts-maxmind-real-more.log` | +| `docker build --build-arg NATIVE_FEATURES=geo-maxmind -t ts-native-maxmind:local .` | Passed with locked release build | `/tmp/ts-maxmind-docker-build.log` | +| `node scripts/test-native-container.mjs ts-native-maxmind:local` | Default-Off routes, startup rejection and in-flight SIGTERM drain passed | `/tmp/ts-maxmind-container-default.log` | +| `node /tmp/ts-maxmind-real/container-smoke.mjs` | Read-only mounted operator dataset, checked IPv4/IPv6 proxy chains, spoof rejection, auth-error headers and SIGTERM passed | `/tmp/ts-maxmind-container-geo.log` | + +The operator snapshot was an extracted 48 MB GeoLite2-City MMDB. It stayed outside +the repository and build context. The loaded snapshot checksum matched the source. +The release container became ready in 2.6 seconds including container startup and +used about 59 MiB idle; peak startup RSS was not measured. The first debug probe +hit the existing fixture's 10-second timeout. Only the opt-in operator test now +has a 120-second allowance. It passed in 21.55 seconds without concurrent image +compilation; additional debug runs under build load took 35.68 and 53.64 seconds. +The initial one-off container probe omitted the image's existing `/data` mount +and failed hosting validation. Adding its normal ephemeral mount fixed the probe; +no runtime source change was needed. + +The local image ID is +`sha256:7a91157d5af1dd70e47fb660649ac1a524348687877c8de7c5c2e50ba73131fd`, +configured as UID/GID `10001:10001`. No git-version build argument was supplied +for this uncommitted source. The image's embedded input hashes matched the current +workspace Cargo files, native Geo implementation, startup tests and all synthetic +fixtures. The SHA-256 of `/tmp/ts-maxmind-checked-inputs.sha256`, covering the changed +Rust/build inputs and fixtures, is +`6c627b6929002146c04dd57a88d5379963e61d954cd7e6f532427fcceb9265ec`. + +Dependency trees confirmed no MaxMind or AWS SDK in the default native graph, and +MaxMind without an AWS SDK when selected. Rust formatting, changed Markdown +formatting and `git diff --check` passed. Actionlint passed with the existing +SC2086 findings excluded; running it against the untouched workflow reproduced +those same three unquoted `GITHUB_OUTPUT` findings. A read-only source review +found no remaining issues. Temporary containers/networks were removed. + +No live Fly deployment, ingress peer discovery, image publication, arm64 build, +WASM suite or full cross-adapter CI rerun is claimed. The existing ignored Next.js +fixture and doctest remain unexecuted. The operator snapshot test is ignored in +ordinary CI and was explicitly run locally. Upstream's unused-metadata validation +limitation in EdgeZero issue #438 remains. These checks do not certify geographic +accuracy, dataset authenticity, consent, or suitability for a particular Fly +network boundary. See [the operator guide](./native-rc-demo.md#optional-native-geo) +for generic reverse-proxy configuration and Fly caveats. + ## Relay consent and EdgeZero pin update, 2026-11-04 PR #1498 was backported as `0a7c30176`. All six workspace EdgeZero declarations