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();