1400 lines
55 KiB
Zig
1400 lines
55 KiB
Zig
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());
|
|
}
|