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()); + } } }