Skip to content

Commit 07e6932

Browse files
authored
IPinfo geolocation support (#114311)
1 parent bee1d91 commit 07e6932

File tree

5 files changed

+205
-5
lines changed

5 files changed

+205
-5
lines changed

modules/ingest-geoip/src/main/java/org/elasticsearch/ingest/geoip/Database.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,19 @@ enum Database {
169169
Property.TYPE
170170
),
171171
Set.of(Property.IP, Property.ASN, Property.ORGANIZATION_NAME, Property.NETWORK)
172-
);
172+
),
173+
CityV2(
174+
Set.of(
175+
Property.IP,
176+
Property.COUNTRY_ISO_CODE,
177+
Property.REGION_NAME,
178+
Property.CITY_NAME,
179+
Property.TIMEZONE,
180+
Property.LOCATION,
181+
Property.POSTAL_CODE
182+
),
183+
Set.of(Property.COUNTRY_ISO_CODE, Property.REGION_NAME, Property.CITY_NAME, Property.LOCATION)
184+
),;
173185

174186
private final Set<Property> properties;
175187
private final Set<Property> defaultProperties;

modules/ingest-geoip/src/main/java/org/elasticsearch/ingest/geoip/IpinfoIpDataLookups.java

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,25 @@ static Long parseAsn(final String asn) {
5858
}
5959
}
6060

61+
/**
62+
* Lax-ly parses a string that contains a double into a Double (or null, if such parsing isn't possible).
63+
* @param latlon a potentially empty (or null) string that is expected to contain a parsable double
64+
* @return the parsed double
65+
*/
66+
static Double parseLocationDouble(final String latlon) {
67+
if (latlon == null || Strings.hasText(latlon) == false) {
68+
return null;
69+
} else {
70+
String stripped = latlon.trim();
71+
try {
72+
return Double.parseDouble(stripped);
73+
} catch (NumberFormatException e) {
74+
logger.trace("Unable to parse non-compliant location string [{}]", latlon);
75+
return null;
76+
}
77+
}
78+
}
79+
6180
public record AsnResult(
6281
Long asn,
6382
@Nullable String country, // not present in the free asn database
@@ -88,6 +107,31 @@ public record CountryResult(
88107
public CountryResult {}
89108
}
90109

110+
public record GeolocationResult(
111+
String city,
112+
String country,
113+
Double latitude,
114+
Double longitude,
115+
String postalCode,
116+
String region,
117+
String timezone
118+
) {
119+
@SuppressWarnings("checkstyle:RedundantModifier")
120+
@MaxMindDbConstructor
121+
public GeolocationResult(
122+
@MaxMindDbParameter(name = "city") String city,
123+
@MaxMindDbParameter(name = "country") String country,
124+
@MaxMindDbParameter(name = "latitude") String latitude,
125+
@MaxMindDbParameter(name = "longitude") String longitude,
126+
// @MaxMindDbParameter(name = "network") String network, // for now we're not exposing this
127+
@MaxMindDbParameter(name = "postal_code") String postalCode,
128+
@MaxMindDbParameter(name = "region") String region,
129+
@MaxMindDbParameter(name = "timezone") String timezone
130+
) {
131+
this(city, country, parseLocationDouble(latitude), parseLocationDouble(longitude), postalCode, region, timezone);
132+
}
133+
}
134+
91135
static class Asn extends AbstractBase<AsnResult> {
92136
Asn(Set<Database.Property> properties) {
93137
super(properties, AsnResult.class);
@@ -183,6 +227,65 @@ protected Map<String, Object> transform(final Result<CountryResult> result) {
183227
}
184228
}
185229

230+
static class Geolocation extends AbstractBase<GeolocationResult> {
231+
Geolocation(final Set<Database.Property> properties) {
232+
super(properties, GeolocationResult.class);
233+
}
234+
235+
@Override
236+
protected Map<String, Object> transform(final Result<GeolocationResult> result) {
237+
GeolocationResult response = result.result;
238+
239+
Map<String, Object> data = new HashMap<>();
240+
for (Database.Property property : this.properties) {
241+
switch (property) {
242+
case IP -> data.put("ip", result.ip);
243+
case COUNTRY_ISO_CODE -> {
244+
String countryIsoCode = response.country;
245+
if (countryIsoCode != null) {
246+
data.put("country_iso_code", countryIsoCode);
247+
}
248+
}
249+
case REGION_NAME -> {
250+
String subdivisionName = response.region;
251+
if (subdivisionName != null) {
252+
data.put("region_name", subdivisionName);
253+
}
254+
}
255+
case CITY_NAME -> {
256+
String cityName = response.city;
257+
if (cityName != null) {
258+
data.put("city_name", cityName);
259+
}
260+
}
261+
case TIMEZONE -> {
262+
String locationTimeZone = response.timezone;
263+
if (locationTimeZone != null) {
264+
data.put("timezone", locationTimeZone);
265+
}
266+
}
267+
case POSTAL_CODE -> {
268+
String postalCode = response.postalCode;
269+
if (postalCode != null) {
270+
data.put("postal_code", postalCode);
271+
}
272+
}
273+
case LOCATION -> {
274+
Double latitude = response.latitude;
275+
Double longitude = response.longitude;
276+
if (latitude != null && longitude != null) {
277+
Map<String, Object> locationObject = new HashMap<>();
278+
locationObject.put("lat", latitude);
279+
locationObject.put("lon", longitude);
280+
data.put("location", locationObject);
281+
}
282+
}
283+
}
284+
}
285+
return data;
286+
}
287+
}
288+
186289
/**
187290
* Just a little record holder -- there's the data that we receive via the binding to our record objects from the Reader via the
188291
* getRecord call, but then we also need to capture the passed-in ip address that came from the caller as well as the network for

modules/ingest-geoip/src/test/java/org/elasticsearch/ingest/geoip/IpinfoIpDataLookupsTests.java

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import static java.util.Map.entry;
3939
import static org.elasticsearch.ingest.geoip.GeoIpTestUtils.copyDatabase;
4040
import static org.elasticsearch.ingest.geoip.IpinfoIpDataLookups.parseAsn;
41+
import static org.elasticsearch.ingest.geoip.IpinfoIpDataLookups.parseLocationDouble;
4142
import static org.hamcrest.Matchers.empty;
4243
import static org.hamcrest.Matchers.equalTo;
4344
import static org.hamcrest.Matchers.is;
@@ -72,6 +73,10 @@ public void testDatabasePropertyInvariants() {
7273
// the second ASN variant database is like a specialization of the ASN database
7374
assertThat(Sets.difference(Database.Asn.properties(), Database.AsnV2.properties()), is(empty()));
7475
assertThat(Database.Asn.defaultProperties(), equalTo(Database.AsnV2.defaultProperties()));
76+
77+
// the second City variant database is like a version of the ordinary City database but lacking many fields
78+
assertThat(Sets.difference(Database.CityV2.properties(), Database.City.properties()), is(empty()));
79+
assertThat(Sets.difference(Database.CityV2.defaultProperties(), Database.City.defaultProperties()), is(empty()));
7580
}
7681

7782
public void testParseAsn() {
@@ -88,6 +93,18 @@ public void testParseAsn() {
8893
assertThat(parseAsn("anythingelse"), nullValue());
8994
}
9095

96+
public void testParseLocationDouble() {
97+
// expected case: "123.45" is 123.45
98+
assertThat(parseLocationDouble("123.45"), equalTo(123.45));
99+
// defensive cases: null and empty becomes null, this is not expected fwiw
100+
assertThat(parseLocationDouble(null), nullValue());
101+
assertThat(parseLocationDouble(""), nullValue());
102+
// defensive cases: we strip whitespace
103+
assertThat(parseLocationDouble(" -123.45 "), equalTo(-123.45));
104+
// bottom case: a non-parsable string is null
105+
assertThat(parseLocationDouble("anythingelse"), nullValue());
106+
}
107+
91108
public void testAsn() throws IOException {
92109
assumeFalse("https://github.com/elastic/elasticsearch/issues/114266", Constants.WINDOWS);
93110
Path configDir = tmpDir;
@@ -100,7 +117,7 @@ public void testAsn() throws IOException {
100117

101118
// this is the 'free' ASN database (sample)
102119
try (DatabaseReaderLazyLoader loader = configDatabases.getDatabase("ip_asn_sample.mmdb")) {
103-
IpDataLookup lookup = new IpinfoIpDataLookups.Asn(Set.of(Database.Property.values()));
120+
IpDataLookup lookup = new IpinfoIpDataLookups.Asn(Database.AsnV2.properties());
104121
Map<String, Object> data = lookup.getData(loader, "5.182.109.0");
105122
assertThat(
106123
data,
@@ -118,7 +135,7 @@ public void testAsn() throws IOException {
118135

119136
// this is the non-free or 'standard' ASN database (sample)
120137
try (DatabaseReaderLazyLoader loader = configDatabases.getDatabase("asn_sample.mmdb")) {
121-
IpDataLookup lookup = new IpinfoIpDataLookups.Asn(Set.of(Database.Property.values()));
138+
IpDataLookup lookup = new IpinfoIpDataLookups.Asn(Database.AsnV2.properties());
122139
Map<String, Object> data = lookup.getData(loader, "23.53.116.0");
123140
assertThat(
124141
data,
@@ -185,7 +202,7 @@ public void testCountry() throws IOException {
185202

186203
// this is the 'free' Country database (sample)
187204
try (DatabaseReaderLazyLoader loader = configDatabases.getDatabase("ip_country_sample.mmdb")) {
188-
IpDataLookup lookup = new IpinfoIpDataLookups.Country(Set.of(Database.Property.values()));
205+
IpDataLookup lookup = new IpinfoIpDataLookups.Country(Database.Country.properties());
189206
Map<String, Object> data = lookup.getData(loader, "4.221.143.168");
190207
assertThat(
191208
data,
@@ -202,6 +219,74 @@ public void testCountry() throws IOException {
202219
}
203220
}
204221

222+
public void testGeolocation() throws IOException {
223+
assumeFalse("https://github.com/elastic/elasticsearch/issues/114266", Constants.WINDOWS);
224+
Path configDir = tmpDir;
225+
copyDatabase("ipinfo/ip_geolocation_sample.mmdb", configDir.resolve("ip_geolocation_sample.mmdb"));
226+
227+
GeoIpCache cache = new GeoIpCache(1000); // real cache to test purging of entries upon a reload
228+
ConfigDatabases configDatabases = new ConfigDatabases(configDir, cache);
229+
configDatabases.initialize(resourceWatcherService);
230+
231+
// this is the non-free or 'standard' Geolocation database (sample)
232+
try (DatabaseReaderLazyLoader loader = configDatabases.getDatabase("ip_geolocation_sample.mmdb")) {
233+
IpDataLookup lookup = new IpinfoIpDataLookups.Geolocation(Database.CityV2.properties());
234+
Map<String, Object> data = lookup.getData(loader, "2.124.90.182");
235+
assertThat(
236+
data,
237+
equalTo(
238+
Map.ofEntries(
239+
entry("ip", "2.124.90.182"),
240+
entry("country_iso_code", "GB"),
241+
entry("region_name", "England"),
242+
entry("city_name", "London"),
243+
entry("timezone", "Europe/London"),
244+
entry("postal_code", "E1W"),
245+
entry("location", Map.of("lat", 51.50853, "lon", -0.12574))
246+
)
247+
)
248+
);
249+
}
250+
}
251+
252+
public void testGeolocationInvariants() {
253+
assumeFalse("https://github.com/elastic/elasticsearch/issues/114266", Constants.WINDOWS);
254+
Path configDir = tmpDir;
255+
copyDatabase("ipinfo/ip_geolocation_sample.mmdb", configDir.resolve("ip_geolocation_sample.mmdb"));
256+
257+
{
258+
final Set<String> expectedColumns = Set.of(
259+
"network",
260+
"city",
261+
"region",
262+
"country",
263+
"postal_code",
264+
"timezone",
265+
"latitude",
266+
"longitude"
267+
);
268+
269+
Path databasePath = configDir.resolve("ip_geolocation_sample.mmdb");
270+
assertDatabaseInvariants(databasePath, (ip, row) -> {
271+
assertThat(row.keySet(), equalTo(expectedColumns));
272+
{
273+
String latitude = (String) row.get("latitude");
274+
assertThat(latitude, equalTo(latitude.trim()));
275+
Double parsed = parseLocationDouble(latitude);
276+
assertThat(parsed, notNullValue());
277+
assertThat(latitude, equalTo(Double.toString(parsed))); // reverse it
278+
}
279+
{
280+
String longitude = (String) row.get("longitude");
281+
assertThat(longitude, equalTo(longitude.trim()));
282+
Double parsed = parseLocationDouble(longitude);
283+
assertThat(parsed, notNullValue());
284+
assertThat(longitude, equalTo(Double.toString(parsed))); // reverse it
285+
}
286+
});
287+
}
288+
}
289+
205290
private static void assertDatabaseInvariants(final Path databasePath, final BiConsumer<InetAddress, Map<String, Object>> rowConsumer) {
206291
try (Reader reader = new Reader(pathToFile(databasePath))) {
207292
Networks<?> networks = reader.networks(Map.class);

modules/ingest-geoip/src/test/java/org/elasticsearch/ingest/geoip/MaxMindSupportTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ public class MaxMindSupportTests extends ESTestCase {
361361

362362
private static final Set<Class<? extends AbstractResponse>> KNOWN_UNSUPPORTED_RESPONSE_CLASSES = Set.of(IpRiskResponse.class);
363363

364-
private static final Set<Database> KNOWN_UNSUPPORTED_DATABASE_VARIANTS = Set.of(Database.AsnV2);
364+
private static final Set<Database> KNOWN_UNSUPPORTED_DATABASE_VARIANTS = Set.of(Database.AsnV2, Database.CityV2);
365365

366366
public void testMaxMindSupport() {
367367
for (Database databaseType : Database.values()) {
Binary file not shown.

0 commit comments

Comments
 (0)