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:
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()"));