diff --git a/.gitignore b/.gitignore
index 77440ae..2d06a61 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,6 @@ git.properties
pom.xml.versionsBackup
application.yml
target/
+
+### MaxMind GeoIP2
+data/
diff --git a/pom.xml b/pom.xml
index 947f2ee..be11eb6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -165,6 +165,20 @@
compile
+
+
+ com.maxmind.geoip2
+ geoip2
+ 4.2.0
+
+
+
+
+ org.codehaus.plexus
+ plexus-archiver
+ 4.9.1
+
+
org.springframework.boot
diff --git a/src/main/java/xyz/mcutils/backend/model/server/BedrockMinecraftServer.java b/src/main/java/xyz/mcutils/backend/model/server/BedrockMinecraftServer.java
index d8dd366..d6e39a3 100644
--- a/src/main/java/xyz/mcutils/backend/model/server/BedrockMinecraftServer.java
+++ b/src/main/java/xyz/mcutils/backend/model/server/BedrockMinecraftServer.java
@@ -32,8 +32,8 @@ public final class BedrockMinecraftServer extends MinecraftServer {
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, motd, players);
+ @NonNull GameMode gamemode, GeoLocation location) {
+ super(hostname, ip, port, records, motd, players, location);
this.id = id;
this.edition = edition;
this.version = version;
@@ -53,7 +53,7 @@ public final class BedrockMinecraftServer extends MinecraftServer {
* @return the Bedrock Minecraft server
*/
@NonNull
- public static BedrockMinecraftServer create(@NonNull String hostname, String ip, int port, DNSRecord[] records, @NonNull String token) {
+ public static BedrockMinecraftServer create(@NonNull String hostname, String ip, int port, DNSRecord[] records, GeoLocation location, @NonNull String token) {
String[] split = token.split(";"); // Split the token
Edition edition = Edition.valueOf(split[0]);
Version version = new Version(Integer.parseInt(split[2]), split[3]);
@@ -70,7 +70,8 @@ public final class BedrockMinecraftServer extends MinecraftServer {
version,
players,
motd,
- gameMode
+ gameMode,
+ location
);
}
diff --git a/src/main/java/xyz/mcutils/backend/model/server/JavaMinecraftServer.java b/src/main/java/xyz/mcutils/backend/model/server/JavaMinecraftServer.java
index 3e8a34b..b657981 100644
--- a/src/main/java/xyz/mcutils/backend/model/server/JavaMinecraftServer.java
+++ b/src/main/java/xyz/mcutils/backend/model/server/JavaMinecraftServer.java
@@ -65,10 +65,10 @@ public final class JavaMinecraftServer extends MinecraftServer {
*/
private boolean mojangBlocked;
- public JavaMinecraftServer(String hostname, String ip, int port, MOTD motd, Players players, DNSRecord[] records,
- @NonNull Version version, Favicon favicon, ForgeModInfo modInfo, ForgeData forgeData,
- boolean preventsChatReports, boolean enforcesSecureChat, boolean previewsChat) {
- super(hostname, ip, port, records, motd, players);
+ public JavaMinecraftServer(String hostname, String ip, int port, MOTD motd, Players players, GeoLocation location,
+ DNSRecord[] records, @NonNull Version version, Favicon favicon, ForgeModInfo modInfo,
+ ForgeData forgeData, boolean preventsChatReports, boolean enforcesSecureChat, boolean previewsChat) {
+ super(hostname, ip, port, records, motd, players, location);
this.version = version;
this.favicon = favicon;
this.modInfo = modInfo;
@@ -88,7 +88,7 @@ public final class JavaMinecraftServer extends MinecraftServer {
* @return the Java Minecraft server
*/
@NonNull
- public static JavaMinecraftServer create(@NonNull String hostname, String ip, int port, DNSRecord[] records, @NonNull JavaServerStatusToken token) {
+ public static JavaMinecraftServer create(@NonNull String hostname, String ip, int port, DNSRecord[] records, GeoLocation location, @NonNull JavaServerStatusToken token) {
String motdString = token.getDescription() instanceof String ? (String) token.getDescription() : null;
if (motdString == null) { // Not a string motd, convert from Json
motdString = new TextComponent(ComponentSerializer.parse(Main.GSON.toJson(token.getDescription()))).toLegacyText();
@@ -99,6 +99,7 @@ public final class JavaMinecraftServer extends MinecraftServer {
port,
MinecraftServer.MOTD.create(motdString),
token.getPlayers(),
+ location,
records,
token.getVersion().detailedCopy(),
JavaMinecraftServer.Favicon.create(token.getFavicon(), ServerUtils.getAddress(hostname, port)),
diff --git a/src/main/java/xyz/mcutils/backend/model/server/MinecraftServer.java b/src/main/java/xyz/mcutils/backend/model/server/MinecraftServer.java
index 2e24aaa..fd23887 100644
--- a/src/main/java/xyz/mcutils/backend/model/server/MinecraftServer.java
+++ b/src/main/java/xyz/mcutils/backend/model/server/MinecraftServer.java
@@ -1,5 +1,6 @@
package xyz.mcutils.backend.model.server;
+import com.maxmind.geoip2.model.CityResponse;
import io.micrometer.common.lang.NonNull;
import lombok.*;
import xyz.mcutils.backend.common.ColorUtils;
@@ -48,6 +49,11 @@ public class MinecraftServer {
*/
private final Players players;
+ /**
+ * The location of the server.
+ */
+ private final GeoLocation location;
+
/**
* A platform a Minecraft
* server can operate on.
@@ -147,4 +153,51 @@ public class MinecraftServer {
@NonNull private final String name;
}
}
+
+ /**
+ * The location of the server.
+ */
+ @AllArgsConstructor @Getter
+ public static class GeoLocation {
+ /**
+ * The country of the server.
+ */
+ private final String country;
+
+ /**
+ * The region of the server.
+ */
+ private final String region;
+
+ /**
+ * The city of the server.
+ */
+ private final String city;
+
+ /**
+ * The latitude of the server.
+ */
+ private final double latitude;
+
+ /**
+ * The longitude of the server.
+ */
+ private final double longitude;
+
+ /**
+ * Gets the location of the server from Maxmind.
+ *
+ * @param response the response from Maxmind
+ * @return the location of the server
+ */
+ public static GeoLocation fromMaxMind(CityResponse response) {
+ return new GeoLocation(
+ response.getCountry().getName(),
+ response.getMostSpecificSubdivision().getName(),
+ response.getCity().getName(),
+ response.getLocation().getLatitude(),
+ response.getLocation().getLongitude()
+ );
+ }
+ }
}
\ No newline at end of file
diff --git a/src/main/java/xyz/mcutils/backend/service/MaxMindService.java b/src/main/java/xyz/mcutils/backend/service/MaxMindService.java
new file mode 100644
index 0000000..bdd13ca
--- /dev/null
+++ b/src/main/java/xyz/mcutils/backend/service/MaxMindService.java
@@ -0,0 +1,116 @@
+package xyz.mcutils.backend.service;
+
+import com.maxmind.geoip2.DatabaseReader;
+import com.maxmind.geoip2.exception.GeoIp2Exception;
+import com.maxmind.geoip2.model.CityResponse;
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+import org.codehaus.plexus.archiver.tar.TarGZipUnArchiver;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import xyz.mcutils.backend.Main;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.URI;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@Service
+@Log4j2(topic = "MaxMind Service")
+public class MaxMindService {
+ /**
+ * The MaxMind database.
+ */
+ private static DatabaseReader database;
+
+ /**
+ * The location of the MaxMind database.
+ */
+ private final String databaseName = "maxmind.mmdb";
+
+ /**
+ * The MaxMind license key.
+ */
+ private final String maxMindLicense;
+
+ public MaxMindService(@Value("${maxmind.license}") String maxMindLicense) {
+ this.maxMindLicense = maxMindLicense;
+
+ File databaseFile = loadDatabase();
+ try {
+ database = new DatabaseReader.Builder(databaseFile).build();
+ log.info("Loaded the MaxMind database from '{}'", databaseFile.getAbsolutePath());
+ } catch (Exception ex) {
+ log.error("Failed to load the MaxMind database, please check the configuration and try again", ex);
+ System.exit(1);
+ }
+ }
+
+ /**
+ * Lookup the GeoIP information for the query.
+ *
+ * @param query The query to lookup
+ * @return The GeoIP information
+ */
+ public static CityResponse lookup(String query) {
+ try {
+ return database.city(InetAddress.getByName(query));
+ } catch (IOException | GeoIp2Exception e) {
+ log.error("Failed to lookup the GeoIP information for '{}'", query, e);
+ throw new RuntimeException("Failed to lookup the GeoIP information for '%s'".formatted(query));
+ }
+ }
+
+ @SneakyThrows
+ private File loadDatabase() {
+ File database = new File("data", databaseName);
+ if (database.exists()) {
+ return database;
+ }
+
+ // Ensure the parent directories exist
+ database.getParentFile().mkdirs();
+
+ String downloadUrl = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz";
+ HttpResponse response = Main.HTTP_CLIENT.send(HttpRequest.newBuilder()
+ .uri(URI.create(downloadUrl.formatted(maxMindLicense)))
+ .build(), HttpResponse.BodyHandlers.ofFile(Files.createTempFile("maxmind", ".tar.gz")));
+ Path downloadedFile = response.body();
+
+ File tempDir = Files.createTempDirectory("maxmind").toFile();
+
+ TarGZipUnArchiver archiver = new TarGZipUnArchiver();
+ archiver.setSourceFile(downloadedFile.toFile());
+ archiver.setDestDirectory(tempDir);
+ archiver.extract();
+
+ File[] files = tempDir.listFiles();
+ if (files == null || files.length == 0) {
+ log.error("Failed to extract the MaxMind database");
+ System.exit(1);
+ }
+
+ // Search for the database file
+ for (File file : files) {
+ // The database is in a subdirectory
+ if (!file.isDirectory()) {
+ continue;
+ }
+
+ // Get the database file
+ File databaseFile = new File(file, "GeoLite2-City.mmdb");
+ if (!databaseFile.exists()) {
+ log.error("Failed to find the MaxMind database in the extracted files");
+ continue;
+ }
+ Files.copy(databaseFile.toPath(), database.toPath());
+ }
+
+ log.info("Downloaded and extracted the MaxMind database to '{}'", database.getAbsolutePath());
+ return database;
+ }
+}
diff --git a/src/main/java/xyz/mcutils/backend/service/pinger/impl/BedrockMinecraftServerPinger.java b/src/main/java/xyz/mcutils/backend/service/pinger/impl/BedrockMinecraftServerPinger.java
index 545fc72..8b4eb9c 100644
--- a/src/main/java/xyz/mcutils/backend/service/pinger/impl/BedrockMinecraftServerPinger.java
+++ b/src/main/java/xyz/mcutils/backend/service/pinger/impl/BedrockMinecraftServerPinger.java
@@ -7,6 +7,8 @@ import xyz.mcutils.backend.exception.impl.BadRequestException;
import xyz.mcutils.backend.exception.impl.ResourceNotFoundException;
import xyz.mcutils.backend.model.dns.DNSRecord;
import xyz.mcutils.backend.model.server.BedrockMinecraftServer;
+import xyz.mcutils.backend.model.server.MinecraftServer;
+import xyz.mcutils.backend.service.MaxMindService;
import xyz.mcutils.backend.service.pinger.MinecraftServerPinger;
import java.io.IOException;
@@ -55,7 +57,8 @@ public final class BedrockMinecraftServerPinger implements MinecraftServerPinger
if (response == null) { // No pong response
throw new ResourceNotFoundException("Server '%s' didn't respond to ping".formatted(hostname));
}
- return BedrockMinecraftServer.create(hostname, ip, port, records, response); // Return the server
+ return BedrockMinecraftServer.create(hostname, ip, port, records,
+ MinecraftServer.GeoLocation.fromMaxMind(MaxMindService.lookup(hostname)), response); // Return the server
} catch (IOException ex ) {
if (ex instanceof UnknownHostException) {
throw new BadRequestException("Unknown hostname '%s'".formatted(hostname));
diff --git a/src/main/java/xyz/mcutils/backend/service/pinger/impl/JavaMinecraftServerPinger.java b/src/main/java/xyz/mcutils/backend/service/pinger/impl/JavaMinecraftServerPinger.java
index 5ef49c3..71226e1 100644
--- a/src/main/java/xyz/mcutils/backend/service/pinger/impl/JavaMinecraftServerPinger.java
+++ b/src/main/java/xyz/mcutils/backend/service/pinger/impl/JavaMinecraftServerPinger.java
@@ -9,7 +9,9 @@ import xyz.mcutils.backend.exception.impl.BadRequestException;
import xyz.mcutils.backend.exception.impl.ResourceNotFoundException;
import xyz.mcutils.backend.model.dns.DNSRecord;
import xyz.mcutils.backend.model.server.JavaMinecraftServer;
+import xyz.mcutils.backend.model.server.MinecraftServer;
import xyz.mcutils.backend.model.token.JavaServerStatusToken;
+import xyz.mcutils.backend.service.MaxMindService;
import xyz.mcutils.backend.service.pinger.MinecraftServerPinger;
import java.io.DataInputStream;
@@ -24,6 +26,13 @@ import java.net.*;
public final class JavaMinecraftServerPinger implements MinecraftServerPinger {
private static final int TIMEOUT = 1500; // The timeout for the socket
+ /**
+ * Ping the server with the given hostname and port.
+ *
+ * @param hostname the hostname of the server
+ * @param port the port of the server
+ * @return the server that was pinged
+ */
@Override
public JavaMinecraftServer ping(String hostname, String ip, int port, DNSRecord[] records) {
log.info("Pinging {}:{}...", hostname, port);
@@ -43,7 +52,8 @@ public final class JavaMinecraftServerPinger implements MinecraftServerPinger