diff --git a/README.md b/README.md index a58656231..493fdb342 100644 --- a/README.md +++ b/README.md @@ -44,5 +44,4 @@ is not currently suitable for production usage. For development and testing purposes, however, Velocity is fully-fledged and ready to go. Velocity supports Minecraft 1.8-1.13.1, and has full support for Paper and Sponge. -Forge support is currently not implemented, but Velocity will work with Forge's -vanilla fallback mode. \ No newline at end of file +Forge is fully supported but mod compatibility may vary. \ No newline at end of file diff --git a/api/src/main/java/com/velocitypowered/api/event/connection/PostLoginEvent.java b/api/src/main/java/com/velocitypowered/api/event/connection/PostLoginEvent.java new file mode 100644 index 000000000..7ecc23bbc --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/connection/PostLoginEvent.java @@ -0,0 +1,28 @@ +package com.velocitypowered.api.event.connection; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.proxy.Player; + +/** + * This event is fired once the player has been successfully authenticated and + * fully initialized and player will be connected to server after this event + */ +public class PostLoginEvent { + + private final Player player; + + public PostLoginEvent(Player player) { + this.player = Preconditions.checkNotNull(player, "player"); + } + + public Player getPlayer() { + return player; + } + + @Override + public String toString() { + return "PostLoginEvent{" + + "player=" + player + + '}'; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/connection/PreLoginEvent.java b/api/src/main/java/com/velocitypowered/api/event/connection/PreLoginEvent.java index ef8edfb9f..04fdc7622 100644 --- a/api/src/main/java/com/velocitypowered/api/event/connection/PreLoginEvent.java +++ b/api/src/main/java/com/velocitypowered/api/event/connection/PreLoginEvent.java @@ -3,8 +3,10 @@ package com.velocitypowered.api.event.connection; import com.google.common.base.Preconditions; import com.velocitypowered.api.event.ResultedEvent; import com.velocitypowered.api.proxy.InboundConnection; +import java.util.Optional; import net.kyori.text.Component; +import net.kyori.text.serializer.ComponentSerializers; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; @@ -52,44 +54,59 @@ public class PreLoginEvent implements ResultedEvent reason; + + private PreLoginComponentResult(Result result, @Nullable Component reason) { + this.result = result; + this.reason = Optional.ofNullable(reason); } - private PreLoginComponentResult(@Nullable Component reason) { - super(reason == null, reason); - // Don't care about this - this.onlineMode = false; + @Override + public boolean isAllowed() { + return result != Result.DISALLOWED; + } + + public Optional getReason() { + return reason; } public boolean isOnlineModeAllowed() { - return this.onlineMode; + return result == Result.FORCE_ONLINE; + } + + public boolean isForceOfflineMode() { + return result == Result.FORCE_OFFLINE; } @Override public String toString() { + if (isForceOfflineMode()) { + return "allowed with force offline mode"; + } if (isOnlineModeAllowed()) { return "allowed with online mode"; } - - return super.toString(); + if (isAllowed()) { + return "allowed"; + } + if (reason.isPresent()) { + return "denied: " + ComponentSerializers.PLAIN.serialize(reason.get()); + } + return "denied"; } /** - * Returns a result indicating the connection will be allowed through the proxy. + * Returns a result indicating the connection will be allowed through + * the proxy. * @return the allowed result */ public static PreLoginComponentResult allowed() { @@ -97,23 +114,41 @@ public class PreLoginEvent implements ResultedEvent { + private final Player player; + private final ServerInfo server; + private final Component originalReason; + private final boolean duringLogin; + private ServerKickResult result; + + public KickedFromServerEvent(Player player, ServerInfo server, Component originalReason, boolean duringLogin, Component fancyReason) { + this.player = Preconditions.checkNotNull(player, "player"); + this.server = Preconditions.checkNotNull(server, "server"); + this.originalReason = Preconditions.checkNotNull(originalReason, "originalReason"); + this.duringLogin = duringLogin; + this.result = new DisconnectPlayer(fancyReason); + } + + @Override + public ServerKickResult getResult() { + return result; + } + + @Override + public void setResult(@NonNull ServerKickResult result) { + this.result = Preconditions.checkNotNull(result, "result"); + } + + public Player getPlayer() { + return player; + } + + public ServerInfo getServer() { + return server; + } + + public Component getOriginalReason() { + return originalReason; + } + + public boolean kickedDuringLogin() { + return duringLogin; + } + + /** + * Represents the base interface for {@link KickedFromServerEvent} results. + */ + public interface ServerKickResult extends ResultedEvent.Result {} + + /** + * Tells the proxy to disconnect the player with the specified reason. + */ + public static class DisconnectPlayer implements ServerKickResult { + private final Component component; + + private DisconnectPlayer(Component component) { + this.component = Preconditions.checkNotNull(component, "component"); + } + + @Override + public boolean isAllowed() { + return true; + } + + public Component getReason() { + return component; + } + + /** + * Creates a new {@link DisconnectPlayer} with the specified reason. + * @param reason the reason to use when disconnecting the player + * @return the disconnect result + */ + public static DisconnectPlayer create(Component reason) { + return new DisconnectPlayer(reason); + } + } + + /** + * Tells the proxy to redirect the player to another server. No messages will be sent from the proxy + * when this result is used. + */ + public static class RedirectPlayer implements ServerKickResult { + private final ServerInfo server; + + private RedirectPlayer(ServerInfo server) { + this.server = Preconditions.checkNotNull(server, "server"); + } + + @Override + public boolean isAllowed() { + return false; + } + + public ServerInfo getServer() { + return server; + } + + /** + * Creates a new redirect result to forward the player to the specified {@code server}. + * @param server the server to send the player to + * @return the redirect result + */ + public static RedirectPlayer create(ServerInfo server) { + return new RedirectPlayer(server); + } + } +} diff --git a/api/src/main/java/com/velocitypowered/api/event/player/ServerPreConnectEvent.java b/api/src/main/java/com/velocitypowered/api/event/player/ServerPreConnectEvent.java index 2d86c06f8..c0ad88736 100644 --- a/api/src/main/java/com/velocitypowered/api/event/player/ServerPreConnectEvent.java +++ b/api/src/main/java/com/velocitypowered/api/event/player/ServerPreConnectEvent.java @@ -14,11 +14,13 @@ import java.util.Optional; */ public class ServerPreConnectEvent implements ResultedEvent { private final Player player; + private final ServerInfo originalServer; private ServerResult result; - public ServerPreConnectEvent(Player player, ServerResult result) { + public ServerPreConnectEvent(Player player, ServerInfo originalServer) { this.player = Preconditions.checkNotNull(player, "player"); - this.result = Preconditions.checkNotNull(result, "result"); + this.originalServer = Preconditions.checkNotNull(originalServer, "originalServer"); + this.result = ServerResult.allowed(originalServer); } public Player getPlayer() { @@ -35,10 +37,15 @@ public class ServerPreConnectEvent implements ResultedEvent getModinfo() { + return Optional.ofNullable(modinfo); + } + @Override public String toString() { return "ServerPing{" + @@ -54,11 +63,19 @@ public class ServerPing { public Builder asBuilder() { Builder builder = new Builder(); builder.version = version; - builder.onlinePlayers = players.online; - builder.maximumPlayers = players.max; - builder.samplePlayers.addAll(players.sample); + if (players != null) { + builder.onlinePlayers = players.online; + builder.maximumPlayers = players.max; + builder.samplePlayers.addAll(players.sample); + } else { + builder.nullOutPlayers = true; + } builder.description = description; builder.favicon = favicon; + builder.nullOutModinfo = modinfo == null; + if (modinfo != null) { + builder.mods.addAll(modinfo.modList); + } return builder; } @@ -74,9 +91,11 @@ public class ServerPing { private int onlinePlayers; private int maximumPlayers; private final List samplePlayers = new ArrayList<>(); + private final List mods = new ArrayList<>(); private Component description; private Favicon favicon; private boolean nullOutPlayers; + private boolean nullOutModinfo; private Builder() { @@ -102,11 +121,26 @@ public class ServerPing { return this; } + public Builder mods(Mod... mods) { + this.mods.addAll(Arrays.asList(mods)); + return this; + } + + public Builder clearMods() { + this.mods.clear(); + return this; + } + public Builder clearSamplePlayers() { this.samplePlayers.clear(); return this; } + public Builder notModCompatible() { + this.nullOutModinfo = true; + return this; + } + public Builder nullPlayers() { this.nullOutPlayers = true; return this; @@ -123,7 +157,8 @@ public class ServerPing { } public ServerPing build() { - return new ServerPing(version, nullOutPlayers ? null : new Players(onlinePlayers, maximumPlayers, samplePlayers), description, favicon); + return new ServerPing(version, nullOutPlayers ? null : new Players(onlinePlayers, maximumPlayers, samplePlayers), description, favicon, + nullOutModinfo ? null : new Modinfo(mods)); } public Version getVersion() { @@ -150,6 +185,10 @@ public class ServerPing { return favicon; } + public List getMods() { + return mods; + } + @Override public String toString() { return "Builder{" + @@ -157,8 +196,11 @@ public class ServerPing { ", onlinePlayers=" + onlinePlayers + ", maximumPlayers=" + maximumPlayers + ", samplePlayers=" + samplePlayers + + ", mods=" + mods + ", description=" + description + ", favicon=" + favicon + + ", nullOutPlayers=" + nullOutPlayers + + ", nullOutModinfo=" + nullOutModinfo + '}'; } } @@ -247,4 +289,25 @@ public class ServerPing { '}'; } } + + public static class Modinfo { + public static final Modinfo DEFAULT = new Modinfo(ImmutableList.of()); + + private final String type = "FML"; + private final List modList; + + public Modinfo(List modList) { + this.modList = ImmutableList.copyOf(modList); + } + } + + public static class Mod { + private final String id; + private final String version; + + public Mod(String id, String version) { + this.id = Preconditions.checkNotNull(id, "id"); + this.version = Preconditions.checkNotNull(version, "version"); + } + } } diff --git a/api/src/main/java/com/velocitypowered/api/util/GameProfile.java b/api/src/main/java/com/velocitypowered/api/util/GameProfile.java index 5fd716436..91300d1ce 100644 --- a/api/src/main/java/com/velocitypowered/api/util/GameProfile.java +++ b/api/src/main/java/com/velocitypowered/api/util/GameProfile.java @@ -57,7 +57,7 @@ public final class GameProfile { '}'; } - public final class Property { + public final static class Property { private final String name; private final String value; private final String signature; diff --git a/build.gradle b/build.gradle index 7de9e8fc0..437c41edb 100644 --- a/build.gradle +++ b/build.gradle @@ -13,8 +13,8 @@ allprojects { // dependency versions junitVersion = '5.3.0-M1' slf4jVersion = '1.7.25' - log4jVersion = '2.11.0' - nettyVersion = '4.1.28.Final' + log4jVersion = '2.11.1' + nettyVersion = '4.1.29.Final' guavaVersion = '25.1-jre' getCurrentBranchName = { @@ -27,6 +27,17 @@ allprojects { return os.toString().trim() } } + + getCurrentShortRevision = { + new ByteArrayOutputStream().withStream { os -> + exec { + executable = "git" + args = ["rev-parse", "HEAD"] + standardOutput = os + } + return os.toString().trim().substring(0, 8) + } + } } repositories { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 53fd890d2..c77aa2dc3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10-all.zip diff --git a/proxy/build.gradle b/proxy/build.gradle index 547faed22..56507cb0a 100644 --- a/proxy/build.gradle +++ b/proxy/build.gradle @@ -14,8 +14,11 @@ compileTestJava { jar { manifest { + def buildNumber = System.getenv("BUILD_NUMBER") ?: "unknown" + def version = "${project.version} (git-${project.ext.getCurrentShortRevision()}, build ${buildNumber})" + attributes 'Main-Class': 'com.velocitypowered.proxy.Velocity' - attributes 'Implementation-Version': project.version + attributes 'Implementation-Version': version } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/Velocity.java b/proxy/src/main/java/com/velocitypowered/proxy/Velocity.java index 9c1ee307f..c628775c4 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/Velocity.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/Velocity.java @@ -19,7 +19,7 @@ public class Velocity { public static void main(String... args) { startTime = System.currentTimeMillis(); - logger.info("Booting up Velocity..."); + logger.info("Booting up Velocity {}...", Velocity.class.getPackage().getImplementationVersion()); VelocityServer server = new VelocityServer(); server.start(); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java index 94825af55..f3acb9caf 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java @@ -29,7 +29,7 @@ public class VelocityCommand implements Command { .append(TextComponent.of(" or the ").resetStyle()) .append(TextComponent.builder("Velocity GitHub") .color(TextColor.GREEN) - .clickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://github.com/astei/velocity")) + .clickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://github.com/VelocityPowered/Velocity")) .build()) .build(); 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 f426a2c9f..feb076dbb 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -1,5 +1,6 @@ package com.velocitypowered.proxy.config; +import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableMap; import com.moandjiezana.toml.Toml; import com.velocitypowered.api.util.Favicon; @@ -48,7 +49,7 @@ public class VelocityConfiguration extends AnnotatedConfig { @Comment({ "Should we forward IP addresses and other data to backend servers?", "Available options:", - "- \"none\": No forwarding will be done. All players will appear to be Should we forward IP addresses and other data to backend servers?connecting from the proxy", + "- \"none\": No forwarding will be done. All players will appear to be connecting from the proxy", " and will have offline-mode UUIDs.", "- \"legacy\": Forward player IPs and UUIDs in BungeeCord-compatible fashion. Use this if you run", " servers using Minecraft 1.12 or lower.", @@ -62,6 +63,10 @@ public class VelocityConfiguration extends AnnotatedConfig { @ConfigKey("forwarding-secret") private byte[] forwardingSecret = generateRandomString(12).getBytes(StandardCharsets.UTF_8); + @Comment("Announce whether or not your server supports Forge/FML. If you run a modded server, we suggest turning this on.") + @ConfigKey("announce-forge") + private boolean announceForge = false; + @Table("[servers]") private final Servers servers; @@ -83,12 +88,13 @@ public class VelocityConfiguration extends AnnotatedConfig { } private VelocityConfiguration(String bind, String motd, int showMaxPlayers, boolean onlineMode, - PlayerInfoForwarding playerInfoForwardingMode, byte[] forwardingSecret, Servers servers, - Advanced advanced, Query query) { + boolean announceForge, PlayerInfoForwarding playerInfoForwardingMode, byte[] forwardingSecret, + Servers servers, Advanced advanced, Query query) { this.bind = bind; this.motd = motd; this.showMaxPlayers = showMaxPlayers; this.onlineMode = onlineMode; + this.announceForge = announceForge; this.playerInfoForwardingMode = playerInfoForwardingMode; this.forwardingSecret = forwardingSecret; this.servers = servers; @@ -103,13 +109,13 @@ public class VelocityConfiguration extends AnnotatedConfig { if (bind.isEmpty()) { logger.error("'bind' option is empty."); valid = false; - } - - try { - AddressUtil.parseAddress(bind); - } catch (IllegalArgumentException e) { - logger.error("'bind' option does not specify a valid IP address.", e); - valid = false; + } else { + try { + AddressUtil.parseAddress(bind); + } catch (IllegalArgumentException e) { + logger.error("'bind' option does not specify a valid IP address.", e); + valid = false; + } } if (!onlineMode) { @@ -118,11 +124,11 @@ public class VelocityConfiguration extends AnnotatedConfig { switch (playerInfoForwardingMode) { case NONE: - logger.info("Player info forwarding is disabled! All players will appear to be connecting from the proxy and will have offline-mode UUIDs."); + logger.warn("Player info forwarding is disabled! All players will appear to be connecting from the proxy and will have offline-mode UUIDs."); break; case MODERN: - if (forwardingSecret.length == 0) { - logger.error("You don't have a forwarding secret set."); + if (forwardingSecret == null || forwardingSecret.length == 0) { + logger.error("You don't have a forwarding secret set. This is required for security."); valid = false; } break; @@ -148,7 +154,7 @@ public class VelocityConfiguration extends AnnotatedConfig { for (String s : servers.getAttemptConnectionOrder()) { if (!servers.getServers().containsKey(s)) { - logger.error("Fallback server " + s + " doesn't exist!"); + logger.error("Fallback server " + s + " is not registered in your configuration!"); valid = false; } } @@ -165,18 +171,18 @@ public class VelocityConfiguration extends AnnotatedConfig { logger.error("Invalid compression level {}", advanced.compressionLevel); valid = false; } else if (advanced.compressionLevel == 0) { - logger.warn("ALL packets going through the proxy are going to be uncompressed. This will increase bandwidth usage."); + logger.warn("ALL packets going through the proxy will be uncompressed. This will increase bandwidth usage."); } if (advanced.compressionThreshold < -1) { logger.error("Invalid compression threshold {}", advanced.compressionLevel); valid = false; } else if (advanced.compressionThreshold == 0) { - logger.warn("ALL packets going through the proxy are going to be compressed. This may hurt performance."); + logger.warn("ALL packets going through the proxy will be compressed. This will compromise throughput and increase CPU usage!"); } if (advanced.loginRatelimit < 0) { - logger.error("Invalid login ratelimit {}", advanced.loginRatelimit); + logger.error("Invalid login ratelimit {}ms", advanced.loginRatelimit); valid = false; } @@ -217,7 +223,7 @@ public class VelocityConfiguration extends AnnotatedConfig { if (motd.startsWith("{")) { motdAsComponent = ComponentSerializers.JSON.deserialize(motd); } else { - motdAsComponent = ComponentSerializers.LEGACY.deserialize(LegacyChatColorUtils.translate('&', motd)); + motdAsComponent = ComponentSerializers.LEGACY.deserialize(motd, '&'); } } return motdAsComponent; @@ -263,54 +269,34 @@ public class VelocityConfiguration extends AnnotatedConfig { return favicon; } - private void setBind(String bind) { - this.bind = bind; + public boolean isAnnounceForge() { + return announceForge; } - private void setMotd(String motd) { - this.motd = motd; + public int getConnectTimeout() { + return advanced.getConnectionTimeout(); } - private void setShowMaxPlayers(int showMaxPlayers) { - this.showMaxPlayers = showMaxPlayers; - } - - private void setOnlineMode(boolean onlineMode) { - this.onlineMode = onlineMode; - } - - private void setPlayerInfoForwardingMode(PlayerInfoForwarding playerInfoForwardingMode) { - this.playerInfoForwardingMode = playerInfoForwardingMode; - } - - private void setForwardingSecret(byte[] forwardingSecret) { - this.forwardingSecret = forwardingSecret; - } - - private void setMotdAsComponent(Component motdAsComponent) { - this.motdAsComponent = motdAsComponent; - } - - private void setFavicon(Favicon favicon) { - this.favicon = favicon; + public int getReadTimeout() { + return advanced.getReadTimeout(); } @Override public String toString() { - - return "VelocityConfiguration{" - + "bind='" + bind + '\'' - + ", motd='" + motd + '\'' - + ", showMaxPlayers=" + showMaxPlayers - + ", onlineMode=" + onlineMode - + ", playerInfoForwardingMode=" + playerInfoForwardingMode - + ", forwardingSecret=" + ByteBufUtil.hexDump(forwardingSecret) - + ", servers=" + servers - + ", advanced=" + advanced - + ", query=" + query - + ", motdAsComponent=" + motdAsComponent - + ", favicon=" + favicon - + '}'; + return MoreObjects.toStringHelper(this) + .add("configVersion", configVersion) + .add("bind", bind) + .add("motd", motd) + .add("showMaxPlayers", showMaxPlayers) + .add("onlineMode", onlineMode) + .add("playerInfoForwardingMode", playerInfoForwardingMode) + .add("forwardingSecret", forwardingSecret) + .add("announceForge", announceForge) + .add("servers", servers) + .add("advanced", advanced) + .add("query", query) + .add("favicon", favicon) + .toString(); } public static VelocityConfiguration read(Path path) throws IOException { @@ -335,6 +321,7 @@ public class VelocityConfiguration extends AnnotatedConfig { toml.getString("motd", "&3A Velocity Server"), toml.getLong("show-max-players", 500L).intValue(), toml.getBoolean("online-mode", true), + toml.getBoolean("announce-forge", false), PlayerInfoForwarding.valueOf(toml.getString("player-info-forwarding-mode", "MODERN").toUpperCase()), forwardingSecret, servers, @@ -441,21 +428,23 @@ public class VelocityConfiguration extends AnnotatedConfig { "Disable by setting to 0"}) @ConfigKey("login-ratelimit") private int loginRatelimit = 3000; + @Comment({"Specify a custom timeout for connection timeouts here. The default is five seconds."}) + @ConfigKey("connection-timeout") + private int connectionTimeout = 5000; + @Comment({"Specify a read timeout for connections here. The default is 30 seconds."}) + @ConfigKey("read-timeout") + private int readTimeout = 30000; private Advanced() { } - private Advanced(int compressionThreshold, int compressionLevel, int loginRatelimit) { - this.compressionThreshold = compressionThreshold; - this.compressionLevel = compressionLevel; - this.loginRatelimit = loginRatelimit; - } - private Advanced(Toml toml) { if (toml != null) { this.compressionThreshold = toml.getLong("compression-threshold", 1024L).intValue(); this.compressionLevel = toml.getLong("compression-level", -1L).intValue(); this.loginRatelimit = toml.getLong("login-ratelimit", 3000L).intValue(); + this.connectionTimeout = toml.getLong("connection-timeout", 5000L).intValue(); + this.readTimeout = toml.getLong("read-timeout", 30000L).intValue(); } } @@ -463,33 +452,31 @@ public class VelocityConfiguration extends AnnotatedConfig { return compressionThreshold; } - public void setCompressionThreshold(int compressionThreshold) { - this.compressionThreshold = compressionThreshold; - } - public int getCompressionLevel() { return compressionLevel; } - public void setCompressionLevel(int compressionLevel) { - this.compressionLevel = compressionLevel; - } - public int getLoginRatelimit() { return loginRatelimit; } - public void setLoginRatelimit(int loginRatelimit) { - this.loginRatelimit = loginRatelimit; + public int getConnectionTimeout() { + return connectionTimeout; + } + + public int getReadTimeout() { + return readTimeout; } @Override public String toString() { - return "Advanced{" - + "compressionThreshold=" + compressionThreshold - + ", compressionLevel=" + compressionLevel - + ", loginRatelimit=" + loginRatelimit - + '}'; + return "Advanced{" + + "compressionThreshold=" + compressionThreshold + + ", compressionLevel=" + compressionLevel + + ", loginRatelimit=" + loginRatelimit + + ", connectionTimeout=" + connectionTimeout + + ", readTimeout=" + readTimeout + + '}'; } } @@ -521,18 +508,10 @@ public class VelocityConfiguration extends AnnotatedConfig { return queryEnabled; } - public void setQueryEnabled(boolean queryEnabled) { - this.queryEnabled = queryEnabled; - } - public int getQueryPort() { return queryPort; } - public void setQueryPort(int queryPort) { - this.queryPort = queryPort; - } - @Override public String toString() { return "Query{" diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java index 6612a9af8..b4a68dfbf 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -45,7 +45,9 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { private MinecraftSessionHandler sessionHandler; private int protocolVersion; private MinecraftConnectionAssociation association; + private boolean isLegacyForge; private final VelocityServer server; + private boolean canSendLegacyFMLResetPacket = false; public MinecraftConnection(Channel channel, VelocityServer server) { this.channel = channel; @@ -105,6 +107,13 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { } } + @Override + public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception { + if (sessionHandler != null) { + sessionHandler.writabilityChanged(); + } + } + public void write(Object msg) { if (channel.isActive()) { channel.writeAndFlush(msg, channel.voidPromise()); @@ -222,4 +231,20 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { public void setAssociation(MinecraftConnectionAssociation association) { this.association = association; } + + public boolean isLegacyForge() { + return isLegacyForge; + } + + public void setLegacyForge(boolean isForge) { + this.isLegacyForge = isForge; + } + + public boolean canSendLegacyFMLResetPacket() { + return canSendLegacyFMLResetPacket; + } + + public void setCanSendLegacyFMLResetPacket(boolean canSendLegacyFMLResetPacket) { + this.canSendLegacyFMLResetPacket = isLegacyForge && canSendLegacyFMLResetPacket; + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java index 4f13190de..146ec86da 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java @@ -29,4 +29,8 @@ public interface MinecraftSessionHandler { default void exception(Throwable throwable) { } + + default void writabilityChanged() { + + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/VelocityConstants.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/VelocityConstants.java index f95053e7d..b524b31e1 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/VelocityConstants.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/VelocityConstants.java @@ -6,4 +6,10 @@ public class VelocityConstants { } public static final String VELOCITY_IP_FORWARDING_CHANNEL = "velocity:player_info"; + + public static final String FORGE_LEGACY_HANDSHAKE_CHANNEL = "FML|HS"; + public static final String FORGE_LEGACY_CHANNEL = "FML"; + public static final String FORGE_MULTIPART_LEGACY_CHANNEL = "FML|MP"; + + public static final byte[] FORGE_LEGACY_HANDSHAKE_RESET_DATA = new byte[] { -2, 0 }; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java index 8fbee6e6d..0f82b12eb 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java @@ -3,7 +3,9 @@ package com.velocitypowered.proxy.connection.backend; import com.velocitypowered.api.event.connection.PluginMessageEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.connection.VelocityConstants; import com.velocitypowered.proxy.connection.client.ClientPlaySessionHandler; +import com.velocitypowered.proxy.connection.util.ConnectionMessages; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.packet.*; @@ -30,7 +32,7 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { if (!connection.getPlayer().isActive()) { // Connection was left open accidentally. Close it so as to avoid "You logged in from another location" // errors. - connection.getMinecraftConnection().close(); + connection.disconnect(); return; } @@ -42,6 +44,7 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { connection.getPlayer().getConnection().write(packet); } else if (packet instanceof Disconnect) { Disconnect original = (Disconnect) packet; + connection.disconnect(); connection.getPlayer().handleConnectionException(connection.getServerInfo(), original); } else if (packet instanceof JoinGame) { playerHandler.handleBackendJoinGame((JoinGame) packet); @@ -67,6 +70,20 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { return; } + if (!connection.hasCompletedJoin() && pm.getChannel().equals(VelocityConstants.FORGE_LEGACY_HANDSHAKE_CHANNEL)) { + if (!connection.isLegacyForge()) { + connection.setLegacyForge(true); + + // We must always reset the handshake before a modded connection is established if + // we haven't done so already. + connection.getPlayer().sendLegacyForgeHandshakeResetPacket(); + } + + // Always forward these messages during login + connection.getPlayer().getConnection().write(pm); + return; + } + PluginMessageEvent event = new PluginMessageEvent(connection, connection.getPlayer(), server.getChannelRegistrar().getFromId(pm.getChannel()), pm.getData()); server.getEventManager().fire(event) @@ -86,10 +103,13 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { if (!connection.getPlayer().isActive()) { // Connection was left open accidentally. Close it so as to avoid "You logged in from another location" // errors. - connection.getMinecraftConnection().close(); + connection.disconnect(); return; } - connection.getPlayer().getConnection().write(buf.retain()); + + if (connection.hasCompletedJoin()) { + connection.getPlayer().getConnection().write(buf.retain()); + } } @Override @@ -97,16 +117,29 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { connection.getPlayer().handleConnectionException(connection.getServerInfo(), throwable); } + public VelocityServer getServer() { + return server; + } + + @Override + public void disconnected() { + if (connection.isGracefulDisconnect()) { + return; + } + connection.getPlayer().handleConnectionException(connection.getServerInfo(), Disconnect.create(ConnectionMessages.UNEXPECTED_DISCONNECT)); + } + private boolean canForwardPluginMessage(PluginMessage message) { ClientPlaySessionHandler playerHandler = (ClientPlaySessionHandler) connection.getPlayer().getConnection().getSessionHandler(); - boolean isMCMessage; + boolean isMCOrFMLMessage; if (connection.getMinecraftConnection().getProtocolVersion() <= ProtocolConstants.MINECRAFT_1_12_2) { - isMCMessage = message.getChannel().startsWith("MC|"); + String channel = message.getChannel(); + isMCOrFMLMessage = channel.startsWith("MC|") || channel.startsWith(VelocityConstants.FORGE_LEGACY_HANDSHAKE_CHANNEL); } else { - isMCMessage = message.getChannel().startsWith("minecraft:"); + isMCOrFMLMessage = message.getChannel().startsWith("minecraft:"); } - return isMCMessage || playerHandler.getClientPluginMsgChannels().contains(message.getChannel()) || + return isMCOrFMLMessage || playerHandler.getClientPluginMsgChannels().contains(message.getChannel()) || server.getChannelRegistrar().registered(message.getChannel()); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java index fba410951..1a7f5e2df 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java @@ -84,6 +84,10 @@ public class LoginSessionHandler implements MinecraftSessionHandler { connection.getPlayer().getConnection().setSessionHandler(new ClientPlaySessionHandler(server, connection.getPlayer())); } else { // The previous server connection should become obsolete. + // Before we remove it, if the server we are departing is modded, we must always reset the client state. + if (existingConnection.isLegacyForge()) { + connection.getPlayer().sendLegacyForgeHandshakeResetPacket(); + } existingConnection.disconnect(); } @@ -119,7 +123,7 @@ public class LoginSessionHandler implements MinecraftSessionHandler { } } - static ByteBuf createForwardingData(byte[] hmacSecret, String address, GameProfile profile) { + private static ByteBuf createForwardingData(byte[] hmacSecret, String address, GameProfile profile) { ByteBuf dataToForward = Unpooled.buffer(); ByteBuf finalData = Unpooled.buffer(); try { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java index 367e33463..496a4a686 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java @@ -33,7 +33,6 @@ import static com.velocitypowered.proxy.network.Connections.HANDLER; import static com.velocitypowered.proxy.network.Connections.MINECRAFT_DECODER; import static com.velocitypowered.proxy.network.Connections.MINECRAFT_ENCODER; import static com.velocitypowered.proxy.network.Connections.READ_TIMEOUT; -import static com.velocitypowered.proxy.network.Connections.SERVER_READ_TIMEOUT_SECONDS; public class VelocityServerConnection implements MinecraftConnectionAssociation, ServerConnection { static final AttributeKey> CONNECTION_NOTIFIER = @@ -43,6 +42,9 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation, private final ConnectedPlayer proxyPlayer; private final VelocityServer server; private MinecraftConnection minecraftConnection; + private boolean legacyForge = false; + private boolean hasCompletedJoin = false; + private boolean gracefulDisconnect = false; public VelocityServerConnection(ServerInfo target, ConnectedPlayer proxyPlayer, VelocityServer server) { this.serverInfo = target; @@ -57,7 +59,7 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation, @Override protected void initChannel(Channel ch) throws Exception { ch.pipeline() - .addLast(READ_TIMEOUT, new ReadTimeoutHandler(SERVER_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + .addLast(READ_TIMEOUT, new ReadTimeoutHandler(server.getConfiguration().getReadTimeout(), TimeUnit.SECONDS)) .addLast(FRAME_DECODER, new MinecraftVarintFrameDecoder()) .addLast(FRAME_ENCODER, MinecraftVarintLengthEncoder.INSTANCE) .addLast(MINECRAFT_DECODER, new MinecraftDecoder(ProtocolConstants.Direction.CLIENTBOUND)) @@ -107,6 +109,8 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation, handshake.setProtocolVersion(proxyPlayer.getConnection().getProtocolVersion()); if (forwardingMode == PlayerInfoForwarding.LEGACY) { handshake.setServerAddress(createBungeeForwardingAddress()); + } else if (proxyPlayer.getConnection().isLegacyForge()) { + handshake.setServerAddress(handshake.getServerAddress() + "\0FML\0"); } else { handshake.setServerAddress(serverInfo.getAddress().getHostString()); } @@ -122,6 +126,12 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation, minecraftConnection.write(login); } + public void writeIfJoined(PluginMessage message) { + if (hasCompletedJoin) { + minecraftConnection.write(message); + } + } + public MinecraftConnection getMinecraftConnection() { return minecraftConnection; } @@ -136,8 +146,11 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation, } public void disconnect() { - minecraftConnection.close(); - minecraftConnection = null; + if (minecraftConnection != null) { + minecraftConnection.close(); + minecraftConnection = null; + gracefulDisconnect = true; + } } @Override @@ -154,4 +167,24 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation, message.setData(data); minecraftConnection.write(message); } + + public boolean isLegacyForge() { + return legacyForge; + } + + public void setLegacyForge(boolean modded) { + legacyForge = modded; + } + + public boolean hasCompletedJoin() { + return hasCompletedJoin; + } + + public void setHasCompletedJoin(boolean hasCompletedJoin) { + this.hasCompletedJoin = hasCompletedJoin; + } + + public boolean isGracefulDisconnect() { + return gracefulDisconnect; + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java index aa296599d..b5a88ee55 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java @@ -3,6 +3,8 @@ package com.velocitypowered.proxy.connection.client; import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.connection.PluginMessageEvent; import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.connection.VelocityConstants; +import com.velocitypowered.proxy.connection.backend.VelocityServerConnection; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.packet.*; @@ -23,7 +25,7 @@ import java.util.*; */ public class ClientPlaySessionHandler implements MinecraftSessionHandler { private static final Logger logger = LogManager.getLogger(ClientPlaySessionHandler.class); - private static final int MAX_PLUGIN_CHANNELS = 128; + private static final int MAX_PLUGIN_CHANNELS = 1024; private final ConnectedPlayer player; private long lastPingID = -1; @@ -31,6 +33,7 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { private boolean spawned = false; private final List serverBossBars = new ArrayList<>(); private final Set clientPluginMsgChannels = new HashSet<>(); + private final Queue loginPluginMessages = new ArrayDeque<>(); private final VelocityServer server; public ClientPlaySessionHandler(VelocityServer server, ConnectedPlayer player) { @@ -51,6 +54,12 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { @Override public void handle(MinecraftPacket packet) { + VelocityServerConnection serverConnection = player.getConnectedServer(); + if (serverConnection == null) { + // No server connection yet, probably transitioning. + return; + } + if (packet instanceof KeepAlive) { KeepAlive keepAlive = (KeepAlive) packet; if (keepAlive.getRandomId() != lastPingID) { @@ -60,6 +69,8 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { } player.setPing(System.currentTimeMillis() - lastPingSent); resetPingData(); + serverConnection.getMinecraftConnection().write(packet); + return; } if (packet instanceof ClientSettings) { @@ -106,7 +117,7 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { player.getConnection().write(response); } else { - player.getConnectedServer().getMinecraftConnection().write(packet); + serverConnection.getMinecraftConnection().write(packet); } } catch (Exception e) { logger.error("Unable to provide tab list completions for " + player.getUsername() + " for command '" + req.getCommand() + "'", e); @@ -121,12 +132,22 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { } // If we don't want to handle this packet, just forward it on. - player.getConnectedServer().getMinecraftConnection().write(packet); + if (serverConnection.hasCompletedJoin()) { + serverConnection.getMinecraftConnection().write(packet); + } } @Override public void handleUnknown(ByteBuf buf) { - player.getConnectedServer().getMinecraftConnection().write(buf.retain()); + VelocityServerConnection serverConnection = player.getConnectedServer(); + if (serverConnection == null) { + // No server connection yet, probably transitioning. + return; + } + + if (serverConnection.hasCompletedJoin()) { + serverConnection.getMinecraftConnection().write(buf.retain()); + } } @Override @@ -144,12 +165,31 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { .build()); } + @Override + public void writabilityChanged() { + VelocityServerConnection server = player.getConnectedServer(); + if (server != null) { + boolean writable = player.getConnection().getChannel().isWritable(); + server.getMinecraftConnection().getChannel().config().setAutoRead(writable); + } + } + public void handleBackendJoinGame(JoinGame joinGame) { - resetPingData(); // reset ping data; + resetPingData(); // reset ping data if (!spawned) { - // nothing special to do here + // Nothing special to do with regards to spawning the player spawned = true; player.getConnection().delayedWrite(joinGame); + + // We have something special to do for legacy Forge servers - during first connection the FML handshake + // will transition to complete regardless. Thus, we need to ensure that a reset packet is ALWAYS sent on + // first switch. + // + // As we know that calling this branch only happens on first join, we set that if we are a Forge + // client that we must reset on the next switch. + // + // The call will handle if the player is not a Forge player appropriately. + player.getConnection().setCanSendLegacyFMLResetPacket(true); } else { // Ah, this is the meat and potatoes of the whole venture! // @@ -193,17 +233,38 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { channel, toRegister)); } + // If we had plugin messages queued during login/FML handshake, send them now. + PluginMessage pm; + while ((pm = loginPluginMessages.poll()) != null) { + player.getConnectedServer().getMinecraftConnection().delayedWrite(pm); + } + // Flush everything player.getConnection().flush(); player.getConnectedServer().getMinecraftConnection().flush(); + player.getConnectedServer().setHasCompletedJoin(true); + if (player.getConnectedServer().isLegacyForge()) { + // We only need to indicate we can send a reset packet if we complete a handshake, that is, + // logged onto a Forge server. + // + // The special case is if we log onto a Vanilla server as our first server, FML will treat this + // as complete and **will** need a reset packet sending at some point. We will handle this + // during initial player connection if the player is detected to be forge. + // + // This is why we use an if statement rather than the result of VelocityServerConnection#isLegacyForge() + // because we don't want to set it false if this is a first connection to a Vanilla server. + // + // See LoginSessionHandler#handle for where the counterpart to this method is + player.getConnection().setCanSendLegacyFMLResetPacket(true); + } } public List getServerBossBars() { return serverBossBars; } - public void handleClientPluginMessage(PluginMessage packet) { - if (packet.getChannel().equals("REGISTER") || packet.getChannel().equals("minecraft:register")) { + private void handleClientPluginMessage(PluginMessage packet) { + if (PluginMessageUtil.isMCRegister(packet)) { List actuallyRegistered = new ArrayList<>(); List channels = PluginMessageUtil.getChannels(packet); for (String channel : channels) { @@ -220,28 +281,32 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { PluginMessage newRegisterPacket = PluginMessageUtil.constructChannelsPacket(packet.getChannel(), actuallyRegistered); player.getConnectedServer().getMinecraftConnection().write(newRegisterPacket); } - - return; - } - - if (packet.getChannel().equals("UNREGISTER") || packet.getChannel().equals("minecraft:unregister")) { + } else if (PluginMessageUtil.isMCUnregister(packet)) { List channels = PluginMessageUtil.getChannels(packet); clientPluginMsgChannels.removeAll(channels); - } - - if (PluginMessageUtil.isMCBrand(packet)) { + player.getConnectedServer().getMinecraftConnection().write(packet); + } else if (PluginMessageUtil.isMCBrand(packet)) { player.getConnectedServer().getMinecraftConnection().write(PluginMessageUtil.rewriteMCBrand(packet)); - return; + } else if (player.getConnectedServer().isLegacyForge() && !player.getConnectedServer().hasCompletedJoin()) { + if (packet.getChannel().equals(VelocityConstants.FORGE_LEGACY_HANDSHAKE_CHANNEL)) { + // Always forward the FML handshake to the remote server. + player.getConnectedServer().getMinecraftConnection().write(packet); + } else { + // The client is trying to send messages too early. This is primarily caused by mods, but it's further + // aggravated by Velocity. To work around these issues, we will queue any non-FML handshake messages to + // be sent once the JoinGame packet has been received by the proxy. + loginPluginMessages.add(packet); + } + } else { + PluginMessageEvent event = new PluginMessageEvent(player, player.getConnectedServer(), + server.getChannelRegistrar().getFromId(packet.getChannel()), packet.getData()); + server.getEventManager().fire(event) + .thenAcceptAsync(pme -> { + if (pme.getResult().isAllowed()) { + player.getConnectedServer().getMinecraftConnection().write(packet); + } + }, player.getConnectedServer().getMinecraftConnection().getChannel().eventLoop()); } - - PluginMessageEvent event = new PluginMessageEvent(player, player.getConnectedServer(), - server.getChannelRegistrar().getFromId(packet.getChannel()), packet.getData()); - server.getEventManager().fire(event) - .thenAcceptAsync(pme -> { - if (pme.getResult().isAllowed()) { - player.getConnectedServer().getMinecraftConnection().write(packet); - } - }, player.getConnectedServer().getMinecraftConnection().getChannel().eventLoop()); } public Set getClientPluginMsgChannels() { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientSettingsWrapper.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientSettingsWrapper.java index e82fd354f..4977c5df9 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientSettingsWrapper.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientSettingsWrapper.java @@ -12,7 +12,7 @@ public class ClientSettingsWrapper implements PlayerSettings { private final SkinParts parts; private Locale locale = null; - public ClientSettingsWrapper(ClientSettings settings) { + ClientSettingsWrapper(ClientSettings settings) { this.settings = settings; this.parts = new SkinParts((byte) settings.getSkinParts()); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java index d8c6765f8..b56b74753 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java @@ -2,6 +2,7 @@ package com.velocitypowered.proxy.connection.client; import com.google.common.base.Preconditions; import com.google.gson.JsonObject; +import com.velocitypowered.api.event.player.KickedFromServerEvent; import com.velocitypowered.api.event.player.PlayerSettingsChangedEvent; import com.velocitypowered.api.event.player.ServerPreConnectEvent; import com.velocitypowered.api.permission.PermissionFunction; @@ -14,6 +15,7 @@ import com.velocitypowered.api.util.MessagePosition; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.proxy.VelocityServer; import com.velocitypowered.proxy.connection.MinecraftConnectionAssociation; +import com.velocitypowered.proxy.connection.VelocityConstants; import com.velocitypowered.proxy.connection.util.ConnectionMessages; import com.velocitypowered.proxy.connection.util.ConnectionRequestResults; import com.velocitypowered.api.util.GameProfile; @@ -36,6 +38,7 @@ import net.kyori.text.serializer.PlainComponentSerializer; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; import java.net.InetSocketAddress; import java.util.List; @@ -190,7 +193,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { logger.error("{}: unable to connect to server {}", this, info.getName(), throwable); userMessage = "Exception connecting to server " + info.getName(); } - handleConnectionException(info, TextComponent.builder() + handleConnectionException(info, null, TextComponent.builder() .content(userMessage + ": ") .color(TextColor.RED) .append(TextComponent.of(error, TextColor.WHITE)) @@ -202,17 +205,23 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { String plainTextReason = PASS_THRU_TRANSLATE.serialize(disconnectReason); if (connectedServer != null && connectedServer.getServerInfo().equals(info)) { logger.error("{}: kicked from server {}: {}", this, info.getName(), plainTextReason); + handleConnectionException(info, disconnectReason, TextComponent.builder() + .content("Kicked from " + info.getName() + ": ") + .color(TextColor.RED) + .append(disconnectReason) + .build()); } else { logger.error("{}: disconnected while connecting to {}: {}", this, info.getName(), plainTextReason); + handleConnectionException(info, disconnectReason, TextComponent.builder() + .content("Unable to connect to " + info.getName() + ": ") + .color(TextColor.RED) + .append(disconnectReason) + .build()); } - handleConnectionException(info, TextComponent.builder() - .content("Unable to connect to " + info.getName() + ": ") - .color(TextColor.RED) - .append(disconnectReason) - .build()); } - public void handleConnectionException(ServerInfo info, Component disconnectReason) { + private void handleConnectionException(ServerInfo info, @Nullable Component kickReason, Component friendlyReason) { + boolean alreadyConnected = connectedServer != null && connectedServer.getServerInfo().equals(info);; connectionInFlight = null; if (connectedServer == null) { // The player isn't yet connected to a server. @@ -220,14 +229,29 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { if (nextServer.isPresent()) { createConnectionRequest(nextServer.get()).fireAndForget(); } else { - connection.closeWith(Disconnect.create(disconnectReason)); + connection.closeWith(Disconnect.create(friendlyReason)); } } else if (connectedServer.getServerInfo().equals(info)) { // Already connected to the server being disconnected from. - // TODO: ServerKickEvent - connection.closeWith(Disconnect.create(disconnectReason)); + if (kickReason != null) { + server.getEventManager().fire(new KickedFromServerEvent(this, info, kickReason, !alreadyConnected, friendlyReason)) + .thenAcceptAsync(event -> { + if (event.getResult() instanceof KickedFromServerEvent.DisconnectPlayer) { + KickedFromServerEvent.DisconnectPlayer res = (KickedFromServerEvent.DisconnectPlayer) event.getResult(); + connection.closeWith(Disconnect.create(res.getReason())); + } else if (event.getResult() instanceof KickedFromServerEvent.RedirectPlayer) { + KickedFromServerEvent.RedirectPlayer res = (KickedFromServerEvent.RedirectPlayer) event.getResult(); + createConnectionRequest(res.getServer()).fireAndForget(); + } else { + // In case someone gets creative, assume we want to disconnect the player. + connection.closeWith(Disconnect.create(friendlyReason)); + } + }, connection.getChannel().eventLoop()); + } else { + connection.closeWith(Disconnect.create(friendlyReason)); + } } else { - connection.write(Chat.create(disconnectReason)); + connection.write(Chat.create(friendlyReason)); } } @@ -256,7 +280,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { } // Otherwise, initiate the connection. - ServerPreConnectEvent event = new ServerPreConnectEvent(this, ServerPreConnectEvent.ServerResult.allowed(request.getServer())); + ServerPreConnectEvent event = new ServerPreConnectEvent(this, request.getServer()); return server.getEventManager().fire(event) .thenCompose((newEvent) -> { if (!newEvent.getResult().isAllowed()) { @@ -265,7 +289,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { ); } - return new VelocityServerConnection(newEvent.getResult().getInfo().get(), this, server).connect(); + return new VelocityServerConnection(newEvent.getResult().getServer().get(), this, server).connect(); }); } @@ -276,6 +300,16 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { this.connectedServer = serverConnection; } + public void sendLegacyForgeHandshakeResetPacket() { + if (connection.canSendLegacyFMLResetPacket()) { + PluginMessage resetPacket = new PluginMessage(); + resetPacket.setChannel(VelocityConstants.FORGE_LEGACY_HANDSHAKE_CHANNEL); + resetPacket.setData(VelocityConstants.FORGE_LEGACY_HANDSHAKE_RESET_DATA); + connection.write(resetPacket); + connection.setCanSendLegacyFMLResetPacket(false); + } + } + public void close(TextComponent reason) { connection.closeWith(Disconnect.create(reason)); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java index 5a5abaf67..54899d2a1 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java @@ -70,9 +70,12 @@ public class HandshakeSessionHandler implements MinecraftSessionHandler { return; } - // Make sure legacy forwarding is not in use on this connection. Make sure that we do _not_ reject Forge, - // although Velocity does not yet support Forge. - if (handshake.getServerAddress().contains("\0") && !handshake.getServerAddress().endsWith("\0FML\0")) { + // Determine if we're using Forge (1.8 to 1.12, may not be the case in 1.13) and store that in the connection + boolean isForge = handshake.getServerAddress().endsWith("\0FML\0"); + connection.setLegacyForge(isForge); + + // Make sure legacy forwarding is not in use on this connection. Make sure that we do _not_ reject Forge + if (handshake.getServerAddress().contains("\0") && !isForge) { connection.closeWith(Disconnect.create(TextComponent.of("Running Velocity behind Velocity is unsupported."))); return; } @@ -105,6 +108,7 @@ public class HandshakeSessionHandler implements MinecraftSessionHandler { new ServerPing.Version(ProtocolConstants.MAXIMUM_GENERIC_VERSION, "Velocity " + ProtocolConstants.SUPPORTED_GENERIC_VERSION_STRING), new ServerPing.Players(server.getPlayerCount(), configuration.getShowMaxPlayers(), ImmutableList.of()), configuration.getMotdComponent(), + null, null ); ProxyPingEvent event = new ProxyPingEvent(new LegacyInboundConnection(connection), ping); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java index df990b50e..63890c3f0 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java @@ -2,12 +2,14 @@ package com.velocitypowered.proxy.connection.client; import com.google.common.base.Preconditions; import com.velocitypowered.api.event.connection.LoginEvent; +import com.velocitypowered.api.event.connection.PostLoginEvent; import com.velocitypowered.api.event.connection.PreLoginEvent; import com.velocitypowered.api.event.connection.PreLoginEvent.PreLoginComponentResult; import com.velocitypowered.api.event.permission.PermissionsSetupEvent; import com.velocitypowered.api.event.player.GameProfileRequestEvent; import com.velocitypowered.api.proxy.InboundConnection; import com.velocitypowered.api.proxy.server.ServerInfo; +import com.velocitypowered.proxy.config.PlayerInfoForwarding; import com.velocitypowered.proxy.connection.VelocityConstants; import com.velocitypowered.api.util.GameProfile; import com.velocitypowered.proxy.protocol.MinecraftPacket; @@ -31,11 +33,14 @@ import java.net.MalformedURLException; import java.net.URL; import java.security.GeneralSecurityException; import java.security.KeyPair; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; public class LoginSessionHandler implements MinecraftSessionHandler { + private static final Logger logger = LogManager.getLogger(LoginSessionHandler.class); private static final String MOJANG_SERVER_AUTH_URL = "https://sessionserver.mojang.com/session/minecraft/hasJoined?username=%s&serverId=%s&ip=%s"; @@ -154,7 +159,7 @@ public class LoginSessionHandler implements MinecraftSessionHandler { return; } - if (server.getConfiguration().isOnlineMode() || result.isOnlineModeAllowed()) { + if (!result.isForceOfflineMode() && (server.getConfiguration().isOnlineMode() || result.isOnlineModeAllowed())) { // Request encryption. EncryptionRequest request = generateRequest(); this.verify = Arrays.copyOf(request.getVerifyToken(), 4); @@ -176,6 +181,12 @@ public class LoginSessionHandler implements MinecraftSessionHandler { } private void initializePlayer(GameProfile profile, boolean onlineMode) { + if (inbound.isLegacyForge() && server.getConfiguration().getPlayerInfoForwardingMode() == PlayerInfoForwarding.LEGACY) { + // We want to add the FML token to the properties + List properties = new ArrayList<>(profile.getProperties()); + properties.add(new GameProfile.Property("forgeClient", "true", "")); + profile = new GameProfile(profile.getId(), profile.getName(), properties); + } GameProfileRequestEvent profileRequestEvent = new GameProfileRequestEvent(apiInbound, profile, onlineMode); server.getEventManager().fire(profileRequestEvent).thenCompose(profileEvent -> { @@ -235,7 +246,9 @@ public class LoginSessionHandler implements MinecraftSessionHandler { logger.info("{} has connected", player); inbound.setSessionHandler(new InitialConnectSessionHandler(player)); - player.createConnectionRequest(toTry.get()).fireAndForget(); + server.getEventManager().fire(new PostLoginEvent(player)).thenRun(() -> { + player.createConnectionRequest(toTry.get()).fireAndForget(); + }); } @Override diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/StatusSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/StatusSessionHandler.java index 863c32e59..ccc53a8ac 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/StatusSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/StatusSessionHandler.java @@ -48,7 +48,8 @@ public class StatusSessionHandler implements MinecraftSessionHandler { new ServerPing.Version(shownVersion, "Velocity " + ProtocolConstants.SUPPORTED_GENERIC_VERSION_STRING), new ServerPing.Players(server.getPlayerCount(), configuration.getShowMaxPlayers(), ImmutableList.of()), configuration.getMotdComponent(), - configuration.getFavicon() + configuration.getFavicon(), + configuration.isAnnounceForge() ? ServerPing.Modinfo.DEFAULT : null ); ProxyPingEvent event = new ProxyPingEvent(inboundWrapper, initialPing); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionMessages.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionMessages.java index b2ff70a41..727750d62 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionMessages.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionMessages.java @@ -7,6 +7,7 @@ public class ConnectionMessages { public static final TextComponent ALREADY_CONNECTED = TextComponent.of("You are already connected to this server!", TextColor.RED); public static final TextComponent IN_PROGRESS = TextComponent.of("You are already connecting to a server!", TextColor.RED); public static final TextComponent INTERNAL_SERVER_CONNECTION_ERROR = TextComponent.of("Internal server connection error"); + public static final TextComponent UNEXPECTED_DISCONNECT = TextComponent.of("Unexpectedly disconnected from server - crash?"); private ConnectionMessages() { throw new AssertionError(); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java b/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java index fb08b63a8..9a115a608 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java @@ -16,11 +16,7 @@ import com.velocitypowered.proxy.protocol.netty.MinecraftVarintFrameDecoder; import com.velocitypowered.proxy.protocol.netty.MinecraftVarintLengthEncoder; import io.netty.bootstrap.Bootstrap; import io.netty.bootstrap.ServerBootstrap; -import io.netty.channel.Channel; -import io.netty.channel.ChannelFutureListener; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelOption; -import io.netty.channel.EventLoopGroup; +import io.netty.channel.*; import io.netty.channel.epoll.Epoll; import io.netty.channel.epoll.EpollDatagramChannel; import io.netty.channel.epoll.EpollEventLoopGroup; @@ -48,6 +44,8 @@ import java.util.concurrent.TimeUnit; import static com.velocitypowered.proxy.network.Connections.*; public final class ConnectionManager { + private static final WriteBufferWaterMark SERVER_WRITE_MARK = new WriteBufferWaterMark(1 << 16, 1 << 18); + private static final Logger logger = LogManager.getLogger(ConnectionManager.class); private final Set endpoints = new HashSet<>(); @@ -72,11 +70,12 @@ public final class ConnectionManager { final ServerBootstrap bootstrap = new ServerBootstrap() .channel(this.transportType.serverSocketChannelClass) .group(this.bossGroup, this.workerGroup) + .childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, SERVER_WRITE_MARK) .childHandler(new ChannelInitializer() { @Override protected void initChannel(final Channel ch) { ch.pipeline() - .addLast(READ_TIMEOUT, new ReadTimeoutHandler(CLIENT_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + .addLast(READ_TIMEOUT, new ReadTimeoutHandler(server.getConfiguration().getReadTimeout(), TimeUnit.SECONDS)) .addLast(LEGACY_PING_DECODER, new LegacyPingDecoder()) .addLast(FRAME_DECODER, new MinecraftVarintFrameDecoder()) .addLast(LEGACY_PING_ENCODER, LegacyPingEncoder.INSTANCE) @@ -126,7 +125,9 @@ public final class ConnectionManager { public Bootstrap createWorker() { return new Bootstrap() .channel(this.transportType.socketChannelClass) - .group(this.workerGroup); + .group(this.workerGroup) + .option(ChannelOption.TCP_NODELAY, true) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, server.getConfiguration().getConnectTimeout()); } public void shutdown() { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/Connections.java b/proxy/src/main/java/com/velocitypowered/proxy/network/Connections.java index 518a3acbe..fb248dc6a 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/Connections.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/Connections.java @@ -13,7 +13,4 @@ public interface Connections { String MINECRAFT_DECODER = "minecraft-decoder"; String MINECRAFT_ENCODER = "minecraft-encoder"; String READ_TIMEOUT = "read-timeout"; - - int CLIENT_READ_TIMEOUT_SECONDS = 30; // client -> proxy - int SERVER_READ_TIMEOUT_SECONDS = 30; // proxy -> server } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java index d45d94040..91fab4eb4 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java @@ -1,5 +1,6 @@ package com.velocitypowered.proxy.protocol; +import com.google.common.base.Strings; import com.google.common.primitives.ImmutableIntArray; import com.velocitypowered.proxy.protocol.packet.*; import io.netty.util.collection.IntObjectHashMap; @@ -258,9 +259,18 @@ public enum StateRegistry { @Override public String toString() { + StringBuilder mappingAsString = new StringBuilder("{"); + for (Object2IntMap.Entry> entry : packetClassToId.object2IntEntrySet()) { + mappingAsString.append(entry.getKey().getSimpleName()).append(" -> ") + .append("0x") + .append(Strings.padStart(Integer.toHexString(entry.getIntValue()), 2, '0')) + .append(", "); + } + mappingAsString.setLength(mappingAsString.length() - 2); + mappingAsString.append("}"); return "ProtocolVersion{" + "id=" + id + - ", packetClassToId=" + packetClassToId + + ", packetClassToId=" + mappingAsString.toString() + '}'; } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftDecoder.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftDecoder.java index 4e05b3647..07ddb287f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftDecoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftDecoder.java @@ -38,11 +38,11 @@ public class MinecraftDecoder extends MessageToMessageDecoder { packet.decode(msg, direction, protocolVersion.id); } catch (Exception e) { throw new CorruptedFrameException("Error decoding " + packet.getClass() + " Direction " + direction - + " Protocol " + protocolVersion + " State " + state + " ID " + Integer.toHexString(packetId), e); + + " Protocol " + protocolVersion.id + " State " + state + " ID " + Integer.toHexString(packetId), e); } if (msg.isReadable()) { throw new CorruptedFrameException("Did not read full packet for " + packet.getClass() + " Direction " + direction - + " Protocol " + protocolVersion + " State " + state + " ID " + Integer.toHexString(packetId)); + + " Protocol " + protocolVersion.id + " State " + state + " ID " + Integer.toHexString(packetId)); } out.add(packet); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftVarintFrameDecoder.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftVarintFrameDecoder.java index 081253594..136204fa2 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftVarintFrameDecoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftVarintFrameDecoder.java @@ -2,8 +2,10 @@ package com.velocitypowered.proxy.protocol.netty; import com.velocitypowered.proxy.protocol.ProtocolUtils; import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.handler.codec.CorruptedFrameException; import java.util.List; @@ -15,12 +17,31 @@ public class MinecraftVarintFrameDecoder extends ByteToMessageDecoder { } in.markReaderIndex(); - int packetLength = ProtocolUtils.readVarInt(in); - if (in.readableBytes() < packetLength) { - in.resetReaderIndex(); - return; + + byte[] lenBuf = new byte[3]; + for (int i = 0; i < lenBuf.length; i++) { + if (!in.isReadable()) { + in.resetReaderIndex(); + return; + } + + lenBuf[i] = in.readByte(); + if (lenBuf[i] > 0) { + int packetLength = ProtocolUtils.readVarInt(Unpooled.wrappedBuffer(lenBuf)); + if (packetLength == 0) { + return; + } + + if (in.readableBytes() < packetLength) { + in.resetReaderIndex(); + return; + } + + out.add(in.readRetainedSlice(packetLength)); + return; + } } - out.add(in.readRetainedSlice(packetLength)); + throw new CorruptedFrameException("VarInt too big"); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/PluginMessageUtil.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/PluginMessageUtil.java index 075058486..e3fdeab31 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/PluginMessageUtil.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/PluginMessageUtil.java @@ -20,13 +20,20 @@ public enum PluginMessageUtil { return message.getChannel().equals("MC|Brand") || message.getChannel().equals("minecraft:brand"); } + public static boolean isMCRegister(PluginMessage message) { + Preconditions.checkNotNull(message, "message"); + return message.getChannel().equals("REGISTER") || message.getChannel().equals("minecraft:register"); + } + + public static boolean isMCUnregister(PluginMessage message) { + Preconditions.checkNotNull(message, "message"); + return message.getChannel().equals("UNREGISTER") || message.getChannel().equals("minecraft:unregister"); + } + public static List getChannels(PluginMessage message) { Preconditions.checkNotNull(message, "message"); - Preconditions.checkArgument(message.getChannel().equals("REGISTER") || - message.getChannel().equals("UNREGISTER") || - message.getChannel().equals("minecraft:register") || - message.getChannel().equals("minecraft:unregister"), - "Unknown channel type " + message.getChannel()); + Preconditions.checkArgument(isMCRegister(message) || isMCUnregister(message),"Unknown channel type %s", + message.getChannel()); String channels = new String(message.getData(), StandardCharsets.UTF_8); return ImmutableList.copyOf(channels.split("\0")); } diff --git a/proxy/src/test/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactoryTest.java b/proxy/src/test/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactoryTest.java index cac1ef295..33fabfcf3 100644 --- a/proxy/src/test/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactoryTest.java +++ b/proxy/src/test/java/com/velocitypowered/proxy/util/concurrency/RecordingThreadFactoryTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; import static org.junit.jupiter.api.Assertions.*; @@ -33,4 +34,36 @@ class RecordingThreadFactoryTest { Thread.sleep(10); assertEquals(0, factory.size()); } + + @Test + void cleanUpAfterExceptionThrown() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch endThread = new CountDownLatch(1); + CountDownLatch hasEnded = new CountDownLatch(1); + RecordingThreadFactory factory = new RecordingThreadFactory((ThreadFactory) r -> { + Thread t = new Thread(r); + t.setUncaughtExceptionHandler((t1, e) -> hasEnded.countDown()); + return t; + }); + factory.newThread(() -> { + started.countDown(); + assertTrue(factory.currentlyInFactory()); + assertEquals(1, factory.size()); + try { + endThread.await(); + } catch (InterruptedException e) { + fail(e); + } + throw new RuntimeException(""); + }).start(); + started.await(); + assertFalse(factory.currentlyInFactory()); + assertEquals(1, factory.size()); + endThread.countDown(); + hasEnded.await(); + + // Wait a little bit to ensure the thread got shut down + Thread.sleep(10); + assertEquals(0, factory.size()); + } } \ No newline at end of file