diff --git a/api/src/main/java/com/velocitypowered/api/event/query/ProxyQueryEvent.java b/api/src/main/java/com/velocitypowered/api/event/query/ProxyQueryEvent.java
new file mode 100644
index 000000000..7b64ffbf5
--- /dev/null
+++ b/api/src/main/java/com/velocitypowered/api/event/query/ProxyQueryEvent.java
@@ -0,0 +1,83 @@
+package com.velocitypowered.api.event.query;
+
+import com.google.common.base.Preconditions;
+import com.velocitypowered.api.proxy.server.QueryResponse;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+import java.net.InetAddress;
+
+/**
+ * This event is fired if proxy is getting queried over GS4 Query protocol
+ */
+public final class ProxyQueryEvent {
+ private final QueryType queryType;
+ private final InetAddress querierAddress;
+ private QueryResponse response;
+
+ public ProxyQueryEvent(QueryType queryType, InetAddress querierAddress, QueryResponse response) {
+ this.queryType = Preconditions.checkNotNull(queryType, "queryType");
+ this.querierAddress = Preconditions.checkNotNull(querierAddress, "querierAddress");
+ this.response = Preconditions.checkNotNull(response, "response");
+ }
+
+ /**
+ * Get query type
+ * @return query type
+ */
+ @NonNull
+ public QueryType getQueryType() {
+ return queryType;
+ }
+
+ /**
+ * Get querier address
+ * @return querier address
+ */
+ @NonNull
+ public InetAddress getQuerierAddress() {
+ return querierAddress;
+ }
+
+ /**
+ * Get query response
+ * @return query response
+ */
+ @NonNull
+ public QueryResponse getResponse() {
+ return response;
+ }
+
+ /**
+ * Set query response
+ * @param response query response
+ */
+ public void setResponse(@NonNull QueryResponse response) {
+ this.response = Preconditions.checkNotNull(response, "response");
+ }
+
+ @Override
+ public String toString() {
+ return "ProxyQueryEvent{" +
+ "queryType=" + queryType +
+ ", querierAddress=" + querierAddress +
+ ", response=" + response +
+ '}';
+ }
+
+ /**
+ * The type of query
+ */
+ public enum QueryType {
+ /**
+ * Basic query asks only a subset of information, such as hostname, game type (hardcoded to
MINECRAFT
), map,
+ * current players, max players, proxy port and proxy hostname
+ */
+ BASIC,
+
+ /**
+ * Full query asks pretty much everything present on this event (only hardcoded values cannot be modified here).
+ */
+ FULL
+ ;
+ }
+}
diff --git a/api/src/main/java/com/velocitypowered/api/event/query/package-info.java b/api/src/main/java/com/velocitypowered/api/event/query/package-info.java
new file mode 100644
index 000000000..42fb5a47f
--- /dev/null
+++ b/api/src/main/java/com/velocitypowered/api/event/query/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Provides events for handling GS4 queries.
+ */
+package com.velocitypowered.api.event.query;
\ No newline at end of file
diff --git a/api/src/main/java/com/velocitypowered/api/proxy/config/ProxyConfig.java b/api/src/main/java/com/velocitypowered/api/proxy/config/ProxyConfig.java
index 85cbaa30d..9285b1fab 100644
--- a/api/src/main/java/com/velocitypowered/api/proxy/config/ProxyConfig.java
+++ b/api/src/main/java/com/velocitypowered/api/proxy/config/ProxyConfig.java
@@ -29,6 +29,13 @@ public interface ProxyConfig {
*/
String getQueryMap();
+ /**
+ * Whether GameSpy 4 queries should show plugins installed on
+ * Velocity by default
+ * @return show plugins in query
+ */
+ boolean shouldQueryShowPlugins();
+
/**
* Get the MOTD component shown in the tab list
* @return the motd component
diff --git a/api/src/main/java/com/velocitypowered/api/proxy/server/QueryResponse.java b/api/src/main/java/com/velocitypowered/api/proxy/server/QueryResponse.java
new file mode 100644
index 000000000..2da751e6f
--- /dev/null
+++ b/api/src/main/java/com/velocitypowered/api/proxy/server/QueryResponse.java
@@ -0,0 +1,303 @@
+package com.velocitypowered.api.proxy.server;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.velocitypowered.api.proxy.config.ProxyConfig;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * GS4 query response. This class is immutable.
+ */
+public final class QueryResponse {
+ private final String hostname;
+ private final String gameVersion;
+ private final String map;
+ private final int currentPlayers;
+ private final int maxPlayers;
+ private final String proxyHost;
+ private final int proxyPort;
+ private final Collection players;
+ private final String proxyVersion;
+ private final Collection plugins;
+
+ private QueryResponse(String hostname, String gameVersion, String map, int currentPlayers, int maxPlayers, String proxyHost, int proxyPort, Collection players, String proxyVersion, Collection plugins) {
+ this.hostname = hostname;
+ this.gameVersion = gameVersion;
+ this.map = map;
+ this.currentPlayers = currentPlayers;
+ this.maxPlayers = maxPlayers;
+ this.proxyHost = proxyHost;
+ this.proxyPort = proxyPort;
+ this.players = players;
+ this.proxyVersion = proxyVersion;
+ this.plugins = plugins;
+ }
+
+ /**
+ * Get hostname which will be used to reply to the query. By default it is {@link ProxyConfig#getMotdComponent()} in plain text without colour codes.
+ * @return hostname
+ */
+ public String getHostname() {
+ return hostname;
+ }
+
+ /**
+ * Get game version which will be used to reply to the query. By default supported Minecraft versions range is sent.
+ * @return game version
+ */
+ public String getGameVersion() {
+ return gameVersion;
+ }
+
+ /**
+ * Get map name which will be used to reply to the query. By default {@link ProxyConfig#getQueryMap()} is sent.
+ * @return map name
+ */
+ public String getMap() {
+ return map;
+ }
+
+ /**
+ * Get current online player count which will be used to reply to the query.
+ * @return online player count
+ */
+ public int getCurrentPlayers() {
+ return currentPlayers;
+ }
+
+ /**
+ * Get max player count which will be used to reply to the query.
+ * @return max player count
+ */
+ public int getMaxPlayers() {
+ return maxPlayers;
+ }
+
+ /**
+ * Get proxy (public facing) hostname
+ * @return proxy hostname
+ */
+ public String getProxyHost() {
+ return proxyHost;
+ }
+
+ /**
+ * Get proxy (public facing) port
+ * @return proxy port
+ */
+ public int getProxyPort() {
+ return proxyPort;
+ }
+
+ /**
+ * Get collection of players which will be used to reply to the query.
+ * @return collection of players
+ */
+ public Collection getPlayers() {
+ return players;
+ }
+
+ /**
+ * Get server software (name and version) which will be used to reply to the query.
+ * @return server software
+ */
+ public String getProxyVersion() {
+ return proxyVersion;
+ }
+
+ /**
+ * Get list of plugins which will be used to reply to the query.
+ * @return collection of plugins
+ */
+ public Collection getPlugins() {
+ return plugins;
+ }
+
+
+ /**
+ * Creates a new {@link Builder} instance from data represented by this response
+ * @return {@link QueryResponse} builder
+ */
+ public Builder toBuilder() {
+ return QueryResponse.builder()
+ .hostname(getHostname())
+ .gameVersion(getGameVersion())
+ .map(getMap())
+ .currentPlayers(getCurrentPlayers())
+ .maxPlayers(getMaxPlayers())
+ .proxyHost(getProxyHost())
+ .proxyPort(getProxyPort())
+ .players(getPlayers())
+ .proxyVersion(getProxyVersion())
+ .plugins(getPlugins());
+ }
+
+ /**
+ * Creates a new {@link Builder} instance
+ * @return {@link QueryResponse} builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * A builder for {@link QueryResponse} objects.
+ */
+ public static final class Builder {
+ @MonotonicNonNull
+ private String hostname;
+
+ @MonotonicNonNull
+ private String gameVersion;
+
+ @MonotonicNonNull
+ private String map;
+
+ @MonotonicNonNull
+ private String proxyHost;
+
+ @MonotonicNonNull
+ private String proxyVersion;
+
+ private int currentPlayers;
+ private int maxPlayers;
+ private int proxyPort;
+
+ private List players = new ArrayList<>();
+ private List plugins = new ArrayList<>();
+
+ private Builder() {}
+
+ public Builder hostname(String hostname) {
+ this.hostname = Preconditions.checkNotNull(hostname, "hostname");
+ return this;
+ }
+
+ public Builder gameVersion(String gameVersion) {
+ this.gameVersion = Preconditions.checkNotNull(gameVersion, "gameVersion");
+ return this;
+ }
+
+ public Builder map(String map) {
+ this.map = Preconditions.checkNotNull(map, "map");
+ return this;
+ }
+
+ public Builder currentPlayers(int currentPlayers) {
+ Preconditions.checkArgument(currentPlayers >= 0, "currentPlayers cannot be negative");
+ this.currentPlayers = currentPlayers;
+ return this;
+ }
+
+ public Builder maxPlayers(int maxPlayers) {
+ Preconditions.checkArgument(maxPlayers >= 0, "maxPlayers cannot be negative");
+ this.maxPlayers = maxPlayers;
+ return this;
+ }
+
+ public Builder proxyHost(String proxyHost) {
+ this.proxyHost = Preconditions.checkNotNull(proxyHost, "proxyHost");
+ return this;
+ }
+
+ public Builder proxyPort(int proxyPort) {
+ Preconditions.checkArgument(proxyPort >= 1 && proxyPort <= 65535, "proxyPort must be between 1-65535");
+ this.proxyPort = proxyPort;
+ return this;
+ }
+
+ public Builder players(Collection players) {
+ this.players.addAll(Preconditions.checkNotNull(players, "players"));
+ return this;
+ }
+
+ public Builder players(String... players) {
+ this.players.addAll(Arrays.asList(Preconditions.checkNotNull(players, "players")));
+ return this;
+ }
+
+ public Builder clearPlayers() {
+ this.players.clear();
+ return this;
+ }
+
+ public Builder proxyVersion(String proxyVersion) {
+ this.proxyVersion = Preconditions.checkNotNull(proxyVersion, "proxyVersion");
+ return this;
+ }
+
+ public Builder plugins(Collection plugins) {
+ this.plugins.addAll(Preconditions.checkNotNull(plugins, "plugins"));
+ return this;
+ }
+
+ public Builder plugins(PluginInformation... plugins) {
+ this.plugins.addAll(Arrays.asList(Preconditions.checkNotNull(plugins, "plugins")));
+ return this;
+ }
+
+ public Builder clearPlugins() {
+ this.plugins.clear();
+ return this;
+ }
+
+ /**
+ * Builds new {@link QueryResponse} with supplied data
+ * @return response
+ */
+ public QueryResponse build() {
+ return new QueryResponse(
+ Preconditions.checkNotNull(hostname, "hostname"),
+ Preconditions.checkNotNull(gameVersion, "gameVersion"),
+ Preconditions.checkNotNull(map, "map"),
+ currentPlayers,
+ maxPlayers,
+ Preconditions.checkNotNull(proxyHost, "proxyHost"),
+ proxyPort,
+ ImmutableList.copyOf(players),
+ Preconditions.checkNotNull(proxyVersion, "proxyVersion"),
+ ImmutableList.copyOf(plugins)
+ );
+ }
+ }
+
+ /**
+ * Plugin information
+ */
+ public static class PluginInformation {
+ private String name;
+ private String version;
+
+ public PluginInformation(String name, String version) {
+ this.name = Preconditions.checkNotNull(name, "name");
+ this.version = Preconditions.checkNotNull(version, "version");
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public void setVersion(@Nullable String version) {
+ this.version = version;
+ }
+
+ @Nullable
+ public String getVersion() {
+ return version;
+ }
+
+ public static PluginInformation of(String name, @Nullable String version) {
+ return new PluginInformation(name, version);
+ }
+ }
+}
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java
index 39ff30899..df11d1bf0 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java
@@ -213,6 +213,11 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi
return query.getQueryMap();
}
+ @Override
+ public boolean shouldQueryShowPlugins() {
+ return query.shouldQueryShowPlugins();
+ }
+
public String getMotd() {
return motd;
}
@@ -504,6 +509,10 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi
@ConfigKey("map")
private String queryMap = "Velocity";
+ @Comment("Whether plugins should be shown in query response by default or not")
+ @ConfigKey("show-plugins")
+ private boolean showPlugins = false;
+
private Query() {
}
@@ -533,13 +542,18 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi
return queryMap;
}
+ public boolean shouldQueryShowPlugins() {
+ return showPlugins;
+ }
+
@Override
public String toString() {
- return "Query{"
- + "queryEnabled=" + queryEnabled
- + ", queryPort=" + queryPort
- + ", queryMap=" + queryMap
- + '}';
+ return "Query{" +
+ "queryEnabled=" + queryEnabled +
+ ", queryPort=" + queryPort +
+ ", queryMap='" + queryMap + '\'' +
+ ", showPlugins=" + showPlugins +
+ '}';
}
}
}
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GS4QueryHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GS4QueryHandler.java
index 79c7e6b08..e9a6a016e 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GS4QueryHandler.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/GS4QueryHandler.java
@@ -3,7 +3,10 @@ package com.velocitypowered.proxy.protocol.netty;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.ImmutableSet;
+import com.velocitypowered.api.event.query.ProxyQueryEvent;
+import com.velocitypowered.api.plugin.PluginContainer;
import com.velocitypowered.api.proxy.Player;
+import com.velocitypowered.api.proxy.server.QueryResponse;
import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.protocol.ProtocolConstants;
import io.netty.buffer.ByteBuf;
@@ -13,13 +16,21 @@ import io.netty.channel.socket.DatagramPacket;
import net.kyori.text.serializer.ComponentSerializers;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import static com.velocitypowered.api.event.query.ProxyQueryEvent.QueryType.BASIC;
+import static com.velocitypowered.api.event.query.ProxyQueryEvent.QueryType.FULL;
public class GS4QueryHandler extends SimpleChannelInboundHandler {
private static final Logger logger = LogManager.getLogger(GS4QueryHandler.class);
@@ -46,6 +57,9 @@ public class GS4QueryHandler extends SimpleChannelInboundHandler
.expireAfterWrite(30, TimeUnit.SECONDS)
.build();
+ @MonotonicNonNull
+ private volatile List pluginInformationList = null;
+
private final VelocityServer server;
public GS4QueryHandler(VelocityServer server) {
@@ -81,6 +95,7 @@ public class GS4QueryHandler extends SimpleChannelInboundHandler
queryResponse.writeByte(QUERY_TYPE_HANDSHAKE);
queryResponse.writeInt(sessionId);
writeString(queryResponse, Integer.toString(challengeToken));
+ ctx.writeAndFlush(responsePacket);
break;
}
@@ -97,28 +112,51 @@ public class GS4QueryHandler extends SimpleChannelInboundHandler
throw new IllegalStateException("Invalid query packet");
}
- // Packet header
- queryResponse.writeByte(QUERY_TYPE_STAT);
- queryResponse.writeInt(sessionId);
+ // Build query response
+ QueryResponse response = QueryResponse.builder()
+ .hostname(ComponentSerializers.PLAIN.serialize(server.getConfiguration().getMotdComponent()))
+ .gameVersion(ProtocolConstants.SUPPORTED_GENERIC_VERSION_STRING)
+ .map(server.getConfiguration().getQueryMap())
+ .currentPlayers(server.getPlayerCount())
+ .maxPlayers(server.getConfiguration().getShowMaxPlayers())
+ .proxyPort(server.getConfiguration().getBind().getPort())
+ .proxyHost(server.getConfiguration().getBind().getHostString())
+ .players(server.getAllPlayers().stream().map(Player::getUsername).collect(Collectors.toList()))
+ .proxyVersion("Velocity")
+ .plugins(server.getConfiguration().shouldQueryShowPlugins() ? getRealPluginInformation() : Collections.emptyList())
+ .build();
- // Start writing the response
- ResponseWriter responseWriter = new ResponseWriter(queryResponse, queryMessage.readableBytes() == 0);
- responseWriter.write("hostname", ComponentSerializers.PLAIN.serialize(server.getConfiguration().getMotdComponent()));
- responseWriter.write("gametype", "SMP");
+ boolean isBasic = queryMessage.readableBytes() == 0;
- responseWriter.write("game_id", "MINECRAFT");
- responseWriter.write("version", ProtocolConstants.SUPPORTED_GENERIC_VERSION_STRING);
- responseWriter.write("plugins", "");
+ // Call event and write response
+ server.getEventManager().fire(new ProxyQueryEvent(isBasic ? BASIC : FULL, senderAddress, response)).whenCompleteAsync((event, exc) -> {
+ // Packet header
+ queryResponse.writeByte(QUERY_TYPE_STAT);
+ queryResponse.writeInt(sessionId);
- responseWriter.write("map", server.getConfiguration().getQueryMap());
- responseWriter.write("numplayers", server.getPlayerCount());
- responseWriter.write("maxplayers", server.getConfiguration().getShowMaxPlayers());
- responseWriter.write("hostport", server.getConfiguration().getBind().getPort());
- responseWriter.write("hostip", server.getConfiguration().getBind().getHostString());
+ // Start writing the response
+ ResponseWriter responseWriter = new ResponseWriter(queryResponse, isBasic);
+ responseWriter.write("hostname", event.getResponse().getHostname());
+ responseWriter.write("gametype", "SMP");
+
+ responseWriter.write("game_id", "MINECRAFT");
+ responseWriter.write("version", event.getResponse().getGameVersion());
+ responseWriter.writePlugins(event.getResponse().getProxyVersion(), event.getResponse().getPlugins());
+
+ responseWriter.write("map", event.getResponse().getMap());
+ responseWriter.write("numplayers", event.getResponse().getCurrentPlayers());
+ responseWriter.write("maxplayers", event.getResponse().getMaxPlayers());
+ responseWriter.write("hostport", event.getResponse().getProxyPort());
+ responseWriter.write("hostip", event.getResponse().getProxyHost());
+
+ if (!responseWriter.isBasic) {
+ responseWriter.writePlayers(event.getResponse().getPlayers());
+ }
+
+ // Send the response
+ ctx.writeAndFlush(responsePacket);
+ }, ctx.channel().eventLoop());
- if (!responseWriter.isBasic) {
- responseWriter.writePlayers(server.getAllPlayers());
- }
break;
}
@@ -126,9 +164,6 @@ public class GS4QueryHandler extends SimpleChannelInboundHandler
throw new IllegalStateException("Invalid query type: " + type);
}
}
-
- // Send the response
- ctx.writeAndFlush(responsePacket);
} catch (Exception e) {
logger.warn("Error while trying to handle a query packet from {}", msg.sender(), e);
// NB: Only need to explicitly release upon exception, writing the response out will decrement the reference
@@ -142,6 +177,22 @@ public class GS4QueryHandler extends SimpleChannelInboundHandler
buf.writeByte(0x00);
}
+ private List getRealPluginInformation() {
+ // Effective Java, Third Edition; Item 83: Use lazy initialization judiciously
+ List res = pluginInformationList;
+ if (res == null) {
+ synchronized (this) {
+ if (pluginInformationList == null) {
+ pluginInformationList = res = server.getPluginManager().getPlugins().stream()
+ .map(PluginContainer::getDescription)
+ .map(desc -> QueryResponse.PluginInformation.of(desc.getName().orElse(desc.getId()), desc.getVersion().orElse(null)))
+ .collect(Collectors.toList());
+ }
+ }
+ }
+ return res;
+ }
+
private static class ResponseWriter {
private final ByteBuf buf;
private final boolean isBasic;
@@ -180,7 +231,7 @@ public class GS4QueryHandler extends SimpleChannelInboundHandler
// Ends packet k/v body writing and writes stat player list to
// the packet if this writer is initialized for full stat response
- void writePlayers(Collection players) {
+ void writePlayers(Collection players) {
if (isBasic) {
return;
}
@@ -189,8 +240,29 @@ public class GS4QueryHandler extends SimpleChannelInboundHandler
buf.writeByte(0x00);
buf.writeBytes(QUERY_RESPONSE_FULL_PADDING2);
- players.forEach(player -> writeString(buf, player.getUsername()));
+ players.forEach(player -> writeString(buf, player));
buf.writeByte(0x00);
}
+
+ void writePlugins(String serverVersion, Collection plugins) {
+ if (isBasic)
+ return;
+
+ StringBuilder pluginsString = new StringBuilder();
+ pluginsString.append(serverVersion).append(':').append(' ');
+ Iterator iterator = plugins.iterator();
+ while (iterator.hasNext()) {
+ QueryResponse.PluginInformation info = iterator.next();
+ pluginsString.append(info.getName());
+ if (info.getVersion() != null) {
+ pluginsString.append(' ').append(info.getVersion());
+ }
+ if (iterator.hasNext()) {
+ pluginsString.append(';').append(' ');
+ }
+ }
+
+ writeString(buf, pluginsString.toString());
+ }
}
}