1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
37use std::{fs, io, path::Path};
38
39#[cfg(feature = "download")]
40pub const RIPE_EXTENDED_LATEST_URL: &str =
41 "https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest";
42
43#[derive(Debug, Clone, Copy)]
51#[repr(C)]
52pub struct GeoInfo {
53 pub country_code: [u8; 2],
54 pub is_eu: bool,
55 pub region: u8,
56}
57
58#[repr(u8)]
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Region {
65 EuropeanUnion = 1,
66 EuropeNonEu = 2,
67 EasternEurope = 3,
68 Turkey = 4,
69 MiddleEast = 5,
70 NorthAfrica = 6,
71 CentralAsia = 7,
72 GulfStates = 8,
73 Other = 255,
74}
75
76impl Region {
77 pub fn as_str(self) -> &'static str {
79 match self {
80 Region::EuropeanUnion => "European Union",
81 Region::EuropeNonEu => "Europe (non-EU)",
82 Region::EasternEurope => "Eastern Europe",
83 Region::Turkey => "Turkey",
84 Region::MiddleEast => "Middle East",
85 Region::NorthAfrica => "North Africa",
86 Region::CentralAsia => "Central Asia",
87 Region::GulfStates => "Gulf States",
88 Region::Other => "Other",
89 }
90 }
91}
92
93fn cc2(country: &str) -> [u8; 2] {
95 let b = country.as_bytes();
96 if b.len() >= 2 { [b[0], b[1]] } else { *b"??" }
98}
99
100impl GeoInfo {
102 pub fn country_code_str(&self) -> &str {
107 std::str::from_utf8(&self.country_code).unwrap_or("??")
109 }
110
111 pub fn region_enum(&self) -> Region {
115 match self.region {
116 1 => Region::EuropeanUnion,
117 2 => Region::EuropeNonEu,
118 3 => Region::EasternEurope,
119 4 => Region::Turkey,
120 5 => Region::MiddleEast,
121 6 => Region::NorthAfrica,
122 7 => Region::CentralAsia,
123 8 => Region::GulfStates,
124 _ => Region::Other,
125 }
126 }
127}
128
129
130pub struct GeoIpDb {
135 v4_ranges: Vec<(u32, u32, GeoInfo)>,
136 v6_ranges: Vec<(u128, u128, GeoInfo)>,
137}
138
139const EU_COUNTRIES: &[&str] = &[
141 "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
142 "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
143 "PL", "PT", "RO", "SK", "SI", "ES", "SE",
144];
145
146include!(concat!(env!("OUT_DIR"), "/generated_data.rs"));
148
149impl GeoIpDb {
150 pub fn new() -> Self {
163 let mut v4_ranges = Vec::with_capacity(IPV4_RANGES.len());
164 let mut v6_ranges = Vec::with_capacity(IPV6_RANGES.len());
165
166 for &(start, end, country) in IPV4_RANGES {
168 let is_eu = EU_COUNTRIES.contains(&country);
169 let region = determine_region(country);
170
171 let geo_info = GeoInfo {
172 country_code: cc2(country),
173 is_eu,
174 region: region as u8,
175 };
176
177 v4_ranges.push((start, end, geo_info));
178 }
179
180 for &(start, end, country) in IPV6_RANGES {
182 let is_eu = EU_COUNTRIES.contains(&country);
183 let region = determine_region(country);
184
185 let geo_info = GeoInfo {
186 country_code: cc2(country),
187 is_eu,
188 region: region as u8,
189 };
190
191 v6_ranges.push((start, end, geo_info));
192 }
193
194 GeoIpDb { v4_ranges, v6_ranges }
199 }
200
201 pub fn from_ripe_delegated_str(content: &str) -> Self {
215 let parsed = crate::parse_ripe_delegated(content);
216
217 let mut v4_ranges: Vec<(u32, u32, GeoInfo)> = Vec::new();
218 let mut v6_ranges: Vec<(u128, u128, GeoInfo)> = Vec::new();
219
220 for r in parsed {
221 let is_eu = EU_COUNTRIES.contains(&r.country.as_str());
222 let region = determine_region(&r.country);
223
224 let geo = GeoInfo {
225 country_code: cc2(&r.country),
226 is_eu,
227 region: region as u8,
228 };
229
230 if let Some(v4) = r.start_v4 {
231 let start: u32 = v4.into();
232 let end = start.saturating_add((r.count as u32).saturating_sub(1));
233 v4_ranges.push((start, end, geo));
234 } else if let Some(v6) = r.start_v6 {
235 let start: u128 = v6.into();
236 let end = start.saturating_add(r.count.saturating_sub(1));
237 v6_ranges.push((start, end, geo));
238 }
239 }
240
241 v4_ranges.sort_by_key(|r| r.0);
242 v6_ranges.sort_by_key(|r| r.0);
243
244 GeoIpDb { v4_ranges, v6_ranges }
245 }
246
247 pub fn from_ripe_delegated_file<P: AsRef<Path>>(path: P) -> io::Result<Self> {
252 let content = fs::read_to_string(path)?;
253 Ok(Self::from_ripe_delegated_str(&content))
254 }
255
256 pub fn from_cache_or_embedded<P: AsRef<Path>>(cache_path: P) -> Self {
261 match Self::from_ripe_delegated_file(cache_path) {
262 Ok(db) => db,
263 Err(_) => Self::new(),
264 }
265 }
266
267 #[inline]
271 pub fn lookup_v4(&self, ip: Ipv4Addr) -> Option<&GeoInfo> {
272 let ip_u32: u32 = ip.into();
273
274 match self.v4_ranges.binary_search_by_key(&ip_u32, |&(start, _, _)| start) {
275 Ok(idx) => Some(&self.v4_ranges[idx].2),
276 Err(idx) => {
277 if idx > 0 {
278 let (start, end, geo) = &self.v4_ranges[idx - 1];
279 if ip_u32 >= *start && ip_u32 <= *end {
280 return Some(geo);
281 }
282 }
283 None
284 }
285 }
286 }
287
288 #[inline]
292 pub fn lookup_v6(&self, ip: Ipv6Addr) -> Option<&GeoInfo> {
293 let ip_u128: u128 = ip.into();
294 let ranges = &self.v6_ranges;
295
296 if ranges.is_empty() {
297 return None;
298 }
299
300 let mut lo: usize = 0;
302 let mut hi: usize = ranges.len();
303 while lo < hi {
304 let mid = lo + (hi - lo) / 2;
305 if ip_u128 < ranges[mid].0 {
306 hi = mid;
307 } else {
308 lo = mid + 1;
309 }
310 }
311
312 if lo == 0 {
313 return None;
314 }
315
316 let (start, end, geo) = &ranges[lo - 1];
317 if ip_u128 >= *start && ip_u128 <= *end {
318 Some(geo)
319 } else {
320 None
321 }
322 }
323
324 pub fn lookup(&self, ip: IpAddr) -> Option<&GeoInfo> {
335 match ip {
336 IpAddr::V4(v4) => self.lookup_v4(v4),
337 IpAddr::V6(v6) => self.lookup_v6(v6),
338 }
339 }
340
341 #[inline]
345 pub fn is_eu(&self, ip: IpAddr) -> bool {
346 self.lookup(ip).map(|info| info.is_eu).unwrap_or(false)
347 }
348
349 pub fn stats(&self) -> DbStats {
353 let total_v4_ranges = self.v4_ranges.len();
354 let total_v6_ranges = self.v6_ranges.len();
355 let eu_v4_ranges = self.v4_ranges.iter().filter(|(_, _, info)| info.is_eu).count();
356 let eu_v6_ranges = self.v6_ranges.iter().filter(|(_, _, info)| info.is_eu).count();
357
358 DbStats {
359 total_v4_ranges,
360 total_v6_ranges,
361 eu_v4_ranges,
362 eu_v6_ranges,
363 non_eu_v4_ranges: total_v4_ranges - eu_v4_ranges,
364 non_eu_v6_ranges: total_v6_ranges - eu_v6_ranges,
365 }
366 }
367}
368
369#[cfg(feature = "download")]
370impl GeoIpDb {
371 pub fn update_cache_from_url<P: AsRef<Path>>(cache_path: P, url: &str) -> io::Result<u64> {
382 let cache_path = cache_path.as_ref();
383
384 if let Some(parent) = cache_path.parent() {
386 fs::create_dir_all(parent)?;
387 }
388
389 let resp = reqwest::blocking::get(url)
391 .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?
392 .error_for_status()
393 .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
394
395 let bytes = resp
396 .bytes()
397 .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
398
399 let tmp_path = cache_path.with_extension("tmp");
401 {
402 let mut f = fs::File::create(&tmp_path)?;
403 use std::io::Write;
404 f.write_all(&bytes)?;
405 f.sync_all()?;
406 }
407
408 if cache_path.exists() {
410 let _ = fs::remove_file(cache_path);
412 }
413 fs::rename(&tmp_path, cache_path)?;
414
415 Ok(bytes.len() as u64)
416 }
417
418 pub fn update_cache<P: AsRef<Path>>(cache_path: P) -> io::Result<u64> {
424 Self::update_cache_from_url(cache_path, RIPE_EXTENDED_LATEST_URL)
425 }
426}
427
428impl Default for GeoIpDb {
429 fn default() -> Self {
430 Self::new()
431 }
432}
433
434#[derive(Debug)]
436pub struct DbStats {
437 pub total_v4_ranges: usize,
438 pub total_v6_ranges: usize,
439 pub eu_v4_ranges: usize,
440 pub eu_v6_ranges: usize,
441 pub non_eu_v4_ranges: usize,
442 pub non_eu_v6_ranges: usize,
443}
444
445fn determine_region(country_code: &str) -> Region {
449 if EU_COUNTRIES.contains(&country_code) {
450 Region::EuropeanUnion
451 } else {
452 match country_code {
453 "GB" | "NO" | "CH" | "IS" | "LI" => Region::EuropeNonEu,
454 "RU" | "UA" | "BY" | "MD" => Region::EasternEurope,
455 "TR" => Region::Turkey,
456 "IL" | "PS" => Region::MiddleEast,
457 "EG" | "TN" | "MA" | "DZ" => Region::NorthAfrica,
458 "KZ" | "UZ" | "TM" | "KG" | "TJ" => Region::CentralAsia,
459 "AE" | "SA" | "QA" | "KW" | "BH" | "OM" => Region::GulfStates,
460 _ => Region::Other,
461 }
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn test_embedded_db() {
471 let db = GeoIpDb::new();
472
473 let stats = db.stats();
474 println!("\n📊 Embedded Database Stats:");
475 println!(" IPv4 ranges: {} (EU: {}, non-EU: {})",
476 stats.total_v4_ranges, stats.eu_v4_ranges, stats.non_eu_v4_ranges);
477 println!(" IPv6 ranges: {} (EU: {}, non-EU: {})",
478 stats.total_v6_ranges, stats.eu_v6_ranges, stats.non_eu_v6_ranges);
479
480 assert!(stats.total_v4_ranges > 0, "Should have IPv4 ranges");
481 }
482
483 #[test]
484 fn test_lookup_german_ipv4() {
485 let db = GeoIpDb::new();
486 let ip: Ipv4Addr = "46.4.0.1".parse().unwrap();
487
488 let info = db.lookup_v4(ip).expect("German IP should be found");
489 assert_eq!(info.country_code_str(), "DE");
490 assert!(info.is_eu);
491 }
492
493 #[test]
494 fn test_lookup_german_ipv6() {
495 let db = GeoIpDb::new();
496 let ip: Ipv6Addr = "2a01:4f8::1".parse().unwrap();
498
499 if let Some(info) = db.lookup_v6(ip) {
500 println!("Found IPv6: {} in {}", ip, info.country_code_str());
501 }
503 }
504
505 #[test]
506 fn test_lookup_any_ip() {
507 let db = GeoIpDb::new();
508
509 let ipv4: IpAddr = "46.4.0.1".parse().unwrap();
511 if let Some(info) = db.lookup(ipv4) {
512 assert_eq!(info.country_code_str(), "DE");
513 }
514
515 let ipv6: IpAddr = "2a01:4f8::1".parse().unwrap();
517 let _ = db.lookup(ipv6);
518 }
519
520 #[test]
521 fn test_is_eu_method() {
522 let db = GeoIpDb::new();
523
524 let ipv4: IpAddr = "46.4.0.1".parse().unwrap();
526 if db.lookup(ipv4).is_some() {
527 assert!(db.is_eu(ipv4));
528 }
529 }
530
531 #[cfg(feature = "download")]
532 fn serve_once(body: &'static str) -> String {
533 use std::io::{Read, Write};
534 use std::net::TcpListener;
535
536 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
537 let addr = listener.local_addr().unwrap();
538
539 std::thread::spawn(move || {
540 let (mut stream, _) = listener.accept().unwrap();
541
542 let mut buf = [0u8; 1024];
544 let _ = stream.read(&mut buf);
545
546 let resp = format!(
547 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
548 body.as_bytes().len(),
549 body
550 );
551 let _ = stream.write_all(resp.as_bytes());
552 let _ = stream.flush();
553 });
554
555 format!("http://{}", addr)
556 }
557
558 #[test]
559 #[cfg(feature = "download")]
560 fn test_update_cache_and_load() {
561 use std::net::IpAddr;
562
563 let delegated = "\
567 # comment
568 2|ripencc|20250101|0000|summary|whatever
569 ripencc|DE|ipv4|46.4.0.0|256|20250101|allocated
570 ripencc|DE|ipv6|2a01:4f8::|32|20250101|allocated
571 ";
572
573 let url = serve_once(delegated);
574
575 let dir = tempfile::tempdir().unwrap();
576 let cache_path = dir.path().join("ripe-cache.txt");
577
578 let bytes = GeoIpDb::update_cache_from_url(&cache_path, &url).unwrap();
579 assert!(bytes > 0);
580 assert!(cache_path.exists());
581
582 let db = GeoIpDb::from_ripe_delegated_file(&cache_path).unwrap();
583
584 let ip: IpAddr = "46.4.0.1".parse().unwrap();
585 let info = db.lookup(ip).expect("should find 46.4.0.1");
586 assert_eq!(info.country_code_str(), "DE");
587 assert!(info.is_eu);
588 }
589
590 #[test]
591 #[cfg(feature = "download")]
592 fn test_update_cache_replaces_existing_file() {
593 let old = "\
594 ripencc|FR|ipv4|46.4.0.0|256|20250101|allocated
595 ";
596 let new = "\
597 ripencc|DE|ipv4|46.4.0.0|256|20250101|allocated
598 ";
599
600 let url = serve_once(new);
601
602 let dir = tempfile::tempdir().unwrap();
603 let cache_path = dir.path().join("ripe-cache.txt");
604
605 std::fs::write(&cache_path, old).unwrap();
606
607 GeoIpDb::update_cache_from_url(&cache_path, &url).unwrap();
608
609 let db = GeoIpDb::from_ripe_delegated_file(&cache_path).unwrap();
610 let info = db.lookup("46.4.0.1".parse().unwrap()).unwrap();
611 assert_eq!(info.country_code_str(), "DE");
612 }
613
614 #[test]
615 #[ignore]
616 #[cfg(feature = "download")]
617 fn smoke_test_real_ripe_download_and_lookup() {
618 let cache = std::path::PathBuf::from("/tmp/ripe-cache.txt");
619
620 let bytes = GeoIpDb::update_cache(&cache).unwrap();
622 assert!(bytes > 1_000_000, "too small, download probably failed");
623
624 let db = GeoIpDb::from_ripe_delegated_file(&cache).unwrap();
626
627 let ip: std::net::IpAddr = "88.198.0.1".parse().unwrap();
629 let info = db.lookup(ip).unwrap();
630 println!("88.198.0.1 -> {}", info.country_code_str());
631 }
632}