diff --git a/src/main/java/cc.fascinated/common/ServerUtils.java b/src/main/java/cc.fascinated/common/ServerUtils.java index 97e45a6..3c0a33c 100644 --- a/src/main/java/cc.fascinated/common/ServerUtils.java +++ b/src/main/java/cc.fascinated/common/ServerUtils.java @@ -13,10 +13,7 @@ public class ServerUtils { */ public static Tuple getHostnameAndPort(String hostname) { String[] split = hostname.split(":"); - if (split.length == 1) { - return new Tuple<>(split[0], 25565); - } - return new Tuple<>(split[0], Integer.parseInt(split[1])); + return new Tuple<>(split[0], split.length == 1 ? -1 : Integer.parseInt(split[1])); } /** diff --git a/src/main/java/cc.fascinated/common/packet/MinecraftBedrockPacket.java b/src/main/java/cc.fascinated/common/packet/MinecraftBedrockPacket.java new file mode 100644 index 0000000..dcf0b2d --- /dev/null +++ b/src/main/java/cc.fascinated/common/packet/MinecraftBedrockPacket.java @@ -0,0 +1,23 @@ +package cc.fascinated.common.packet; + +import lombok.NonNull; + +import java.io.IOException; +import java.net.DatagramSocket; + +/** + * Represents a packet in the + * Minecraft Bedrock protocol. + * + * @author Braydon + * @see Protocol Docs + */ +public interface MinecraftBedrockPacket { + /** + * Process this packet. + * + * @param socket the socket to process the packet for + * @throws IOException if an I/O error occurs + */ + void process(@NonNull DatagramSocket socket) throws IOException; +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/common/packet/impl/bedrock/BedrockPacketUnconnectedPing.java b/src/main/java/cc.fascinated/common/packet/impl/bedrock/BedrockPacketUnconnectedPing.java new file mode 100644 index 0000000..3e9a9b6 --- /dev/null +++ b/src/main/java/cc.fascinated/common/packet/impl/bedrock/BedrockPacketUnconnectedPing.java @@ -0,0 +1,42 @@ +package cc.fascinated.common.packet.impl.bedrock; + +import cc.fascinated.common.packet.MinecraftBedrockPacket; +import lombok.NonNull; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * This packet is sent by the client to the server to + * request a pong response from the server. The server + * will respond with a string containing the server's status. + * + * @author Braydon + * @see Protocol Docs + */ +public final class BedrockPacketUnconnectedPing implements MinecraftBedrockPacket { + private static final byte ID = 0x01; // The ID of the packet + private static final byte[] MAGIC = { 0, -1, -1, 0, -2, -2, -2, -2, -3, -3, -3, -3, 18, 52, 86, 120 }; + + /** + * Process this packet. + * + * @param socket the socket to process the packet for + * @throws IOException if an I/O error occurs + */ + @Override + public void process(@NonNull DatagramSocket socket) throws IOException { + // Construct the packet buffer + ByteBuffer buffer = ByteBuffer.allocate(33).order(ByteOrder.LITTLE_ENDIAN);; + buffer.put(ID); // Packet ID + buffer.putLong(System.currentTimeMillis()); // Timestamp + buffer.put(MAGIC); // Magic + buffer.putLong(0L); // Client GUID + + // Send the packet + socket.send(new DatagramPacket(buffer.array(), 0, buffer.limit())); + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/common/packet/impl/bedrock/BedrockPacketUnconnectedPong.java b/src/main/java/cc.fascinated/common/packet/impl/bedrock/BedrockPacketUnconnectedPong.java new file mode 100644 index 0000000..f2e0627 --- /dev/null +++ b/src/main/java/cc.fascinated/common/packet/impl/bedrock/BedrockPacketUnconnectedPong.java @@ -0,0 +1,62 @@ +package cc.fascinated.common.packet.impl.bedrock; + +import cc.fascinated.common.packet.MinecraftBedrockPacket; +import cc.fascinated.model.server.BedrockMinecraftServer; +import lombok.Getter; +import lombok.NonNull; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +/** + * This packet is sent by the server to the client in + * response to the {@link BedrockPacketUnconnectedPing}. + * + * @author Braydon + * @see Protocol Docs + */ +@Getter +public final class BedrockPacketUnconnectedPong implements MinecraftBedrockPacket { + private static final byte ID = 0x1C; // The ID of the packet + + /** + * The response from the server, null if none. + */ + private String response; + + /** + * Process this packet. + * + * @param socket the socket to process the packet for + * @throws IOException if an I/O error occurs + */ + @Override + public void process(@NonNull DatagramSocket socket) throws IOException { + // Handle receiving of the packet + byte[] receiveData = new byte[2048]; + DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length); + socket.receive(receivePacket); + + // Construct a buffer from the received packet + ByteBuffer buffer = ByteBuffer.wrap(receivePacket.getData()).order(ByteOrder.LITTLE_ENDIAN); + byte id = buffer.get(); // The received packet id + if (id == ID) { + String response = new String(buffer.array(), StandardCharsets.UTF_8).trim(); // Extract the response + + // Trim the length of the response (short) from the + // start of the string, which begins with the edition name + for (BedrockMinecraftServer.Edition edition : BedrockMinecraftServer.Edition.values()) { + int startIndex = response.indexOf(edition.name()); + if (startIndex != -1) { + response = response.substring(startIndex); + break; + } + } + this.response = response; + } + } +} \ No newline at end of file diff --git a/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java b/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java index d975775..cb4aa0a 100644 --- a/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java +++ b/src/main/java/cc.fascinated/model/mojang/JavaServerStatusToken.java @@ -1,6 +1,7 @@ package cc.fascinated.model.mojang; import cc.fascinated.model.server.JavaMinecraftServer; +import cc.fascinated.model.server.MinecraftServer; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.ToString; @@ -19,7 +20,7 @@ public final class JavaServerStatusToken { /** * The players on the server. */ - private final JavaMinecraftServer.Players players; + private final MinecraftServer.Players players; /** * The motd of the server. diff --git a/src/main/java/cc.fascinated/model/server/BedrockMinecraftServer.java b/src/main/java/cc.fascinated/model/server/BedrockMinecraftServer.java new file mode 100644 index 0000000..82e5114 --- /dev/null +++ b/src/main/java/cc.fascinated/model/server/BedrockMinecraftServer.java @@ -0,0 +1,109 @@ +package cc.fascinated.model.server; + +import lombok.*; + +/** + * A Bedrock edition {@link MinecraftServer}. + * + * @author Braydon + */ +@Getter @ToString(callSuper = true) @EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = true) +public final class BedrockMinecraftServer extends MinecraftServer { + /** + * The unique ID of this server. + */ + @EqualsAndHashCode.Include @NonNull private final String uniqueId; + + /** + * The edition of this server. + */ + @NonNull private final Edition edition; + + /** + * The version information of this server. + */ + @NonNull private final Version version; + + /** + * The gamemode of this server. + */ + @NonNull private final GameMode gamemode; + + private BedrockMinecraftServer(@NonNull String uniqueId, @NonNull String hostname, String ip, int port, + @NonNull Edition edition, @NonNull Version version, @NonNull Players players, + @NonNull MOTD motd, @NonNull GameMode gamemode) { + super(hostname, ip, port, motd, players); + this.uniqueId = uniqueId; + this.edition = edition; + this.version = version; + this.gamemode = gamemode; + } + + /** + * Create a new Bedrock 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 Bedrock Minecraft server + */ + @NonNull + public static BedrockMinecraftServer create(@NonNull String hostname, String ip, int port, @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]); + 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], Integer.parseInt(split[9])); + return new BedrockMinecraftServer(split[6], hostname, ip, port, edition, version, players, motd, gameMode); + } + + /** + * The edition of a Bedrock server. + */ + @AllArgsConstructor @Getter + public enum Edition { + /** + * Minecraft: Pocket Edition. + */ + MCPE, + + /** + * Minecraft: Education Edition. + */ + MCEE + } + + /** + * Version information for a server. + */ + @AllArgsConstructor @Getter @ToString + public static class Version { + /** + * The protocol version of the server. + */ + private final int protocol; + + /** + * The version name of the server. + */ + @NonNull private final String name; + } + + /** + * The gamemode of a server. + */ + @AllArgsConstructor @Getter @ToString + public static class GameMode { + /** + * The name of this gamemode. + */ + @NonNull private final String name; + + /** + * The numeric of this gamemode. + */ + private final int numericId; + } +} diff --git a/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java b/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java index 8c5a257..70f7877 100644 --- a/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java +++ b/src/main/java/cc.fascinated/model/server/JavaMinecraftServer.java @@ -12,8 +12,6 @@ import lombok.Setter; import net.md_5.bungee.api.chat.TextComponent; import net.md_5.bungee.chat.ComponentSerializer; -import java.awt.*; - /** * @author Braydon */ @@ -25,11 +23,6 @@ public final class JavaMinecraftServer extends MinecraftServer { */ @NonNull private final Version version; - /** - * The players on the server. - */ - private Players players; - /** * The favicon of the server. */ @@ -41,9 +34,8 @@ public final class JavaMinecraftServer extends MinecraftServer { 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); + super(hostname, ip, port, motd, players); this.version = version; - this.players = players; this.favicon = favicon; } @@ -117,25 +109,6 @@ public final class JavaMinecraftServer extends MinecraftServer { } - @Getter @AllArgsConstructor - public static class Players { - - /** - * The maximum amount of players the server can hold. - */ - private final int max; - - /** - * The amount of players currently online. - */ - private final int online; - - /** - * The sample of players currently online. - */ - private final String[] sample; - } - @Getter @AllArgsConstructor public static class Favicon { diff --git a/src/main/java/cc.fascinated/model/server/MinecraftServer.java b/src/main/java/cc.fascinated/model/server/MinecraftServer.java index 0ee7e18..f4889d3 100644 --- a/src/main/java/cc.fascinated/model/server/MinecraftServer.java +++ b/src/main/java/cc.fascinated/model/server/MinecraftServer.java @@ -2,6 +2,7 @@ package cc.fascinated.model.server; import cc.fascinated.common.ColorUtils; import cc.fascinated.service.pinger.MinecraftServerPinger; +import cc.fascinated.service.pinger.impl.BedrockMinecraftServerPinger; import cc.fascinated.service.pinger.impl.JavaMinecraftServerPinger; import io.micrometer.common.lang.NonNull; import lombok.AllArgsConstructor; @@ -9,17 +10,38 @@ import lombok.Getter; import lombok.ToString; import java.util.Arrays; +import java.util.UUID; /** * @author Braydon */ @AllArgsConstructor @Getter @ToString public class MinecraftServer { + /** + * The hostname of the server. + */ private final String hostname; + + /** + * The IP address of the server. + */ private final String ip; + + /** + * The port of the server. + */ private final int port; + + /** + * The motd for the server. + */ private final MOTD motd; + /** + * The players on the server. + */ + private Players players; + /** * A platform a Minecraft * server can operate on. @@ -29,7 +51,12 @@ public class MinecraftServer { /** * The Java edition of Minecraft. */ - JAVA(new JavaMinecraftServerPinger(), 25565); + JAVA(new JavaMinecraftServerPinger(), 25565), + + /** + * The Bedrock edition of Minecraft. + */ + BEDROCK(new BedrockMinecraftServerPinger(), 19132); /** * The server pinger for this platform. @@ -77,4 +104,41 @@ public class MinecraftServer { ); } } + + /** + * Player count data for a server. + */ + @AllArgsConstructor @Getter @ToString + public static class Players { + /** + * The online players on this server. + */ + private final int online; + + /** + * The maximum allowed players on this server. + */ + private final int max; + + /** + * A sample of players on this server, null or empty if no sample. + */ + private final Sample[] sample; + + /** + * A sample player. + */ + @AllArgsConstructor @Getter @ToString + public static class Sample { + /** + * The unique id of this player. + */ + @NonNull private final UUID id; + + /** + * The name of this player. + */ + @NonNull private final String name; + } + } } \ No newline at end of file diff --git a/src/main/java/cc.fascinated/service/MojangService.java b/src/main/java/cc.fascinated/service/MojangService.java index 265d9b2..f588c43 100644 --- a/src/main/java/cc.fascinated/service/MojangService.java +++ b/src/main/java/cc.fascinated/service/MojangService.java @@ -12,7 +12,6 @@ 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; diff --git a/src/main/java/cc.fascinated/service/ServerService.java b/src/main/java/cc.fascinated/service/ServerService.java index dcd51be..a0994cb 100644 --- a/src/main/java/cc.fascinated/service/ServerService.java +++ b/src/main/java/cc.fascinated/service/ServerService.java @@ -38,14 +38,15 @@ public class ServerService { * @return the server */ public CachedMinecraftServer getServer(String platformName, String hostname, int port) { - log.info("Getting server: {} {}:{}", platformName, hostname, port); MinecraftServer.Platform platform = EnumUtils.getEnumConstant(MinecraftServer.Platform.class, platformName.toUpperCase()); if (platform == null) { - log.info("Invalid platform: {} for server {}:{}", platformName, hostname, port); + log.info("Invalid platform: {} for server {}", platformName, hostname); throw new BadRequestException("Invalid platform: %s".formatted(platformName)); } + port = port == -1 ? platform.getDefaultPort() : port; // Use the default port if the port is -1 (not provided) String key = "%s-%s:%s".formatted(platformName, hostname, port); + log.info("Getting server: {}:{}", hostname, port); Optional cached = serverCacheRepository.findById(key); if (cached.isPresent()) { log.info("Server {}:{} is cached", hostname, port); @@ -54,7 +55,6 @@ public class ServerService { InetSocketAddress address = platform == MinecraftServer.Platform.JAVA ? DNSUtils.resolveSRV(hostname) : null; if (address != null) { - port = port != -1 ? port : platform.getDefaultPort(); // If the port is -1, set it to the default port hostname = address.getHostName(); } @@ -65,8 +65,7 @@ public class ServerService { ); if (platform == MinecraftServer.Platform.JAVA) { // Check if the server is blocked by Mojang - JavaMinecraftServer javaServer = (JavaMinecraftServer) server.getServer(); - javaServer.setMojangBanned(mojangService.isServerBlocked(hostname)); + ((JavaMinecraftServer) server.getServer()).setMojangBanned(mojangService.isServerBlocked(hostname)); } log.info("Found server: {}:{}", hostname, port); diff --git a/src/main/java/cc.fascinated/service/pinger/impl/BedrockMinecraftServerPinger.java b/src/main/java/cc.fascinated/service/pinger/impl/BedrockMinecraftServerPinger.java new file mode 100644 index 0000000..dd57e55 --- /dev/null +++ b/src/main/java/cc.fascinated/service/pinger/impl/BedrockMinecraftServerPinger.java @@ -0,0 +1,72 @@ +package cc.fascinated.service.pinger.impl; + +import cc.fascinated.common.DNSUtils; +import cc.fascinated.common.packet.impl.bedrock.BedrockPacketUnconnectedPing; +import cc.fascinated.common.packet.impl.bedrock.BedrockPacketUnconnectedPong; +import cc.fascinated.exception.impl.BadRequestException; +import cc.fascinated.exception.impl.ResourceNotFoundException; +import cc.fascinated.model.server.BedrockMinecraftServer; +import cc.fascinated.service.pinger.MinecraftServerPinger; +import lombok.NonNull; +import lombok.extern.log4j.Log4j2; + +import java.io.IOException; +import java.net.*; + +/** + * The {@link MinecraftServerPinger} for pinging + * {@link BedrockMinecraftServer} over UDP. + * + * @author Braydon + */ +@Log4j2(topic = "Bedrock MC Server Pinger") +public final class BedrockMinecraftServerPinger implements MinecraftServerPinger { + private static final int TIMEOUT = 3000; // 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 BedrockMinecraftServer ping(@NonNull String hostname, int port) { + InetAddress inetAddress = DNSUtils.resolveA(hostname); // Resolve the hostname to an IP address + String ip = inetAddress == null ? null : inetAddress.getHostAddress(); // Get the IP address + if (ip != null) { // Was the IP resolved? + log.info("Resolved hostname: {} -> {}", hostname, ip); + } + log.info("Pinging {}:{}...", hostname, port); + long before = System.currentTimeMillis(); // Timestamp before pinging + + // Open a socket connection to the server + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(TIMEOUT); + socket.connect(new InetSocketAddress(hostname, port)); + + long ping = System.currentTimeMillis() - before; // Calculate the ping + log.info("Pinged {}:{} in {}ms", hostname, port, ping); + + // Send the unconnected ping packet + new BedrockPacketUnconnectedPing().process(socket); + + // Handle the received unconnected pong packet + BedrockPacketUnconnectedPong unconnectedPong = new BedrockPacketUnconnectedPong(); + unconnectedPong.process(socket); + String response = unconnectedPong.getResponse(); + if (response == null) { // No pong response + throw new ResourceNotFoundException("Server didn't respond to ping"); + } + return BedrockMinecraftServer.create(hostname, ip, port, response); // Return the server + } catch (IOException ex) { + if (ex instanceof UnknownHostException) { + throw new BadRequestException("Unknown hostname: %s".formatted(hostname)); + } else if (ex instanceof SocketTimeoutException) { + throw new ResourceNotFoundException(ex); + } + log.error("An error occurred pinging %s:%s:".formatted(hostname, port), ex); + } + return null; + } +} \ No newline at end of file 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 0085d62..d8265fe 100644 --- a/src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java +++ b/src/main/java/cc.fascinated/service/pinger/impl/JavaMinecraftServerPinger.java @@ -9,7 +9,6 @@ import cc.fascinated.exception.impl.BadRequestException; import cc.fascinated.exception.impl.ResourceNotFoundException; import cc.fascinated.model.mojang.JavaServerStatusToken; import cc.fascinated.model.server.JavaMinecraftServer; -import cc.fascinated.model.server.MinecraftServer; import cc.fascinated.service.pinger.MinecraftServerPinger; import lombok.extern.log4j.Log4j2;