Geo location lookup
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 46s

This commit is contained in:
Braydon 2024-04-22 22:47:49 -04:00
parent 1392d82480
commit f3a57dc8d9
6 changed files with 306 additions and 14 deletions

@ -107,6 +107,12 @@
<version>1.20-R0.2</version> <version>1.20-R0.2</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<dependency>
<groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-archiver</artifactId>
<version>4.9.2</version>
<scope>compile</scope>
</dependency>
<!-- DNS Lookup --> <!-- DNS Lookup -->
<dependency> <dependency>
@ -116,6 +122,14 @@
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<!-- GeoIP -->
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>4.2.0</version>
<scope>compile</scope>
</dependency>
<!-- SwaggerUI --> <!-- SwaggerUI -->
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>

@ -23,6 +23,11 @@
*/ */
package me.braydon.mc.model; package me.braydon.mc.model;
import com.maxmind.geoip2.model.CityResponse;
import com.maxmind.geoip2.record.City;
import com.maxmind.geoip2.record.Continent;
import com.maxmind.geoip2.record.Country;
import com.maxmind.geoip2.record.Location;
import lombok.*; import lombok.*;
import me.braydon.mc.common.ColorUtils; import me.braydon.mc.common.ColorUtils;
import me.braydon.mc.model.dns.DNSRecord; import me.braydon.mc.model.dns.DNSRecord;
@ -41,7 +46,7 @@ import java.util.UUID;
* *
* @author Braydon * @author Braydon
*/ */
@AllArgsConstructor @Getter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString @AllArgsConstructor @Setter @Getter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString
public class MinecraftServer { public class MinecraftServer {
/** /**
* The hostname of this server. * The hostname of this server.
@ -63,6 +68,11 @@ public class MinecraftServer {
*/ */
@NonNull private final DNSRecord[] records; @NonNull private final DNSRecord[] records;
/**
* The Geo location of this server, null if unknown.
*/
private GeoLocation geo;
/** /**
* The player counts of this server. * The player counts of this server.
*/ */
@ -73,6 +83,74 @@ public class MinecraftServer {
*/ */
@NonNull private final MOTD motd; @NonNull private final MOTD motd;
/**
* The Geo location of a server.
*/
@AllArgsConstructor @Getter @ToString
public static class GeoLocation {
/**
* The continent of this server.
*/
@NonNull private final LocationData continent;
/**
* The country of this server.
*/
@NonNull private final LocationData country;
/**
* The city of this server, null if unknown.
*/
private final String city;
/**
* The latitude of this server.
*/
private final double latitude;
/**
* The longitude of this server.
*/
private final double longitude;
/**
* Create new geo location data
* from the given city response.
*
* @param geo the geo city response
* @return the geo location
*/
@NonNull
public static GeoLocation create(@NonNull CityResponse geo) {
Continent continent = geo.getContinent();
Country country = geo.getCountry();
City city = geo.getCity();
Location location = geo.getLocation();
return new GeoLocation(
new LocationData(continent.getCode(), continent.getName()),
new LocationData(country.getIsoCode(), country.getName()),
city == null ? null : city.getName(),
location.getLatitude(), location.getLongitude()
);
}
/**
* Data for a location.
*/
@AllArgsConstructor @Getter @ToString
public static class LocationData {
/**
* The location code.
*/
@NonNull private final String code;
/**
* The location name.
*/
@NonNull private final String name;
}
}
/** /**
* Player count data for a server. * Player count data for a server.
*/ */

@ -54,10 +54,10 @@ public final class BedrockMinecraftServer extends MinecraftServer {
*/ */
@NonNull private final GameMode gamemode; @NonNull private final GameMode gamemode;
private BedrockMinecraftServer(@NonNull String id, @NonNull String hostname, String ip, int port, @NonNull DNSRecord[] records, private BedrockMinecraftServer(@NonNull String id, @NonNull String hostname, String ip, int port, GeoLocation geo,
@NonNull Edition edition, @NonNull Version version, @NonNull Players players, @NonNull MOTD motd, @NonNull DNSRecord[] records, @NonNull Edition edition, @NonNull Version version,
@NonNull GameMode gamemode) { @NonNull Players players, @NonNull MOTD motd, @NonNull GameMode gamemode) {
super(hostname, ip, port, records, players, motd); super(hostname, ip, port, records, geo, players, motd);
this.id = id; this.id = id;
this.edition = edition; this.edition = edition;
this.version = version; this.version = version;
@ -83,7 +83,7 @@ public final class BedrockMinecraftServer extends MinecraftServer {
Players players = new Players(Integer.parseInt(split[4]), Integer.parseInt(split[5]), null); Players players = new Players(Integer.parseInt(split[4]), Integer.parseInt(split[5]), null);
MOTD motd = MOTD.create(split[1] + "\n" + split[7]); MOTD motd = MOTD.create(split[1] + "\n" + split[7]);
GameMode gameMode = new GameMode(split[8], split.length > 9 ? Integer.parseInt(split[9]) : -1); GameMode gameMode = new GameMode(split[8], split.length > 9 ? Integer.parseInt(split[9]) : -1);
return new BedrockMinecraftServer(split[6], hostname, ip, port, records, edition, version, players, motd, gameMode); return new BedrockMinecraftServer(split[6], hostname, ip, port, null, records, edition, version, players, motd, gameMode);
} }
/** /**

@ -123,11 +123,12 @@ public final class JavaMinecraftServer extends MinecraftServer {
*/ */
private boolean mojangBanned; private boolean mojangBanned;
private JavaMinecraftServer(@NonNull String hostname, String ip, int port, @NonNull DNSRecord[] records, @NonNull Version version, private JavaMinecraftServer(@NonNull String hostname, String ip, int port, @NonNull DNSRecord[] records, GeoLocation geo,
@NonNull Players players, @NonNull MOTD motd, Favicon favicon, String software, Plugin[] plugins, @NonNull Version version, @NonNull Players players, @NonNull MOTD motd, Favicon favicon,
ModInfo modInfo, ForgeData forgeData, String world, boolean queryEnabled, boolean previewsChat, String software, Plugin[] plugins, ModInfo modInfo, ForgeData forgeData, String world,
boolean enforcesSecureChat, boolean preventsChatReports, boolean mojangBanned) { boolean queryEnabled, boolean previewsChat, boolean enforcesSecureChat, boolean preventsChatReports,
super(hostname, ip, port, records, players, motd); boolean mojangBanned) {
super(hostname, ip, port, records, geo, players, motd);
this.version = version; this.version = version;
this.favicon = favicon; this.favicon = favicon;
this.software = software; this.software = software;
@ -173,7 +174,7 @@ public final class JavaMinecraftServer extends MinecraftServer {
} }
String world = challengeStatusToken == null ? null : challengeStatusToken.getMap(); // The main server world String world = challengeStatusToken == null ? null : challengeStatusToken.getMap(); // The main server world
return new JavaMinecraftServer(hostname, ip, port, records, statusToken.getVersion().detailedCopy(), Players.create(statusToken.getPlayers()), return new JavaMinecraftServer(hostname, ip, port, records, null, statusToken.getVersion().detailedCopy(), Players.create(statusToken.getPlayers()),
MOTD.create(motdString), Favicon.create(statusToken.getFavicon(), hostname), software, plugins, statusToken.getModInfo(), MOTD.create(motdString), Favicon.create(statusToken.getFavicon(), hostname), software, plugins, statusToken.getModInfo(),
statusToken.getForgeData(), world, challengeStatusToken != null, statusToken.isPreviewsChat(), statusToken.getForgeData(), world, challengeStatusToken != null, statusToken.isPreviewsChat(),
statusToken.isEnforcesSecureChat(), statusToken.isPreventsChatReports(), false statusToken.isEnforcesSecureChat(), statusToken.isPreventsChatReports(), false

@ -23,14 +23,189 @@
*/ */
package me.braydon.mc.service; package me.braydon.mc.service;
import com.maxmind.db.CHMCache;
import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.model.CityResponse;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.*;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.io.FileUtils;
import org.codehaus.plexus.archiver.tar.TarGZipUnArchiver;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.net.InetAddress;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
/** /**
* @author Braydon * @author Braydon
*/ */
@Service @Service @Log4j2(topic = "MaxMind")
public final class MaxMindService { public final class MaxMindService {
/**
* The directory to store databases.
*/
private static final File DATABASES_DIRECTORY = new File("databases");
/**
* The endpoint to download database files from.
*/
private static final String DATABASE_DOWNLOAD_ENDPOINT = "https://download.maxmind.com/app/geoip_download?edition_id=%s&license_key=%s&suffix=tar.gz";
@Value("${maxmind.license}") @Value("${maxmind.license}")
private String license; private String license;
/**
* The currently loaded databases.
*/
private final Map<Database, DatabaseReader> databases = new HashMap<>();
@PostConstruct
public void onInitialize() {
loadDatabases(); // Load the databases
}
/**
* Load the databases.
*/
@SneakyThrows
private void loadDatabases() {
log.info("Loading databases...");
// Create the directory if it doesn't exist
if (!DATABASES_DIRECTORY.exists()) {
DATABASES_DIRECTORY.mkdirs();
}
// Download missing databases
for (Database database : Database.values()) {
File databaseFile = new File(DATABASES_DIRECTORY, database.getEdition() + ".mmdb");
if (!databaseFile.exists()) { // Doesn't exist, download it
downloadDatabase(database, databaseFile);
}
// Load the database and store it
databases.put(database, new DatabaseReader.Builder(databaseFile)
.withCache(new CHMCache()) // Enable caching
.build()
);
log.info("Loaded database {}", database.getEdition());
}
log.info("Loaded {} database(s)", databases.size());
}
/**
* Lookup a city by the given address.
*
* @param address the address
* @return the city response
*/
@SneakyThrows @NonNull
public CityResponse lookupCity(@NonNull InetAddress address) {
return getDatabase(Database.CITY).city(address);
}
/**
* Download the required files
* for the given database.
*
* @param database the database to download
* @param databaseFile the file for the database
*/
@SneakyThrows
private void downloadDatabase(@NonNull Database database, @NonNull File databaseFile) {
File downloadedFile = new File(DATABASES_DIRECTORY, database.getEdition() + ".tar.gz"); // The downloaded file
// Download the database if required
if (!downloadedFile.exists()) {
log.info("Downloading database {}...", database.getEdition());
long before = System.currentTimeMillis();
try (
BufferedInputStream inputStream = new BufferedInputStream(new URL(DATABASE_DOWNLOAD_ENDPOINT.formatted(database.getEdition(), license)).openStream());
FileOutputStream fileOutputStream = new FileOutputStream(downloadedFile)
) {
byte[] dataBuffer = new byte[1024];
int bytesRead;
while ((bytesRead = inputStream.read(dataBuffer, 0, 1024)) != -1) {
fileOutputStream.write(dataBuffer, 0, bytesRead);
}
}
log.info("Downloaded database {} in {}ms", database.getEdition(), System.currentTimeMillis() - before);
}
// Extract the database once downloaded
log.info("Extracting database {}...", database.getEdition());
TarGZipUnArchiver archiver = new TarGZipUnArchiver();
archiver.setSourceFile(downloadedFile);
archiver.setDestDirectory(DATABASES_DIRECTORY);
archiver.extract();
log.info("Extracted database {}", database.getEdition());
// Locate the database file in the extracted directory
File[] files = DATABASES_DIRECTORY.listFiles();
assert files != null; // Ensure files is present
dirLoop: for (File directory : files) {
if (!directory.isDirectory() || !directory.getName().startsWith(database.getEdition())) {
continue;
}
File[] downloadedFiles = directory.listFiles();
assert downloadedFiles != null; // Ensures downloaded files is present
// Find the file for the database, move it to the
// correct directory, and delete the downloaded contents
for (File file : downloadedFiles) {
if (file.isFile() && file.getName().equals(databaseFile.getName())) {
Files.move(file.toPath(), databaseFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
// Delete the downloaded contents
FileUtils.deleteDirectory(directory);
FileUtils.deleteQuietly(downloadedFile);
break dirLoop; // We're done here
}
}
}
}
/**
* Get the reader for the given database.
*
* @param database the database to get
* @return the database reader
*/
@NonNull
public DatabaseReader getDatabase(@NonNull Database database) {
return databases.get(database);
}
/**
* Cleanup when the app is destroyed.
*/
@PreDestroy @SneakyThrows
public void cleanup() {
for (DatabaseReader database : databases.values()) {
database.close();
}
databases.clear();
}
/**
* A database for MaxMind.
*/
@AllArgsConstructor @Getter @ToString
public enum Database {
CITY("GeoLite2-City");
/**
* The edition of this database.
*/
@NonNull private final String edition;
}
} }

@ -27,6 +27,7 @@ import com.google.common.base.Joiner;
import com.google.common.base.Splitter; import com.google.common.base.Splitter;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import com.google.common.hash.Hashing; import com.google.common.hash.Hashing;
import com.maxmind.geoip2.model.CityResponse;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.Getter; import lombok.Getter;
@ -65,6 +66,7 @@ import org.springframework.stereotype.Service;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -91,6 +93,11 @@ public final class MojangService {
private static final Splitter DOT_SPLITTER = Splitter.on('.'); private static final Splitter DOT_SPLITTER = Splitter.on('.');
private static final Joiner DOT_JOINER = Joiner.on('.'); private static final Joiner DOT_JOINER = Joiner.on('.');
/**
* The MaxMind service to use for Geo lookups.
*/
@NonNull private final MaxMindService maxMindService;
/** /**
* The cache repository for {@link Player}'s by their username. * The cache repository for {@link Player}'s by their username.
*/ */
@ -135,8 +142,9 @@ public final class MojangService {
private final ExpiringSet<String> blockedServersCache = new ExpiringSet<>(ExpirationPolicy.CREATED, 10L, TimeUnit.MINUTES); private final ExpiringSet<String> blockedServersCache = new ExpiringSet<>(ExpirationPolicy.CREATED, 10L, TimeUnit.MINUTES);
@Autowired @Autowired
public MojangService(@NonNull PlayerNameCacheRepository playerNameCache, @NonNull PlayerCacheRepository playerCache, public MojangService(@NonNull MaxMindService maxMindService, @NonNull PlayerNameCacheRepository playerNameCache, @NonNull PlayerCacheRepository playerCache,
@NonNull SkinPartTextureCacheRepository skinPartTextureCache, @NonNull MinecraftServerCacheRepository minecraftServerCache) { @NonNull SkinPartTextureCacheRepository skinPartTextureCache, @NonNull MinecraftServerCacheRepository minecraftServerCache) {
this.maxMindService = maxMindService;
this.playerNameCache = playerNameCache; this.playerNameCache = playerNameCache;
this.playerCache = playerCache; this.playerCache = playerCache;
this.skinPartTextureCache = skinPartTextureCache; this.skinPartTextureCache = skinPartTextureCache;
@ -454,11 +462,24 @@ public final class MojangService {
log.info("Resolved hostname: {} -> {}", hostname, ip); log.info("Resolved hostname: {} -> {}", hostname, ip);
} }
// Attempt to perform a Geo lookup on the server
CityResponse geo = null; // The server's Geo location
try {
log.info("Looking up Geo location data for {}...", ip);
geo = maxMindService.lookupCity(InetAddress.getByName(ip)); // Get the Geo location
} catch (Exception ex) {
log.error("Failed looking up Geo location data for %s:".formatted(ip), ex);
}
// Build our server model, cache it, and then return it // Build our server model, cache it, and then return it
MinecraftServer response = platform.getPinger().ping(hostname, ip, port, records.toArray(new DNSRecord[0])); // Ping the server and await a response MinecraftServer response = platform.getPinger().ping(hostname, ip, port, records.toArray(new DNSRecord[0])); // Ping the server and await a response
if (response == null) { // No response from ping if (response == null) { // No response from ping
throw new ResourceNotFoundException("Server didn't respond to ping"); throw new ResourceNotFoundException("Server didn't respond to ping");
} }
if (geo != null) { // Update Geo location data in the server if present
response.setGeo(MinecraftServer.GeoLocation.create(geo));
}
CachedMinecraftServer minecraftServer = new CachedMinecraftServer( CachedMinecraftServer minecraftServer = new CachedMinecraftServer(
platform.name() + "-" + lookupHostname, response, System.currentTimeMillis() platform.name() + "-" + lookupHostname, response, System.currentTimeMillis()
); );
@ -567,6 +588,9 @@ public final class MojangService {
return blocked; return blocked;
} }
/**
* Cleanup when the app is destroyed.
*/
@PreDestroy @PreDestroy
public void cleanup() { public void cleanup() {
mojangServerStatuses.clear(); mojangServerStatuses.clear();