Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/build*/
/compile_commands.json
# Downloaded and generated by utils/update-ip-country-db.py, required by -DWITH_IP_GEOLOCATION=ON
/src/network/ip_country/data.cpp
/.cache/
/.claude/
/.vscode/
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ cmake --build build-claude --target regen-protobuf --parallel
- `-DENABLE_ONIONREQ=ON/OFF` — include onion request / network functionality (default ON)
- `-DWARNINGS_AS_ERRORS=ON` — treat warnings as errors
- `-DSUBMODULE_CHECK=OFF` — skip submodule freshness checks (useful during dev)
- `-DWITH_IP_GEOLOCATION=ON` — bundle the DB-IP IP-to-country database, +1.79MB (default OFF, in
which case `session::ip_country` lookups all report unknown). Requires running
`utils/update-ip-country-db.py` first: the generated table is not committed, and cmake fails with
instructions if it is missing.

## Architecture Overview

Expand Down
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ option(USE_LTO "Use Link-Time Optimization" ${use_lto_default})

option(ENABLE_NETWORKING_SROUTER "Build with session-router networking support" ON)

# Off by default: it adds ~1.8MB of database to the binary, and a client that already ships its own
# geo data wants nothing to do with it. With it off the lookup API still exists and reports every
# address as unknown, so nothing needs an #ifdef.
option(WITH_IP_GEOLOCATION "Build with the bundled DB-IP IP-to-country database" OFF)

if(USE_LTO)
include(CheckIPOSupported)
check_ipo_supported(RESULT IPO_ENABLED OUTPUT ipo_error)
Expand Down
57 changes: 57 additions & 0 deletions include/session/network/ip_country.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#pragma once

#include <optional>
#include <oxen/quic/ip.hpp>
#include <string_view>

namespace session::ip_country {

using ipv4 = oxen::quic::ipv4;

/// API: ip_country/available
///
/// Whether this build of libsession-util carries a bundled IP-to-country database, i.e. whether it
/// was built with the `WITH_IP_GEOLOCATION` cmake option. When it is false the database is empty
/// and every lookup returns nullopt, so a client compiles and runs identically either way and needs
/// no preprocessor test of its own.
///
/// Outputs:
/// - `bool` -- true if a database is bundled.
bool available();

/// API: ip_country/lookup
///
/// Looks up the country an IPv4 address is assigned to.
///
/// Inputs:
/// - `ip` -- the address. `ipv4` (i.e. `oxen::quic::ipv4`) constructs from a string ("1.2.3.4"),
/// from an `in_addr`, or from octets, and is what `service_node::ip` already holds.
///
/// Outputs:
/// - `std::optional<std::string_view>` -- the ISO 3166-1 alpha-2 country code, or nullopt if the
/// address is in unassigned or reserved space, or if no database is bundled. The view points at
/// static storage, so it stays valid forever.
std::optional<std::string_view> lookup(ipv4 ip);

/// API: ip_country/attribution
///
/// The credit that the bundled database's licence (CC BY 4.0) requires be displayed wherever its
/// results are. Show this, rather than composing your own, so that every Session client credits it
/// identically.
///
/// Outputs:
/// - `std::string_view` -- the attribution line, or empty when no database is bundled (in which
/// case there is nothing to attribute).
std::string_view attribution();

/// API: ip_country/database_version
///
/// The bundled database's release, e.g. "dbip-country-lite-2026-09". The snapshot is refreshed by
/// hand (see `utils/update-ip-country-db.py`), so this is how a client reports which vintage it
/// resolved an address against.
///
/// Outputs:
/// - `std::string_view` -- the release identifier, or empty when no database is bundled.
std::string_view database_version();

} // namespace session::ip_country
17 changes: 17 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ add_libsession_util_library(network
onionreq/hop_encryption.cpp
onionreq/parser.cpp
onionreq/response_parser.cpp
network/ip_country/lookup.cpp
network/key_types.cpp
network/network_config.cpp
network/request_queue.cpp
Expand Down Expand Up @@ -216,6 +217,22 @@ target_link_libraries(network
sessiondep::libevent_core
)

# The two databases define the same accessors, so the lookup code is identical either way and only
# the table it searches differs; see src/network/ip_country/data.hpp.
if(WITH_IP_GEOLOCATION)
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/network/ip_country/data.cpp")
# Generating it needs a ~4.5MB download, so it is neither committed nor fetched during a
# build: ask for it explicitly, once, and it stays until the next refresh.
message(FATAL_ERROR
"WITH_IP_GEOLOCATION is enabled but the database has not been generated yet. Run\n"
" ${PROJECT_SOURCE_DIR}/utils/update-ip-country-db.py\n"
"to download a DB-IP Lite release and generate it, then re-run cmake.")
endif()
target_sources(network PRIVATE network/ip_country/data.cpp)
else()
target_sources(network PRIVATE network/ip_country/no_data.cpp)
endif()

if(ENABLE_NETWORKING_SROUTER)
target_sources(network
PRIVATE
Expand Down
42 changes: 42 additions & 0 deletions src/network/ip_country/data.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#pragma once

#include <cstdint>
#include <session/network/ip_country.hpp>
#include <span>
#include <string_view>

namespace session::ip_country::detail {

/// The bundled database, as a tiling of the IPv4 space: `range_starts()` holds the first address of
/// each range in ascending order and `range_codes()` the country of each, so a range runs until the
/// next one starts and no end column is needed. Both are empty when built without
/// `WITH_IP_GEOLOCATION`, which is what makes every lookup a miss in that build without the lookup
/// code itself knowing anything about the option.
///
/// Exactly one of `data.cpp` and `no_data.cpp` is compiled in, chosen by that option. `data.cpp`
/// is not in git: `utils/update-ip-country-db.py` downloads a DB-IP release and generates it, and
/// cmake refuses to configure with the option on until it has been run.

/// First address of each range, ascending, starting at 0.0.0.0. This is the only array a lookup
/// binary searches; the table's size rests on `ipv4` being nothing but its uint32_t.
static_assert(sizeof(ipv4) == sizeof(uint32_t));
std::span<const ipv4> range_starts();

/// Country of the range at the same index in `range_starts()`, as an index into
/// `country_codes()`; index 0 means unassigned or reserved.
///
/// The uint8_t element caps the code table at 256 entries (246 are in use). Widening it is a
/// change to this type, to the array in the generated data, and to the generator's own check.
std::span<const uint8_t> range_codes();

/// The country code table that `range_codes()` indexes: two-letter ISO 3166-1 alpha-2 codes,
/// sorted, with the empty "unknown" code at index 0.
std::span<const std::string_view> country_codes();

/// The attribution required by the database's licence, empty when no database is bundled.
std::string_view attribution();

/// The bundled release, e.g. "dbip-country-lite-2026-09", empty when no database is bundled.
std::string_view database_version();

} // namespace session::ip_country::detail
35 changes: 35 additions & 0 deletions src/network/ip_country/lookup.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#include <algorithm>
#include <session/network/ip_country.hpp>

#include "data.hpp"

namespace session::ip_country {

bool available() {
return !detail::range_starts().empty();
}

std::optional<std::string_view> lookup(oxen::quic::ipv4 ip) {
auto starts = detail::range_starts();
auto next = std::ranges::upper_bound(starts, ip);
// The table tiles the whole address space from 0.0.0.0 up, so the only way not to land in a
// range is for there to be no ranges at all, i.e. a build without the bundled database.
if (next == starts.begin())
return std::nullopt;

auto code = detail::range_codes()[next - starts.begin() - 1];
if (code == 0)
return std::nullopt;

return detail::country_codes()[code];
}

std::string_view attribution() {
return detail::attribution();
}

std::string_view database_version() {
return detail::database_version();
}

} // namespace session::ip_country
28 changes: 28 additions & 0 deletions src/network/ip_country/no_data.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#include "data.hpp"

// The database compiled in when WITH_IP_GEOLOCATION is off: an empty one, so that lookups miss
// rather than the API disappearing. See data.hpp.

namespace session::ip_country::detail {

std::span<const ipv4> range_starts() {
return {};
}

std::span<const uint8_t> range_codes() {
return {};
}

std::span<const std::string_view> country_codes() {
return {};
}

std::string_view attribution() {
return {};
}

std::string_view database_version() {
return {};
}

} // namespace session::ip_country::detail
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ set(LIB_SESSION_UTESTS_SOURCES
list(APPEND LIB_SESSION_UTESTS_SOURCES
test_backend_session_file_server.cpp
test_backed_session_open_group_server.cpp
test_ip_country.cpp
test_network_swarm.cpp
test_onionreq.cpp
test_onion_request_router.cpp
Expand Down
162 changes: 162 additions & 0 deletions tests/test_ip_country.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#include <algorithm>
#include <catch2/catch_test_macros.hpp>
#include <oxen/log/format.hpp>
#include <session/network/ip_country.hpp>

#include "../src/network/ip_country/data.hpp"

using namespace session::ip_country;
using namespace oxen::log::literals;

namespace {

// The country a range's code index means, i.e. what a lookup anywhere in that range must return.
std::optional<std::string_view> country_of(uint8_t code) {
if (code == 0)
return std::nullopt;
return detail::country_codes()[code];
}

} // namespace

TEST_CASE("ip-to-country database shape", "[ip_country]") {
auto starts = detail::range_starts();
auto codes = detail::range_codes();
auto table = detail::country_codes();

REQUIRE(starts.size() == codes.size());
REQUIRE(available() == !starts.empty());

if (!available()) {
// Built without WITH_IP_GEOLOCATION, so there is nothing to check the shape of beyond its
// being consistently empty; the lookups themselves are exercised below either way.
CHECK(table.empty());
CHECK(attribution().empty());
CHECK(database_version().empty());
return;
}

CHECK_FALSE(attribution().empty());
CHECK_FALSE(database_version().empty());

// Index 0 is the unknown slot rather than a country; the rest are alpha-2 codes.
REQUIRE(table.size() >= 2);
CHECK(table[0].empty());

for (size_t i = 1; i < table.size(); i++) {
auto cc = table[i];
if (cc.size() != 2 ||
!std::ranges::all_of(cc, [](char c) { return c >= 'A' && c <= 'Z'; })) {
FAIL("code table entry " << i << " (" << cc << ") is not an alpha-2 country code");
break;
}
}

// A lookup finds the range a search lands in and stops, so the table has to start at 0.0.0.0
// and ascend; anything else silently mislabels the addresses below the first entry.
CHECK(starts.front() == ipv4{0, 0, 0, 0});

size_t out_of_order = 0, bad_code = 0, unmerged = 0;
for (size_t i = 0; i < starts.size(); i++) {
if (i > 0 && !(starts[i - 1] < starts[i]))
out_of_order++;
if (codes[i] >= table.size())
bad_code++;
// Not a correctness requirement, but the generator merges neighbours with the same country,
// so a run of them means it stopped doing its job.
if (i > 0 && codes[i - 1] == codes[i])
unmerged++;
}
CHECK(out_of_order == 0);
CHECK(bad_code == 0);
CHECK(unmerged == 0);

// The codes are numbered by descending range count (ties alphabetical), which is what keeps the
// generated source small and its month-to-month diff shallow. Recount them and check the
// numbering still follows, since a generator that quietly stopped sorting would cost both.
std::vector<size_t> ranges_per_country(table.size(), 0);
for (auto code : codes)
ranges_per_country[code]++;

for (size_t i = 2; i < table.size(); i++) {
auto prev = ranges_per_country[i - 1], cur = ranges_per_country[i];
if (prev < cur || (prev == cur && !(table[i - 1] < table[i]))) {
FAIL("country " << table[i] << " (" << cur << " ranges) is numbered after "
<< table[i - 1] << " (" << prev << " ranges)");
break;
}
}
}

TEST_CASE("ip-to-country range boundaries", "[ip_country]") {
auto starts = detail::range_starts();
auto codes = detail::range_codes();

if (!available()) {
// Every lookup misses, which is the whole point of the empty database: a client needs no
// #ifdef of its own.
CHECK_FALSE(lookup(ipv4{1, 1, 1, 1}));
CHECK_FALSE(lookup(ipv4{"95.216.0.0"}));
CHECK_FALSE(lookup(ipv4{0, 0, 0, 0}));
CHECK_FALSE(lookup(ipv4{255, 255, 255, 255}));
return;
}

// Walk a sample of ranges spread across the table, checking each one's first and last address
// and the first address of the next range: an off-by-one in the search shows up as a range
// bleeding into its neighbour.
size_t step = std::max<size_t>(1, starts.size() / 500);
size_t mismatches = 0;
std::string first_failure;
auto check = [&](ipv4 ip, std::optional<std::string_view> expected) {
auto got = lookup(ip);
if (got == expected)
return;
mismatches++;
if (first_failure.empty())
first_failure = "{} gave {} rather than {}"_format(
ip.to_string(), got.value_or("(unknown)"), expected.value_or("(unknown)"));
};

for (size_t i = 0; i < starts.size(); i += step) {
auto expected = country_of(codes[i]);
check(starts[i], expected);

// The range runs until the next one starts, or to the top of the address space for the
// last one.
ipv4 last = i + 1 < starts.size() ? ipv4{starts[i + 1].addr - 1} : ipv4{255, 255, 255, 255};
check(last, expected);
if (i + 1 < starts.size())
check(starts[i + 1], country_of(codes[i + 1]));
}

INFO(first_failure);
CHECK(mismatches == 0);
}

TEST_CASE("ip-to-country reserved space", "[ip_country]") {
if (!available())
return;

// DB-IP labels space that belongs to no country -- 0.0.0.0/8, the RFC1918 blocks, loopback,
// link-local, multicast and up -- with its ZZ marker, which the generator folds into the
// unknown code. This is the code == 0 path.
CHECK_FALSE(lookup(ipv4{"0.0.0.0"}));
CHECK_FALSE(lookup(ipv4{"10.0.0.1"}));
CHECK_FALSE(lookup(ipv4{"127.0.0.1"}));
CHECK_FALSE(lookup(ipv4{"169.254.1.1"}));
CHECK_FALSE(lookup(ipv4{"192.168.1.1"}));
CHECK_FALSE(lookup(ipv4{"255.255.255.255"}));
}

TEST_CASE("ip-to-country smoke test against the bundled snapshot", "[ip_country]") {
if (!available())
return;

// Unlike everything above, this asserts what the data says rather than how the lookup works,
// so it can legitimately fail after a refresh: Hetzner's Helsinki space is about as stable an
// anchor as free geo data offers, but if this is what breaks, check the new snapshot and move
// the anchor rather than treating it as a bug.
CHECK(lookup(ipv4{"95.216.0.0"}) == "FI");
CHECK(lookup(ipv4{"95.216.33.113"}) == "FI");
}
Loading