307 lines
12 KiB
Zig
307 lines
12 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 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),
|
|
};
|
|
}
|
|
|
|
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,
|
|
|
|
pub fn init(allocator: std.mem.Allocator, io: std.Io, db_path: []const u8, config: Config) !GeoIP {
|
|
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;
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *GeoIP) void {
|
|
c.MMDB_close(self.mmdb);
|
|
self.allocator.destroy(self.mmdb);
|
|
self.fallback_client.deinit(self.allocator);
|
|
}
|
|
|
|
pub fn lookup(self: *GeoIP, ip: []const u8) ?Location {
|
|
// Try MaxMind first
|
|
const result = lookupInternal(self.mmdb, ip) catch return null;
|
|
|
|
log.debug("lookup geoip db for ip {s}. Found: {}", .{ ip, result.found_entry });
|
|
if (result.found_entry)
|
|
if (self.extractCoordinates(ip, result)) |coords|
|
|
return coords;
|
|
|
|
// Fallback to configured online provider
|
|
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;
|
|
}
|
|
|
|
pub fn isUSIp(self: *GeoIP, ip: []const u8) bool {
|
|
var result = lookupInternal(self.mmdb, ip) catch return false;
|
|
if (!result.found_entry) return false;
|
|
|
|
// SAFETY: The C API will initialize this on the next line
|
|
var country_data: c.MMDB_entry_data_s = undefined;
|
|
const status = c.MMDB_get_value(&result.entry, &country_data, "country", "iso_code", @as([*c]const u8, null));
|
|
|
|
if (status != c.MMDB_SUCCESS or !country_data.has_data) {
|
|
log.info("lookup found result, but no country available in data for ip {s}. MMDB_get_value returned {d}", .{ ip, status });
|
|
return false;
|
|
}
|
|
|
|
const country_code = country_data.unnamed_0.utf8_string[0..country_data.data_size];
|
|
return std.mem.eql(u8, country_code, "US");
|
|
}
|
|
|
|
/// 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 IP2Location.
|
|
const max_accuracy_radius_km = 200;
|
|
|
|
fn extractCoordinates(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result_s) ?Location {
|
|
if (!result.found_entry) return null;
|
|
|
|
var entry_copy = result.entry;
|
|
|
|
// Check accuracy_radius first -- reject low-confidence entries so we
|
|
// fall back to the IP2Location online lookup instead.
|
|
// 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));
|
|
if (acc_status == c.MMDB_SUCCESS and accuracy_data.has_data) {
|
|
const radius = accuracy_data.unnamed_0.uint16;
|
|
if (radius > max_accuracy_radius_km) {
|
|
log.info("GeoLite2 accuracy_radius for ip {s} is {d} km (>{d} km threshold), falling back to IP2Location", .{ ip, radius, max_accuracy_radius_km });
|
|
return 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;
|
|
}
|
|
|
|
var coords = [_]f64{ latitude_data.unnamed_0.double_value, longitude_data.unnamed_0.double_value };
|
|
|
|
// Depending on how this is compiled, the byteswap may or may not be necessary
|
|
// original c, compiled with zig, statically linked: byteSwap
|
|
// pre=built, dynamically linked, do not byte swap
|
|
// I'm not sure precisely what causes this
|
|
std.mem.byteSwapAllElements(f64, &coords);
|
|
|
|
const latitude = coords[0];
|
|
const longitude = coords[1];
|
|
|
|
// 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
|
|
"";
|
|
|
|
const final_name = Location.buildDisplayName(self.allocator, city, subdivision, country, ip);
|
|
|
|
return .{
|
|
.allocator = self.allocator,
|
|
.name = final_name,
|
|
.coords = .{
|
|
.latitude = latitude,
|
|
.longitude = longitude,
|
|
},
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|