diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java index 02c085241..3218960b9 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java @@ -3,19 +3,35 @@ package com.velocitypowered.proxy.command.builtin; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; + +import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSyntaxException; import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.command.SimpleCommand; import com.velocitypowered.api.permission.Tristate; import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.PluginDescription; import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.util.ProxyVersion; import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.util.InformationUtils; + +import java.net.ConnectException; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; + import net.kyori.adventure.identity.Identity; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; @@ -25,6 +41,10 @@ import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.BoundRequestBuilder; +import org.asynchttpclient.ListenableFuture; +import org.asynchttpclient.Response; import org.checkerframework.checker.nullness.qual.NonNull; public class VelocityCommand implements SimpleCommand { @@ -52,6 +72,7 @@ public class VelocityCommand implements SimpleCommand { .put("version", new Info(server)) .put("plugins", new Plugins(server)) .put("reload", new Reload(server)) + .put("dump", new Dump(server)) .build(); } @@ -289,4 +310,140 @@ public class VelocityCommand implements SimpleCommand { return source.getPermissionValue("velocity.command.plugins") == Tristate.TRUE; } } + + private static class Dump implements SubCommand { + + private static final Logger logger = LogManager.getLogger(Dump.class); + private final ProxyServer server; + + private Dump(ProxyServer server) { + this.server = server; + } + + @Override + public void execute(CommandSource source, String @NonNull [] args) { + if (args.length != 0) { + source.sendMessage(Identity.nil(), Component.text("/velocity dump", NamedTextColor.RED)); + return; + } + + Collection allServers = ImmutableSet.copyOf(server.getAllServers()); + JsonObject servers = new JsonObject(); + for (RegisteredServer iter : allServers) { + servers.add(iter.getServerInfo().getName(), + InformationUtils.collectServerInfo(iter)); + } + JsonArray connectOrder = new JsonArray(); + List attemptedConnectionOrder = ImmutableList.copyOf( + server.getConfiguration().getAttemptConnectionOrder()); + for (int i = 0; i < attemptedConnectionOrder.size(); i++) { + connectOrder.add(attemptedConnectionOrder.get(i)); + } + + JsonObject proxyConfig = InformationUtils.collectProxyConfig(server.getConfiguration()); + proxyConfig.add("servers", servers); + proxyConfig.add("connectOrder", connectOrder); + proxyConfig.add("forcedHosts", + InformationUtils.collectForcedHosts(server.getConfiguration())); + + JsonObject dump = new JsonObject(); + dump.add("versionInfo", InformationUtils.collectProxyInfo(server.getVersion())); + dump.add("platform", InformationUtils.collectEnvironmentInfo()); + dump.add("config", proxyConfig); + dump.add("plugins", InformationUtils.collectPluginInfo(server)); + + source.sendMessage(Component.text().content("Uploading gathered information...").build()); + AsyncHttpClient httpClient = ((VelocityServer) server).getAsyncHttpClient(); + + BoundRequestBuilder request = + httpClient.preparePost("https://dump.velocitypowered.com/documents"); + request.setHeader("Content-Type", "text/plain"); + request.addHeader("User-Agent", server.getVersion().getName() + "/" + + server.getVersion().getVersion()); + request.setBody( + InformationUtils.toHumanReadableString(dump).getBytes(StandardCharsets.UTF_8)); + + ListenableFuture future = request.execute(); + future.addListener(() -> { + try { + Response response = future.get(); + if (response.getStatusCode() != 200) { + source.sendMessage(Component.text() + .content("An error occurred while communicating with the Velocity servers. " + + "The servers may be temporarily unavailable or there is an issue " + + "with your network settings. You can find more information in the " + + "log or console of your Velocity server.") + .color(NamedTextColor.RED).build()); + logger.error("Invalid status code while POST-ing Velocity dump: " + + response.getStatusCode()); + logger.error("Headers: \n--------------BEGIN HEADERS--------------\n" + + response.getHeaders().toString() + + "\n---------------END HEADERS---------------"); + return; + } + JsonObject key = InformationUtils.parseString( + response.getResponseBody(StandardCharsets.UTF_8)); + if (!key.has("key")) { + throw new JsonSyntaxException("Missing Dump-Url-response"); + } + String url = "https://dump.velocitypowered.com/" + + key.get("key").getAsString() + ".json"; + source.sendMessage(Component.text() + .content("Created an anonymised report containing useful information about " + + "this proxy. If a developer requested it, you may share the " + + "following link with them:") + .append(Component.newline()) + .append(Component.text(">> " + url) + .color(NamedTextColor.GREEN) + .clickEvent(ClickEvent.openUrl(url))) + .append(Component.newline()) + .append(Component.text("Note: This link is only valid for a few days") + .color(NamedTextColor.GRAY) + ).build()); + } catch (InterruptedException e) { + source.sendMessage(Component.text() + .content("Could not complete the request, the command was interrupted." + + "Please refer to the proxy-log or console for more information.") + .color(NamedTextColor.RED).build()); + logger.error("Failed to complete dump command, " + + "the executor was interrupted: " + e.getMessage()); + e.printStackTrace(); + } catch (ExecutionException e) { + TextComponent.Builder message = Component.text() + .content("An error occurred while attempting to upload the gathered " + + "information to the Velocity servers.") + .append(Component.newline()) + .color(NamedTextColor.RED); + if (e.getCause() instanceof UnknownHostException + || e.getCause() instanceof ConnectException) { + message.append(Component.text( + "Likely cause: Invalid system DNS settings or no internet connection")); + } + source.sendMessage(message + .append(Component.newline() + .append(Component.text( + "Error details can be found in the proxy log / console")) + ).build()); + + logger.error("Failed to complete dump command, " + + "the executor encountered an Exception: " + e.getCause().getMessage()); + e.getCause().printStackTrace(); + } catch (JsonParseException e) { + source.sendMessage(Component.text() + .content("An error occurred on the Velocity-servers and the dump could not " + + "be completed. Please contact the Velocity staff about this problem. " + + "If you do, provide the details about this error from the Velocity " + + "console or server log.") + .color(NamedTextColor.RED).build()); + logger.error("Invalid response from the Velocity servers: " + e.getMessage()); + e.printStackTrace(); + } + }, MoreExecutors.directExecutor()); + } + + @Override + public boolean hasPermission(final CommandSource source, final String @NonNull [] args) { + return source.getPermissionValue("velocity.command.plugins") == Tristate.TRUE; + } + } } 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 a3f7537f2..5660b913e 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -7,6 +7,7 @@ import com.electronwill.nightconfig.toml.TomlFormat; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.gson.annotations.Expose; import com.velocitypowered.api.proxy.config.ProxyConfig; import com.velocitypowered.api.util.Favicon; import com.velocitypowered.proxy.util.AddressUtil; @@ -42,20 +43,20 @@ public class VelocityConfiguration implements ProxyConfig { private static final Logger logger = LogManager.getLogger(VelocityConfiguration.class); - private String bind = "0.0.0.0:25577"; - private String motd = "&3A Velocity Server"; - private int showMaxPlayers = 500; - private boolean onlineMode = true; - private boolean preventClientProxyConnections = false; - private PlayerInfoForwarding playerInfoForwardingMode = PlayerInfoForwarding.NONE; + @Expose private String bind = "0.0.0.0:25577"; + @Expose private String motd = "&3A Velocity Server"; + @Expose private int showMaxPlayers = 500; + @Expose private boolean onlineMode = true; + @Expose private boolean preventClientProxyConnections = false; + @Expose private PlayerInfoForwarding playerInfoForwardingMode = PlayerInfoForwarding.NONE; private byte[] forwardingSecret = generateRandomString(12).getBytes(StandardCharsets.UTF_8); - private boolean announceForge = false; - private boolean onlineModeKickExistingPlayers = false; - private PingPassthroughMode pingPassthrough = PingPassthroughMode.DISABLED; + @Expose private boolean announceForge = false; + @Expose private boolean onlineModeKickExistingPlayers = false; + @Expose private PingPassthroughMode pingPassthrough = PingPassthroughMode.DISABLED; private final Servers servers; private final ForcedHosts forcedHosts; - private final Advanced advanced; - private final Query query; + @Expose private final Advanced advanced; + @Expose private final Query query; private final Metrics metrics; private final Messages messages; private net.kyori.adventure.text.@MonotonicNonNull Component motdAsComponent; @@ -622,18 +623,18 @@ public class VelocityConfiguration implements ProxyConfig { private static class Advanced { - private int compressionThreshold = 256; - private int compressionLevel = -1; - private int loginRatelimit = 3000; - private int connectionTimeout = 5000; - private int readTimeout = 30000; - private boolean proxyProtocol = false; - private boolean tcpFastOpen = false; - private boolean bungeePluginMessageChannel = true; - private boolean showPingRequests = false; - private boolean failoverOnUnexpectedServerDisconnect = true; - private boolean announceProxyCommands = true; - private boolean logCommandExecutions = false; + @Expose private int compressionThreshold = 256; + @Expose private int compressionLevel = -1; + @Expose private int loginRatelimit = 3000; + @Expose private int connectionTimeout = 5000; + @Expose private int readTimeout = 30000; + @Expose private boolean proxyProtocol = false; + @Expose private boolean tcpFastOpen = false; + @Expose private boolean bungeePluginMessageChannel = true; + @Expose private boolean showPingRequests = false; + @Expose private boolean failoverOnUnexpectedServerDisconnect = true; + @Expose private boolean announceProxyCommands = true; + @Expose private boolean logCommandExecutions = false; private Advanced() { } @@ -725,10 +726,10 @@ public class VelocityConfiguration implements ProxyConfig { private static class Query { - private boolean queryEnabled = false; - private int queryPort = 25577; - private String queryMap = "Velocity"; - private boolean showPlugins = false; + @Expose private boolean queryEnabled = false; + @Expose private int queryPort = 25577; + @Expose private String queryMap = "Velocity"; + @Expose private boolean showPlugins = false; private Query() { } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/InformationUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/util/InformationUtils.java new file mode 100644 index 000000000..c75597f4d --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/InformationUtils.java @@ -0,0 +1,250 @@ +package com.velocitypowered.proxy.util; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.PluginDescription; +import com.velocitypowered.api.plugin.meta.PluginDependency; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.config.ProxyConfig; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import com.velocitypowered.api.util.ProxyVersion; + +import io.netty.channel.unix.DomainSocketAddress; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.List; +import java.util.Map; + +import joptsimple.internal.Strings; + +public enum InformationUtils { + ; + + /** + * Retrieves a {@link JsonArray} containing basic information about all + * running plugins on the {@link ProxyServer} instance. + * + * @param proxy the proxy instance to retrieve from + * @return {@link JsonArray} containing zero or more {@link JsonObject} + */ + public static JsonArray collectPluginInfo(ProxyServer proxy) { + List allPlugins = ImmutableList.copyOf( + proxy.getPluginManager().getPlugins()); + JsonArray plugins = new JsonArray(); + + for (PluginContainer plugin : allPlugins) { + PluginDescription desc = plugin.getDescription(); + JsonObject current = new JsonObject(); + current.addProperty("id", desc.getId()); + if (desc.getName().isPresent()) { + current.addProperty("name", desc.getName().get()); + } + if (desc.getVersion().isPresent()) { + current.addProperty("version", desc.getVersion().get()); + } + if (!desc.getAuthors().isEmpty()) { + current.addProperty("authors", + Strings.join(desc.getAuthors(), ",")); + } + if (desc.getDescription().isPresent()) { + current.addProperty("description", desc.getDescription().get()); + } + if (desc.getUrl().isPresent()) { + current.addProperty("url", desc.getUrl().get()); + } + if (!desc.getDependencies().isEmpty()) { + JsonArray dependencies = new JsonArray(); + for (PluginDependency dependency : desc.getDependencies()) { + dependencies.add(dependency.getId()); + } + current.add("dependencies", dependencies); + } + plugins.add(current); + } + return plugins; + } + + /** + * Creates a {@link JsonObject} containing information about the + * current environment the project is run under. + * + * @return {@link JsonObject} containing environment info + */ + public static JsonObject collectEnvironmentInfo() { + JsonObject envInfo = new JsonObject(); + envInfo.addProperty("operatingSystemType", System.getProperty("os.name")); + envInfo.addProperty("operatingSystemVersion", System.getProperty("os.version")); + envInfo.addProperty("operatingSystemArchitecture", System.getProperty("os.arch")); + envInfo.addProperty("javaVersion", System.getProperty("java.version")); + envInfo.addProperty("javaVendor", System.getProperty("java.vendor")); + return envInfo; + } + + /** + * Creates a {@link JsonObject} containing information about the + * forced hosts of the {@link ProxyConfig} instance. + * + * @return {@link JsonArray} containing forced hosts + */ + public static JsonObject collectForcedHosts(ProxyConfig config) { + JsonObject forcedHosts = new JsonObject(); + Map> allForcedHosts = ImmutableMap.copyOf( + config.getForcedHosts()); + for (Map.Entry> entry : allForcedHosts.entrySet()) { + JsonArray host = new JsonArray(); + for (int i = 0; i < entry.getValue().size(); i++) { + host.add(entry.getValue().get(i)); + } + forcedHosts.add(entry.getKey(), host); + } + return forcedHosts; + } + + /** + * Anonymises or redacts a given {@link InetAddress} + * public address bits. + * + * @param address The address to redact + * @return {@link String} address with public parts redacted + */ + public static String anonymizeInetAddress(InetAddress address) { + if (address instanceof Inet4Address) { + Inet4Address v4 = (Inet4Address) address; + if (v4.isAnyLocalAddress() || v4.isLoopbackAddress() + || v4.isLinkLocalAddress() + || v4.isSiteLocalAddress()) { + return address.getHostAddress(); + } else { + byte[] addr = v4.getAddress(); + return (addr[0] & 0xff) + "." + (addr[1] & 0xff) + ".XXX.XXX"; + } + } else if (address instanceof Inet6Address) { + Inet6Address v6 = (Inet6Address) address; + if (v6.isAnyLocalAddress() || v6.isLoopbackAddress() + || v6.isSiteLocalAddress() + || v6.isSiteLocalAddress()) { + return address.getHostAddress(); + } else { + String[] bits = v6.getHostAddress().split(":"); + String ret = ""; + boolean flag = false; + for (int iter = 0; iter < bits.length; iter++) { + if (flag) { + ret += ":X"; + continue; + } + if (!bits[iter].equals("0")) { + if (iter == 0) { + ret = bits[iter]; + } else { + ret = "::" + bits[iter]; + } + flag = true; + } + } + return ret; + } + } else { + return address.getHostAddress(); + } + } + + /** + * Creates a {@link JsonObject} containing most relevant + * information of the {@link RegisteredServer} for diagnosis. + * + * @param server the server to evaluate + * @return {@link JsonObject} containing server and diagnostic info + */ + public static JsonObject collectServerInfo(RegisteredServer server) { + JsonObject info = new JsonObject(); + info.addProperty("currentPlayers", server.getPlayersConnected().size()); + SocketAddress address = server.getServerInfo().getAddress(); + if (address instanceof InetSocketAddress) { + InetSocketAddress iaddr = (InetSocketAddress) address; + if (iaddr.isUnresolved()) { + // Greetings form Netty 4aa10db9 + info.addProperty("host", iaddr.getHostString()); + } else { + info.addProperty("host", anonymizeInetAddress(iaddr.getAddress())); + } + info.addProperty("port", iaddr.getPort()); + } else if (address instanceof DomainSocketAddress) { + DomainSocketAddress daddr = (DomainSocketAddress) address; + info.addProperty("path", daddr.path()); + } else { + info.addProperty("info", address.toString()); + } + return info; + } + + /** + * Creates a {@link JsonObject} containing information about the + * current environment the project is run under. + * + * @param version the proxy instance to retrieve from + * @return {@link JsonObject} containing environment info + */ + public static JsonObject collectProxyInfo(ProxyVersion version) { + return (JsonObject) serializeObject(version, false); + } + + /** + * Creates a {@link JsonObject} containing most relevant + * information of the {@link ProxyConfig} for diagnosis. + * + * @param config the config instance to retrieve from + * @return {@link JsonObject} containing select config values + */ + public static JsonObject collectProxyConfig(ProxyConfig config) { + return (JsonObject) serializeObject(config, true); + } + + /** + * Creates a human-readable String from a {@link JsonElement}. + * + * @param json the {@link JsonElement} object + * @return the human-readable String + */ + public static String toHumanReadableString(JsonElement json) { + return GSON_WITHOUT_EXCLUDES.toJson(json); + } + + /** + * Creates a {@link JsonObject} from a String. + * + * @param toParse the String to parse + * @return {@link JsonObject} object + */ + public static JsonObject parseString(String toParse) { + return GSON_WITHOUT_EXCLUDES.fromJson(toParse, JsonObject.class); + } + + private static JsonElement serializeObject(Object toSerialize, boolean withExcludes) { + return JsonParser.parseString( + withExcludes ? GSON_WITH_EXCLUDES.toJson(toSerialize) : + GSON_WITHOUT_EXCLUDES.toJson(toSerialize)); + } + + private static final Gson GSON_WITH_EXCLUDES = new GsonBuilder() + .setPrettyPrinting() + .excludeFieldsWithoutExposeAnnotation() + .create(); + + private static final Gson GSON_WITHOUT_EXCLUDES = new GsonBuilder() + .setPrettyPrinting() + .create(); + + +}