iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 64402afc38277d0dd09212c75fcc0f21ca559fb9
parent f706eefa57e6a18b9d66d3bc26c8ff88b1b6a4a2
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 15 Sep 2026 13:02:20 +0200

feat(p2p): show peer country codes

Diffstat:
Ascripts/update_geoip.py | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/http.rs | 8++++++--
Msrc/adapters/http/api.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/adapters/http/index_html.rs | 5++++-
Msrc/adapters/http/types.rs | 10++++++++++
Asrc/ip_geolocation.rs | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ip_geolocation/embedded/README.md | 36++++++++++++++++++++++++++++++++++++
Asrc/ip_geolocation/embedded/ip-country.bin | 0
Msrc/lib.rs | 1+
Msrc/main_tests.rs | 2++
10 files changed, 571 insertions(+), 7 deletions(-)

diff --git a/scripts/update_geoip.py b/scripts/update_geoip.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Build the bundled IP-to-country database from PDDL user-country CSVs.""" + +from __future__ import annotations + +import argparse +import csv +import ipaddress +import struct +import tempfile +import time +import urllib.request +from pathlib import Path + + +IPV4_URL = "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv4-cidr.csv" +IPV6_URL = "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6-cidr.csv" +MAGIC = b"IUNAGEO2" + + +def download(url: str, destination: Path) -> None: + request = urllib.request.Request(url, headers={"User-Agent": "iuna-geoip-updater/1"}) + with urllib.request.urlopen(request, timeout=60) as response: + destination.write_bytes(response.read()) + + +def records_from_csv(path: Path, version: int) -> list[tuple[int, int, bytes, bytes]]: + records: list[tuple[int, int, bytes, bytes]] = [] + with path.open(newline="", encoding="ascii") as source: + for line_number, row in enumerate(csv.reader(source), 1): + if len(row) != 2: + raise ValueError(f"{path}:{line_number}: expected CIDR,country") + cidr, country_text = row + country = country_text.upper().encode("ascii") + if len(country) != 2 or not country.isalpha(): + raise ValueError(f"{path}:{line_number}: invalid country code") + network = ipaddress.ip_network(cidr, strict=True) + if network.version != version: + raise ValueError(f"{path}:{line_number}: unexpected IP version") + records.append((version, network.prefixlen, country, network.network_address.packed)) + return records + + +def write_database(records: list[tuple[int, int, bytes, bytes]], output: Path) -> None: + groups: dict[tuple[int, int], dict[bytes, bytes]] = {} + for version, prefix, country, address in records: + group = groups.setdefault((version, prefix), {}) + previous = group.setdefault(address, country) + if previous != country: + raise ValueError(f"conflicting countries for {address.hex()}/{prefix}") + + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("wb") as database: + database.write(MAGIC) + for version, max_prefix in ((4, 32), (6, 128)): + for prefix in range(max_prefix + 1): + database.write(struct.pack("<I", len(groups.get((version, prefix), ())))) + for version, max_prefix in ((4, 32), (6, 128)): + for prefix in range(max_prefix + 1): + for address, country in sorted(groups.get((version, prefix), {}).items()): + database.write(address) + database.write(country) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ipv4-csv", type=Path) + parser.add_argument("--ipv6-csv", type=Path) + parser.add_argument( + "--output", + type=Path, + default=Path("src/ip_geolocation/embedded/ip-country.bin"), + ) + args = parser.parse_args() + + with tempfile.TemporaryDirectory(prefix="iuna-geoip-") as temporary_directory: + temporary = Path(temporary_directory) + ipv4_csv = args.ipv4_csv or temporary / "user-country-ipv4-cidr.csv" + ipv6_csv = args.ipv6_csv or temporary / "user-country-ipv6-cidr.csv" + if args.ipv4_csv is None: + download(IPV4_URL, ipv4_csv) + time.sleep(1) + if args.ipv6_csv is None: + download(IPV6_URL, ipv6_csv) + + records = records_from_csv(ipv4_csv, 4) + records.extend(records_from_csv(ipv6_csv, 6)) + write_database(records, args.output) + print(f"wrote {len(records)} prefixes to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -21,6 +21,7 @@ use tokio::{net::TcpListener, sync::Mutex}; use crate::{ adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork}, app::{SharedNode, SharedPeerBook}, + ip_geolocation::IpGeolocation, }; mod actions; @@ -98,8 +99,9 @@ mod types; use types::{ ActionResponse, AuthForm, AuthStatusResponse, BlocksQuery, ChangePasswordForm, ConfigForm, ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, NetworkHealthLocalState, - NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, WalletTransactionContext, - WalletTransactionFilters, WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow, + NetworkHealthResponse, Page, PageQuery, PeerPresentation, UiBlock, UiTransaction, + WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, + WalletTransactionsQuery, WalletUtxoRow, }; pub async fn serve( @@ -109,6 +111,8 @@ pub async fn serve( ui_config: Arc<Mutex<UiConfig>>, options: ServeOptions, ) -> Result<()> { + // Validate the embedded prefix index before the UI can issue its first peer request. + IpGeolocation::bundled(); let addr = options.addr; let setup_capability = auth::random_hex(32)?; let state = HttpState { diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::net::SocketAddr; use anyhow::Result; use axum::{ @@ -10,13 +11,14 @@ use crate::{ adapters::p2p::P2pMetrics, app::{NodeStatus, PeerInfo}, domain::{OutPoint, Transaction, TxOutput}, + ip_geolocation::IpGeolocation, }; use super::types::{LeaderboardEntry, MetricsLeaderboards}; use super::{ BlocksQuery, ConfigResponse, MempoolCounts, MetricsQuery, MetricsResponse, - NetworkHealthLocalState, NetworkHealthResponse, Page, PageQuery, UiBlock, UiTransaction, - WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, + NetworkHealthLocalState, NetworkHealthResponse, Page, PageQuery, PeerPresentation, UiBlock, + UiTransaction, WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, WalletTransactionsQuery, WalletUtxoRow, }; use super::{ @@ -349,8 +351,23 @@ fn wallet_utxo_rows_from_ui_data( pub(super) async fn api_peers( State(state): State<HttpState>, Query(query): Query<PageQuery>, -) -> Json<Page<PeerInfo>> { - Json(page_items(state.peers.lock().await.list(), query)) +) -> Json<Page<PeerPresentation>> { + let peers = peer_presentations(state.peers.lock().await.list(), IpGeolocation::bundled()); + Json(page_items(peers, query)) +} + +fn peer_presentations(peers: Vec<PeerInfo>, geolocation: &IpGeolocation) -> Vec<PeerPresentation> { + peers + .into_iter() + .map(|peer| { + let country_code = peer + .address + .parse::<SocketAddr>() + .ok() + .and_then(|address| geolocation.country_for_ip(address.ip())); + PeerPresentation { peer, country_code } + }) + .collect() } pub(super) async fn api_p2p_metrics(State(state): State<HttpState>) -> Json<P2pMetrics> { @@ -504,3 +521,33 @@ pub(super) async fn api_network_health( let peers = state.peers.lock().await.list(); Json(network_health(local, &peers, mempool)) } + +#[cfg(test)] +mod tests { + use super::peer_presentations; + use crate::{app::PeerBook, ip_geolocation::IpGeolocation}; + + #[test] + fn presents_multiple_peer_countries_without_guessing_hostnames() { + let peers = PeerBook::from_addresses(vec![ + "8.8.8.8:9444".to_string(), + "[2001:4860:4860::8888]:9444".to_string(), + "seed.example:9444".to_string(), + ]) + .list(); + let geolocation = IpGeolocation::from_entries(&[ + ("8.8.8.0".parse().unwrap(), 24, "NL"), + ("2001:4860::".parse().unwrap(), 32, "US"), + ]); + + let presented = peer_presentations(peers, &geolocation); + assert_eq!(presented[0].country_code.unwrap().as_str(), "NL"); + assert_eq!(presented[1].country_code.unwrap().as_str(), "US"); + assert_eq!(presented[2].country_code, None); + + let json = serde_json::to_value(presented).unwrap(); + assert_eq!(json[0]["country_code"], "NL"); + assert_eq!(json[1]["country_code"], "US"); + assert!(json[2].get("country_code").is_none()); + } +} diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -382,6 +382,8 @@ pub(super) const INDEX_HTML: &str = concat!( .peer-status.banned { border-color: #713434; color: #ffb1a8; background: #2a1717; } .peer-status.error { border-color: #713434; color: #ffb1a8; background: #2a1717; } .peer-actions { display: flex; gap: 6px; align-items: center; } + .peer-address { display: inline-flex; gap: 7px; align-items: center; flex-wrap: wrap; } + .country-code { border: 1px solid #3a4248; border-radius: 4px; padding: 1px 5px; color: #a8b2b8; font-size: 10px; font-weight: 850; letter-spacing: .06em; } .peer-details { padding: 4px 7px; font-size: 12px; } .peer-remove { padding: 4px 7px; border-color: #4f3737; background: #221717; color: #ffb1a8; font-size: 12px; } .peer-remove:hover { border-color: #ffb1a8; color: #ffd4cf; } @@ -1036,7 +1038,7 @@ pub(super) const INDEX_HTML: &str = concat!( <template x-for="peer in peers" :key="peer.address"> <tr> <td data-label="Status"><span class="peer-status" :class="peerStatus(peer)" x-text="peerStatusLabel(peer)"></span></td> - <td data-label="Address"><code x-text="peer.address"></code></td> + <td data-label="Address"><span class="peer-address"><code x-text="peer.address"></code><span class="country-code" x-show="peer.country_code" x-text="peer.country_code"></span></span></td> <td data-label="Direction" x-text="peer.direction"></td> <td data-label="Last contact" x-text="peerLastContactLabel(peer)"></td> <td data-label="Clock" x-show="developmentMode()" x-text="peerClockLabel(peer)"></td> @@ -1564,6 +1566,7 @@ pub(super) const INDEX_HTML: &str = concat!( <h3>Connection</h3> <div class="peer-modal-grid"> <div class="peer-modal-field"><span class="tx-label">Direction</span><span class="peer-modal-value" x-text="peerDetail().direction"></span></div> + <div class="peer-modal-field"><span class="tx-label">Country</span><span class="peer-modal-value" x-text="peerDetail().country_code || '-'"></span></div> <div class="peer-modal-field"><span class="tx-label">Last contact</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_contact_ms)"></span></div> <div class="peer-modal-field"><span class="tx-label">Last success</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_success_ms)"></span></div> <div class="peer-modal-field"><span class="tx-label">Messages sent</span><span class="peer-modal-value" x-text="peerDetail().messages_sent ?? 0"></span></div> diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -3,7 +3,9 @@ use serde::{Deserialize, Serialize, Serializer}; use crate::{ adapters::{config_store::UiConfig, ui_data_store::BlockMetricRow}, + app::PeerInfo, domain::{Amount, BurnLeaderRank, OutPoint, Transaction, TxOutput}, + ip_geolocation::CountryCode, }; #[derive(Debug, Deserialize)] @@ -64,6 +66,14 @@ pub(super) struct NetworkHealthResponse { pub(super) last_error: Option<String>, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(super) struct PeerPresentation { + #[serde(flatten)] + pub(super) peer: PeerInfo, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) country_code: Option<CountryCode>, +} + #[derive(Clone, Copy, Debug, Default)] pub(super) struct MempoolCounts { pub(super) plain_transactions: usize, diff --git a/src/ip_geolocation.rs b/src/ip_geolocation.rs @@ -0,0 +1,368 @@ +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::OnceLock, +}; + +use serde::{Serialize, Serializer}; + +const DATABASE_MAGIC: &[u8; 8] = b"IUNAGEO2"; +// Compile-time input: the bytes become part of every executable that uses this crate. +const DATABASE: &[u8] = include_bytes!("ip_geolocation/embedded/ip-country.bin"); + +static BUNDLED_GEOLOCATION: OnceLock<IpGeolocation> = OnceLock::new(); + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct CountryCode([u8; 2]); + +impl CountryCode { + fn new(bytes: [u8; 2]) -> Option<Self> { + bytes + .iter() + .all(u8::is_ascii_uppercase) + .then_some(Self(bytes)) + } + + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.0).expect("country codes contain ASCII only") + } +} + +impl Serialize for CountryCode { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Default)] +struct PrefixGroup { + offset: u32, + count: u32, +} + +struct EmbeddedDatabase { + bytes: &'static [u8], + ipv4: [PrefixGroup; 33], + ipv6: [PrefixGroup; 129], +} + +impl EmbeddedDatabase { + fn parse(bytes: &'static [u8]) -> Result<Self, &'static str> { + const HEADER_SIZE: usize = 8 + (33 + 129) * 4; + if bytes.len() < HEADER_SIZE || &bytes[..8] != DATABASE_MAGIC { + return Err("invalid database header"); + } + + let mut counts_cursor = 8; + let mut read_count = || { + let count = + u32::from_le_bytes(bytes[counts_cursor..counts_cursor + 4].try_into().unwrap()); + counts_cursor += 4; + count + }; + let ipv4_counts = std::array::from_fn::<_, 33, _>(|_| read_count()); + let ipv6_counts = std::array::from_fn::<_, 129, _>(|_| read_count()); + let mut data_cursor = HEADER_SIZE; + let mut ipv4 = [PrefixGroup::default(); 33]; + let mut ipv6 = [PrefixGroup::default(); 129]; + + for (group, count) in ipv4.iter_mut().zip(ipv4_counts) { + *group = prefix_group(data_cursor, count, 6, bytes.len())?; + data_cursor += count as usize * 6; + } + for (group, count) in ipv6.iter_mut().zip(ipv6_counts) { + *group = prefix_group(data_cursor, count, 18, bytes.len())?; + data_cursor += count as usize * 18; + } + if data_cursor != bytes.len() { + return Err("trailing database data"); + } + Ok(Self { bytes, ipv4, ipv6 }) + } + + fn lookup_ipv4(&self, address: Ipv4Addr) -> Option<CountryCode> { + let address = u32::from(address); + for prefix in (0..=32).rev() { + let network = address & prefix_mask_u32(prefix); + let group = self.ipv4[prefix as usize]; + if let Some(country) = binary_search_group(self.bytes, group, 4, u128::from(network)) { + return Some(country); + } + } + None + } + + fn lookup_ipv6(&self, address: Ipv6Addr) -> Option<CountryCode> { + let address = u128::from(address); + for prefix in (0..=128).rev() { + let network = address & prefix_mask_u128(prefix); + let group = self.ipv6[prefix as usize]; + if let Some(country) = binary_search_group(self.bytes, group, 16, network) { + return Some(country); + } + } + None + } +} + +fn prefix_group( + offset: usize, + count: u32, + record_size: usize, + database_len: usize, +) -> Result<PrefixGroup, &'static str> { + let byte_len = (count as usize) + .checked_mul(record_size) + .ok_or("database size overflow")?; + offset + .checked_add(byte_len) + .filter(|end| *end <= database_len) + .ok_or("truncated database")?; + Ok(PrefixGroup { + offset: offset.try_into().map_err(|_| "database offset overflow")?, + count, + }) +} + +fn binary_search_group( + database: &[u8], + group: PrefixGroup, + address_len: usize, + target: u128, +) -> Option<CountryCode> { + let record_size = address_len + 2; + let mut left = 0usize; + let mut right = group.count as usize; + while left < right { + let middle = left + (right - left) / 2; + let offset = group.offset as usize + middle * record_size; + let record = &database[offset..offset + record_size]; + let value = match address_len { + 4 => u128::from(u32::from_be_bytes(record[..4].try_into().unwrap())), + 16 => u128::from_be_bytes(record[..16].try_into().unwrap()), + _ => unreachable!(), + }; + match value.cmp(&target) { + std::cmp::Ordering::Less => left = middle + 1, + std::cmp::Ordering::Greater => right = middle, + std::cmp::Ordering::Equal => { + return CountryCode::new([record[address_len], record[address_len + 1]]); + } + } + } + None +} + +fn prefix_mask_u32(prefix: u32) -> u32 { + if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + } +} + +fn prefix_mask_u128(prefix: u32) -> u128 { + if prefix == 0 { + 0 + } else { + u128::MAX << (128 - prefix) + } +} + +pub struct IpGeolocation { + database: EmbeddedDatabase, +} + +impl IpGeolocation { + pub fn bundled() -> &'static Self { + BUNDLED_GEOLOCATION.get_or_init(|| { + Self::from_database(DATABASE).expect("bundled IP geolocation database must be valid") + }) + } + + pub fn country_for_ip(&self, address: IpAddr) -> Option<CountryCode> { + match address { + IpAddr::V4(address) if ipv4_is_geolocatable(address) => { + self.database.lookup_ipv4(address) + } + IpAddr::V6(address) => { + if let Some(mapped) = address.to_ipv4_mapped() { + return self.country_for_ip(IpAddr::V4(mapped)); + } + ipv6_is_geolocatable(address).then_some(())?; + self.database.lookup_ipv6(address) + } + IpAddr::V4(_) => None, + } + } + + fn from_database(database: &'static [u8]) -> Result<Self, &'static str> { + Ok(Self { + database: EmbeddedDatabase::parse(database)?, + }) + } + + #[cfg(test)] + pub(crate) fn from_entries(entries: &[(IpAddr, u8, &str)]) -> Self { + let mut ipv4 = std::array::from_fn::<_, 33, _>(|_| Vec::new()); + let mut ipv6 = std::array::from_fn::<_, 129, _>(|_| Vec::new()); + for (address, prefix, country) in entries { + let bytes: [u8; 2] = country.as_bytes().try_into().unwrap(); + match address { + IpAddr::V4(address) => { + ipv4[*prefix as usize].push((address.octets().to_vec(), bytes)); + } + IpAddr::V6(address) => { + ipv6[*prefix as usize].push((address.octets().to_vec(), bytes)); + } + } + } + let mut database = DATABASE_MAGIC.to_vec(); + for count in ipv4.iter().chain(ipv6.iter()).map(Vec::len) { + database.extend_from_slice(&(count as u32).to_le_bytes()); + } + for group in ipv4.iter_mut().chain(ipv6.iter_mut()) { + group.sort_unstable(); + for (address, country) in group { + database.extend_from_slice(address); + database.extend_from_slice(country); + } + } + Self::from_database(Box::leak(database.into_boxed_slice())).unwrap() + } +} + +fn ipv4_is_geolocatable(address: Ipv4Addr) -> bool { + let [a, b, c, _] = address.octets(); + !(a == 0 + || a == 10 + || a == 127 + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 192 && b == 88 && c == 99) + || (a == 192 && b == 168) + || (a == 198 && (b == 18 || b == 19)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || a >= 224) +} + +fn ipv6_is_geolocatable(address: Ipv6Addr) -> bool { + let octets = address.octets(); + !(address.is_unspecified() + || address.is_loopback() + || octets[0] == 0xff + || octets[0] & 0xfe == 0xfc + || (octets[0] == 0xfe && octets[1] & 0xc0 == 0x80) + || (octets[0] == 0xfe && octets[1] & 0xc0 == 0xc0) + || (octets[0] == 0x20 && octets[1] == 0x01 && octets[2] == 0x0d && octets[3] == 0xb8)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> IpGeolocation { + IpGeolocation::from_entries(&[ + ("0.0.0.0".parse().unwrap(), 8, "US"), + ("8.0.0.0".parse().unwrap(), 8, "US"), + ("8.8.0.0".parse().unwrap(), 16, "NL"), + ("10.0.0.0".parse().unwrap(), 8, "US"), + ("127.0.0.0".parse().unwrap(), 8, "US"), + ("169.254.0.0".parse().unwrap(), 16, "US"), + ("172.16.0.0".parse().unwrap(), 12, "US"), + ("192.168.0.0".parse().unwrap(), 16, "US"), + ("224.0.0.0".parse().unwrap(), 4, "US"), + ("::".parse().unwrap(), 0, "US"), + ("2001:4860::".parse().unwrap(), 32, "US"), + ("fc00::".parse().unwrap(), 7, "US"), + ]) + } + + #[test] + fn finds_ipv4_country() { + assert_eq!( + fixture() + .country_for_ip("8.1.2.3".parse().unwrap()) + .unwrap() + .as_str(), + "US" + ); + } + + #[test] + fn finds_ipv6_country() { + assert_eq!( + fixture() + .country_for_ip("2001:4860:4860::8888".parse().unwrap()) + .unwrap() + .as_str(), + "US" + ); + } + + #[test] + fn uses_longest_prefix_match() { + assert_eq!( + fixture() + .country_for_ip("8.8.8.8".parse().unwrap()) + .unwrap() + .as_str(), + "NL" + ); + } + + #[test] + fn unknown_address_has_no_country() { + assert_eq!(fixture().country_for_ip("11.0.0.1".parse().unwrap()), None); + } + + #[test] + fn non_geographic_addresses_have_no_country() { + let geolocation = fixture(); + for address in [ + "10.0.0.1", + "172.16.0.1", + "192.168.1.10", + "127.0.0.1", + "169.254.1.1", + "0.0.0.0", + "224.0.0.1", + "::", + "::1", + "fe80::1", + "fc00::1", + "ff02::1", + ] { + assert_eq!( + geolocation.country_for_ip(address.parse().unwrap()), + None, + "{address}" + ); + } + } + + #[test] + fn bundled_database_loads_both_address_families() { + let geolocation = IpGeolocation::bundled(); + assert_eq!( + geolocation + .country_for_ip("8.8.8.8".parse().unwrap()) + .unwrap() + .as_str(), + "US" + ); + assert_eq!( + geolocation + .country_for_ip("2001:4860:4860::8888".parse().unwrap()) + .unwrap() + .as_str(), + "US" + ); + } +} diff --git a/src/ip_geolocation/embedded/README.md b/src/ip_geolocation/embedded/README.md @@ -0,0 +1,36 @@ +# Bundled IP geolocation data + +`ip-country.bin` is generated from the IPv4 and IPv6 `user-country` CIDR +datasets published by [sapics/ip-location-db](https://github.com/sapics/ip-location-db). +That dataset is dedicated to the public domain under the +[Open Data Commons PDDL 1.0](https://opendatacommons.org/licenses/pddl/1-0/), +which permits use, modification, embedding, and redistribution without an +attribution requirement. + +Rust's `include_bytes!` embeds the generated database into the executable at +compile time. It is not a runtime file or web asset, and lookups never use the +network or filesystem. + +Update the database from the repository root with: + +```sh +python3 scripts/update_geoip.py +``` + +For a reproducible/offline regeneration from previously downloaded archives: + +```sh +python3 scripts/update_geoip.py \ + --ipv4-csv /path/to/user-country-ipv4-cidr.csv \ + --ipv6-csv /path/to/user-country-ipv6-cidr.csv +``` + +The compact binary format starts with `IUNAGEO2`, followed by record counts for +each IPv4 and IPv6 prefix length and sorted fixed-width network-address/country +records. Lookup checks prefix groups from most to least specific and uses binary +search within each group. This provides longest-prefix-match without allocating +a large in-memory trie at startup. + +The source-data provenance is retained here for auditability; neither the PDDL +nor the dataset requires it to be displayed in the application or shipped as a +separate runtime file. diff --git a/src/ip_geolocation/embedded/ip-country.bin b/src/ip_geolocation/embedded/ip-country.bin Binary files differ. diff --git a/src/lib.rs b/src/lib.rs @@ -3,3 +3,4 @@ pub mod app; #[path = "adapters/chain_store/compact.rs"] pub(crate) mod compact; pub mod domain; +pub mod ip_geolocation; diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -134,6 +134,8 @@ fn management_ui_exposes_complete_peer_details() { assert!(html.contains("peerDetail().last_hello?.protocol_version")); assert!(html.contains("peerCapabilities(peerDetail())")); assert!(html.contains("Health and enforcement")); + assert!(html.contains("x-show=\"peer.country_code\"")); + assert!(html.contains("peerDetail().country_code || '-'")); assert!(javascript.contains("selectedPeerAddress: null")); assert!(javascript.contains("peerDetail()")); assert!(javascript.contains("closePeerModal()"));