diff --git a/pom.xml b/pom.xml index 3c48c16..b44375c 100644 --- a/pom.xml +++ b/pom.xml @@ -55,65 +55,18 @@ - - org.projectlombok - lombok - 1.18.32 - provided - - - - org.apache.logging.log4j - log4j-api - 2.20.0 - compile - - - org.apache.logging.log4j - log4j-core - 2.20.0 - compile - - - org.yaml - snakeyaml - 2.2 - compile - - - com.google.code.gson - gson - 2.10.1 - compile - + org.springframework.boot spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-thymeleaf - compile - - - net.jodah - expiringmap - 0.5.11 - compile - - - org.apache.httpcomponents.client5 - httpclient5 - 5.3.1 - compile - - - - com.github.dnsjava - dnsjava - v3.5.2 - compile + + + + org.springframework.boot + spring-boot-starter-json + + @@ -132,6 +85,47 @@ jedis + + + org.projectlombok + lombok + 1.18.32 + provided + + + com.google.code.gson + gson + 2.10.1 + compile + + + net.jodah + expiringmap + 0.5.11 + compile + + + net.md-5 + bungeecord-chat + 1.20-R0.2 + compile + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + compile + + + + + com.github.dnsjava + dnsjava + v3.5.2 + compile + + org.springdoc @@ -140,25 +134,7 @@ compile - - - org.junit.jupiter - junit-jupiter-engine - 5.10.2 - test - - - org.junit.jupiter - junit-jupiter-api - 5.10.2 - test - - - org.springframework - spring-test - 6.1.5 - test - + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/cc.fascinated/common/ExpiringSet.java b/src/main/java/cc.fascinated/common/ExpiringSet.java new file mode 100644 index 0000000..2562eb4 --- /dev/null +++ b/src/main/java/cc.fascinated/common/ExpiringSet.java @@ -0,0 +1,132 @@ +package cc.fascinated.common; + +import lombok.NonNull; +import net.jodah.expiringmap.ExpirationPolicy; +import net.jodah.expiringmap.ExpiringMap; + +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * A simple set that expires elements after a certain + * amount of time, utilizing the {@link ExpiringMap} library. + * + * @param The type of element to store within this set + * @author Braydon + */ +public final class ExpiringSet implements Iterable { + /** + * The internal cache for this set. + */ + @NonNull private final ExpiringMap cache; + + /** + * The lifetime (in millis) of the elements in this set. + */ + private final long lifetime; + + public ExpiringSet(@NonNull ExpirationPolicy expirationPolicy, long duration, @NonNull TimeUnit timeUnit) { + this(expirationPolicy, duration, timeUnit, ignored -> {}); + } + + public ExpiringSet(@NonNull ExpirationPolicy expirationPolicy, long duration, @NonNull TimeUnit timeUnit, @NonNull Consumer onExpire) { + //noinspection unchecked + this.cache = ExpiringMap.builder() + .expirationPolicy(expirationPolicy) + .expiration(duration, timeUnit) + .expirationListener((key, ignored) -> onExpire.accept((T) key)) + .build(); + this.lifetime = timeUnit.toMillis(duration); // Get the lifetime in millis + } + + /** + * Add an element to this set. + * + * @param element the element + * @return whether the element was added + */ + public boolean add(@NonNull T element) { + boolean contains = contains(element); // Does this set already contain the element? + this.cache.put(element, System.currentTimeMillis() + this.lifetime); + return !contains; + } + + /** + * Get the entry time of an element in this set. + * + * @param element the element + * @return the entry time, -1 if not contained + */ + public long getEntryTime(@NonNull T element) { + return contains(element) ? this.cache.get(element) - this.lifetime : -1L; + } + + /** + * Check if an element is + * contained within this set. + * + * @param element the element + * @return whether the element is contained + */ + public boolean contains(@NonNull T element) { + Long timeout = this.cache.get(element); // Get the timeout for the element + return timeout != null && (timeout > System.currentTimeMillis()); + } + + /** + * Check if this set is empty. + * + * @return whether this set is empty + */ + public boolean isEmpty() { + return this.cache.isEmpty(); + } + + /** + * Get the size of this set. + * + * @return the size + */ + public int size() { + return this.cache.size(); + } + + /** + * Remove an element from this set. + * + * @param element the element + * @return whether the element was removed + */ + public boolean remove(@NonNull T element) { + return this.cache.remove(element) != null; + } + + /** + * Clear this set. + */ + public void clear() { + this.cache.clear(); + } + + /** + * Get the elements in this set. + * + * @return the elements + */ + @NonNull + public Set getElements() { + return this.cache.keySet(); + } + + /** + * Returns an iterator over elements of type {@code T}. + * + * @return an Iterator. + */ + @Override @NonNull + public Iterator iterator() { + return this.cache.keySet().iterator(); + } +} diff --git a/src/main/java/cc.fascinated/controller/ServerController.java b/src/main/java/cc.fascinated/controller/ServerController.java index 5a7f64d..26722e5 100644 --- a/src/main/java/cc.fascinated/controller/ServerController.java +++ b/src/main/java/cc.fascinated/controller/ServerController.java @@ -3,6 +3,7 @@ package cc.fascinated.controller; import cc.fascinated.common.ServerUtils; import cc.fascinated.common.Tuple; import cc.fascinated.model.cache.CachedMinecraftServer; +import cc.fascinated.service.MojangService; import cc.fascinated.service.ServerService; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -12,13 +13,21 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.Map; + @RestController @Tag(name = "Server Controller", description = "The Server Controller is used to get information about a server.") @RequestMapping(value = "/server/") public class ServerController { + private final ServerService serverService; + private final MojangService mojangService; + @Autowired - private ServerService serverService; + public ServerController(ServerService serverService, MojangService mojangService) { + this.serverService = serverService; + this.mojangService = mojangService; + } @ResponseBody @GetMapping(value = "/{platform}/{hostnameAndPort}", produces = MediaType.APPLICATION_JSON_VALUE) @@ -44,4 +53,13 @@ public class ServerController { .header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(ServerUtils.getAddress(hostname, port))) .body(serverService.getServerFavicon(hostname, port)); } + + @ResponseBody + @GetMapping(value = "/blocked/{hostname}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getServerBlockedStatus( + @Parameter(description = "The hostname of the server", example = "play.hypixel.net") @PathVariable String hostname) { + return ResponseEntity.ok(Map.of( + "banned", mojangService.isServerBlocked(hostname) + )); + } } diff --git a/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java b/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java index 6b660a6..d975775 100644 --- a/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java +++ b/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java @@ -24,7 +24,7 @@ public final class JavaServerStatusToken { /** * The motd of the server. */ - private final String description; + private final Object description; /** * The favicon of the server. diff --git a/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java b/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java index f59012a..8c5a257 100644 --- a/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java +++ b/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java @@ -1,11 +1,18 @@ package cc.fascinated.model.server; +import cc.fascinated.Main; import cc.fascinated.common.JavaMinecraftVersion; +import cc.fascinated.common.ServerUtils; import cc.fascinated.config.Config; +import cc.fascinated.model.mojang.JavaServerStatusToken; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NonNull; import lombok.Setter; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.chat.ComponentSerializer; + +import java.awt.*; /** * @author Braydon @@ -28,6 +35,11 @@ public final class JavaMinecraftServer extends MinecraftServer { */ private Favicon favicon; + /** + * The mojang banned status of the server. + */ + private boolean mojangBanned; + public JavaMinecraftServer(String hostname, String ip, int port, MOTD motd, @NonNull Version version, Players players, Favicon favicon) { super(hostname, ip, port, motd); this.version = version; @@ -35,6 +47,32 @@ public final class JavaMinecraftServer extends MinecraftServer { this.favicon = favicon; } + /** + * Create a new Java Minecraft server. + * + * @param hostname the hostname of the server + * @param ip the IP address of the server + * @param port the port of the server + * @param token the status token + * @return the Java Minecraft server + */ + @NonNull + public static JavaMinecraftServer create(@NonNull String hostname, String ip, int port, @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(); + } + return new JavaMinecraftServer( + hostname, + ip, + port, + MinecraftServer.MOTD.create(motdString), + token.getVersion().detailedCopy(), + token.getPlayers(), + JavaMinecraftServer.Favicon.create(token.getFavicon(), ServerUtils.getAddress(hostname, port)) + ); + } + @AllArgsConstructor @Getter public static class Version { /** diff --git a/src/main/java/cc.fascinated/service/MojangAPIService.java b/src/main/java/cc.fascinated/service/MojangAPIService.java deleted file mode 100644 index 54c6330..0000000 --- a/src/main/java/cc.fascinated/service/MojangAPIService.java +++ /dev/null @@ -1,40 +0,0 @@ -package cc.fascinated.service; - -import cc.fascinated.common.WebRequest; -import cc.fascinated.model.mojang.MojangProfile; -import cc.fascinated.model.mojang.MojangUsernameToUuid; -import lombok.extern.log4j.Log4j2; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -@Service @Log4j2 -public class MojangAPIService { - - @Value("${mojang.session-server}") - private String mojangSessionServerUrl; - - @Value("${mojang.api}") - private String mojangApiUrl; - - /** - * Gets the Session Server profile of the - * player with the given UUID. - * - * @param id the uuid or name of the player - * @return the profile - */ - public MojangProfile getProfile(String id) { - return WebRequest.getAsEntity(mojangSessionServerUrl + "/session/minecraft/profile/" + id, MojangProfile.class); - } - - /** - * Gets the UUID of the player using - * the name of the player. - * - * @param id the name of the player - * @return the profile - */ - public MojangUsernameToUuid getUuidFromUsername(String id) { - return WebRequest.getAsEntity(mojangApiUrl + "/users/profiles/minecraft/" + id, MojangUsernameToUuid.class); - } -} diff --git a/src/main/java/cc.fascinated/service/MojangService.java b/src/main/java/cc.fascinated/service/MojangService.java new file mode 100644 index 0000000..265d9b2 --- /dev/null +++ b/src/main/java/cc.fascinated/service/MojangService.java @@ -0,0 +1,166 @@ +package cc.fascinated.service; + +import cc.fascinated.common.ExpiringSet; +import cc.fascinated.common.WebRequest; +import cc.fascinated.model.mojang.MojangProfile; +import cc.fascinated.model.mojang.MojangUsernameToUuid; +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 io.micrometer.common.lang.NonNull; +import lombok.SneakyThrows; +import lombok.extern.log4j.Log4j2; +import net.jodah.expiringmap.ExpirationPolicy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service @Log4j2 +public class MojangService { + + private static final String SESSION_SERVER_ENDPOINT = "https://sessionserver.mojang.com"; + private static final String API_ENDPOINT = "https://api.mojang.com"; + private static final String FETCH_BLOCKED_SERVERS = SESSION_SERVER_ENDPOINT + "/blockedservers"; + private static final Splitter DOT_SPLITTER = Splitter.on('.'); + private static final Joiner DOT_JOINER = Joiner.on('.'); + + /** + * A list of banned server hashes provided by Mojang. + *

+ * This is periodically fetched from Mojang, see + * {@link #fetchBlockedServers()} for more info. + *

+ * + * @see Mojang API + */ + private List bannedServerHashes; + + /** + * A cache of blocked server hostnames. + * + * @see #isServerHostnameBlocked(String) for more + */ + private final ExpiringSet blockedServersCache = new ExpiringSet<>(ExpirationPolicy.CREATED, 10L, TimeUnit.MINUTES); + + public MojangService() { + new Timer().scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + fetchBlockedServers(); + } + }, 0L, 60L * 15L * 1000L); + } + + /** + * Fetch a list of blocked servers from Mojang. + */ + @SneakyThrows + private void fetchBlockedServers() { + try ( + InputStream inputStream = new URL(FETCH_BLOCKED_SERVERS).openStream(); + Scanner scanner = new Scanner(inputStream, StandardCharsets.UTF_8).useDelimiter("\n"); + ) { + List hashes = new ArrayList<>(); + while (scanner.hasNext()) { + hashes.add(scanner.next()); + } + bannedServerHashes = Collections.synchronizedList(hashes); + log.info("Fetched {} banned server hashes", bannedServerHashes.size()); + } + } + + /** + * Check if the server with the + * given hostname is blocked by Mojang. + * + * @param hostname the server hostname to check + * @return whether the hostname is blocked + */ + public boolean isServerBlocked(@NonNull String hostname) { + // Remove trailing dots + while (hostname.charAt(hostname.length() - 1) == '.') { + hostname = hostname.substring(0, hostname.length() - 1); + } + // Is the hostname banned? + if (isServerHostnameBlocked(hostname)) { + return true; + } + List splitDots = Lists.newArrayList(DOT_SPLITTER.split(hostname)); // Split the hostname by dots + boolean isIp = splitDots.size() == 4; // Is it an IP address? + if (isIp) { + for (String element : splitDots) { + try { + int part = Integer.parseInt(element); + if (part >= 0 && part <= 255) { // Ensure the part is within the valid range + continue; + } + } catch (NumberFormatException ignored) { + // Safely ignore, not a number + } + isIp = false; + break; + } + } + // Check if the hostname is blocked + if (!isIp && isServerHostnameBlocked("*." + hostname)) { + return true; + } + // Additional checks for the hostname + while (splitDots.size() > 1) { + splitDots.remove(isIp ? splitDots.size() - 1 : 0); + String starredPart = isIp ? DOT_JOINER.join(splitDots) + ".*" : "*." + DOT_JOINER.join(splitDots); + if (isServerHostnameBlocked(starredPart)) { + return true; + } + } + return false; + } + + /** + * Check if the hash for the given + * hostname is in the blocked server list. + * + * @param hostname the hostname to check + * @return whether the hostname is blocked + */ + private boolean isServerHostnameBlocked(@NonNull String hostname) { + // Check the cache first for the hostname + if (blockedServersCache.contains(hostname)) { + return true; + } + String hashed = Hashing.sha1().hashBytes(hostname.toLowerCase().getBytes(StandardCharsets.ISO_8859_1)).toString(); + boolean blocked = bannedServerHashes.contains(hashed); // Is the hostname blocked? + if (blocked) { // Cache the blocked hostname + blockedServersCache.add(hostname); + } + return blocked; + } + + /** + * Gets the Session Server profile of the + * player with the given UUID. + * + * @param id the uuid or name of the player + * @return the profile + */ + public MojangProfile getProfile(String id) { + return WebRequest.getAsEntity(SESSION_SERVER_ENDPOINT + "/session/minecraft/profile/" + id, MojangProfile.class); + } + + /** + * Gets the UUID of the player using + * the name of the player. + * + * @param id the name of the player + * @return the profile + */ + public MojangUsernameToUuid getUuidFromUsername(String id) { + return WebRequest.getAsEntity(API_ENDPOINT + "/users/profiles/minecraft/" + id, MojangUsernameToUuid.class); + } +} diff --git a/src/main/java/cc.fascinated/service/PlayerService.java b/src/main/java/cc.fascinated/service/PlayerService.java index 4bc23e4..4d8c718 100644 --- a/src/main/java/cc.fascinated/service/PlayerService.java +++ b/src/main/java/cc.fascinated/service/PlayerService.java @@ -24,12 +24,12 @@ import java.util.UUID; @Service @Log4j2 public class PlayerService { - private final MojangAPIService mojangAPIService; + private final MojangService mojangAPIService; private final PlayerCacheRepository playerCacheRepository; private final PlayerNameCacheRepository playerNameCacheRepository; @Autowired - public PlayerService(MojangAPIService mojangAPIService, PlayerCacheRepository playerCacheRepository, PlayerNameCacheRepository playerNameCacheRepository) { + public PlayerService(MojangService mojangAPIService, PlayerCacheRepository playerCacheRepository, PlayerNameCacheRepository playerNameCacheRepository) { this.mojangAPIService = mojangAPIService; this.playerCacheRepository = playerCacheRepository; this.playerNameCacheRepository = playerNameCacheRepository; diff --git a/src/main/java/cc.fascinated/service/ServerService.java b/src/main/java/cc.fascinated/service/ServerService.java index f0898e8..dcd51be 100644 --- a/src/main/java/cc.fascinated/service/ServerService.java +++ b/src/main/java/cc.fascinated/service/ServerService.java @@ -20,10 +20,12 @@ import java.util.Optional; public class ServerService { private static final String DEFAULT_SERVER_ICON = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAASFBMVEWwsLBBQUE9PT1JSUlFRUUuLi5MTEyzs7M0NDQ5OTlVVVVQUFAmJia5ubl+fn5zc3PFxcVdXV3AwMCJiYmUlJRmZmbQ0NCjo6OL5p+6AAAFVklEQVRYw+1W67K0KAzkJnIZdRAZ3/9NtzvgXM45dX7st1VbW7XBUVDSdEISRqn/5R+T82/+nsr/XZn/SHm/3x9/ArA/IP8qwPK433d44VubZ/XT6/cJy0L792VZfnDrcRznr86d748u92X5vtaxOe228zcCy+MSMpg/5SwRopsYMv8oigCwngbQhE/rzhwAYMpxnvMvHhgy/8AgByJolzb5pPqEbvtgMBBmtvkbgxKmaaIZ5TyPum6Viue6te241N+s+W6nOlucgjEx6Nay9zZta1XVxejW+Q5ZhhkDS31lgOTegjUBor33CQilbC2GYGy9y9bN8ytevjE4a2stajHDAgAcUkoYwzO6zQi8ZflC+XO0+exiuNa3OQtIJOCk13neUjv7VO7Asu/3LwDFeg37sQtQhy4lAQH6IR9ztca0E3oI5PtDAlJ1tHGplrJ12jjrrXPWYvXsU042Bl/qUr3B9qzPSKaovpvjgglYL2F1x+Zs7gIvpLYuq46wr3H5/RJxyvM6sXOY762oU4YZ3mAz1lpc9O3Y30VJUM/iWhBIib63II/LA4COEMxcSmrH4ddl/wTYe3RIO0vK2VI9wQy6AxRsJpb3AAALvXb6TxvUCYSdOQo5Mh0GySkJc7rB405GUEfzbbl/iFpPoNQVNUQAZG06nkI6RCABRqRA9IimH6Up5Mhybtu2IlewB2Sf6AmQ4ZU9rfBELvyA23Yub6LWWtUBgK3OB79L7FILLDKWd4wpxmMRAMoLQR1ItLoiWUmhFtjptab7LQDgRARliLITLrcBkHNp9VACUH1UDRQEYGuYxzyM9H0mBccQNnCkQ3Q1UHBaO6sNyw0CelEtBGXKSoE+fJWZh5GupyneMIkCOMESAniMAzMreLvuO+pnmBQSp4C+ELCiMSGVLPh7M023SSBAiAA5yPh2m0wigEbWKnw3qDrrscF00cciCATGwNQRAv2YGvyD4Y36QGhqOS4AcABAA88oGvBCRho5H2+UiW6EfyM1L5l8a56rqdvE6lFakc3ScVDOBNBUoFM8c1vgnhAG5VsAqMD6Q9IwwtAkR39iGEQF1ZBxgU+v9UGL6MBQYiTdJllIBtx5y0rixGdAZ1YysbS53TAVy3vf4aabEpt1T0HoB2Eg4Yv5OKNwyHgmNvPKaQAYLG3EIyIqcL6Fj5C2jhXL9EpCdRMROE5nCW3qm1vfR6wYh0HKGG3wY+JgLkUWQ/WMfI8oMvIWMY7aCncNxxpSmHRUCEzDdSR0+dRwIQaMWW1FE0AOGeKkx0OLwYanBK3qfC0BSmIlozkuFcvSkulckoIB2FbHWu0y9gMHsEapMMEoySNUA2RDrduxIqr5POQV2zZ++IBOwVrFO9THrtjU2uWsCMZjxXl88Hmeaz1rPdAqXyJl68F5RTtdvN1aIyYEAMAWJaCMHvon7s23jljlxoKBEgNv6LQ25/rZIQyOdwDO3jLsqE2nbVAil21LxqFpZ2xJ3CFuE33QCo7kfkfO8kpW6gdioxdzZDLOaMMwidzeKD0RxaD7cnHHsu0jVkW5oTwwMGI0lwwA36u2nMY8AKzErLW9JxFiteyzZsAAxY1vPe5Uf68lIDVjV8JZpPfjxbc/QuyRKdAQJaAdIA4tCTht+kQJ1I4nbdjfHxgpTSLyI19pb/iuK7+9YJaZCxEIKj79YZ6uDU8f97878teRN1FzA7OvquSrVKUgk+S6ROpJfA7GpN6RPkx4voshXgu91p7CGHeA+IY8dUUVXwT7PYw12Xsj0Lfh9X4ac9XgKW86cj8bPh8XmyDOD88FLoB+YPXp4YtyB3gBPXu98xeRI2zploVCBQAAAABJRU5ErkJggg=="; + private final MojangService mojangService; private final MinecraftServerCacheRepository serverCacheRepository; @Autowired - public ServerService(MinecraftServerCacheRepository serverCacheRepository) { + public ServerService(MojangService mojangService, MinecraftServerCacheRepository serverCacheRepository) { + this.mojangService = mojangService; this.serverCacheRepository = serverCacheRepository; } @@ -61,6 +63,12 @@ public class ServerService { platform.getPinger().ping(hostname, port), System.currentTimeMillis() ); + + if (platform == MinecraftServer.Platform.JAVA) { // Check if the server is blocked by Mojang + JavaMinecraftServer javaServer = (JavaMinecraftServer) server.getServer(); + javaServer.setMojangBanned(mojangService.isServerBlocked(hostname)); + } + log.info("Found server: {}:{}", hostname, port); serverCacheRepository.save(server); server.setCached(-1); // Indicate that the server is not cached diff --git a/src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java b/src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java index 0a29371..0085d62 100644 --- a/src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java +++ b/src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java @@ -51,15 +51,7 @@ public final class JavaMinecraftServerPinger implements MinecraftServerPinger