diff --git a/api/src/main/java/com/velocitypowered/api/proxy/ConnectionRequestBuilder.java b/api/src/main/java/com/velocitypowered/api/proxy/ConnectionRequestBuilder.java new file mode 100644 index 000000000..5283ef422 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/proxy/ConnectionRequestBuilder.java @@ -0,0 +1,84 @@ +package com.velocitypowered.api.proxy; + +import com.velocitypowered.api.server.ServerInfo; +import net.kyori.text.Component; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * Represents a connection request. A connection request is created using {@link Player#createConnectionRequest(ServerInfo)} + * and is used to allow a plugin to compose and request a connection to another Minecraft server using a fluent API. + */ +public interface ConnectionRequestBuilder { + /** + * Returns the server that this connection request represents. + * @return the server this request will connect to + */ + ServerInfo getServer(); + + /** + * Initiates the connection to the remote server and emits a result on the {@link CompletableFuture} after the user + * has logged on. No messages will be communicated to the client: the user is responsible for all error handling. + * @return a {@link CompletableFuture} representing the status of this connection + */ + CompletableFuture connect(); + + /** + * Initiates the connection to the remote server without waiting for a result. Velocity will use generic error + * handling code to notify the user. + */ + void fireAndForget(); + + /** + * Represents the result of a connection request. + */ + interface Result { + /** + * Determines whether or not the connection request was successful. + * @return whether or not the request succeeded + */ + default boolean isSuccessful() { + return getStatus() == Status.SUCCESS; + } + + /** + * Returns the status associated with this result. + * @return the status for this result + */ + Status getStatus(); + + /** + * Returns a reason for the failure to connect to the server. None may be provided. + * @return the reason why the user could not connect to the server + */ + Optional getReason(); + } + + /** + * Represents the status of a connection request initiated by a {@link ConnectionRequestBuilder}. + */ + enum Status { + /** + * The player was successfully connected to the server. + */ + SUCCESS, + /** + * The player is already connected to this server. + */ + ALREADY_CONNECTED, + /** + * The connection is already in progress. + */ + CONNECTION_IN_PROGRESS, + /** + * A plugin has cancelled this connection. + */ + CONNECTION_CANCELLED, + /** + * The server disconnected the user. A reason may be provided in the {@link Result} object. + */ + SERVER_DISCONNECTED + } + +} diff --git a/api/src/main/java/com/velocitypowered/api/proxy/Player.java b/api/src/main/java/com/velocitypowered/api/proxy/Player.java index eb500e474..f2f233459 100644 --- a/api/src/main/java/com/velocitypowered/api/proxy/Player.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/Player.java @@ -57,4 +57,11 @@ public interface Player { * @param position the position for the message */ void sendMessage(@Nonnull Component component, @Nonnull MessagePosition position); + + /** + * Creates a new connection request so that the player can connect to another server. + * @param info the server to connect to + * @return a new connection request + */ + ConnectionRequestBuilder createConnectionRequest(@Nonnull ServerInfo info); } 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 ad0f1b1ce..f86fe7757 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 @@ -4,6 +4,7 @@ import com.velocitypowered.proxy.VelocityServer; import com.velocitypowered.proxy.config.IPForwardingMode; import com.velocitypowered.proxy.connection.VelocityConstants; import com.velocitypowered.proxy.connection.client.ClientPlaySessionHandler; +import com.velocitypowered.proxy.connection.util.ConnectionRequestResults; import com.velocitypowered.proxy.data.GameProfile; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.ProtocolUtils; @@ -65,6 +66,14 @@ public class LoginSessionHandler implements MinecraftSessionHandler { } else if (packet instanceof Disconnect) { Disconnect disconnect = (Disconnect) packet; connection.disconnect(); + + // Do we have an outstanding notification? If so, fulfill it. + ServerConnection.ConnectionNotifier n = connection.getMinecraftConnection().getChannel() + .pipeline().get(ServerConnection.ConnectionNotifier.class); + if (n != null) { + n.getResult().complete(ConnectionRequestResults.forDisconnect(disconnect)); + } + connection.getProxyPlayer().handleConnectionException(connection.getServerInfo(), disconnect); } else if (packet instanceof SetCompression) { SetCompression sc = (SetCompression) packet; @@ -80,6 +89,15 @@ public class LoginSessionHandler implements MinecraftSessionHandler { // The previous server connection should become obsolete. existingConnection.disconnect(); } + + // Do we have an outstanding notification? If so, fulfill it. + ServerConnection.ConnectionNotifier n = connection.getMinecraftConnection().getChannel() + .pipeline().get(ServerConnection.ConnectionNotifier.class); + if (n != null) { + n.onComplete(); + connection.getMinecraftConnection().getChannel().pipeline().remove(n); + } + connection.getMinecraftConnection().setSessionHandler(new BackendPlaySessionHandler(connection)); connection.getProxyPlayer().setConnectedServer(connection); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ServerConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ServerConnection.java index 794d94e7a..08f58325f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ServerConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ServerConnection.java @@ -1,7 +1,9 @@ package com.velocitypowered.proxy.connection.backend; +import com.velocitypowered.api.proxy.ConnectionRequestBuilder; import com.velocitypowered.proxy.config.IPForwardingMode; import com.velocitypowered.proxy.connection.MinecraftConnectionAssociation; +import com.velocitypowered.proxy.connection.util.ConnectionRequestResults; import com.velocitypowered.proxy.protocol.ProtocolConstants; import com.velocitypowered.proxy.protocol.netty.MinecraftDecoder; import com.velocitypowered.proxy.protocol.netty.MinecraftEncoder; @@ -17,6 +19,7 @@ import com.velocitypowered.proxy.connection.client.ConnectedPlayer; import io.netty.channel.*; import io.netty.handler.timeout.ReadTimeoutHandler; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import static com.velocitypowered.network.Connections.FRAME_DECODER; @@ -28,6 +31,8 @@ import static com.velocitypowered.network.Connections.READ_TIMEOUT; import static com.velocitypowered.network.Connections.SERVER_READ_TIMEOUT_SECONDS; public class ServerConnection implements MinecraftConnectionAssociation { + static final String CONNECTION_NOTIFIER = "connection-notifier"; + private final ServerInfo serverInfo; private final ConnectedPlayer proxyPlayer; private final VelocityServer server; @@ -39,7 +44,8 @@ public class ServerConnection implements MinecraftConnectionAssociation { this.server = server; } - public void connect() { + public CompletableFuture connect() { + CompletableFuture result = new CompletableFuture<>(); server.initializeGenericBootstrap() .handler(new ChannelInitializer() { @Override @@ -49,7 +55,8 @@ public class ServerConnection implements MinecraftConnectionAssociation { .addLast(FRAME_DECODER, new MinecraftVarintFrameDecoder()) .addLast(FRAME_ENCODER, MinecraftVarintLengthEncoder.INSTANCE) .addLast(MINECRAFT_DECODER, new MinecraftDecoder(ProtocolConstants.Direction.CLIENTBOUND)) - .addLast(MINECRAFT_ENCODER, new MinecraftEncoder(ProtocolConstants.Direction.SERVERBOUND)); + .addLast(MINECRAFT_ENCODER, new MinecraftEncoder(ProtocolConstants.Direction.SERVERBOUND)) + .addLast(CONNECTION_NOTIFIER, new ConnectionNotifier(result)); MinecraftConnection connection = new MinecraftConnection(ch); connection.setState(StateRegistry.HANDSHAKE); @@ -68,10 +75,11 @@ public class ServerConnection implements MinecraftConnectionAssociation { minecraftConnection.setSessionHandler(new LoginSessionHandler(ServerConnection.this)); startHandshake(); } else { - proxyPlayer.handleConnectionException(serverInfo, future.cause()); + result.completeExceptionally(future.cause()); } } }); + return result; } private String createBungeeForwardingAddress() { @@ -131,4 +139,25 @@ public class ServerConnection implements MinecraftConnectionAssociation { public String toString() { return "[server connection] " + proxyPlayer.getProfile().getName() + " -> " + serverInfo.getName(); } + + static class ConnectionNotifier extends ChannelInboundHandlerAdapter { + private final CompletableFuture result; + + public ConnectionNotifier(CompletableFuture result) { + this.result = result; + } + + public CompletableFuture getResult() { + return result; + } + + public void onComplete() { + result.complete(ConnectionRequestResults.SUCCESSFUL); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + result.completeExceptionally(cause); + } + } } 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 1c1ad4687..bc9c11986 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 @@ -83,8 +83,7 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { Chat chat = (Chat) packet; if (chat.getMessage().equals("/connect")) { ServerInfo info = new ServerInfo("test", new InetSocketAddress("localhost", 25566)); - ServerConnection connection = new ServerConnection(info, player, VelocityServer.getServer()); - connection.connect(); + player.createConnectionRequest(info).fireAndForget(); return; } } 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 924c8b6b3..f80909338 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,10 +2,13 @@ package com.velocitypowered.proxy.connection.client; import com.google.common.base.Preconditions; import com.google.gson.JsonObject; +import com.velocitypowered.api.proxy.ConnectionRequestBuilder; 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.util.ConnectionMessages; +import com.velocitypowered.proxy.connection.util.ConnectionRequestResults; import com.velocitypowered.proxy.data.GameProfile; import com.velocitypowered.proxy.protocol.packet.Chat; import com.velocitypowered.proxy.connection.MinecraftConnection; @@ -28,6 +31,7 @@ import java.net.InetSocketAddress; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { private static final PlainComponentSerializer PASS_THRU_TRANSLATE = new PlainComponentSerializer((c) -> "", TranslatableComponent::key); @@ -58,7 +62,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { @Override public Optional getCurrentServer() { - return Optional.empty(); + return connectedServer != null ? Optional.of(connectedServer.getServerInfo()) : Optional.empty(); } public GameProfile getProfile() { @@ -102,6 +106,11 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { connection.write(chat); } + @Override + public ConnectionRequestBuilder createConnectionRequest(@Nonnull ServerInfo info) { + return new ConnectionRequestBuilderImpl(info); + } + public ServerConnection getConnectedServer() { return connectedServer; } @@ -118,8 +127,10 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { String error = ThrowableUtils.briefDescription(throwable); String userMessage; if (connectedServer != null && connectedServer.getServerInfo().equals(info)) { + logger.error("{}: exception occurred in connection to {}", this, info.getName(), throwable); userMessage = "Exception in server " + info.getName(); } else { + logger.error("{}: unable to connect to server {}", this, info.getName(), throwable); userMessage = "Exception connecting to server " + info.getName(); } handleConnectionException(info, TextComponent.builder() @@ -151,7 +162,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { } } - public Optional getNextServerToTry() { + Optional getNextServerToTry() { List serversToTry = VelocityServer.getServer().getConfiguration().getAttemptConnectionOrder(); if (tryIndex >= serversToTry.size()) { return Optional.empty(); @@ -162,7 +173,25 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { return VelocityServer.getServer().getServers().getServer(toTryName); } - public void connect(ServerInfo info) { + private CompletableFuture connect(ConnectionRequestBuilderImpl request) { + if (connectionInFlight != null) { + return CompletableFuture.completedFuture( + ConnectionRequestResults.plainResult(ConnectionRequestBuilder.Status.CONNECTION_IN_PROGRESS) + ); + } + + if (connectedServer != null && connectedServer.getServerInfo().equals(request.getServer())) { + return CompletableFuture.completedFuture( + ConnectionRequestResults.plainResult(ConnectionRequestBuilder.Status.ALREADY_CONNECTED) + ); + } + + // Otherwise, initiate the connection. + ServerConnection connection = new ServerConnection(request.getServer(), this, VelocityServer.getServer()); + return connection.connect(); + } + + void connect(ServerInfo info) { Preconditions.checkNotNull(info, "info"); Preconditions.checkState(connectionInFlight == null, "A connection is already active!"); ServerConnection connection = new ServerConnection(info, this, VelocityServer.getServer()); @@ -194,4 +223,48 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player { public String toString() { return "[connected player] " + getProfile().getName() + " (" + getRemoteAddress() + ")"; } + + private class ConnectionRequestBuilderImpl implements ConnectionRequestBuilder { + private final ServerInfo info; + + public ConnectionRequestBuilderImpl(ServerInfo info) { + this.info = Preconditions.checkNotNull(info, "info"); + } + + @Override + public ServerInfo getServer() { + return info; + } + + @Override + public CompletableFuture connect() { + return ConnectedPlayer.this.connect(this); + } + + @Override + public void fireAndForget() { + connect() + .whenComplete((status, throwable) -> { + if (throwable != null) { + handleConnectionException(info, throwable); + return; + } + + switch (status.getStatus()) { + case ALREADY_CONNECTED: + sendMessage(ConnectionMessages.ALREADY_CONNECTED); + break; + case CONNECTION_IN_PROGRESS: + sendMessage(ConnectionMessages.IN_PROGRESS); + break; + case CONNECTION_CANCELLED: + // Ignored; the plugin probably already handled this. + break; + case SERVER_DISCONNECTED: + handleConnectionException(info, Disconnect.create(status.getReason().orElse(ConnectionMessages.INTERNAL_SERVER_CONNECTION_ERROR))); + break; + } + }); + } + } } 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 cb182d8af..12e69a4eb 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 @@ -140,6 +140,6 @@ public class LoginSessionHandler implements MinecraftSessionHandler { inbound.setAssociation(player); inbound.setState(StateRegistry.PLAY); inbound.setSessionHandler(new InitialConnectSessionHandler(player)); - player.connect(toTry.get()); + player.createConnectionRequest(toTry.get()).fireAndForget(); } } 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 new file mode 100644 index 000000000..b2ff70a41 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionMessages.java @@ -0,0 +1,14 @@ +package com.velocitypowered.proxy.connection.util; + +import net.kyori.text.TextComponent; +import net.kyori.text.format.TextColor; + +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"); + + private ConnectionMessages() { + throw new AssertionError(); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionRequestResults.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionRequestResults.java new file mode 100644 index 000000000..8e2d8ec8d --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/util/ConnectionRequestResults.java @@ -0,0 +1,45 @@ +package com.velocitypowered.proxy.connection.util; + +import com.velocitypowered.api.proxy.ConnectionRequestBuilder; +import com.velocitypowered.proxy.protocol.packet.Disconnect; +import net.kyori.text.Component; +import net.kyori.text.serializer.ComponentSerializers; + +import java.util.Optional; + +public class ConnectionRequestResults { + public static final ConnectionRequestBuilder.Result SUCCESSFUL = plainResult(ConnectionRequestBuilder.Status.SUCCESS); + + private ConnectionRequestResults() { + throw new AssertionError(); + } + + public static ConnectionRequestBuilder.Result plainResult(ConnectionRequestBuilder.Status status) { + return new ConnectionRequestBuilder.Result() { + @Override + public ConnectionRequestBuilder.Status getStatus() { + return status; + } + + @Override + public Optional getReason() { + return Optional.empty(); + } + }; + } + + public static ConnectionRequestBuilder.Result forDisconnect(Disconnect disconnect) { + Component deserialized = ComponentSerializers.JSON.deserialize(disconnect.getReason()); + return new ConnectionRequestBuilder.Result() { + @Override + public ConnectionRequestBuilder.Status getStatus() { + return ConnectionRequestBuilder.Status.SERVER_DISCONNECTED; + } + + @Override + public Optional getReason() { + return Optional.of(deserialized); + } + }; + } +}