diff --git a/build.gradle b/build.gradle index 3f56c37..41a05bd 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ buildscript { apply plugin: 'foxloader.dev' -version '1.0.1' +version '1.1.0' foxloader { // forceReload = true diff --git a/src/server/java/io/thiemann/kurt/query/QueryModServer.java b/src/server/java/io/thiemann/kurt/query/QueryModServer.java index 667587c..675364e 100644 --- a/src/server/java/io/thiemann/kurt/query/QueryModServer.java +++ b/src/server/java/io/thiemann/kurt/query/QueryModServer.java @@ -22,7 +22,7 @@ public void onServerStart(NetworkPlayer.ConnectionType connectionType) { boolean queryEnabled = props.getBooleanProperty("enable-query", false); if (!queryEnabled) { - getLogger().info("Query server disabled"); + getLogger().info("Query server is disabled"); return; } @@ -33,7 +33,8 @@ public void onServerStart(NetworkPlayer.ConnectionType connectionType) { if (queryAddressString.length() > 0) { try { inetAddress = InetAddress.getByName(queryAddressString); - } catch (UnknownHostException ignore) {} + } catch (UnknownHostException ignore) { + } } try { diff --git a/src/server/java/io/thiemann/kurt/query/query/QueryServer.java b/src/server/java/io/thiemann/kurt/query/query/QueryServer.java index 397ec54..8b7d0ed 100644 --- a/src/server/java/io/thiemann/kurt/query/query/QueryServer.java +++ b/src/server/java/io/thiemann/kurt/query/query/QueryServer.java @@ -1,42 +1,43 @@ package io.thiemann.kurt.query.query; +import com.fox2code.foxloader.network.NetworkPlayer; +import io.thiemann.kurt.query.query.packet.*; import net.minecraft.server.MinecraftServer; -import org.luaj.vm2.ast.Str; - -import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.net.DatagramPacket; -import java.net.DatagramSocket; -import java.net.InetAddress; -import java.net.SocketException; -import java.nio.Buffer; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; - -//Async udp server to handle Minecraft query requests +import java.net.*; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + + public class QueryServer { - private static final byte[] challenge = "9513307\0".getBytes(); private final MinecraftServer server; private final DatagramSocket socket; private final Thread thread; + private final Map challenges = new ConcurrentHashMap<>(); + private long lastChallengeClear = 0; public QueryServer(MinecraftServer server, int port, InetAddress laddr) throws SocketException { this.server = server; this.socket = new DatagramSocket(port, laddr); this.thread = new Thread(this::run); this.thread.start(); + } private void run() { while (!this.socket.isClosed()) { + if (System.currentTimeMillis() - this.lastChallengeClear > 30000) { + this.challenges.clear(); + this.lastChallengeClear = System.currentTimeMillis(); + } + try { byte[] buf = new byte[1024]; DatagramPacket packet = new DatagramPacket(buf, buf.length); this.socket.receive(packet); this.handlePacket(packet); - } catch (IOException ignored) {} + } catch (IOException ignored) { + } } } @@ -44,155 +45,93 @@ private void run() { * Handle incoming packet * Packet format * - * @param packet incoming packet + * @param msg incoming packet */ - private void handlePacket(DatagramPacket packet) { - ByteBuffer buffer = ByteBuffer.wrap(packet.getData()); - buffer.order(ByteOrder.BIG_ENDIAN); - short magic = buffer.getShort(); - if (magic != (short)0xFEFD) { + private void handlePacket(DatagramPacket msg) { + ServerBoundPacket packet; + try { + packet = ServerBoundPacket.fromBuffer(msg.getData(), msg.getLength()); + } catch (QueryProtocolException e) { return; } - byte type = buffer.get(); - int sessionId = buffer.getInt(); - if (type == 9) { - //send handshake response - byte[] response = makeHandshakeResponse(sessionId); - DatagramPacket responsePacket = new DatagramPacket(response, response.length, packet.getAddress(), packet.getPort()); - try { - this.socket.send(responsePacket); - } catch (IOException ignored) {} - } - if(type != 0) { + ClientBoundPacket response; + if (packet instanceof ServerBoundHandshakePacket) { + response = this.handshake((ServerBoundHandshakePacket) packet, msg.getSocketAddress()); + } else if (packet instanceof ServerBoundStatPacket) { + if (((ServerBoundStatPacket) packet).isFull()) { + response = this.fullStat((ServerBoundStatPacket) packet, msg.getSocketAddress()); + } else { + response = this.basicStat((ServerBoundStatPacket) packet, msg.getSocketAddress()); + } + } else { return; } - //get payload as byte array - byte[] payload = new byte[packet.getLength() - 7]; - boolean isFull = payload.length == 8; - - byte[] response; - if(isFull) { - //send full stat response - response = makeFullStatResponse(sessionId); - } else { - //send basic stat response - response = makeBasicStatResponse(sessionId); + if (response == null) { + return; } - DatagramPacket responsePacket = new DatagramPacket(response, response.length, packet.getAddress(), packet.getPort()); + byte[] payload = response.serialize(); + DatagramPacket responsePacket = new DatagramPacket(payload, payload.length, msg.getSocketAddress()); try { this.socket.send(responsePacket); - } catch (IOException ignored) {} + } catch (IOException ignored) { + } } - /** - * Generate a Handshake response - * Packet format - * Instead of an actual challenge we just send a constant value - * - * @param sessionId session id - * @return payload - */ - private byte[] makeHandshakeResponse(int sessionId) { - ByteBuffer buffer = ByteBuffer.allocate(13); - buffer.order(ByteOrder.BIG_ENDIAN); - buffer.put((byte)9); - buffer.putInt(sessionId); - buffer.put(challenge); - return buffer.array(); - } + private ClientBoundHandshakePacket handshake(ServerBoundHandshakePacket packet, SocketAddress address) { + int randomToken = ((int) (Math.random() * 1000000)) & 0x0F0F0F0F; + this.challenges.put(address.toString(), randomToken); - /** - * Generate a basic stat response - * Packet format - * - * @param sessionId session id - * @return payload - */ - private byte[] makeBasicStatResponse(int sessionId) { - ByteBuffer buffer = ByteBuffer.allocate(1024); - buffer.order(ByteOrder.BIG_ENDIAN); - buffer.put((byte)0); - buffer.putInt(sessionId); - buffer.put((this.server.getMotd() + "\0").getBytes(StandardCharsets.UTF_8)); - buffer.put("SMP\0".getBytes()); - buffer.put("world\0".getBytes()); - buffer.put((this.server.configManager.playersOnline() + "\0").getBytes()); - buffer.put((this.server.configManager.getMaxPlayers() + "\0").getBytes()); - buffer.order(ByteOrder.LITTLE_ENDIAN); - buffer.putShort((short)this.getMinecraftServerPort()); - buffer.put((this.getMinecraftServerIp() + "\0").getBytes()); - - //only return written length as byte array - byte[] response = new byte[buffer.position()]; - ((Buffer) buffer).rewind(); - buffer.get(response); - return response; + return new ClientBoundHandshakePacket(packet.getSessionId(), randomToken); } - /** - * Generate a full stat response - * Packet format - * - * @param sessionId session id - * @return payload - */ - private byte[] makeFullStatResponse(int sessionId) { - ByteBuffer buffer = ByteBuffer.allocate(1024); - buffer.order(ByteOrder.BIG_ENDIAN); - buffer.put((byte)0); - buffer.putInt(sessionId); - buffer.put(new byte[] {0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, (byte)0x80, 0x00}); - - buffer.put("hostname\0".getBytes()); - buffer.put((this.server.getMotd() + "\0").getBytes(StandardCharsets.UTF_8)); - - buffer.put("gametype\0".getBytes()); - buffer.put("SMP\0".getBytes()); - - buffer.put("game_id\0".getBytes()); - buffer.put("MINECRAFT\0".getBytes()); - - buffer.put("version\0".getBytes()); - buffer.put("Beta 1.7\0".getBytes()); - - buffer.put("plugins\0".getBytes()); - buffer.put("FoxLoader\0".getBytes()); - - buffer.put("map\0".getBytes()); - buffer.put("world\0".getBytes()); - - buffer.put("numplayers\0".getBytes()); - buffer.put((this.server.configManager.playersOnline() + "\0").getBytes()); - - buffer.put("maxplayers\0".getBytes()); - buffer.put((this.server.configManager.getMaxPlayers() + "\0").getBytes()); - - buffer.put("hostport\0".getBytes()); - buffer.put((this.getMinecraftServerPort() + "\0").getBytes()); - - buffer.put("hostip\0".getBytes()); - buffer.put((this.getMinecraftServerIp() + "\0").getBytes()); - - buffer.put((byte)0x00); + private ClientBoundBasicStatPacket basicStat(ServerBoundStatPacket packet, SocketAddress address) { + if (!this.challenges.containsKey(address.toString())) { + return null; + } - buffer.put(new byte[] {0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00}); + if (packet.getChallenge() != this.challenges.get(address.toString())) { + return null; + } - this.server.configManager.playerEntities.forEach((player) -> { - System.out.println(player.getPlayerName()); - buffer.put((player.getPlayerName() + "\0").getBytes(StandardCharsets.UTF_8)); - }); + return new ClientBoundBasicStatPacket( + packet.getSessionId(), + this.server.getMotd(), + "SMP", + "world", + this.server.configManager.playersOnline(), + this.server.configManager.getMaxPlayers(), + this.getMinecraftServerPort(), + this.getMinecraftServerIp() + ); + } + private ClientBoundFullStatPacket fullStat(ServerBoundStatPacket packet, SocketAddress address) { + if (!this.challenges.containsKey(address.toString())) { + return null; + } - buffer.put((byte)0x00); + if (packet.getChallenge() != this.challenges.get(address.toString())) { + System.out.println("Wrong challenge: " + packet.getChallenge() + " != " + this.challenges.get(address.toString()) + " (" + address.toString() + ")"); + return null; + } - //only return written length as byte array - byte[] response = new byte[buffer.position()]; - ((Buffer) buffer).rewind(); - buffer.get(response); - return response; + return new ClientBoundFullStatPacket( + packet.getSessionId(), + this.server.getMotd(), + "SMP", + "MINECRAFT", + "Beta 1.7", + "FoxLoader", + "world", + this.server.configManager.playersOnline(), + this.server.configManager.getMaxPlayers(), + this.getMinecraftServerPort(), + this.getMinecraftServerIp(), + this.server.configManager.playerEntities.stream().map(NetworkPlayer::getPlayerName).toArray(String[]::new) + ); } private int getMinecraftServerPort() { diff --git a/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundBasicStatPacket.java b/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundBasicStatPacket.java new file mode 100644 index 0000000..dee7b7f --- /dev/null +++ b/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundBasicStatPacket.java @@ -0,0 +1,48 @@ +package io.thiemann.kurt.query.query.packet; + +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +public class ClientBoundBasicStatPacket extends ClientBoundPacket { + + private final String motd; + private final String gameType; + private final String map; + private final int playersOnline; + private final int maxPlayers; + private final int port; + private final String host; + + public ClientBoundBasicStatPacket(int sessionId, String motd, String gameType, String map, int playersOnline, int maxPlayers, int port, String host) { + super(PacketType.STAT, sessionId); + this.motd = motd; + this.gameType = gameType; + this.map = map; + this.playersOnline = playersOnline; + this.maxPlayers = maxPlayers; + this.port = port; + this.host = host; + } + + @Override + protected byte[] serializePayload() { + ByteBuffer buffer = ByteBuffer.allocate(1024); + buffer.order(ByteOrder.LITTLE_ENDIAN); + + buffer.put((this.motd + "\0").getBytes(StandardCharsets.UTF_8)); + buffer.put((this.gameType + "\0").getBytes(StandardCharsets.UTF_8)); + buffer.put((this.map + "\0").getBytes(StandardCharsets.UTF_8)); + buffer.put((this.playersOnline + "\0").getBytes(StandardCharsets.UTF_8)); + buffer.put((this.maxPlayers + "\0").getBytes(StandardCharsets.UTF_8)); + buffer.putShort((short) this.port); + buffer.put((this.host + "\0").getBytes(StandardCharsets.UTF_8)); + + //only return written length as byte array + byte[] response = new byte[buffer.position()]; + ((Buffer) buffer).rewind(); + buffer.get(response); + return response; + } +} diff --git a/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundFullStatPacket.java b/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundFullStatPacket.java new file mode 100644 index 0000000..51638e7 --- /dev/null +++ b/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundFullStatPacket.java @@ -0,0 +1,70 @@ +package io.thiemann.kurt.query.query.packet; + +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +public class ClientBoundFullStatPacket extends ClientBoundPacket { + + private final String hostName; + private final String gameType; + private final String gameId; + private final String version; + private final String plugins; + private final String map; + private final int numPlayers; + private final int maxPlayers; + private final int port; + private final String hostIp; + private final String[] players; + + public ClientBoundFullStatPacket(int sessionId, String hostName, String gameType, String gameId, String version, String plugins, String map, int numPlayers, int maxPlayers, int port, String hostIp, String[] players) { + super(PacketType.STAT, sessionId); + this.hostName = hostName; + this.gameType = gameType; + this.gameId = gameId; + this.version = version; + this.plugins = plugins; + this.map = map; + this.numPlayers = numPlayers; + this.maxPlayers = maxPlayers; + this.port = port; + this.hostIp = hostIp; + this.players = players; + } + + private void putKV(String key, String value, ByteBuffer buffer) { + buffer.put((key + "\0").getBytes(StandardCharsets.UTF_8)); + buffer.put((value + "\0").getBytes(StandardCharsets.UTF_8)); + } + + @Override + protected byte[] serializePayload() { + ByteBuffer buffer = ByteBuffer.allocate(1024); + buffer.put(new byte[]{0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, (byte) 0x80, 0x00}); + + this.putKV("hostname", this.hostName, buffer); + this.putKV("gametype", this.gameType, buffer); + this.putKV("game_id", this.gameId, buffer); + this.putKV("version", this.version, buffer); + this.putKV("plugins", this.plugins, buffer); + this.putKV("map", this.map, buffer); + this.putKV("numplayers", String.valueOf(this.numPlayers), buffer); + this.putKV("maxplayers", String.valueOf(this.maxPlayers), buffer); + this.putKV("hostport", String.valueOf(this.port), buffer); + this.putKV("hostip", this.hostIp, buffer); + buffer.put((byte) 0); + + buffer.put(new byte[]{0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00}); + + for (String player : this.players) { + buffer.put((player + "\0").getBytes(StandardCharsets.UTF_8)); + } + buffer.put((byte) 0); + + byte[] response = new byte[buffer.position()]; + ((Buffer) buffer).rewind(); + buffer.get(response); + return response; + } +} diff --git a/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundHandshakePacket.java b/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundHandshakePacket.java new file mode 100644 index 0000000..c3e70fb --- /dev/null +++ b/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundHandshakePacket.java @@ -0,0 +1,20 @@ +package io.thiemann.kurt.query.query.packet; + +public class ClientBoundHandshakePacket extends ClientBoundPacket { + + private final int challengeToken; + + public ClientBoundHandshakePacket(int sessionId, int challengeToken) { + super(PacketType.HANDSHAKE, sessionId); + this.challengeToken = challengeToken; + } + + @Override + protected byte[] serializePayload() { + return (this.challengeToken + "\0").getBytes(); + } + + public int getChallengeToken() { + return challengeToken; + } +} diff --git a/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundPacket.java b/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundPacket.java new file mode 100644 index 0000000..75c76ed --- /dev/null +++ b/src/server/java/io/thiemann/kurt/query/query/packet/ClientBoundPacket.java @@ -0,0 +1,38 @@ +package io.thiemann.kurt.query.query.packet; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public abstract class ClientBoundPacket { + private final PacketType type; + + private final int sessionId; + + public ClientBoundPacket(PacketType type, int sessionId) { + this.type = type; + this.sessionId = sessionId; + } + + public PacketType getType() { + return type; + } + + public int getSessionId() { + return sessionId; + } + + protected abstract byte[] serializePayload(); + + public byte[] serialize() { + byte[] payload = serializePayload(); + ByteBuffer buffer = ByteBuffer.allocate(5 + payload.length); + buffer.order(ByteOrder.BIG_ENDIAN); + + buffer.put(this.getType().getId()); + buffer.putInt(this.getSessionId()); + + buffer.put(payload); + + return buffer.array(); + } +} diff --git a/src/server/java/io/thiemann/kurt/query/query/packet/PacketType.java b/src/server/java/io/thiemann/kurt/query/query/packet/PacketType.java new file mode 100644 index 0000000..36d3a1e --- /dev/null +++ b/src/server/java/io/thiemann/kurt/query/query/packet/PacketType.java @@ -0,0 +1,26 @@ +package io.thiemann.kurt.query.query.packet; + +public enum PacketType { + HANDSHAKE((byte) 9), + STAT((byte) 0); + + private final byte id; + + PacketType(byte id) { + this.id = id; + } + + public byte getId() { + return id; + } + + public static PacketType fromId(int id) throws QueryProtocolException { + for (PacketType type : PacketType.values()) { + if (type.getId() == id) { + return type; + } + } + + throw new QueryProtocolException("Unknown packet type: " + id); + } +} diff --git a/src/server/java/io/thiemann/kurt/query/query/packet/QueryProtocolException.java b/src/server/java/io/thiemann/kurt/query/query/packet/QueryProtocolException.java new file mode 100644 index 0000000..4be2489 --- /dev/null +++ b/src/server/java/io/thiemann/kurt/query/query/packet/QueryProtocolException.java @@ -0,0 +1,7 @@ +package io.thiemann.kurt.query.query.packet; + +public class QueryProtocolException extends Exception { + public QueryProtocolException(String message) { + super(message); + } +} diff --git a/src/server/java/io/thiemann/kurt/query/query/packet/ServerBoundHandshakePacket.java b/src/server/java/io/thiemann/kurt/query/query/packet/ServerBoundHandshakePacket.java new file mode 100644 index 0000000..ac28c6b --- /dev/null +++ b/src/server/java/io/thiemann/kurt/query/query/packet/ServerBoundHandshakePacket.java @@ -0,0 +1,7 @@ +package io.thiemann.kurt.query.query.packet; + +public class ServerBoundHandshakePacket extends ServerBoundPacket { + public ServerBoundHandshakePacket(byte[] buffer, int length) throws QueryProtocolException { + super(buffer, length); + } +} diff --git a/src/server/java/io/thiemann/kurt/query/query/packet/ServerBoundPacket.java b/src/server/java/io/thiemann/kurt/query/query/packet/ServerBoundPacket.java new file mode 100644 index 0000000..b5ef919 --- /dev/null +++ b/src/server/java/io/thiemann/kurt/query/query/packet/ServerBoundPacket.java @@ -0,0 +1,59 @@ +package io.thiemann.kurt.query.query.packet; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public abstract class ServerBoundPacket { + private final byte[] buffer; + + private final PacketType type; + + private final int sessionId; + + private final byte[] payload; + + public static ServerBoundPacket fromBuffer(byte[] buffer, int length) throws QueryProtocolException { + switch (PacketType.fromId(buffer[2])) { + case HANDSHAKE: + return new ServerBoundHandshakePacket(buffer, length); + case STAT: + return new ServerBoundStatPacket(buffer, length); + default: + throw new QueryProtocolException("Invalid packet type"); + } + } + + public ServerBoundPacket(byte[] buffer, int length) throws QueryProtocolException { + this.buffer = buffer; + + ByteBuffer bb = ByteBuffer.wrap(buffer); + bb.order(ByteOrder.BIG_ENDIAN); + + short magic = bb.getShort(); + if (magic != (short) 0xFEFD) { + throw new QueryProtocolException("Invalid magic"); + } + + this.type = PacketType.fromId(bb.get()); + this.sessionId = bb.getInt(); + + this.payload = new byte[length - 7]; + bb.get(this.payload); + } + + public byte[] getBuffer() { + return buffer; + } + + public PacketType getType() { + return type; + } + + public int getSessionId() { + return sessionId; + } + + public byte[] getPayload() { + return payload; + } +} diff --git a/src/server/java/io/thiemann/kurt/query/query/packet/ServerBoundStatPacket.java b/src/server/java/io/thiemann/kurt/query/query/packet/ServerBoundStatPacket.java new file mode 100644 index 0000000..d1c2cd0 --- /dev/null +++ b/src/server/java/io/thiemann/kurt/query/query/packet/ServerBoundStatPacket.java @@ -0,0 +1,33 @@ +package io.thiemann.kurt.query.query.packet; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public class ServerBoundStatPacket extends ServerBoundPacket { + private final boolean full; + + private final int challenge; + + public ServerBoundStatPacket(byte[] buffer, int length) throws QueryProtocolException { + super(buffer, length); + + byte[] payload = this.getPayload(); + + /*for (int i = 0; i < payload.length; i++) { + System.out.println("payload[" + i + "] = " + payload[i]); + }*/ + + ByteBuffer bb = ByteBuffer.wrap(payload); + bb.order(ByteOrder.BIG_ENDIAN); + this.challenge = bb.getInt(); + this.full = payload.length == 8; + } + + public boolean isFull() { + return full; + } + + public int getChallenge() { + return challenge; + } +}