Added checking for Mojang blocked servers
All checks were successful
Deploy App / docker (ubuntu-latest, 2.44.0) (push) Successful in 1m2s

This commit is contained in:
Braydon 2024-04-08 00:57:19 -04:00
parent a8407a8ecc
commit ddd4bc105c
6 changed files with 309 additions and 14 deletions

@ -12,7 +12,7 @@ Hi there! Looking for usage? View the [Wiki](https://git.rainnny.club/Rainnny/RE
## TODO
- [x] Server Icon Route
- [ ] Unit Tests
- [ ] Blacklist Checking
- [x] Blacklist Checking
- [x] HTTP Codes in wiki
- [ ] Route for 3D player heads/body?

@ -89,6 +89,12 @@
<version>2.10.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>net.jodah</groupId>
<artifactId>expiringmap</artifactId>
<version>0.5.11</version>
<scope>compile</scope>
</dependency>
<!-- DNS Lookup -->
<dependency>

@ -0,0 +1,134 @@
package me.braydon.mc.common;
import lombok.NonNull;
import net.jodah.expiringmap.ExpirationPolicy;
import net.jodah.expiringmap.ExpiringMap;
import javax.annotation.concurrent.ThreadSafe;
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 <T> The type of element to store within this set
* @author Braydon
*/
@ThreadSafe
public final class ExpiringSet<T> implements Iterable<T> {
/**
* The internal cache for this set.
*/
@NonNull private final ExpiringMap<T, Long> 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<T> 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<T> getElements() {
return this.cache.keySet();
}
/**
* Returns an iterator over elements of type {@code T}.
*
* @return an Iterator.
*/
@Override @NonNull
public Iterator<T> iterator() {
return this.cache.keySet().iterator();
}
}

@ -689,6 +689,8 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* The controller for handling
* {@link MinecraftServer} related requests.
@ -721,12 +723,27 @@ public final class ServerController {
*/
@GetMapping("/{platform}/{hostname}")
@ResponseBody
public ResponseEntity<CachedMinecraftServer> getServer(@PathVariable @NonNull String platform,@PathVariable @NonNull String hostname)
public ResponseEntity<CachedMinecraftServer> getServer(@PathVariable @NonNull String platform, @PathVariable @NonNull String hostname)
throws BadRequestException, InvalidMinecraftServerPlatform, ResourceNotFoundException
{
return ResponseEntity.ofNullable(mojangService.getMinecraftServer(platform, hostname));
}
/**
* 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
*/
@GetMapping("/blocked/{hostname}")
@ResponseBody
public ResponseEntity<Map<String, Object>> isServerBlocked(@PathVariable @NonNull String hostname) {
return ResponseEntity.ok(Map.of(
"blocked", mojangService.isServerBlocked(hostname)
));
}
/**
* Get the server icon of a Minecraft
* server by its platform and hostname.

@ -677,14 +677,12 @@
package me.braydon.mc.model.server;
import com.google.gson.annotations.SerializedName;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import lombok.*;
import me.braydon.mc.RESTfulMC;
import me.braydon.mc.common.MinecraftVersion;
import me.braydon.mc.model.MinecraftServer;
import me.braydon.mc.model.token.JavaServerStatusToken;
import me.braydon.mc.service.MojangService;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.chat.ComponentSerializer;
@ -693,7 +691,7 @@ import net.md_5.bungee.chat.ComponentSerializer;
*
* @author Braydon
*/
@Getter
@Setter @Getter
public final class JavaMinecraftServer extends MinecraftServer {
/**
* The version information of this server.
@ -718,10 +716,15 @@ public final class JavaMinecraftServer extends MinecraftServer {
/**
* Is this server on the list
* of blocked servers by Mojang?
* <p>
* This value is later set by the
* {@link MojangService} when a server
* is requested.
* </p>
*
* @see <a href="https://wiki.vg/Mojang_API#Blocked_Servers">Mojang API</a>
*/
private final boolean mojangBanned;
private boolean mojangBanned;
private JavaMinecraftServer(@NonNull String hostname, String ip, int port, @NonNull Players players,
Favicon favicon, @NonNull MOTD motd, @NonNull Version version, ModInfo modInfo,

@ -676,7 +676,13 @@
*/
package me.braydon.mc.service;
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 jakarta.annotation.PostConstruct;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import me.braydon.mc.common.*;
import me.braydon.mc.common.web.JsonWebException;
@ -688,19 +694,23 @@ import me.braydon.mc.model.*;
import me.braydon.mc.model.cache.CachedMinecraftServer;
import me.braydon.mc.model.cache.CachedPlayer;
import me.braydon.mc.model.cache.CachedPlayerName;
import me.braydon.mc.model.server.JavaMinecraftServer;
import me.braydon.mc.model.token.MojangProfileToken;
import me.braydon.mc.model.token.MojangUsernameToUUIDToken;
import me.braydon.mc.repository.MinecraftServerCacheRepository;
import me.braydon.mc.repository.PlayerCacheRepository;
import me.braydon.mc.repository.PlayerNameCacheRepository;
import net.jodah.expiringmap.ExpirationPolicy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* A service for interacting with the Mojang API.
@ -714,12 +724,16 @@ public final class MojangService {
private static final String API_ENDPOINT = "https://api.mojang.com";
private static final String UUID_TO_PROFILE = SESSION_SERVER_ENDPOINT + "/session/minecraft/profile/%s";
private static final String USERNAME_TO_UUID = API_ENDPOINT + "/users/profiles/minecraft/%s";
private static final String FETCH_BLOCKED_SERVERS = SESSION_SERVER_ENDPOINT + "/blockedservers";
private static final int DEFAULT_PART_TEXTURE_SIZE = 128;
private static final int MAX_PART_TEXTURE_SIZE = 512;
private static final String DEFAULT_SERVER_ICON = "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";
private static final Splitter DOT_SPLITTER = Splitter.on('.');
private static final Joiner DOT_JOINER = Joiner.on('.');
/**
* The cache repository for {@link Player}'s by their username.
*/
@ -735,6 +749,24 @@ public final class MojangService {
*/
@NonNull private final MinecraftServerCacheRepository minecraftServerCache;
/**
* A list of banned server hashes provided by Mojang.
* <p>
* This is periodically fetched from Mojang, see
* {@link #fetchBlockedServers()} for more info.
* </p>
*
* @see <a href="https://wiki.vg/Mojang_API#Blocked_Servers">Mojang API</a>
*/
private List<String> bannedServerHashes;
/**
* A cache of blocked server hostnames.
*
* @see #isServerHostnameBlocked(String) for more
*/
private final ExpiringSet<String> blockedServersCache = new ExpiringSet<>(ExpirationPolicy.CREATED, 10L, TimeUnit.MINUTES);
@Autowired
public MojangService(@NonNull PlayerNameCacheRepository playerNameCache, @NonNull PlayerCacheRepository playerCache,
@NonNull MinecraftServerCacheRepository minecraftServerCache) {
@ -743,6 +775,18 @@ public final class MojangService {
this.minecraftServerCache = minecraftServerCache;
}
@PostConstruct
public void onInitialize() {
// Schedule a task to fetch blocked
// servers from Mojang every 15 minutes.
new Timer().scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
fetchBlockedServers();
}
}, 0L, 60L * 15L * 1000L);
}
/**
* Get the part of a skin texture for
* a player by their username or UUID.
@ -886,16 +930,63 @@ public final class MojangService {
return Base64.getDecoder().decode(icon); // Return the decoded favicon
}
/**
* 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<String> 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;
}
/**
* Resolve a Minecraft server on the given
* platform with the given hostname.
*
* @param platformName the name of the platform
* @param hostname the hostname of the server
* @param hostname the hostname of the server
* @return the resolved Minecraft server
* @throws BadRequestException if the hostname is unknown
* @throws BadRequestException if the hostname is unknown
* @throws InvalidMinecraftServerPlatform if the platform is invalid
* @throws ResourceNotFoundException if the server isn't found
* @throws ResourceNotFoundException if the server isn't found
*/
@NonNull
public CachedMinecraftServer getMinecraftServer(@NonNull String platformName, @NonNull String hostname)
@ -925,6 +1016,12 @@ public final class MojangService {
platform.getPinger().ping(hostname, port),
System.currentTimeMillis()
);
// Get the blocked status of the Java server
if (platform == MinecraftServer.Platform.JAVA) {
((JavaMinecraftServer) minecraftServer.getValue()).setMojangBanned(isServerBlocked(hostname));
}
minecraftServerCache.save(minecraftServer);
log.info("Cached server: {}", hostname);
minecraftServer.setCached(-1L); // Set to -1 to indicate it's not cached in the response
@ -967,4 +1064,42 @@ public final class MojangService {
throw ex;
}
}
/**
* 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<String> 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 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;
}
}