Skip to content

Commit 00221c1

Browse files
committed
Add CIRCL GeoIP integration proof
1 parent 1698466 commit 00221c1

16 files changed

Lines changed: 582 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ behavior when the change improves security or project direction.
1515
access-decision plugins with separate clock and randomness grants.
1616
- Add checked-in WASI randomness policy/config examples, standalone real-Wasm
1717
smoke coverage, and live native HTTP/1 listener coverage.
18+
- Restore trusted-client GeoIP context lookup in the native HTTP router for
19+
HTTP/1 and HTTP/2 access policy.
20+
- Support CIRCL Geo Open's combined Country and ASN MMDB schema and add an
21+
opt-in checksum-pinned real-database smoke covering static, proxy, and
22+
load-balanced country/ASN policy.
1823

1924
### Security
2025

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ load-balancer = [
7373
stream-proxy = ["proxy", "dep:fluxheim-stream", "fluxheim-config/stream-proxy"]
7474
udp-proxy = ["proxy", "fluxheim-config/udp-proxy"]
7575
traffic-mirror = ["proxy", "fluxheim-config/traffic-mirror", "fluxheim-server/traffic-mirror"]
76-
geoip = ["proxy", "fluxheim-config/geoip", "fluxheim-geoip/runtime"]
76+
geoip = ["proxy", "fluxheim-config/geoip", "fluxheim-server/geoip"]
7777
wasm = ["dep:fluxheim-wasm", "fluxheim-config/wasm", "fluxheim-server/wasm", "fluxheim-wasm/runtime"]
7878
wasm-proxy-abi = ["wasm", "fluxheim-config/wasm-proxy-abi", "fluxheim-server/wasm-proxy-abi", "fluxheim-wasm/proxy-abi"]
7979
wasm-wasi = ["wasm", "fluxheim-config/wasm-wasi", "fluxheim-server/wasm-wasi", "fluxheim-wasm/wasi"]
@@ -253,7 +253,6 @@ fluxheim-acme = { path = "crates/fluxheim-acme", optional = true }
253253
fluxheim-compression = { path = "crates/fluxheim-compression" }
254254
fluxheim-config = { path = "crates/fluxheim-config" }
255255
fluxheim-cache = { path = "crates/fluxheim-cache" }
256-
fluxheim-geoip = { path = "crates/fluxheim-geoip" }
257256
fluxheim-headers = { path = "crates/fluxheim-headers" }
258257
fluxheim-load-balancer = { path = "crates/fluxheim-load-balancer", optional = true }
259258
fluxheim-observability = { path = "crates/fluxheim-observability" }

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ Fluxheim is licensed under the European Union Public Licence 1.2.
134134
| Config module split || `1.4.3`; config loading, shared helpers, domain validation, and large config tests are split into focused `config_*` modules while keeping `crate::config::*` stable. |
135135
| Load-balancer module split || `1.5.0`; health checks, backend state, persistence, selection algorithms, backend policy/status, and file/DNS discovery are split into focused `load_balancer/*` modules while keeping `crate::load_balancer::*` stable. |
136136
| Apple Silicon macOS dev builds || `1.4.4`; Level 1 developer support with Mac-safe runtime paths while some upstream macOS support remains experimental. |
137-
| GeoIP/Geo-Context policy || `1.4.5`; optional bounded local MMDB support with trusted-file enforcement for MaxMind GeoIP2/GeoLite2 and CIRCL Geo Open datasets, plus vhost/route country and ASN ACLs. |
137+
| GeoIP/Geo-Context policy || `1.4.5`; optional bounded local MMDB support with trusted-file enforcement for MaxMind GeoIP2/GeoLite2 and CIRCL Geo Open datasets, plus vhost/route country and ASN ACLs. The [GeoIP guide](docs/geoip.md) includes a pinned real CIRCL database proof for static, proxy, and load-balancer paths. |
138138
| Pingora-free runtime || `1.6.34`; normal Fluxheim builds no longer compile Pingora crates. Server/listener/TLS, HTTP/1, HTTP/2, WebSocket, cache, load-balancer, admin, metrics, stream, and background-service paths run through Fluxheim-owned Rust crates. |
139139
| HTTP/3/QUIC || Planned as a Fluxheim-owned `1.9` protocol milestone using the Rust `quinn`/`h3` stack after the `1.8` macOS/Windows production parity line. |
140140
| WASM extensibility | 🧪 | Active `1.7.x` line. `1.7.0` added the optional `wasm` feature, strict plugin-file loading, bounded Wasmtime execution, and real-Wasm smoke coverage. `1.7.1` adds config-level plugin registry validation, deterministic attachment ordering, admission limits, metrics, and live native HTTP/1 access-decision hooks. `1.7.2` adds bounded native HTTP/1 request/response header hooks. `1.7.3` starts bounded native HTTP/1 route-decision hooks with configured canary and mirror branch selection, including selected native load-balanced and persistent routes. `1.7.4` starts VCL-like cache-policy hooks with bounded cache-lookup and cache-store decisions for continue/pass/bypass/skip-store/deny around cache lookup and storage. `1.7.5` adds bounded symbolic cache-key component hooks for low-cardinality cache variants plus fixed-ID cache-store TTL/tag/header metadata, symbolic content-type inspection, and a checked-in cache-policy example. `1.7.6` starts mature-runtime hardening with explicit compiled-module identities that isolate module reuse by plugin digest, ABI version, native feature surface, and Fluxheim version. `1.7.7` adds the opt-in `wasm-proxy-abi` compatibility preview boundary. `1.7.8` starts an opt-in WASI Preview 1 boundary with explicit clock/randomness grants and fail-closed import filtering. Direct backend choice, plugin-provided persistence keys, dynamic mirror/shadow target choice, and richer store policy hooks remain staged for later `1.7.x`. |
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
use std::net::IpAddr;
2+
use std::path::PathBuf;
3+
4+
use fluxheim_config::{GeoIpConfig, GeoIpDatabaseConfig, GeoIpProvider};
5+
use fluxheim_geoip::{GeoIpPolicyUsage, GeoIpRuntime};
6+
7+
fn main() -> Result<(), Box<dyn std::error::Error>> {
8+
let mut arguments = std::env::args().skip(1);
9+
let provider = match arguments.next().as_deref() {
10+
Some("circl-geo-open") => GeoIpProvider::CirclGeoOpen,
11+
Some("maxmind") => GeoIpProvider::Maxmind,
12+
_ => return Err("usage: lookup <circl-geo-open|maxmind> <database.mmdb> <ip>...".into()),
13+
};
14+
let path = arguments
15+
.next()
16+
.map(PathBuf::from)
17+
.ok_or("missing MMDB path")?;
18+
let addresses = arguments
19+
.map(|value| value.parse::<IpAddr>())
20+
.collect::<Result<Vec<_>, _>>()?;
21+
if addresses.is_empty() {
22+
return Err("at least one IP address is required".into());
23+
}
24+
25+
let config = GeoIpConfig {
26+
enabled: true,
27+
fallback_enabled: true,
28+
databases: vec![GeoIpDatabaseConfig { provider, path }],
29+
};
30+
let runtime = GeoIpRuntime::from_config(
31+
&config,
32+
GeoIpPolicyUsage {
33+
country: true,
34+
asn: true,
35+
},
36+
)?
37+
.ok_or("GeoIP runtime was not enabled")?;
38+
39+
for address in addresses {
40+
match runtime.lookup(address) {
41+
Some(context) => println!(
42+
"{address}\tcountry={}\tasn={}",
43+
context.country_iso().unwrap_or("-"),
44+
context
45+
.asn()
46+
.map(|asn| asn.to_string())
47+
.as_deref()
48+
.unwrap_or("-")
49+
),
50+
None => println!("{address}\tcountry=-\tasn=-"),
51+
}
52+
}
53+
Ok(())
54+
}

crates/fluxheim-geoip/src/geoip_runtime/tests.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use super::{
2-
GeoIpLoadLimits, GeoIpPolicyUsage, GeoIpRuntime, admitted_geoip_total, normalized_country,
3-
open_verified_mmdb, read_verified_mmdb_with_post_read,
2+
GeoIpLoadLimits, GeoIpPolicyUsage, GeoIpRuntime, admitted_geoip_total, normalized_asn,
3+
normalized_country, open_verified_mmdb, read_verified_mmdb_with_post_read,
44
};
55
use fluxheim_config::{GeoIpConfig, GeoIpDatabaseConfig, GeoIpProvider};
66

@@ -27,6 +27,16 @@ fn normalizes_country_codes() {
2727
assert_eq!(normalized_country(&"A".repeat(4096)), None);
2828
}
2929

30+
#[test]
31+
fn normalizes_circl_asn_strings() {
32+
assert_eq!(normalized_asn("13335"), Some(13_335));
33+
assert_eq!(normalized_asn("0"), None);
34+
assert_eq!(normalized_asn("+13335"), None);
35+
assert_eq!(normalized_asn("13335 "), None);
36+
assert_eq!(normalized_asn("4294967296"), None);
37+
assert_eq!(normalized_asn(&"1".repeat(11)), None);
38+
}
39+
3040
#[test]
3141
fn aggregate_limit_rejects_before_mmdb_parse() {
3242
let path = trusted_test_file("geoip-aggregate-limit", b"not-a-valid-mmdb");

crates/fluxheim-geoip/src/geoip_runtime/value.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::io;
22

33
const MAX_RAW_COUNTRY_CODE_BYTES: usize = 8;
4+
const MAX_RAW_ASN_BYTES: usize = 10;
45

56
pub(super) fn normalized_country(value: &str) -> Option<String> {
67
if value.len() > MAX_RAW_COUNTRY_CODE_BYTES {
@@ -23,3 +24,13 @@ pub(super) fn admitted_geoip_total(current: u64, next: u64, maximum: u64) -> io:
2324
}
2425
Ok(total)
2526
}
27+
28+
pub(super) fn normalized_asn(value: &str) -> Option<u32> {
29+
if value.is_empty()
30+
|| value.len() > MAX_RAW_ASN_BYTES
31+
|| !value.bytes().all(|byte| byte.is_ascii_digit())
32+
{
33+
return None;
34+
}
35+
value.parse::<u32>().ok().filter(|asn| *asn > 0)
36+
}

crates/fluxheim-geoip/src/lib.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ mod geoip_runtime {
7878
mod value;
7979

8080
use file_path::{O_NOFOLLOW, path_contains_symlink};
81-
use value::{admitted_geoip_total, normalized_country};
81+
use value::{admitted_geoip_total, normalized_asn, normalized_country};
8282

8383
const MAX_GEOIP_DATABASE_BYTES: u64 = 512 * 1024 * 1024;
8484
const MAX_TOTAL_GEOIP_DATABASE_BYTES: u64 = 1024 * 1024 * 1024;
@@ -187,6 +187,7 @@ mod geoip_runtime {
187187

188188
#[derive(Debug)]
189189
struct GeoIpDatabase {
190+
provider: GeoIpProvider,
190191
reader: Reader<Vec<u8>>,
191192
database_type: String,
192193
}
@@ -205,6 +206,7 @@ mod geoip_runtime {
205206
})?;
206207
let database_type = reader.metadata().database_type.clone();
207208
Ok(Self {
209+
provider,
208210
reader,
209211
database_type,
210212
})
@@ -228,7 +230,18 @@ mod geoip_runtime {
228230
.decode::<geoip2::Asn<'_>>()
229231
.ok()
230232
.flatten()
231-
.and_then(|asn| asn.autonomous_system_number.filter(|asn| *asn > 0));
233+
.and_then(|asn| asn.autonomous_system_number.filter(|asn| *asn > 0))
234+
.or_else(|| {
235+
(self.provider == GeoIpProvider::CirclGeoOpen)
236+
.then(|| {
237+
lookup
238+
.decode_path::<&str>(&path!["country", "AutonomousSystemNumber"])
239+
.ok()
240+
.flatten()
241+
.and_then(normalized_asn)
242+
})
243+
.flatten()
244+
});
232245
(country.and_then(normalized_country), asn)
233246
}
234247

crates/fluxheim-server/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ compression = ["dep:fluxheim-compression"]
1717
compression-brotli = ["compression", "fluxheim-compression/brotli"]
1818
compression-gzip = ["compression", "fluxheim-compression/gzip"]
1919
compression-zstd = ["compression", "fluxheim-compression/zstd"]
20+
geoip = ["dep:fluxheim-geoip", "fluxheim-config/geoip", "fluxheim-geoip/runtime"]
2021
load-balancer = ["dep:fluxheim-load-balancer"]
2122
otel-tracing = ["dep:fluxheim-observability", "fluxheim-config/otel-tracing"]
2223
php-fpm = ["dep:fastcgi-client", "dep:fluxheim-php-fpm", "tokio/fs"]
@@ -38,6 +39,7 @@ fluxheim-common = { path = "../fluxheim-common" }
3839
fluxheim-cache = { path = "../fluxheim-cache" }
3940
fluxheim-compression = { path = "../fluxheim-compression", optional = true }
4041
fluxheim-config = { path = "../fluxheim-config" }
42+
fluxheim-geoip = { path = "../fluxheim-geoip", optional = true }
4143
fluxheim-headers = { path = "../fluxheim-headers" }
4244
fluxheim-load-balancer = { path = "../fluxheim-load-balancer", optional = true }
4345
fluxheim-observability = { path = "../fluxheim-observability", optional = true }

crates/fluxheim-server/src/native_http1_host_router.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use std::time::Duration;
77

88
use fluxheim_config::config_net::{normalize_host, normalize_host_pattern};
99
use fluxheim_config::{Config, VhostConfig};
10+
#[cfg(feature = "geoip")]
11+
use fluxheim_geoip::{GeoIpPolicyUsage, GeoIpRuntime};
1012

1113
#[path = "native_http1_host_router_cache.rs"]
1214
mod native_http1_host_router_cache;
@@ -42,6 +44,8 @@ pub struct NativeHttp1HostRouter {
4244
wildcard_hosts: Vec<NativeHttp1WildcardHost>,
4345
default_proxy: Arc<NativeHttp1RouteProxy>,
4446
strict: bool,
47+
#[cfg(feature = "geoip")]
48+
geoip: Option<Arc<GeoIpRuntime>>,
4549
}
4650

4751
#[derive(Clone, Debug)]
@@ -103,6 +107,10 @@ pub enum NativeHttp1HostRouterConfigError {
103107
first_scope: String,
104108
second_scope: String,
105109
},
110+
#[cfg(feature = "geoip")]
111+
GeoIp {
112+
reason: String,
113+
},
106114
}
107115

108116
impl std::fmt::Display for NativeHttp1HostRouterConfigError {
@@ -142,6 +150,8 @@ impl std::fmt::Display for NativeHttp1HostRouterConfigError {
142150
formatter,
143151
"storage-bin cache root {path:?} is shared by {first_scope} and {second_scope}; each cache policy requires a unique root"
144152
),
153+
#[cfg(feature = "geoip")]
154+
Self::GeoIp { reason } => write!(formatter, "GeoIP runtime: {reason}"),
145155
}
146156
}
147157
}
@@ -206,6 +216,12 @@ impl NativeHttp1HostRouter {
206216
NativeHttp1HostRouterConfigError,
207217
> {
208218
validate_unique_storage_bin_roots(config)?;
219+
#[cfg(feature = "geoip")]
220+
let geoip = GeoIpRuntime::from_config(&config.geoip, geoip_policy_usage(config))
221+
.map_err(|error| NativeHttp1HostRouterConfigError::GeoIp {
222+
reason: error.to_string(),
223+
})?
224+
.map(Arc::new);
209225
#[cfg(not(feature = "load-balancer"))]
210226
let _ = config;
211227
#[cfg_attr(not(feature = "load-balancer"), allow(unused_mut))]
@@ -221,6 +237,8 @@ impl NativeHttp1HostRouter {
221237
collect_load_balancer_services.then_some(&mut load_balancer_services),
222238
#[cfg(feature = "load-balancer")]
223239
Some(&mut load_balancer_admin_pools),
240+
#[cfg(feature = "geoip")]
241+
geoip,
224242
)
225243
.map(|router| (router, load_balancer_services, load_balancer_admin_pools));
226244
}
@@ -281,6 +299,8 @@ impl NativeHttp1HostRouter {
281299
wildcard_hosts,
282300
default_proxy,
283301
strict: config.server.host_routing.strict,
302+
#[cfg(feature = "geoip")]
303+
geoip,
284304
},
285305
load_balancer_services,
286306
load_balancer_admin_pools,
@@ -297,6 +317,7 @@ impl NativeHttp1HostRouter {
297317
#[cfg(feature = "load-balancer")] load_balancer_admin_pools: Option<
298318
&mut Vec<crate::NativeLoadBalancerAdminPool>,
299319
>,
320+
#[cfg(feature = "geoip")] geoip: Option<Arc<GeoIpRuntime>>,
300321
) -> Result<Self, NativeHttp1HostRouterConfigError> {
301322
let default_proxy =
302323
match NativeHttp1RouteProxy::from_root_config_with_load_balancer_services(
@@ -319,6 +340,8 @@ impl NativeHttp1HostRouter {
319340
wildcard_hosts: Vec::new(),
320341
default_proxy,
321342
strict: false,
343+
#[cfg(feature = "geoip")]
344+
geoip,
322345
})
323346
}
324347

@@ -386,6 +409,16 @@ impl NativeHttp1Handler for NativeHttp1HostRouter {
386409
fn prepare_request_context(&self, request: &mut NativeHttp1Request) {
387410
if let Ok(proxy) = self.select_request(request) {
388411
proxy.prepare_request_context(request);
412+
#[cfg(feature = "geoip")]
413+
if let Some(geoip) = &self.geoip {
414+
request.geo_context = proxy
415+
.access_client_ip(request)
416+
.and_then(|client_ip| geoip.lookup(client_ip))
417+
.map(|context| crate::NativeHttp1GeoContext {
418+
country_iso: context.country_iso().map(str::to_owned),
419+
asn: context.asn(),
420+
});
421+
}
389422
}
390423
}
391424

@@ -424,6 +457,27 @@ impl NativeHttp1Handler for NativeHttp1HostRouter {
424457
}
425458
}
426459

460+
#[cfg(feature = "geoip")]
461+
fn geoip_policy_usage(config: &Config) -> GeoIpPolicyUsage {
462+
let mut usage = GeoIpPolicyUsage::default();
463+
for vhost in &config.vhosts {
464+
record_geoip_policy_usage(&vhost.access, &mut usage);
465+
for route in &vhost.routes {
466+
record_geoip_policy_usage(&route.access, &mut usage);
467+
}
468+
}
469+
usage
470+
}
471+
472+
#[cfg(feature = "geoip")]
473+
fn record_geoip_policy_usage(
474+
access: &fluxheim_config::AccessPolicyConfig,
475+
usage: &mut GeoIpPolicyUsage,
476+
) {
477+
usage.country |= !access.allow_countries.is_empty() || !access.deny_countries.is_empty();
478+
usage.asn |= !access.allow_asns.is_empty() || !access.deny_asns.is_empty();
479+
}
480+
427481
fn route_proxy_from_config(
428482
config: &Config,
429483
vhost: &VhostConfig,

0 commit comments

Comments
 (0)