Add Query system

This commit is contained in:
Kieran Wallbanks 2021-04-26 18:02:06 +01:00
parent c92829e3cf
commit 94ecb8de7b
11 changed files with 786 additions and 0 deletions

View File

@ -0,0 +1,217 @@
package net.minestom.server.extras.query;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectMaps;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import net.minestom.server.MinecraftServer;
import net.minestom.server.extras.query.event.BasicQueryEvent;
import net.minestom.server.extras.query.response.QueryResponse;
import net.minestom.server.timer.Task;
import net.minestom.server.utils.InetAddressWithPort;
import net.minestom.server.utils.NetworkUtils;
import net.minestom.server.utils.binary.BinaryWriter;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Random;
/**
* Utility class to manage responses to the UT3 Query Protocol.
* @see <a href="https://wiki.vg/Query">wiki.vg</a>
*/
public class Query {
public static final Charset CHARSET = StandardCharsets.ISO_8859_1;
private static final Logger LOGGER = LoggerFactory.getLogger(Query.class);
private static final Random RANDOM = new Random();
private static volatile boolean started;
private static volatile DatagramSocket socket;
private static volatile Thread thread;
private static final Int2ObjectMap<InetAddressWithPort> CHALLENGE_TOKENS = Int2ObjectMaps.synchronize(new Int2ObjectOpenHashMap<>());
private static volatile Task task;
private Query() { }
/**
* Starts the query system, responding to queries on a random port, logging if it could not be started.
*
* @return the port
* @throws IllegalArgumentException if the system was already running
*/
public static int start() {
if (socket != null) {
throw new IllegalArgumentException("System is already running");
} else {
int port;
try {
port = NetworkUtils.getFreePort();
} catch (IOException e) {
LOGGER.warn("Could not find an open port!", e);
return -1;
}
start(port);
return port;
}
}
/**
* Starts the query system, responding to queries on a given port, logging if it could not be started.
*
* @param port the port
* @return {@code true} if the query system started successfully, {@code false} otherwise
*/
public static boolean start(int port) {
if (socket != null) {
return false;
} else {
try {
socket = new DatagramSocket(port);
} catch (SocketException e) {
LOGGER.warn("Could not open the query port!", e);
return false;
}
thread = new Thread(Query::run);
thread.start();
started = true;
/*task = MinecraftServer.getSchedulerManager()
.buildTask(CHALLENGE_TOKENS::clear)
.repeat(30, TimeUnit.SECOND)
.schedule();*/
return true;
}
}
/**
* Stops the query system.
*
* @return {@code true} if the query system was stopped, {@code false} if it was not running
*/
public boolean stop() {
if (!started) {
return false;
} else {
started = false;
thread = null;
socket.close();
socket = null;
task.cancel();
CHALLENGE_TOKENS.clear();
return true;
}
}
/**
* Checks if the query system has been started.
*
* @return {@code true} if it has been started, {@code false} otherwise
*/
public boolean isStarted() {
return started;
}
private static void run() {
final byte[] buffer = new byte[16];
while (started) {
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
// try and receive the packet
try {
socket.receive(packet);
} catch (IOException e) {
if (!started) {
LOGGER.error("An error occurred whilst receiving a query packet.", e);
continue;
} else {
return;
}
}
// get the contents
ByteBuf data = Unpooled.wrappedBuffer(packet.getData());
// check the magic field
if (data.readUnsignedShort() != 0xFEFD) {
continue;
}
// now check the query type
byte type = data.readByte();
if (type == 9) { // handshake
int sessionID = data.readInt();
int challengeToken = RANDOM.nextInt();
CHALLENGE_TOKENS.put(challengeToken, new InetAddressWithPort(packet.getAddress(), packet.getPort()));
// send the response
BinaryWriter response = new BinaryWriter(32);
response.writeByte((byte) 9);
response.writeInt(sessionID);
response.writeNullTerminatedString(String.valueOf(challengeToken), CHARSET);
try {
byte[] responseData = response.toByteArray();
socket.send(new DatagramPacket(responseData, responseData.length, packet.getAddress(), packet.getPort()));
} catch (IOException e) {
LOGGER.error("An error occurred whilst sending a query handshake packet.", e);
}
} else if (type == 0) { // stat
int sessionID = data.readInt();
int challengeToken = data.readInt();
InetAddressWithPort sender = new InetAddressWithPort(packet.getAddress(), packet.getPort());
if (CHALLENGE_TOKENS.containsKey(challengeToken) && CHALLENGE_TOKENS.get(challengeToken).equals(sender)) {
int remaining = data.readableBytes();
if (remaining == 0) { // basic
BasicQueryEvent event = new BasicQueryEvent(new InetAddressWithPort(packet.getAddress(), packet.getPort()));
MinecraftServer.getGlobalEventHandler().callCancellableEvent(BasicQueryEvent.class, event,
() -> sendResponse(event.getQueryResponse(), sessionID, sender));
} else if (remaining == 8) { // full
BasicQueryEvent event = new BasicQueryEvent(new InetAddressWithPort(packet.getAddress(), packet.getPort()));
MinecraftServer.getGlobalEventHandler().callCancellableEvent(BasicQueryEvent.class, event,
() -> sendResponse(event.getQueryResponse(), sessionID, sender));
}
}
}
}
}
private static void sendResponse(@NotNull QueryResponse queryResponse, int sessionID, @NotNull InetAddressWithPort sender) {
// header
BinaryWriter response = new BinaryWriter();
response.writeByte((byte) 0);
response.writeInt(sessionID);
// payload
queryResponse.write(response);
// send!
byte[] responseData = response.toByteArray();
try {
socket.send(new DatagramPacket(responseData, responseData.length, sender.getInetAddress(), sender.getPort()));
} catch (IOException e) {
LOGGER.error("An error occurred whilst sending a query handshake packet.", e);
}
}
}

View File

@ -0,0 +1,20 @@
package net.minestom.server.extras.query.event;
import net.minestom.server.extras.query.response.BasicQueryResponse;
import net.minestom.server.utils.InetAddressWithPort;
import org.jetbrains.annotations.NotNull;
/**
* An event called when a basic query is received and ready to be responded to.
*/
public class BasicQueryEvent extends QueryEvent<BasicQueryResponse> {
/**
* Creates a new basic query event.
*
* @param sender the sender
*/
public BasicQueryEvent(@NotNull InetAddressWithPort sender) {
super(sender, new BasicQueryResponse());
}
}

View File

@ -0,0 +1,20 @@
package net.minestom.server.extras.query.event;
import net.minestom.server.extras.query.response.FullQueryResponse;
import net.minestom.server.utils.InetAddressWithPort;
import org.jetbrains.annotations.NotNull;
/**
* An event called when a full query is received and ready to be responded to.
*/
public class FullQueryEvent extends QueryEvent<FullQueryResponse> {
/**
* Creates a new full query event.
*
* @param sender the sender
*/
public FullQueryEvent(@NotNull InetAddressWithPort sender) {
super(sender, new FullQueryResponse());
}
}

View File

@ -0,0 +1,71 @@
package net.minestom.server.extras.query.event;
import net.minestom.server.event.CancellableEvent;
import net.minestom.server.event.Event;
import net.minestom.server.extras.query.response.QueryResponse;
import net.minestom.server.utils.InetAddressWithPort;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
/**
* An event called when a query is received and ready to be responded to.
*
* @param <T> the type of the response
*/
public abstract class QueryEvent<T extends QueryResponse> extends Event implements CancellableEvent {
private final InetAddressWithPort sender;
private T response;
private boolean cancelled;
/**
* Creates a new query event.
*
* @param sender the sender
* @param response the initial response
*/
public QueryEvent(@NotNull InetAddressWithPort sender, @NotNull T response) {
this.sender = sender;
this.response = response;
this.cancelled = false;
}
/**
* Gets the query response that will be sent back to the sender.
* This can be mutated.
*
* @return the response
*/
public T getQueryResponse() {
return this.response;
}
/**
* Sets the query response that will be sent back to the sender.
*
* @param response the response
*/
public void setQueryResponse(@NotNull T response) {
this.response = Objects.requireNonNull(response, "response");
}
/**
* Gets the IP address and port of the initiator of the query.
*
* @return the initiator
*/
public @NotNull InetAddressWithPort getSender() {
return this.sender;
}
@Override
public boolean isCancelled() {
return this.cancelled;
}
@Override
public void setCancelled(boolean cancel) {
this.cancelled = cancel;
}
}

View File

@ -0,0 +1,147 @@
package net.minestom.server.extras.query.response;
import net.minestom.server.MinecraftServer;
import net.minestom.server.extras.query.Query;
import net.minestom.server.utils.binary.BinaryWriter;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
/**
* A basic query response containing a fixed set of responses.
*/
public class BasicQueryResponse implements QueryResponse {
private String motd, gametype, map, numPlayers, maxPlayers;
/**
* Creates a new basic query response with pre-filled default values.
*/
public BasicQueryResponse() {
this.motd = "A Minestom Server";
this.gametype = "SMP";
this.map = "world";
this.numPlayers = String.valueOf(MinecraftServer.getConnectionManager().getOnlinePlayers().size());
this.maxPlayers = String.valueOf(Integer.parseInt(this.numPlayers) + 1);
}
/**
* Gets the MoTD.
*
* @return the motd
*/
public @NotNull String getMotd() {
return this.motd;
}
/**
* Sets the MoTD.
*
* @param motd the motd
*/
public void setMotd(@NotNull String motd) {
this.motd = Objects.requireNonNull(motd, "motd");
}
/**
* Gets the gametype.
*
* @return the gametype
*/
public @NotNull String getGametype() {
return this.gametype;
}
/**
* Sets the gametype.
*
* @param gametype the gametype
*/
public void setGametype(@NotNull String gametype) {
this.gametype = Objects.requireNonNull(gametype, "gametype");
}
/**
* Gets the map.
*
* @return the map
*/
public @NotNull String getMap() {
return this.map;
}
/**
* Sets the map.
*
* @param map the map
*/
public void setMap(@NotNull String map) {
this.map = Objects.requireNonNull(map, "map");
}
/**
* Gets the number of players.
*
* @return the number of players
*/
public @NotNull String getNumPlayers() {
return this.numPlayers;
}
/**
* Sets the number of players.
*
* @param numPlayers the number of players
*/
public void setNumPlayers(@NotNull String numPlayers) {
this.numPlayers = Objects.requireNonNull(numPlayers, "numPlayers");
}
/**
* Sets the number of players.
* This method is just an overload for {@link #setNumPlayers(String)}.
*
* @param numPlayers the number of players
*/
public void setNumPlayers(int numPlayers) {
this.setNumPlayers(String.valueOf(numPlayers));
}
/**
* Gets the max number of players.
*
* @return the max number of players
*/
public @NotNull String getMaxPlayers() {
return this.maxPlayers;
}
/**
* Sets the max number of players.
*
* @param maxPlayers the max number of players
*/
public void setMaxPlayers(@NotNull String maxPlayers) {
this.maxPlayers = Objects.requireNonNull(maxPlayers, "maxPlayers");
}
/**
* Sets the max number of players.
* This method is just an overload for {@link #setMaxPlayers(String)}
*
* @param maxPlayers the max number of players
*/
public void setMaxPlayers(int maxPlayers) {
this.setMaxPlayers(String.valueOf(maxPlayers));
}
@Override
public void write(@NotNull BinaryWriter writer) {
writer.writeNullTerminatedString(this.motd, Query.CHARSET);
writer.writeNullTerminatedString(this.gametype, Query.CHARSET);
writer.writeNullTerminatedString(this.map, Query.CHARSET);
writer.writeNullTerminatedString(this.numPlayers, Query.CHARSET);
writer.writeNullTerminatedString(this.maxPlayers, Query.CHARSET);
writer.getBuffer().writeShortLE(MinecraftServer.getNettyServer().getPort());
writer.writeNullTerminatedString(Objects.requireNonNullElse(MinecraftServer.getNettyServer().getAddress(), ""), Query.CHARSET);
}
}

View File

@ -0,0 +1,160 @@
package net.minestom.server.extras.query.response;
import net.kyori.adventure.text.serializer.plain.PlainComponentSerializer;
import net.minestom.server.MinecraftServer;
import net.minestom.server.extensions.Extension;
import net.minestom.server.extras.query.Query;
import net.minestom.server.utils.binary.BinaryWriter;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.stream.Collectors;
/**
* A full query response containing a dynamic set of responses.
*/
public class FullQueryResponse implements QueryResponse {
private static final PlainComponentSerializer PLAIN = PlainComponentSerializer.plain();
private static final byte[] PADDING_10 = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
PADDING_11 = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
private Map<String, String> kv;
private List<String> players;
/**
* Creates a new full query response with default values set.
*/
public FullQueryResponse() {
this.kv = new HashMap<>();
// populate defaults
for (QueryKey key : QueryKey.VALUES) {
this.kv.put(key.getKey(), key.getValue());
}
this.players = MinecraftServer.getConnectionManager().getOnlinePlayers()
.stream()
.map(player -> PLAIN.serialize(player.getName()))
.collect(Collectors.toList());
}
/**
* Puts a key-value mapping into the response.
*
* @param key the key
* @param value the value
*/
public void put(@NotNull QueryKey key, @NotNull String value) {
this.put(key.getKey(), value);
}
/**
* Puts a key-value mapping into the response.
*
* @param key the key
* @param value the value
*/
public void put(@NotNull String key, @NotNull String value) {
this.kv.put(key, value);
}
/**
* Gets the map containing the key-value mappings.
*
* @return the map
*/
public @NotNull Map<String, String> getKeyValuesMap() {
return this.kv;
}
/**
* Sets the map containing the key-value mappings.
*
* @param map the map
*/
public void setKeyValuesMap(@NotNull Map<String, String> map) {
this.kv = Objects.requireNonNull(map, "map");
}
/**
* Adds some players to the response.
*
* @param players the players
*/
public void addPlayers(@NotNull String @NotNull... players) {
Collections.addAll(this.players, players);
}
/**
* Adds some players to the response.
*
* @param players the players
*/
public void addPlayers(@NotNull Collection<String> players) {
this.players.addAll(players);
}
/**
* Gets the list of players.
*
* @return the list
*/
public @NotNull List<String> getPlayers() {
return this.players;
}
/**
* Sets the list of players.
*
* @param players the players
*/
public void setPlayers(@NotNull List<String> players) {
this.players = Objects.requireNonNull(players, "players)");
}
/**
* Generates the default plugins value. That being the server name and version followed
* by the name and version for each extension.
*
* @return the string result
*/
public static String generatePluginsValue() {
StringBuilder builder = new StringBuilder(MinecraftServer.getBrandName())
.append(' ')
.append(MinecraftServer.VERSION_NAME);
if (!MinecraftServer.getExtensionManager().getExtensions().isEmpty()) {
for (Extension extension : MinecraftServer.getExtensionManager().getExtensions()) {
builder.append(extension.getOrigin().getName())
.append(' ')
.append(extension.getOrigin().getVersion())
.append("; ");
}
builder.delete(builder.length() - 2, builder.length());
}
return builder.toString();
}
@Override
public void write(@NotNull BinaryWriter writer) {
writer.writeBytes(PADDING_10);
// key-values
for (Map.Entry<String, String> entry : this.kv.entrySet()) {
writer.writeNullTerminatedString(entry.getKey(), Query.CHARSET);
writer.writeNullTerminatedString(entry.getValue(), Query.CHARSET);
}
writer.writeNullTerminatedString("", Query.CHARSET);
writer.writeBytes(PADDING_11);
// players
for (String player : this.players) {
writer.writeNullTerminatedString(player, Query.CHARSET);
}
writer.writeNullTerminatedString("", Query.CHARSET);
}
}

View File

@ -0,0 +1,57 @@
package net.minestom.server.extras.query.response;
import net.minestom.server.MinecraftServer;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Supplier;
/**
* An enum of default query keys.
*/
public enum QueryKey {
HOSTNAME(() -> "A Minestom Server"),
GAME_TYPE(() -> "SMP"),
GAME_ID("game_id", () -> "MINECRAFT"),
VERSION(() -> MinecraftServer.VERSION_NAME),
PLUGINS(FullQueryResponse::generatePluginsValue),
MAP(() -> "world"),
NUM_PLAYERS("numplayers", () -> String.valueOf(MinecraftServer.getConnectionManager().getOnlinePlayers().size())),
MAX_PLAYERS("maxplayers", () -> String.valueOf(MinecraftServer.getConnectionManager().getOnlinePlayers().size() + 1)),
HOST_PORT("hostport", () -> String.valueOf(MinecraftServer.getNettyServer().getPort())),
HOST_IP("hostip", () -> Objects.requireNonNullElse(MinecraftServer.getNettyServer().getAddress(), "localhost"));
static QueryKey[] VALUES = QueryKey.values();
private final String key;
private final Supplier<String> value;
QueryKey(@NotNull Supplier<String> value) {
this(null, value);
}
QueryKey(@Nullable String key, @NotNull Supplier<String> value) {
this.key = Objects.requireNonNullElse(key, this.name().toLowerCase(Locale.ROOT).replace('_', ' '));
this.value = value;
}
/**
* Gets the key of this query key.
*
* @return the key
*/
public @NotNull String getKey() {
return this.key;
}
/**
* Gets the value of this query key.
*
* @return the value
*/
public @NotNull String getValue() {
return this.value.get();
}
}

View File

@ -0,0 +1,17 @@
package net.minestom.server.extras.query.response;
import net.minestom.server.utils.binary.BinaryWriter;
import org.jetbrains.annotations.NotNull;
/**
* A query response.
*/
public interface QueryResponse {
/**
* Writes the query response to a writer.
*
* @param writer the writer to write the response to
*/
void write(@NotNull BinaryWriter writer);
}

View File

@ -0,0 +1,60 @@
package net.minestom.server.utils;
import java.net.InetAddress;
import java.util.Objects;
import org.apache.commons.lang3.Validate;
import org.jetbrains.annotations.NotNull;
/**
* A utility class to hold an {@link InetAddress} and a port.
*/
public class InetAddressWithPort {
private final InetAddress inetAddress;
private final int port;
/**
* Creates a new {@link InetAddressWithPort}.
*
* @param inetAddress the inet address
* @param port the port
*/
public InetAddressWithPort(@NotNull InetAddress inetAddress, int port) {
Validate.inclusiveBetween(1, 65535, port, "port must be a valid port");
this.inetAddress = Objects.requireNonNull(inetAddress, "inetAddress");
this.port = port;
}
/**
* Gets the inet address.
*
* @return the inet address
*/
public @NotNull InetAddress getInetAddress() {
return this.inetAddress;
}
/**
* Gets the port.
*
* @return the port
*/
public int getPort() {
return this.port;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
InetAddressWithPort that = (InetAddressWithPort) o;
return port == that.port && Objects.equals(inetAddress, that.inetAddress);
}
@Override
public int hashCode() {
return Objects.hash(inetAddress, port);
}
}

View File

@ -3,6 +3,9 @@ package net.minestom.server.utils.binary;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil; import io.netty.buffer.ByteBufUtil;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import java.nio.charset.Charset;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.minestom.server.MinecraftServer; import net.minestom.server.MinecraftServer;
import net.minestom.server.adventure.AdventureSerializer; import net.minestom.server.adventure.AdventureSerializer;
@ -176,6 +179,17 @@ public class BinaryWriter extends OutputStream {
buffer.writeCharSequence(string, StandardCharsets.UTF_8); buffer.writeCharSequence(string, StandardCharsets.UTF_8);
} }
/**
* Writes a null terminated string to the buffer. This method adds the null character
* to the end of the string before writing.
*
* @param string the string to write
* @param charset the charset to encode in
*/
public void writeNullTerminatedString(@NotNull String string, @NotNull Charset charset) {
buffer.writeCharSequence(string + '\0', charset);
}
/** /**
* Writes a JsonMessage to the buffer. * Writes a JsonMessage to the buffer.
* Simply a writeSizedString with message.toString() * Simply a writeSizedString with message.toString()

View File

@ -14,6 +14,7 @@ import net.minestom.server.event.server.ServerListPingEvent;
import net.minestom.server.extras.lan.OpenToLAN; import net.minestom.server.extras.lan.OpenToLAN;
import net.minestom.server.extras.lan.OpenToLANConfig; import net.minestom.server.extras.lan.OpenToLANConfig;
import net.minestom.server.extras.optifine.OptifineSupport; import net.minestom.server.extras.optifine.OptifineSupport;
import net.minestom.server.extras.query.Query;
import net.minestom.server.instance.block.BlockManager; import net.minestom.server.instance.block.BlockManager;
import net.minestom.server.instance.block.rule.vanilla.RedstonePlacementRule; import net.minestom.server.instance.block.rule.vanilla.RedstonePlacementRule;
import net.minestom.server.ping.ResponseData; import net.minestom.server.ping.ResponseData;
@ -110,6 +111,8 @@ public class Main {
minecraftServer.start("0.0.0.0", 25565); minecraftServer.start("0.0.0.0", 25565);
//Runtime.getRuntime().addShutdownHook(new Thread(MinecraftServer::stopCleanly)); //Runtime.getRuntime().addShutdownHook(new Thread(MinecraftServer::stopCleanly));
Query.start(25566);
} }
} }