diff --git a/API/pom.xml b/API/pom.xml
index 56d68fb..42d4233 100644
--- a/API/pom.xml
+++ b/API/pom.xml
@@ -107,6 +107,12 @@
1.20-R0.2
compile
+
+ org.codehaus.plexus
+ plexus-archiver
+ 4.9.2
+ compile
+
@@ -116,6 +122,14 @@
compile
+
+
+ com.maxmind.geoip2
+ geoip2
+ 4.2.0
+ compile
+
+
org.springdoc
diff --git a/API/src/main/java/me/braydon/mc/model/MinecraftServer.java b/API/src/main/java/me/braydon/mc/model/MinecraftServer.java
index 0c72aeb..4530599 100644
--- a/API/src/main/java/me/braydon/mc/model/MinecraftServer.java
+++ b/API/src/main/java/me/braydon/mc/model/MinecraftServer.java
@@ -23,6 +23,11 @@
*/
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 me.braydon.mc.common.ColorUtils;
import me.braydon.mc.model.dns.DNSRecord;
@@ -41,7 +46,7 @@ import java.util.UUID;
*
* @author Braydon
*/
-@AllArgsConstructor @Getter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString
+@AllArgsConstructor @Setter @Getter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString
public class MinecraftServer {
/**
* The hostname of this server.
@@ -63,6 +68,11 @@ public class MinecraftServer {
*/
@NonNull private final DNSRecord[] records;
+ /**
+ * The Geo location of this server, null if unknown.
+ */
+ private GeoLocation geo;
+
/**
* The player counts of this server.
*/
@@ -73,6 +83,74 @@ public class MinecraftServer {
*/
@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.
*/
diff --git a/API/src/main/java/me/braydon/mc/model/server/BedrockMinecraftServer.java b/API/src/main/java/me/braydon/mc/model/server/BedrockMinecraftServer.java
index 0bae16f..db8f947 100644
--- a/API/src/main/java/me/braydon/mc/model/server/BedrockMinecraftServer.java
+++ b/API/src/main/java/me/braydon/mc/model/server/BedrockMinecraftServer.java
@@ -54,10 +54,10 @@ public final class BedrockMinecraftServer extends MinecraftServer {
*/
@NonNull private final GameMode gamemode;
- private BedrockMinecraftServer(@NonNull String id, @NonNull String hostname, String ip, int port, @NonNull DNSRecord[] records,
- @NonNull Edition edition, @NonNull Version version, @NonNull Players players, @NonNull MOTD motd,
- @NonNull GameMode gamemode) {
- super(hostname, ip, port, records, players, motd);
+ private BedrockMinecraftServer(@NonNull String id, @NonNull String hostname, String ip, int port, GeoLocation geo,
+ @NonNull DNSRecord[] records, @NonNull Edition edition, @NonNull Version version,
+ @NonNull Players players, @NonNull MOTD motd, @NonNull GameMode gamemode) {
+ super(hostname, ip, port, records, geo, players, motd);
this.id = id;
this.edition = edition;
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);
MOTD motd = MOTD.create(split[1] + "\n" + split[7]);
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);
}
/**
diff --git a/API/src/main/java/me/braydon/mc/model/server/JavaMinecraftServer.java b/API/src/main/java/me/braydon/mc/model/server/JavaMinecraftServer.java
index bcc3f0c..d967a85 100644
--- a/API/src/main/java/me/braydon/mc/model/server/JavaMinecraftServer.java
+++ b/API/src/main/java/me/braydon/mc/model/server/JavaMinecraftServer.java
@@ -123,11 +123,12 @@ public final class JavaMinecraftServer extends MinecraftServer {
*/
private boolean mojangBanned;
- private JavaMinecraftServer(@NonNull String hostname, String ip, int port, @NonNull DNSRecord[] records, @NonNull Version version,
- @NonNull Players players, @NonNull MOTD motd, Favicon favicon, String software, Plugin[] plugins,
- ModInfo modInfo, ForgeData forgeData, String world, boolean queryEnabled, boolean previewsChat,
- boolean enforcesSecureChat, boolean preventsChatReports, boolean mojangBanned) {
- super(hostname, ip, port, records, players, motd);
+ private JavaMinecraftServer(@NonNull String hostname, String ip, int port, @NonNull DNSRecord[] records, GeoLocation geo,
+ @NonNull Version version, @NonNull Players players, @NonNull MOTD motd, Favicon favicon,
+ String software, Plugin[] plugins, ModInfo modInfo, ForgeData forgeData, String world,
+ boolean queryEnabled, boolean previewsChat, boolean enforcesSecureChat, boolean preventsChatReports,
+ boolean mojangBanned) {
+ super(hostname, ip, port, records, geo, players, motd);
this.version = version;
this.favicon = favicon;
this.software = software;
@@ -173,7 +174,7 @@ public final class JavaMinecraftServer extends MinecraftServer {
}
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(),
statusToken.getForgeData(), world, challengeStatusToken != null, statusToken.isPreviewsChat(),
statusToken.isEnforcesSecureChat(), statusToken.isPreventsChatReports(), false
diff --git a/API/src/main/java/me/braydon/mc/service/MaxMindService.java b/API/src/main/java/me/braydon/mc/service/MaxMindService.java
index 8c71667..31b4b0f 100644
--- a/API/src/main/java/me/braydon/mc/service/MaxMindService.java
+++ b/API/src/main/java/me/braydon/mc/service/MaxMindService.java
@@ -23,14 +23,189 @@
*/
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.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
*/
-@Service
+@Service @Log4j2(topic = "MaxMind")
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}")
private String license;
+
+ /**
+ * The currently loaded databases.
+ */
+ private final Map 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;
+ }
}
\ No newline at end of file
diff --git a/API/src/main/java/me/braydon/mc/service/MojangService.java b/API/src/main/java/me/braydon/mc/service/MojangService.java
index 5c37bb4..9477004 100644
--- a/API/src/main/java/me/braydon/mc/service/MojangService.java
+++ b/API/src/main/java/me/braydon/mc/service/MojangService.java
@@ -27,6 +27,7 @@ import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.collect.Lists;
import com.google.common.hash.Hashing;
+import com.maxmind.geoip2.model.CityResponse;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
@@ -65,6 +66,7 @@ import org.springframework.stereotype.Service;
import java.awt.image.BufferedImage;
import java.io.InputStream;
+import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URL;
import java.nio.charset.StandardCharsets;
@@ -91,6 +93,11 @@ public final class MojangService {
private static final Splitter DOT_SPLITTER = Splitter.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.
*/
@@ -135,8 +142,9 @@ public final class MojangService {
private final ExpiringSet blockedServersCache = new ExpiringSet<>(ExpirationPolicy.CREATED, 10L, TimeUnit.MINUTES);
@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) {
+ this.maxMindService = maxMindService;
this.playerNameCache = playerNameCache;
this.playerCache = playerCache;
this.skinPartTextureCache = skinPartTextureCache;
@@ -454,11 +462,24 @@ public final class MojangService {
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
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
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(
platform.name() + "-" + lookupHostname, response, System.currentTimeMillis()
);
@@ -567,6 +588,9 @@ public final class MojangService {
return blocked;
}
+ /**
+ * Cleanup when the app is destroyed.
+ */
@PreDestroy
public void cleanup() {
mojangServerStatuses.clear();