add IpWhoIs as default fallback
All checks were successful
Generic zig build / build (push) Successful in 1m17s
Generic zig build / deploy (push) Successful in 3m5s

This commit is contained in:
Emil Lerch 2026-03-05 14:29:15 -08:00
parent 315eb73bfa
commit 72b3c624d1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 239 additions and 30 deletions

View file

@ -2,13 +2,19 @@ const std = @import("std");
const Config = @This(); const Config = @This();
pub const GeoIpFallback = enum {
ipwhois,
ip2location,
};
listen_host: []const u8, listen_host: []const u8,
listen_port: u16, listen_port: u16,
cache_size: usize, cache_size: usize,
cache_dir: []const u8, cache_dir: []const u8,
/// GeoLite2 is used for GeoIP (IP -> geographic location) /// GeoLite2 is used for GeoIP (IP -> geographic location)
/// IP2Location is a fallback if IP is not found in this db /// When GeoLite2 data is missing or low-confidence, the configured
/// fallback provider is used (ipwho.is by default, or IP2Location)
geolite_path: []const u8, geolite_path: []const u8,
/// Geocache file stores location lookups /// Geocache file stores location lookups
@ -16,11 +22,18 @@ geolite_path: []const u8,
/// a web service from Nominatum (https://nominatim.org/) is used /// a web service from Nominatum (https://nominatim.org/) is used
geocache_file: ?[]const u8, geocache_file: ?[]const u8,
/// Which online service to use as a fallback when GeoLite2 has no data.
/// Default: ipwhois (ipwho.is). Alternative: ip2location (ip2location.io)
geoip_fallback: GeoIpFallback,
/// If provided, when GeoLite2 is missing data, https://www.ip2location.com/ /// If provided, when GeoLite2 is missing data, https://www.ip2location.com/
/// can be used. This will also be cached in the cached file /// can be used. This will also be cached in the cached file
ip2location_api_key: ?[]const u8, ip2location_api_key: ?[]const u8,
ip2location_cache_file: []const u8, ip2location_cache_file: []const u8,
/// Cache file for ipwho.is lookups
ipwhois_cache_file: []const u8,
pub fn load(allocator: std.mem.Allocator) !Config { pub fn load(allocator: std.mem.Allocator) !Config {
var env = try std.process.getEnvMap(allocator); var env = try std.process.getEnvMap(allocator);
defer env.deinit(); defer env.deinit();
@ -54,6 +67,12 @@ pub fn load(allocator: std.mem.Allocator) !Config {
}); });
}, },
.geocache_file = if (env.get("WTTR_GEOCACHE_FILE")) |v| try allocator.dupe(u8, v) else try std.fs.path.join(allocator, &[_][]const u8{ default_cache_dir, "geocache.json" }), .geocache_file = if (env.get("WTTR_GEOCACHE_FILE")) |v| try allocator.dupe(u8, v) else try std.fs.path.join(allocator, &[_][]const u8{ default_cache_dir, "geocache.json" }),
.geoip_fallback = blk: {
if (env.get("WTTR_GEOIP_FALLBACK")) |v| {
if (std.mem.eql(u8, v, "ip2location")) break :blk .ip2location;
}
break :blk .ipwhois;
},
.ip2location_api_key = if (env.get("IP2LOCATION_API_KEY")) |v| try allocator.dupe(u8, v) else null, .ip2location_api_key = if (env.get("IP2LOCATION_API_KEY")) |v| try allocator.dupe(u8, v) else null,
.ip2location_cache_file = blk: { .ip2location_cache_file = blk: {
if (env.get("IP2LOCATION_CACHE_FILE")) |v| { if (env.get("IP2LOCATION_CACHE_FILE")) |v| {
@ -61,6 +80,12 @@ pub fn load(allocator: std.mem.Allocator) !Config {
} }
break :blk try std.fmt.allocPrint(allocator, "{s}/ip2location.cache", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir}); break :blk try std.fmt.allocPrint(allocator, "{s}/ip2location.cache", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
}, },
.ipwhois_cache_file = blk: {
if (env.get("IPWHOIS_CACHE_FILE")) |v| {
break :blk try allocator.dupe(u8, v);
}
break :blk try std.fmt.allocPrint(allocator, "{s}/ipwhois.cache", .{env.get("WTTR_CACHE_DIR") orelse default_cache_dir});
},
}; };
} }
@ -71,6 +96,7 @@ pub fn deinit(self: Config, allocator: std.mem.Allocator) void {
if (self.geocache_file) |f| allocator.free(f); if (self.geocache_file) |f| allocator.free(f);
if (self.ip2location_api_key) |k| allocator.free(k); if (self.ip2location_api_key) |k| allocator.free(k);
allocator.free(self.ip2location_cache_file); allocator.free(self.ip2location_cache_file);
allocator.free(self.ipwhois_cache_file);
} }
test "config loads defaults" { test "config loads defaults" {

View file

@ -143,7 +143,7 @@ pub const MockHarness = struct {
const geoip = try allocator.create(GeoIp); const geoip = try allocator.create(GeoIp);
errdefer allocator.destroy(geoip); errdefer allocator.destroy(geoip);
geoip.* = GeoIp.init(allocator, config.geolite_path, null, config.ip2location_cache_file) catch geoip.* = GeoIp.init(allocator, config.geolite_path, config) catch
return error.SkipZigTest; return error.SkipZigTest;
errdefer geoip.deinit(); errdefer geoip.deinit();

View file

@ -1,6 +1,8 @@
const std = @import("std"); const std = @import("std");
const Ip2location = @import("Ip2location.zig"); const Ip2location = @import("Ip2location.zig");
const IpWhoIs = @import("IpWhoIs.zig");
const Location = @import("resolver.zig").Location; const Location = @import("resolver.zig").Location;
const Config = @import("../Config.zig");
const c = @cImport({ const c = @cImport({
@cInclude("maxminddb.h"); @cInclude("maxminddb.h");
@ -9,11 +11,36 @@ const c = @cImport({
const GeoIP = @This(); const GeoIP = @This();
const log = std.log.scoped(.geoip); const log = std.log.scoped(.geoip);
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, mmdb: *c.MMDB_s,
ip2location_client: *Ip2location, fallback_client: FallbackClient,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, db_path: []const u8, api_key: ?[]const u8, cache_path: []const u8) !GeoIP { pub fn init(allocator: std.mem.Allocator, db_path: []const u8, config: Config) !GeoIP {
const path_z = try std.heap.c_allocator.dupeZ(u8, db_path); const path_z = try std.heap.c_allocator.dupeZ(u8, db_path);
defer std.heap.c_allocator.free(path_z); defer std.heap.c_allocator.free(path_z);
@ -24,22 +51,29 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, api_key: ?[]const
if (status != c.MMDB_SUCCESS) if (status != c.MMDB_SUCCESS)
return error.CannotOpenDatabase; return error.CannotOpenDatabase;
const client: *Ip2location = try allocator.create(Ip2location); const fallback_client: FallbackClient = switch (config.geoip_fallback) {
errdefer allocator.destroy(client); .ip2location => blk: {
client.* = try Ip2location.init(allocator, api_key, cache_path); const client = try allocator.create(Ip2location);
errdefer { errdefer allocator.destroy(client);
client.deinit(); client.* = try Ip2location.init(allocator, config.ip2location_api_key, config.ip2location_cache_file);
allocator.destroy(client); 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 },
std.log.info( );
"IP2Location fallback: {s} (cache: {s})", break :blk .{ .ip2location = client };
.{ if (api_key) |_| "key provided, 50k/mo limit" else "no key, 1k/day limit", cache_path }, },
); .ipwhois => blk: {
const client = try allocator.create(IpWhoIs);
errdefer allocator.destroy(client);
client.* = try IpWhoIs.init(allocator, config.ipwhois_cache_file);
std.log.info("GeoIP fallback: ipwho.is (cache: {s})", .{config.ipwhois_cache_file});
break :blk .{ .ipwhois = client };
},
};
return GeoIP{ return GeoIP{
.mmdb = mmdb, .mmdb = mmdb,
.ip2location_client = client, .fallback_client = fallback_client,
.allocator = allocator, .allocator = allocator,
}; };
} }
@ -47,8 +81,7 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, api_key: ?[]const
pub fn deinit(self: *GeoIP) void { pub fn deinit(self: *GeoIP) void {
c.MMDB_close(self.mmdb); c.MMDB_close(self.mmdb);
self.allocator.destroy(self.mmdb); self.allocator.destroy(self.mmdb);
self.ip2location_client.deinit(); self.fallback_client.deinit(self.allocator);
self.allocator.destroy(self.ip2location_client);
} }
pub fn lookup(self: *GeoIP, ip: []const u8) ?Location { pub fn lookup(self: *GeoIP, ip: []const u8) ?Location {
@ -60,8 +93,8 @@ pub fn lookup(self: *GeoIP, ip: []const u8) ?Location {
if (self.extractCoordinates(ip, result)) |coords| if (self.extractCoordinates(ip, result)) |coords|
return coords; return coords;
// Fallback to IP2Location // Fallback to configured online provider
return self.ip2location_client.lookup(ip); return self.fallback_client.lookup(ip);
} }
fn lookupInternal(mmdb: *c.MMDB_s, ip: []const u8) !c.MMDB_lookup_result_s { fn lookupInternal(mmdb: *c.MMDB_s, ip: []const u8) !c.MMDB_lookup_result_s {
@ -196,13 +229,14 @@ test "MMDB functions are callable" {
} }
test "GeoIP init with invalid path fails" { test "GeoIP init with invalid path fails" {
const result = GeoIP.init(std.testing.allocator, "/nonexistent/path.mmdb", null, ""); const config = try Config.load(std.testing.allocator);
defer config.deinit(std.testing.allocator);
const result = GeoIP.init(std.testing.allocator, "/nonexistent/path.mmdb", config);
try std.testing.expectError(error.CannotOpenDatabase, result); try std.testing.expectError(error.CannotOpenDatabase, result);
} }
test "isUSIp detects US IPs" { test "isUSIp detects US IPs" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const Config = @import("../Config.zig");
const config = try Config.load(allocator); const config = try Config.load(allocator);
defer config.deinit(allocator); defer config.deinit(allocator);
const build_options = @import("build_options"); const build_options = @import("build_options");
@ -213,7 +247,7 @@ test "isUSIp detects US IPs" {
try GeoLite2.ensureDatabase(std.testing.allocator, db_path); try GeoLite2.ensureDatabase(std.testing.allocator, db_path);
} }
var geoip = GeoIP.init(std.testing.allocator, db_path, null, config.ip2location_cache_file) catch var geoip = GeoIP.init(std.testing.allocator, db_path, config) catch
return error.SkipZigTest; return error.SkipZigTest;
defer geoip.deinit(); defer geoip.deinit();
@ -226,7 +260,6 @@ test "isUSIp detects US IPs" {
} }
test "lookup works" { test "lookup works" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const Config = @import("../Config.zig");
const config = try Config.load(allocator); const config = try Config.load(allocator);
defer config.deinit(allocator); defer config.deinit(allocator);
const build_options = @import("build_options"); const build_options = @import("build_options");
@ -237,7 +270,7 @@ test "lookup works" {
try GeoLite2.ensureDatabase(std.testing.allocator, db_path); try GeoLite2.ensureDatabase(std.testing.allocator, db_path);
} }
var geoip = GeoIP.init(std.testing.allocator, db_path, null, config.ip2location_cache_file) catch var geoip = GeoIP.init(std.testing.allocator, db_path, config) catch
return error.SkipZigTest; return error.SkipZigTest;
defer geoip.deinit(); defer geoip.deinit();

150
src/location/IpWhoIs.zig Normal file
View file

@ -0,0 +1,150 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const Location = @import("resolver.zig").Location;
const Cache = @import("Ip2location.zig").Cache;
const Self = @This();
const log = std.log.scoped(.ipwhois);
allocator: Allocator,
http_client: std.http.Client,
cache: *Cache,
pub fn init(allocator: Allocator, cache_path: []const u8) !Self {
const cache = try allocator.create(Cache);
errdefer allocator.destroy(cache);
cache.* = try .init(allocator, cache_path);
return .{
.allocator = allocator,
.http_client = std.http.Client{ .allocator = allocator },
.cache = cache,
};
}
pub fn deinit(self: *Self) void {
self.cache.deinit();
self.allocator.destroy(self.cache);
self.http_client.deinit();
}
pub fn lookup(self: *Self, ip_str: []const u8) ?Location {
// Parse IP to u128 for cache lookup
const addr = std.net.Address.parseIp(ip_str, 0) catch return null;
const ip_u128: u128 = switch (addr.any.family) {
std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))),
std.posix.AF.INET6 => std.mem.readInt(u128, @ptrCast(&addr.in6.sa.addr), .big),
else => return null,
};
const family: u8 = if (addr.any.family == std.posix.AF.INET) 4 else 6;
// Check cache first
if (self.cache.get(ip_u128)) |result|
return result;
// Fetch from API
const result = self.fetch(ip_str) catch |err| {
log.err("API lookup failed: {}", .{err});
return null;
};
// Store in cache
self.cache.put(ip_u128, family, result) catch |err| {
log.warn("Failed to cache result: {}", .{err});
};
return result;
}
fn fetch(self: *Self, ip_str: []const u8) !Location {
log.info("Fetching geolocation for IP {s}", .{ip_str});
if (@import("builtin").is_test) return error.LookupUnavailableInUnitTest;
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try w.writeAll("https://ipwho.is/");
try w.writeAll(ip_str);
// Request only the fields we need
try w.writeAll("?fields=city,region,country,latitude,longitude&output=json");
var response_buf: [4096]u8 = undefined;
var writer = std.Io.Writer.fixed(&response_buf);
const result = try self.http_client.fetch(.{
.location = .{ .url = w.buffered() },
.method = .GET,
.response_writer = &writer,
.extra_headers = &.{
.{ .name = "User-Agent", .value = "wttr.in" },
},
});
if (result.status != .ok) {
log.err("API returned status {}", .{result.status});
return error.ApiError;
}
const response_body = response_buf[0..writer.end];
// Parse JSON response
const parsed = try std.json.parseFromSlice(
std.json.Value,
self.allocator,
response_body,
.{},
);
defer parsed.deinit();
const obj = parsed.value.object;
// Check for success field
if (obj.get("success")) |success| {
if (success == .bool and !success.bool) {
const msg = if (obj.get("message")) |m| if (m == .string) m.string else "unknown" else "unknown";
log.err("API returned error for ip {s}: {s}", .{ ip_str, msg });
return error.ApiError;
}
}
const lat = obj.get("latitude") orelse return error.MissingLatitude;
const lon = obj.get("longitude") orelse return error.MissingLongitude;
if (lat != .float and lat != .integer) {
log.err("Latitude returned from ipwho.is for ip {s} is not a number", .{ip_str});
return error.MissingLatitude;
}
if (lon != .float and lon != .integer) {
log.err("Longitude returned from ipwho.is for ip {s} is not a number", .{ip_str});
return error.MissingLongitude;
}
const city = getString(obj, "city");
const region = getString(obj, "region");
const country = getString(obj, "country");
const display_name = Location.buildDisplayName(
self.allocator,
city,
region,
country,
ip_str,
);
const lat_val: f64 = if (lat == .float) @floatCast(lat.float) else @floatFromInt(lat.integer);
const lon_val: f64 = if (lon == .float) @floatCast(lon.float) else @floatFromInt(lon.integer);
return Location{
.allocator = self.allocator,
.name = display_name,
.coords = .{
.latitude = lat_val,
.longitude = lon_val,
},
};
}
inline fn getString(obj: std.json.ObjectMap, key: []const u8) []const u8 {
const maybe_val = obj.get(key);
if (maybe_val == null) return "";
if (maybe_val.? != .string) return "";
return maybe_val.?.string;
}

View file

@ -304,7 +304,7 @@ test "resolve IP address with GeoIP" {
try GeoLite2.ensureDatabase(allocator, config.geolite_path); try GeoLite2.ensureDatabase(allocator, config.geolite_path);
} }
var geoip = GeoIp.init(allocator, config.geolite_path, null, config.ip2location_cache_file) catch var geoip = GeoIp.init(allocator, config.geolite_path, config) catch
return error.SkipZigTest; return error.SkipZigTest;
defer geoip.deinit(); defer geoip.deinit();

View file

@ -38,12 +38,11 @@ pub fn main() !u8 {
// Ensure GeoLite2 database exists // Ensure GeoLite2 database exists
try GeoLite2.ensureDatabase(allocator, cfg.geolite_path); try GeoLite2.ensureDatabase(allocator, cfg.geolite_path);
// Initialize GeoIP database with optional IP2Location fallback // Initialize GeoIP database with configured fallback
var geoip = GeoIp.init( var geoip = GeoIp.init(
allocator, allocator,
cfg.geolite_path, cfg.geolite_path,
cfg.ip2location_api_key, cfg,
cfg.ip2location_cache_file,
) catch |err| { ) catch |err| {
std.log.err("Failed to load GeoIP database from {s}: {}", .{ cfg.geolite_path, err }); std.log.err("Failed to load GeoIP database from {s}: {}", .{ cfg.geolite_path, err });
return err; return err;
@ -105,4 +104,5 @@ test {
_ = @import("location/GeoCache.zig"); _ = @import("location/GeoCache.zig");
_ = @import("location/Airports.zig"); _ = @import("location/Airports.zig");
_ = @import("location/resolver.zig"); _ = @import("location/resolver.zig");
_ = @import("location/IpWhoIs.zig");
} }