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>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-archiver</artifactId>
<version>4.9.2</version>
<scope>compile</scope>
</dependency>
<!-- DNS Lookup -->
<dependency>
@ -116,6 +122,14 @@
<scope>compile</scope>
</dependency>
<!-- GeoIP -->
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>4.2.0</version>
<scope>compile</scope>
</dependency>
<!-- SwaggerUI -->
<dependency>
<groupId>org.springdoc</groupId>

@ -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.
*/

@ -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);
}
/**

@ -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

@ -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<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.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<String> 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();