wttr/src/location/GeoIp.zig
Emil Lerch 1e6a5e28ca
All checks were successful
Generic zig build / build (push) Successful in 1m18s
Generic zig build / deploy (push) Successful in 14s
enable ip2location unconditionally as it can work without an api key
2026-01-06 12:13:45 -08:00

200 lines
6.9 KiB
Zig

const std = @import("std");
const Coordinates = @import("../Coordinates.zig");
const Ip2location = @import("Ip2location.zig");
const c = @cImport({
@cInclude("maxminddb.h");
});
const GeoIP = @This();
const log = std.log.scoped(.geoip);
mmdb: *c.MMDB_s,
ip2location_client: *Ip2location,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, db_path: []const u8, api_key: ?[]const u8, cache_path: []const u8) !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 client: *Ip2location = try allocator.create(Ip2location);
errdefer allocator.destroy(client);
client.* = try Ip2location.init(allocator, api_key, cache_path);
errdefer {
client.deinit();
allocator.destroy(client);
}
std.log.info(
"IP2Location fallback: {s} (cache: {s})",
.{ if (api_key) |_| "key provided, 50k/mo limit" else "no key, 1k/day limit", cache_path },
);
return GeoIP{
.mmdb = mmdb,
.ip2location_client = client,
.allocator = allocator,
};
}
pub fn deinit(self: *GeoIP) void {
c.MMDB_close(self.mmdb);
self.allocator.destroy(self.mmdb);
self.ip2location_client.deinit();
log.debug("destroying client", .{});
self.allocator.destroy(self.ip2location_client);
}
pub fn lookup(self: *GeoIP, ip: []const u8) ?Coordinates {
// 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 IP2Location
return self.ip2location_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");
}
fn extractCoordinates(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result_s) ?Coordinates {
_ = self;
if (!result.found_entry) return null;
var 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];
return .{
.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 result = GeoIP.init(std.testing.allocator, "/nonexistent/path.mmdb", null, "");
try std.testing.expectError(error.CannotOpenDatabase, result);
}
test "isUSIp detects US IPs" {
const allocator = std.testing.allocator;
const Config = @import("../Config.zig");
const config = try Config.load(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, db_path);
}
var geoip = GeoIP.init(std.testing.allocator, db_path, null, config.ip2location_cache_file) 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 = @import("../Config.zig");
const config = try Config.load(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, db_path);
}
var geoip = GeoIP.init(std.testing.allocator, db_path, null, config.ip2location_cache_file) catch
return error.SkipZigTest;
defer geoip.deinit();
// Test that the function doesn't crash with various IPs
const maybe_coords = geoip.lookup("73.158.64.1");
try std.testing.expect(maybe_coords != null);
const coords = maybe_coords.?;
try std.testing.expectEqual(@as(f64, 37.5958), coords.latitude);
}