const std = @import("std"); const Ip2location = @import("Ip2location.zig"); const IpWhoIs = @import("IpWhoIs.zig"); const Location = @import("resolver.zig").Location; const Config = @import("../Config.zig"); const Pins = @import("Pins.zig"); const c = @cImport({ @cInclude("maxminddb.h"); }); const GeoIP = @This(); const log = std.log.scoped(.geoip); // libmaxminddb writes into `MMDB_entry_data_s` values that we declare on the // Zig stack, so Zig's view of that struct must match the C compiler's exactly. // // It did not: `MMDB_UINT128_USING_MODE` types `mmdb_uint128_t` as // `unsigned int __attribute__((__mode__(TI)))`, which translate-c cannot // represent. Zig saw 32 bytes / 8-byte alignment where clang saw 48 / 16, so // every `MMDB_get_value` call wrote `entry_data->offset` (C offset 32) past the // end of the variable Zig had reserved -- silent stack corruption whose blast // radius depended on the adjacent stack slot. // // `build.zig` now selects `MMDB_UINT128_IS_BYTE_ARRAY`, under which both // compilers agree. This assertion fails the build if that ever regresses, // rather than letting the corruption return unnoticed. comptime { std.debug.assert(@sizeOf(c.MMDB_entry_data_s) == 40); std.debug.assert(@alignOf(c.MMDB_entry_data_s) == 8); } const FallbackClient = union(enum) { ip2location: *Ip2location, ipwhois: *IpWhoIs, fn lookup(self: FallbackClient, ip: []const u8) ?Location { return switch (self) { .ip2location => |client| client.lookup(ip), .ipwhois => |client| client.lookup(ip), }; } /// Country code for an address already in the provider's cache. /// /// Cache-only by design: unit selection must not be able to trigger an /// outbound request, and the cache is permanent, so anything previously /// resolved is still there. fn cachedCountry(self: FallbackClient, ip: []const u8) ?[2]u8 { const packed_ip = Ip2location.packIp(ip) orelse return null; return switch (self) { .ip2location => |client| client.cache.getCountry(packed_ip.key), .ipwhois => |client| client.cache.getCountry(packed_ip.key), }; } /// Full cached answer for an address, without contacting the provider. fn cachedLookup(self: FallbackClient, ip: []const u8) ?Location { const packed_ip = Ip2location.packIp(ip) orelse return null; return switch (self) { .ip2location => |client| client.cache.get(packed_ip.key), .ipwhois => |client| client.cache.get(packed_ip.key), }; } /// Human-readable provider name, for diagnostics and logs. fn name(self: FallbackClient) []const u8 { return switch (self) { .ip2location => "ip2location.io", .ipwhois => "ipwho.is", }; } /// Where this provider's permanent cache lives. fn cachePath(self: FallbackClient) []const u8 { return switch (self) { .ip2location => |client| client.cache.path, .ipwhois => |client| client.cache.path, }; } fn deinit(self: FallbackClient, allocator: std.mem.Allocator) void { switch (self) { .ip2location => |client| { client.deinit(); allocator.destroy(client); }, .ipwhois => |client| { client.deinit(); allocator.destroy(client); }, } } }; mmdb: *c.MMDB_s, fallback_client: FallbackClient, allocator: std.mem.Allocator, io: std.Io, /// Retained so `reload` can reopen the database after it is replaced on disk. db_path: []const u8, /// Guards `mmdb` and `pins`. Lookups hold this shared; the reload paths hold it /// exclusively to swap state. Request threads, the background refresher, and the /// SIGHUP handler all touch this state, so the swaps cannot be unsynchronized. lock: std.Io.RwLock, /// Manual IP-range overrides, consulted before the database. Separate from the /// fallback cache on purpose: the fallback only runs when GeoLite2 declines to /// answer, and the addresses that need overriding are exactly the ones GeoLite2 /// answers confidently and wrongly. pins: Pins, /// Path pins are loaded from, retained so SIGHUP can re-read them. pins_path: []const u8, pub fn init(allocator: std.mem.Allocator, io: std.Io, db_path: []const u8, config: Config) !GeoIP { const mmdb = try openDatabase(allocator, db_path); errdefer { c.MMDB_close(mmdb); allocator.destroy(mmdb); } const db_path_copy = try allocator.dupe(u8, db_path); errdefer allocator.free(db_path_copy); const pins_path_copy = try allocator.dupe(u8, config.pins_file); errdefer allocator.free(pins_path_copy); // A malformed or unreadable pins file must not stop the server: overrides are // an enhancement, and losing them is far better than refusing to serve. var pins = Pins.load(allocator, io, config.pins_file) catch |err| blk: { std.log.warn("could not load pins from {s} ({t}); continuing without overrides", .{ config.pins_file, err }); break :blk Pins.init(allocator); }; errdefer pins.deinit(); if (pins.entries.items.len > 0) std.log.info("loaded {d} IP pin(s) from {s}", .{ pins.entries.items.len, config.pins_file }); const fallback_client: FallbackClient = switch (config.geoip_fallback) { .ip2location => blk: { const client = try allocator.create(Ip2location); errdefer allocator.destroy(client); client.* = try Ip2location.init(allocator, io, config.ip2location_api_key, config.ip2location_cache_file); std.log.info( "GeoIP fallback: IP2Location ({s}, cache: {s})", .{ if (config.ip2location_api_key) |_| "key provided, 50k/mo limit" else "no key, 1k/day limit", config.ip2location_cache_file }, ); break :blk .{ .ip2location = client }; }, .ipwhois => blk: { const client = try allocator.create(IpWhoIs); errdefer allocator.destroy(client); client.* = try IpWhoIs.init(allocator, io, config.ipwhois_cache_file); std.log.info("GeoIP fallback: ipwho.is (cache: {s})", .{config.ipwhois_cache_file}); break :blk .{ .ipwhois = client }; }, }; return GeoIP{ .mmdb = mmdb, .fallback_client = fallback_client, .allocator = allocator, .io = io, .db_path = db_path_copy, .lock = .init, .pins = pins, .pins_path = pins_path_copy, }; } /// Opens an mmdb file, returning an owned handle. fn openDatabase(allocator: std.mem.Allocator, db_path: []const u8) !*c.MMDB_s { const path_z = try std.heap.c_allocator.dupeZ(u8, db_path); defer std.heap.c_allocator.free(path_z); const mmdb = try allocator.create(c.MMDB_s); errdefer allocator.destroy(mmdb); const status = c.MMDB_open(path_z.ptr, c.MMDB_MODE_MMAP, mmdb); if (status != c.MMDB_SUCCESS) return error.CannotOpenDatabase; return mmdb; } /// Reopens the database from `db_path`, replacing the in-use handle. /// /// The file is opened *before* the exclusive lock is taken so that lookups only /// block for the pointer swap rather than for the open. The previous handle is /// closed after the lock is released: lookups never retain the pointer beyond /// their own critical section, so once the swap is published no thread can still /// reach the old handle. /// /// Reopening is required after the file is replaced on disk. The database is /// mapped with `MMDB_MODE_MMAP`, so an atomic rename leaves this process mapped /// to the old inode until it opens the new one. pub fn reload(self: *GeoIP) !void { const new_mmdb = try openDatabase(self.allocator, self.db_path); self.lock.lockUncancelable(self.io); const old_mmdb = self.mmdb; self.mmdb = new_mmdb; self.lock.unlock(self.io); c.MMDB_close(old_mmdb); self.allocator.destroy(old_mmdb); log.info("GeoLite2 database reloaded from {s}", .{self.db_path}); } pub fn deinit(self: *GeoIP) void { c.MMDB_close(self.mmdb); self.allocator.destroy(self.mmdb); self.allocator.free(self.db_path); self.allocator.free(self.pins_path); self.pins.deinit(); self.fallback_client.deinit(self.allocator); } /// Re-reads the pins file, replacing the in-memory overrides. /// /// Parsing happens before the exclusive lock is taken so lookups only block for /// the swap. A parse failure leaves the existing pins installed. pub fn reloadPins(self: *GeoIP) !void { const fresh = try Pins.load(self.allocator, self.io, self.pins_path); self.lock.lockUncancelable(self.io); var old = self.pins; self.pins = fresh; self.lock.unlock(self.io); old.deinit(); log.info("reloaded {d} IP pin(s) from {s}", .{ self.pins.entries.items.len, self.pins_path }); } /// Where a resolution came from. /// /// Reported by `explain` and named in the per-resolution log line, because /// "which source answered" is the question an operator actually has when a /// client is placed in the wrong city. pub const Source = enum { pin, database, fallback_cache, fallback_online, pub fn label(self: Source) []const u8 { return switch (self) { .pin => "pin", .database => "geolite2", .fallback_cache => "fallback (cached)", .fallback_online => "fallback (fetched)", }; } }; pub fn lookup(self: *GeoIP, ip: []const u8) ?Location { // The shared lock has to cover `readEntry` as well as the lookup itself: // `MMDB_lookup_result_s.entry` holds a pointer back to the `MMDB_s`, which // `readEntry` dereferences. const from_db: ?Location = blk: { self.lock.lockSharedUncancelable(self.io); defer self.lock.unlockShared(self.io); // Manual overrides win outright. They exist precisely for addresses the // database resolves confidently and incorrectly, so consulting the // database first would defeat the purpose. if (self.pins.lookup(self.allocator, ip)) |pinned| { log.debug("pin matched for ip {s} -> {s}", .{ ip, pinned.name }); break :blk pinned; } const result = lookupInternal(self.mmdb, ip) catch break :blk null; log.debug("lookup geoip db for ip {s}. Found: {}", .{ ip, result.found_entry }); if (!result.found_entry) break :blk null; const hit = self.readEntry(ip, result) orelse break :blk null; // Reject low-confidence entries so the online provider gets a chance. // `readEntry` reports the radius rather than acting on it, so that // `explain` can describe this decision without repeating it. if (hit.accuracy_radius) |radius| { if (radius > max_accuracy_radius_km) { log.info( "GeoLite2 accuracy_radius for ip {s} is {d} km (>{d} km threshold), falling back to the online provider", .{ ip, radius, max_accuracy_radius_km }, ); hit.location.deinit(); break :blk null; } } break :blk hit.location; }; if (from_db) |coords| return coords; // Fallback to configured online provider. Deliberately outside the lock: // this performs a network request and must not block a reload. return self.fallback_client.lookup(ip); } fn lookupInternal(mmdb: *c.MMDB_s, ip: []const u8) !c.MMDB_lookup_result_s { const ip_z = try std.heap.c_allocator.dupeZ(u8, ip); defer std.heap.c_allocator.free(ip_z); var gai_error: c_int = 0; var mmdb_error: c_int = 0; const result = c.MMDB_lookup_string(mmdb, ip_z.ptr, &gai_error, &mmdb_error); if (mmdb_error != 0) { log.warn("got error on MMDB_lookup_string for ip {s}. gai = {d}, mmdb_error = {d}", .{ ip, gai_error, mmdb_error }); return error.MMDBLookupError; } return result; } /// A full account of how an address resolves, for `wttr explain`. /// /// Reports every stage rather than just the winner, because the interesting /// failures are the ones where an early stage answered confidently and wrongly /// and later stages were therefore never consulted. pub const Explanation = struct { allocator: std.mem.Allocator, /// False when the argument was not an address at all. Everything else is /// skipped in that case: nothing should reach a provider with a value that is /// not an address. parsed: bool, /// How many pins are loaded, so "no pin matched" reads differently from /// "there are no pins". pins_loaded: usize, pin: ?Pinned, database: Database, fallback: Fallback, /// The provider that would be consulted, named even when it is not reached. fallback_provider: []const u8, fallback_cache_path: []const u8, /// The answer `lookup` would return, and where it came from. result: ?Result, /// The country `lookupCountry` would return, which decides units. country: ?[2]u8, pub const Pinned = struct { /// Owned text of the matching range. cidr: []const u8, location: Location, }; pub const Result = struct { location: Location, source: Source, }; pub const Database = union(enum) { /// No entry for this address. absent, /// An entry whose coordinates could not be read. unusable, /// An entry within the confidence threshold, so `lookup` uses it. accepted: DbHit, /// An entry too coarse to trust, so `lookup` falls through to the /// provider. This is the case the threshold exists for. rejected: DbHit, pub fn hit(self: Database) ?DbHit { return switch (self) { .accepted, .rejected => |h| h, .absent, .unusable => null, }; } }; pub const Fallback = union(enum) { /// Answered from the permanent cache. cached: Location, /// Fetched over the network during this run. fetched: Location, /// Not cached, and no online lookup was asked for. not_cached, /// An online lookup was asked for and did not produce an answer. failed, pub fn location(self: Fallback) ?Location { return switch (self) { .cached, .fetched => |loc| loc, .not_cached, .failed => null, }; } }; /// Whether the provider's answer is the one `lookup` would return. /// /// Derived rather than stored: the report needs to show what the provider /// says even when an earlier stage won, so "do we have an answer" and "is it /// the answer" are separate questions. pub fn fallbackWasUsed(self: Explanation) bool { const r = self.result orelse return false; return r.source == .fallback_cache or r.source == .fallback_online; } pub fn deinit(self: Explanation) void { if (self.pin) |p| { self.allocator.free(p.cidr); p.location.deinit(); } if (self.database.hit()) |h| h.location.deinit(); if (self.fallback.location()) |loc| loc.deinit(); // `result` borrows whichever stage won rather than owning a copy, so it // is deliberately not freed here. } }; pub const ExplainOptions = struct { /// Whether a cache miss may be resolved over the network. /// /// Off by default, and that default matters: the fallback cache is permanent /// and append-only, so a diagnostic that fetched by default would grow the /// cache and spend the provider's quota every time it ran. online: bool = false, }; /// Explains how `ip` resolves, walking every stage. /// /// This re-runs the chain rather than replaying a past request. The result /// matches what a running server would answer as long as that server's pins and /// mapped database are the same as the ones on disk, which is why the caller /// reports both files. pub fn explain(self: *GeoIP, ip: []const u8, opts: ExplainOptions) !Explanation { var out: Explanation = .{ .allocator = self.allocator, .parsed = Ip2location.packIp(ip) != null, .pins_loaded = 0, .pin = null, .database = .absent, // Overwritten below for any address that parses. Left as "not cached" so // an address that never reaches the provider reads as untried rather than // as a miss the provider reported. .fallback = .not_cached, .fallback_provider = self.fallback_client.name(), .fallback_cache_path = self.fallback_client.cachePath(), .result = null, .country = null, }; // Reads whatever has been filled in so far, so a failure partway through // does not leak the stages that already succeeded. errdefer out.deinit(); { self.lock.lockSharedUncancelable(self.io); defer self.lock.unlockShared(self.io); out.pins_loaded = self.pins.entries.items.len; if (out.parsed) { if (self.pins.matchFor(ip)) |entry| { var buf: [64]u8 = undefined; const text = try Pins.formatCidr( .{ .family = entry.family, .network = entry.network, .prefix_len = entry.prefix_len }, &buf, ); // Both copies are owned, because `entry` points into the pin set // and is only valid while the lock is held. const cidr = try self.allocator.dupe(u8, text); errdefer self.allocator.free(cidr); const name = try self.allocator.dupe(u8, entry.name); out.pin = .{ .cidr = cidr, .location = .{ .allocator = self.allocator, .name = name, .coords = entry.coords, .iso_country = entry.iso_country, }, }; } // The database is read even when a pin matched. An operator debugging // a pin needs to see what it is overriding. if (lookupInternal(self.mmdb, ip)) |result| { if (!result.found_entry) { out.database = .absent; } else if (self.readEntry(ip, result)) |hit| { const too_coarse = if (hit.accuracy_radius) |r| r > max_accuracy_radius_km else false; out.database = if (too_coarse) .{ .rejected = hit } else .{ .accepted = hit }; } else { out.database = .unusable; } } else |_| { out.database = .absent; } } } if (!out.parsed) return out; // The provider is consulted even when an earlier stage already answered. // "What would the other source have said" is the question worth asking when // the database answers confidently and wrongly, and comparing the two is the // whole reason to run this. It does not affect `result`: precedence below // still prefers the pin and the database. // // Reading the cache is free. Going to the network is not, which is why it // stays behind `--online` regardless of whether the answer is needed. if (self.fallback_client.cachedLookup(ip)) |cached| { out.fallback = .{ .cached = cached }; } else if (opts.online) { if (self.fallback_client.lookup(ip)) |fetched| { out.fallback = .{ .fetched = fetched }; } else { out.fallback = .failed; } } else { out.fallback = .not_cached; } // Precedence, restated here against the collected stages so the report and // `resolve` cannot disagree about who wins. The drift test below asserts // this matches `lookup` for real addresses. if (out.pin) |p| { out.result = .{ .location = p.location, .source = .pin }; } else if (out.database.hit()) |h| { if (out.database == .accepted) out.result = .{ .location = h.location, .source = .database }; } if (out.result == null) { switch (out.fallback) { .cached => |loc| out.result = .{ .location = loc, .source = .fallback_cache }, .fetched => |loc| out.result = .{ .location = loc, .source = .fallback_online }, else => {}, } } out.country = self.lookupCountry(ip); return out; } pub fn isUSIp(self: *GeoIP, ip: []const u8) bool { const iso = self.lookupCountry(ip) orelse return false; return std.mem.eql(u8, &iso, "US"); } /// ISO 3166-1 alpha-2 country code for an address, for unit selection. /// /// Walks the same precedence as `lookup` -- pins, then the database, then the /// online provider's cache -- so both an operator's pin and a previously fetched /// fallback answer influence which units a client sees. This previously read the /// database directly, which meant a pinned or fallback-resolved client could be /// rendered in metric while the location it was given was in the US. /// /// Never performs a live fallback fetch. Units are not worth an outbound request /// on their own, and for a request with no explicit location the resolution in /// `handleWeather` has already populated the cache by the time this runs. The /// residual case -- an explicit location query from an address seen for the first /// time, whose only answer would come from the network -- gets the default units /// once and the right ones on every later request. /// /// A matching pin with no recorded country falls through to the database rather /// than answering "unknown", so pins written before the country was stored keep /// behaving exactly as they did. pub fn lookupCountry(self: *GeoIP, ip: []const u8) ?[2]u8 { { // Covers `pins` and `mmdb` only. The cache read below is left outside for // the same reason `lookup` calls the fallback outside the lock: it is not // guarded by this lock on the write side either. self.lock.lockSharedUncancelable(self.io); defer self.lock.unlockShared(self.io); if (self.pins.lookupCountry(ip)) |iso| return iso; if (lookupInternal(self.mmdb, ip)) |result| { if (result.found_entry) { if (readIso(result)) |iso| return iso; } } else |_| {} } return self.fallback_client.cachedCountry(ip); } /// Reads `country/iso_code` from a lookup result. /// /// Shared by `lookupCountry` and `extractCoordinates` so there is one place that /// knows where the country code lives and how it is validated. fn readIso(result: c.MMDB_lookup_result_s) ?[2]u8 { var entry_copy = result.entry; // SAFETY: iso_data is set by MMDB_get_value var iso_data: c.MMDB_entry_data_s = undefined; const status = c.MMDB_get_value(&entry_copy, &iso_data, "country", "iso_code", @as([*c]const u8, null)); if (status != c.MMDB_SUCCESS or !iso_data.has_data) return null; return Location.isoFrom(iso_data.unnamed_0.utf8_string[0..iso_data.data_size]); } /// Maximum accuracy radius (in km) to trust from GeoLite2. Entries with a /// radius above this are too coarse for weather lookups (e.g. backbone/transit /// IPs that MaxMind maps to the wrong city) and should fall back to the /// configured online provider. /// /// Note this only catches entries GeoLite2 admits are vague. An entry can be /// precise *and* wrong, which is what pins exist for. pub const max_accuracy_radius_km = 200; /// A database entry, read but not yet judged. pub const DbHit = struct { location: Location, /// MaxMind's own confidence signal, in km. Null when the entry carries none. accuracy_radius: ?u16, }; /// Reads an entry into a `Location`, without applying the confidence gate. /// /// The gate is the caller's business: `resolve` rejects a coarse entry so the /// online provider gets a chance, while `explain` reports the same decision /// rather than acting on it. Keeping the reading here means one place knows the /// database's field layout. /// /// Must be called with the shared lock held: the returned strings are read /// through `result.entry`, which points into the mapped database. fn readEntry(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result_s) ?DbHit { if (!result.found_entry) return null; var entry_copy = result.entry; // SAFETY: accuracy_data set by MMDB_get_value var accuracy_data: c.MMDB_entry_data_s = undefined; const acc_status = c.MMDB_get_value(&entry_copy, &accuracy_data, "location", "accuracy_radius", @as([*c]const u8, null)); const accuracy_radius: ?u16 = if (acc_status == c.MMDB_SUCCESS and accuracy_data.has_data) accuracy_data.unnamed_0.uint16 else null; entry_copy = result.entry; // SAFETY: latitude_data set by MMDB_get_value var latitude_data: c.MMDB_entry_data_s = undefined; const lat_status = c.MMDB_get_value(&entry_copy, &latitude_data, "location", "latitude", @as([*c]const u8, null)); if (lat_status != c.MMDB_SUCCESS or !latitude_data.has_data) { log.info("lookup found result, but no latitude available in data for ip {s}. MMDB_get_value returned {d}", .{ ip, lat_status }); return null; } // SAFETY: longitude_data set by MMDB_get_value var longitude_data: c.MMDB_entry_data_s = undefined; const lon_status = c.MMDB_get_value(&entry_copy, &longitude_data, "location", "longitude", @as([*c]const u8, null)); if (lon_status != c.MMDB_SUCCESS or !longitude_data.has_data) { log.info("lookup found result, but no longitude available in data for ip {s}. MMDB_get_value returned {d}", .{ ip, lon_status }); return null; } // No byte swapping here: `build.zig` sets `MMDB_LITTLE_ENDIAN` for the target, // so libmaxminddb converts the database's big-endian doubles to host order in // `get_ieee754_double` before we ever see them. If that define were ever // dropped, these coordinates would silently become nonsense, which the // "lookup works" test below is positioned to catch. const latitude = latitude_data.unnamed_0.double_value; const longitude = longitude_data.unnamed_0.double_value; // Extract location name parts // SAFETY: value set by MMDB_get_value var city_data: c.MMDB_entry_data_s = undefined; entry_copy = result.entry; const city_status = c.MMDB_get_value(&entry_copy, &city_data, "city", "names", "en", @as([*c]const u8, null)); const city = if (city_status == c.MMDB_SUCCESS and city_data.has_data) city_data.unnamed_0.utf8_string[0..city_data.data_size] else ""; // SAFETY: value set by MMDB_get_value var subdivision_data: c.MMDB_entry_data_s = undefined; entry_copy = result.entry; const subdivision_status = c.MMDB_get_value(&entry_copy, &subdivision_data, "subdivisions", "0", "names", "en", @as([*c]const u8, null)); const subdivision = if (subdivision_status == c.MMDB_SUCCESS and subdivision_data.has_data) subdivision_data.unnamed_0.utf8_string[0..subdivision_data.data_size] else ""; // SAFETY: value set by MMDB_get_value var country_data: c.MMDB_entry_data_s = undefined; entry_copy = result.entry; const country_status = c.MMDB_get_value(&entry_copy, &country_data, "country", "names", "en", @as([*c]const u8, null)); const country = if (country_status == c.MMDB_SUCCESS and country_data.has_data) country_data.unnamed_0.utf8_string[0..country_data.data_size] else ""; // The ISO code as well as the display name: unit selection needs the country // itself, and deriving it by matching the name against "United States" would // break on any of the other languages the database carries. const iso_country = readIso(result); const final_name = Location.buildDisplayName(self.allocator, city, subdivision, country, ip); return .{ .location = .{ .allocator = self.allocator, .name = final_name, .coords = .{ .latitude = latitude, .longitude = longitude, }, .iso_country = iso_country, }, .accuracy_radius = accuracy_radius, }; } test "MMDB functions are callable" { const mmdb_error = c.MMDB_strerror(0); try std.testing.expect(mmdb_error[0] != 0); } test "GeoIP init with invalid path fails" { const config = try Config.loadForTest(std.testing.allocator); defer config.deinit(std.testing.allocator); const result = GeoIP.init(std.testing.allocator, std.testing.io, "/nonexistent/path.mmdb", config); try std.testing.expectError(error.CannotOpenDatabase, result); } test "isUSIp detects US IPs" { const allocator = std.testing.allocator; const config = try Config.loadForTest(allocator); defer config.deinit(allocator); const build_options = @import("build_options"); const db_path = config.geolite_path; if (build_options.download_geoip) { const GeoLite2 = @import("GeoLite2.zig"); try GeoLite2.ensureDatabase(std.testing.allocator, std.testing.io, db_path); } var geoip = GeoIP.init(std.testing.allocator, std.testing.io, db_path, config) catch return error.SkipZigTest; defer geoip.deinit(); // Test that the function doesn't crash with various IPs try std.testing.expect(geoip.isUSIp("73.158.64.1")); // Test invalid IP returns false const invalid = geoip.isUSIp("invalid"); try std.testing.expect(!invalid); } test "lookup works" { const allocator = std.testing.allocator; const config = try Config.loadForTest(allocator); defer config.deinit(allocator); const build_options = @import("build_options"); const db_path = config.geolite_path; if (build_options.download_geoip) { const GeoLite2 = @import("GeoLite2.zig"); try GeoLite2.ensureDatabase(std.testing.allocator, std.testing.io, db_path); } var geoip = GeoIP.init(std.testing.allocator, std.testing.io, db_path, config) catch return error.SkipZigTest; defer geoip.deinit(); // Test that lookup returns a valid location for a well-known residential IP. // We don't assert exact values since the GeoLite2 database is fetched from // the latest upstream release and city/coordinate mappings change over time. const maybe_result = geoip.lookup("73.158.64.1"); try std.testing.expect(maybe_result != null); const result = maybe_result.?; defer result.deinit(); try std.testing.expect(result.coords.latitude > 37.0 and result.coords.latitude < 38.0); try std.testing.expect(result.coords.longitude < -121.0 and result.coords.longitude > -123.0); try std.testing.expect(result.name.len > 0); } test "reload swaps the database while lookups keep working" { const allocator = std.testing.allocator; const config = try Config.loadForTest(allocator); defer config.deinit(allocator); const build_options = @import("build_options"); const db_path = config.geolite_path; if (build_options.download_geoip) { const GeoLite2 = @import("GeoLite2.zig"); try GeoLite2.ensureDatabase(allocator, std.testing.io, db_path); } var geoip = GeoIP.init(allocator, std.testing.io, db_path, config) catch return error.SkipZigTest; defer geoip.deinit(); // A residential IP that resolves from the database rather than the online // fallback, so the assertions below exercise the mmap and not the network. const test_ip = "73.158.64.1"; const before = geoip.lookup(test_ip) orelse return error.SkipZigTest; defer before.deinit(); const handle_before = geoip.mmdb; try geoip.reload(); // A fresh handle must be installed, and the old one must not be reused. try std.testing.expect(geoip.mmdb != handle_before); // Same query, same answer: the swap installed an equivalent database and // left the lookup path intact. const after = geoip.lookup(test_ip) orelse return error.TestUnexpectedResult; defer after.deinit(); try std.testing.expectEqualStrings(before.name, after.name); try std.testing.expectEqual(before.coords.latitude, after.coords.latitude); try std.testing.expectEqual(before.coords.longitude, after.coords.longitude); // Lookups that go through the shared lock a second time still work, which // catches a reload that left the lock in a bad state. try std.testing.expect(geoip.isUSIp(test_ip)); } test "a pin overrides a confident but wrong database answer" { const allocator = std.testing.allocator; const io = std.testing.io; const build_options = @import("build_options"); var config = try Config.loadForTest(allocator); defer config.deinit(allocator); if (build_options.download_geoip) { const GeoLite2 = @import("GeoLite2.zig"); try GeoLite2.ensureDatabase(allocator, io, config.geolite_path); } // This is the bug this feature exists for: GeoLite2 places this AT&T address // in Fort Worth, Texas with accuracy_radius = 20. The radius is low enough // that the low-confidence fallback never triggers, so nothing but an explicit // override can correct it. const wrong_ip = "12.94.132.170"; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const pins_path = try std.fmt.allocPrint(allocator, "{s}/pins", .{path_buf[0..dir_len]}); defer allocator.free(pins_path); // Start with no pins and confirm the database's wrong answer is what we get. allocator.free(config.pins_file); config.pins_file = try allocator.dupe(u8, pins_path); { var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); const unpinned = geoip.lookup(wrong_ip) orelse return error.SkipZigTest; defer unpinned.deinit(); // Guard against a future database that fixes this on its own; if that // happens this test needs a different address rather than a silent pass. if (std.mem.indexOf(u8, unpinned.name, "Texas") == null) return error.SkipZigTest; } // Write a pin covering the whole /24 the address sits in. { var pins: Pins = .init(allocator); defer pins.deinit(); try pins.put( try Pins.parseCidr("12.94.132.0/24"), "San Francisco, California, United States", .{ .latitude = 37.7749, .longitude = -122.4194 }, Location.isoFrom("US"), ); try pins.save(io, pins_path); } var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); const pinned = geoip.lookup(wrong_ip) orelse return error.TestUnexpectedResult; defer pinned.deinit(); try std.testing.expectEqualStrings("San Francisco, California, United States", pinned.name); try std.testing.expectEqual(@as(f64, 37.7749), pinned.coords.latitude); try std.testing.expectEqual(@as(f64, -122.4194), pinned.coords.longitude); } test "reloadPins picks up a pin written after startup" { const allocator = std.testing.allocator; const io = std.testing.io; var config = try Config.loadForTest(allocator); defer config.deinit(allocator); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const pins_path = try std.fmt.allocPrint(allocator, "{s}/pins", .{path_buf[0..dir_len]}); defer allocator.free(pins_path); allocator.free(config.pins_file); config.pins_file = try allocator.dupe(u8, pins_path); var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); try std.testing.expectEqual(@as(usize, 0), geoip.pins.entries.items.len); { var pins: Pins = .init(allocator); defer pins.deinit(); try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Somewhere", .{ .latitude = 1, .longitude = 2 }, null); try pins.save(io, pins_path); } try geoip.reloadPins(); try std.testing.expectEqual(@as(usize, 1), geoip.pins.entries.items.len); const hit = geoip.lookup("203.0.113.7") orelse return error.TestUnexpectedResult; defer hit.deinit(); try std.testing.expectEqualStrings("Somewhere", hit.name); } /// Shared setup for the `lookupCountry` tests. /// /// Both the pins file and the fallback cache are redirected into a temp /// directory. The cache in particular is permanent and append-only, so a test /// left pointing at the configured path would write entries into the developer's /// real cache that nothing ever removes. fn testConfigIn(allocator: std.mem.Allocator, io: std.Io, dir: []const u8) !Config { var config = try Config.loadForTest(allocator); errdefer config.deinit(allocator); if (@import("build_options").download_geoip) { const GeoLite2 = @import("GeoLite2.zig"); try GeoLite2.ensureDatabase(allocator, io, config.geolite_path); } allocator.free(config.pins_file); config.pins_file = try std.fmt.allocPrint(allocator, "{s}/pins", .{dir}); allocator.free(config.ipwhois_cache_file); config.ipwhois_cache_file = try std.fmt.allocPrint(allocator, "{s}/ipwhois", .{dir}); return config; } test "lookupCountry: a pin decides units instead of the database" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); // GeoLite2 has this AT&T address in Texas, so the database says US. Unit // selection used to read the database directly, which meant a client pinned // to London was still rendered in Fahrenheit. const ip = "12.94.132.170"; { var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); const unpinned = geoip.lookupCountry(ip) orelse return error.SkipZigTest; if (!std.mem.eql(u8, &unpinned, "US")) return error.SkipZigTest; } { var pins: Pins = .init(allocator); defer pins.deinit(); try pins.put(try Pins.parseCidr("12.94.132.0/24"), "London, United Kingdom", .{ .latitude = 51.5, .longitude = -0.1 }, Location.isoFrom("GB")); try pins.save(io, config.pins_file); } var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); const iso = geoip.lookupCountry(ip) orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings("GB", &iso); try std.testing.expect(!geoip.isUSIp(ip)); } test "lookupCountry: a pin applies where the database has no entry at all" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); // TEST-NET-3, reserved for documentation and absent from GeoLite2. Standing // in for any address the database does not cover. const ip = "203.0.113.7"; { var pins: Pins = .init(allocator); defer pins.deinit(); try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Seattle, Washington, United States", .{ .latitude = 47.6, .longitude = -122.3 }, Location.isoFrom("US")); try pins.save(io, config.pins_file); } var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); const iso = geoip.lookupCountry(ip) orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings("US", &iso); try std.testing.expect(geoip.isUSIp(ip)); } test "lookupCountry: a pin with no country falls through to the database" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); const ip = "12.94.132.170"; // Pins written before the country was recorded exist on disk. They must keep // behaving as they did rather than forcing every pinned client to metric. { var pins: Pins = .init(allocator); defer pins.deinit(); try pins.put(try Pins.parseCidr("12.94.132.0/24"), "Somewhere", .{ .latitude = 1, .longitude = 2 }, null); try pins.save(io, config.pins_file); } var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); // The pin still decides the location... const pinned = geoip.lookup(ip) orelse return error.TestUnexpectedResult; defer pinned.deinit(); try std.testing.expectEqualStrings("Somewhere", pinned.name); // ...while the country comes from the database, as it did before pins existed. const iso = geoip.lookupCountry(ip) orelse return error.SkipZigTest; try std.testing.expectEqualStrings("US", &iso); } test "lookupCountry: a cached fallback answer decides units" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); // Absent from GeoLite2, so the only source of a country is the cache. This is // the case unit selection could not see before: the fallback runs only when // the database declines, and units never consulted the fallback at all. const ip = "198.51.100.7"; const key = Ip2location.packIp(ip).?; { var cache = try Ip2location.Cache.init(allocator, io, config.ipwhois_cache_file); defer cache.deinit(); try cache.put(key.key, key.family, .{ .allocator = allocator, .name = "Seattle, Washington, United States", .coords = .{ .latitude = 47.6, .longitude = -122.3 }, .iso_country = Location.isoFrom("US"), }); } var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); const iso = geoip.lookupCountry(ip) orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings("US", &iso); try std.testing.expect(geoip.isUSIp(ip)); } test "lookupCountry: null when no source knows the address" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); // No pin, nothing in the database, nothing cached. Must not resolve to a // country, and must not attempt a network request to find one. try std.testing.expect(geoip.lookupCountry("203.0.113.7") == null); try std.testing.expect(!geoip.isUSIp("203.0.113.7")); // A malformed address must be rejected rather than reaching any provider. try std.testing.expect(geoip.lookupCountry("not-an-address") == null); try std.testing.expect(!geoip.isUSIp("not-an-address")); } test "explain agrees with lookup for every source" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); // `explain` walks the chain separately from `resolve` so it can report the // stages `resolve` short-circuits past. That means the two can drift, and a // diagnostic that disagrees with the code it describes is worse than none. // Cover one address per source. { var pins: Pins = .init(allocator); defer pins.deinit(); try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Pinned Place", .{ .latitude = 1.5, .longitude = 2.5 }, Location.isoFrom("US")); try pins.save(io, config.pins_file); } // Seeded so the fallback-cache source is exercised without a network call. const cached_ip = "198.51.100.7"; { var cache = try Ip2location.Cache.init(allocator, io, config.ipwhois_cache_file); defer cache.deinit(); const key = Ip2location.packIp(cached_ip).?; try cache.put(key.key, key.family, .{ .allocator = allocator, .name = "Cached Place", .coords = .{ .latitude = 3.5, .longitude = 4.5 }, .iso_country = Location.isoFrom("US"), }); } var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); const cases = [_][]const u8{ "203.0.113.7", // pin "73.158.64.1", // database cached_ip, // fallback cache // Null agreement is covered by an address that parses as nothing, rather // than by one that is merely absent: for an absent address `lookup` is // supposed to reach the provider, which a unit test cannot let it do. // The absent case is asserted against `explain` alone below. "not-an-address", }; for (cases) |ip| { const explanation = try geoip.explain(ip, .{}); defer explanation.deinit(); const direct = geoip.lookup(ip); defer if (direct) |d| d.deinit(); if (direct) |expected| { const got = explanation.result orelse { std.debug.print("explain found nothing for {s}, lookup returned {s}\n", .{ ip, expected.name }); return error.TestUnexpectedResult; }; try std.testing.expectEqualStrings(expected.name, got.location.name); try std.testing.expectEqual(expected.coords.latitude, got.location.coords.latitude); try std.testing.expectEqual(expected.coords.longitude, got.location.coords.longitude); try std.testing.expectEqual(expected.iso_country, got.location.iso_country); } else { if (explanation.result) |got| { std.debug.print("explain found {s} for {s}, lookup returned nothing\n", .{ got.location.name, ip }); return error.TestUnexpectedResult; } } // And the units it reports have to be the ones the handler would apply. try std.testing.expectEqual(geoip.lookupCountry(ip), explanation.country); } } test "explain: reports the source each address resolved through" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); { var pins: Pins = .init(allocator); defer pins.deinit(); try pins.put(try Pins.parseCidr("203.0.113.0/24"), "Pinned Place", .{ .latitude = 1.5, .longitude = 2.5 }, Location.isoFrom("US")); try pins.save(io, config.pins_file); } var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); { const e = try geoip.explain("203.0.113.7", .{}); defer e.deinit(); try std.testing.expectEqual(Source.pin, e.result.?.source); try std.testing.expectEqualStrings("203.0.113.0/24", e.pin.?.cidr); try std.testing.expectEqual(@as(usize, 1), e.pins_loaded); // A pin does not stop the database from being reported: an operator // checking a pin needs to see what it overrides. Here there is nothing. try std.testing.expectEqual(Explanation.Database.absent, std.meta.activeTag(e.database)); } { const e = try geoip.explain("73.158.64.1", .{}); defer e.deinit(); try std.testing.expectEqual(Source.database, e.result.?.source); try std.testing.expect(e.pin == null); // The radius is the whole point of the report, so it must be populated. const hit = e.database.hit() orelse return error.TestUnexpectedResult; try std.testing.expect(hit.accuracy_radius != null); // The provider is still reported for comparison, but must not be the // answer, and must not have been fetched without --online. try std.testing.expect(!e.fallbackWasUsed()); try std.testing.expect(std.meta.activeTag(e.fallback) != .fetched); } { // Absent everywhere. Must report the miss rather than reaching the network. const e = try geoip.explain("192.0.2.99", .{}); defer e.deinit(); try std.testing.expect(e.result == null); try std.testing.expectEqual(Explanation.Database.absent, std.meta.activeTag(e.database)); try std.testing.expectEqual(Explanation.Fallback.not_cached, std.meta.activeTag(e.fallback)); } { // Not an address: no stage may run, because the argument would otherwise // be interpolated into a provider URL. const e = try geoip.explain("not-an-address", .{}); defer e.deinit(); try std.testing.expect(!e.parsed); try std.testing.expect(e.result == null); try std.testing.expect(e.pin == null); // Not even the cache is consulted: nothing that is not an address should // reach a provider or its cache key. try std.testing.expectEqual(Explanation.Fallback.not_cached, std.meta.activeTag(e.fallback)); } } test "explain: a pin wins over an accepted database entry" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); // The original case: GeoLite2 answers this confidently, so the report has to // show both the pin that won and the entry it displaced. { var pins: Pins = .init(allocator); defer pins.deinit(); try pins.put(try Pins.parseCidr("12.94.132.0/24"), "Seattle, Washington, United States", .{ .latitude = 47.6, .longitude = -122.3 }, Location.isoFrom("US")); try pins.save(io, config.pins_file); } var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); const e = try geoip.explain("12.94.132.170", .{}); defer e.deinit(); try std.testing.expectEqual(Source.pin, e.result.?.source); try std.testing.expectEqualStrings("Seattle, Washington, United States", e.result.?.location.name); const hit = e.database.hit() orelse return error.SkipZigTest; // Different answer, still reported, and reported as one the database was // confident about. That combination is what tells an operator the pin is // load-bearing rather than redundant. try std.testing.expect(!std.mem.eql(u8, hit.location.name, "Seattle, Washington, United States")); try std.testing.expectEqual(Explanation.Database.accepted, std.meta.activeTag(e.database)); } test "explain: reports the provider's answer alongside the database that displaced it" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); // An address the database answers, with a *different* provider answer seeded // in the cache. `resolve` never reaches the provider for this address, but the // report has to show what it would have said: when the database is confidently // wrong, a side-by-side is what makes that visible. const ip = "73.158.64.1"; { var cache = try Ip2location.Cache.init(allocator, io, config.ipwhois_cache_file); defer cache.deinit(); const key = Ip2location.packIp(ip).?; try cache.put(key.key, key.family, .{ .allocator = allocator, .name = "Provider Answer, Elsewhere", .coords = .{ .latitude = 11.25, .longitude = 22.5 }, .iso_country = Location.isoFrom("GB"), }); } var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); const e = try geoip.explain(ip, .{}); defer e.deinit(); // The database still wins, and is still what the server would serve. const db = e.database.hit() orelse return error.SkipZigTest; try std.testing.expectEqual(Source.database, e.result.?.source); try std.testing.expectEqualStrings(db.location.name, e.result.?.location.name); // And the provider's answer is reported, flagged as not the one in force. try std.testing.expect(!e.fallbackWasUsed()); const provider = e.fallback.location() orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings("Provider Answer, Elsewhere", provider.name); try std.testing.expectEqual(@as(f64, 11.25), provider.coords.latitude); try std.testing.expectEqual(Explanation.Fallback.cached, std.meta.activeTag(e.fallback)); // Reporting it must not have changed which units the handler applies: the // provider cache is only consulted for units when nothing earlier answered. const iso = e.country orelse return error.SkipZigTest; try std.testing.expectEqualStrings("US", &iso); } test "explain: does not reach the network for an unneeded provider answer" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPath(io, &path_buf); const dir = path_buf[0..dir_len]; var config = try testConfigIn(allocator, io, dir); defer config.deinit(allocator); var geoip = GeoIP.init(allocator, io, config.geolite_path, config) catch return error.SkipZigTest; defer geoip.deinit(); // Now that the provider is reported even when unused, the default must still // never fetch. Otherwise every run of this command would spend quota and // append to a cache that is never evicted. const e = try geoip.explain("73.158.64.1", .{}); defer e.deinit(); try std.testing.expectEqual(Explanation.Fallback.not_cached, std.meta.activeTag(e.fallback)); // A fetch would also have been recorded in the cache file. var cache = try Ip2location.Cache.init(allocator, io, config.ipwhois_cache_file); defer cache.deinit(); try std.testing.expectEqual(@as(usize, 0), cache.entries.count()); }