Add unstable API for custom Login Plugin Messages (#2074)

* Add LoginPluginMessageBox to allow sending custom login plugin messages

* throw in ConnectionManager because AsyncUtils has a try catch

* Stack requests in AsyncPlayerPreLoginEvent so the user-facing API is scoped to the login stage

* Fix addPluginRequest javadoc

* feat: encapsulate velocityproxy logic, other minor tweaks

* fix: revert velocityproxy changes

---------

Co-authored-by: mworzala <mattheworzala@gmail.com>
This commit is contained in:
Samuel 2024-04-01 16:36:13 -04:00 committed by GitHub
parent 63f02929ed
commit 0c9527118a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 231 additions and 99 deletions

View File

@ -25,6 +25,9 @@ public final class ServerFlag {
public static final int PLAYER_PACKET_PER_TICK = Integer.getInteger("minestom.packet-per-tick", 20);
public static final int PLAYER_PACKET_QUEUE_SIZE = Integer.getInteger("minestom.packet-queue-size", 1000);
public static final int SEND_LIGHT_AFTER_BLOCK_PLACEMENT_DELAY = Integer.getInteger("minestom.send-light-after-block-placement-delay", 100);
public static final long KEEP_ALIVE_DELAY = Long.getLong("minestom.keep-alive-delay", 10_000);
public static final long KEEP_ALIVE_KICK = Long.getLong("minestom.keep-alive-kick", 30_000);
public static final long LOGIN_PLUGIN_MESSAGE_TIMEOUT = Long.getLong("minestom.login-plugin-message-timeout", 5_000);
// Packet sending optimizations
public static final boolean GROUPED_PACKET = PropertyUtils.getBoolean("minestom.grouped-packet", true);

View File

@ -2,9 +2,12 @@ package net.minestom.server.event.player;
import net.minestom.server.entity.Player;
import net.minestom.server.event.trait.PlayerEvent;
import net.minestom.server.network.plugin.LoginPluginMessageProcessor;
import net.minestom.server.network.plugin.LoginPluginResponse;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* Called before the player initialization, it can be used to kick the player before any connection
@ -13,11 +16,14 @@ import java.util.UUID;
public class AsyncPlayerPreLoginEvent implements PlayerEvent {
private final Player player;
private final LoginPluginMessageProcessor pluginMessageProcessor;
private String username;
private UUID playerUuid;
public AsyncPlayerPreLoginEvent(@NotNull Player player) {
public AsyncPlayerPreLoginEvent(@NotNull Player player, @NotNull LoginPluginMessageProcessor pluginMessageProcessor) {
this.player = player;
this.pluginMessageProcessor = pluginMessageProcessor;
this.username = player.getUsername();
this.playerUuid = player.getUuid();
}
@ -60,6 +66,19 @@ public class AsyncPlayerPreLoginEvent implements PlayerEvent {
this.playerUuid = playerUuid;
}
/**
* Sends a login plugin message request. Can be useful to negotiate with modded clients or
* proxies before moving on to the Configuration state.
*
* @param channel the plugin message channel
* @param requestPayload the contents of the plugin message, can be null for empty
*
* @return a CompletableFuture for the response. The thread on which it completes is asynchronous.
*/
public @NotNull CompletableFuture<LoginPluginResponse> sendPluginRequest(String channel, byte[] requestPayload) {
return pluginMessageProcessor.request(channel, requestPayload);
}
@Override
public @NotNull Player getPlayer() {
return player;

View File

@ -3,6 +3,7 @@ package net.minestom.server.extras;
import net.minestom.server.MinecraftServer;
import net.minestom.server.ServerFlag;
import net.minestom.server.extras.mojangAuth.MojangCrypt;
import net.minestom.server.extras.velocity.VelocityProxy;
import net.minestom.server.utils.validate.Check;
import org.jetbrains.annotations.Nullable;
@ -21,6 +22,8 @@ public final class MojangAuth {
public static void init() {
Check.stateCondition(enabled, "Mojang auth is already enabled!");
Check.stateCondition(MinecraftServer.process().isAlive(), "The server has already been started!");
Check.stateCondition(VelocityProxy.isEnabled(), "Velocity modern forwarding should not be enabled with MojangAuth");
MojangAuth.enabled = true;
// Generate necessary fields...
MojangAuth.keyPair = MojangCrypt.generateKeyPair();

View File

@ -1,6 +1,8 @@
package net.minestom.server.extras.velocity;
import net.minestom.server.extras.MojangAuth;
import net.minestom.server.network.NetworkBuffer;
import net.minestom.server.utils.validate.Check;
import org.jetbrains.annotations.NotNull;
import javax.crypto.Mac;
@ -32,6 +34,9 @@ public final class VelocityProxy {
* be sure to do not hardcode it in your code but to retrieve it from a file or anywhere else safe
*/
public static void enable(@NotNull String secret) {
Check.stateCondition(enabled, "Velocity modern forwarding is already enabled");
Check.stateCondition(MojangAuth.isEnabled(), "Velocity modern forwarding should not be enabled with MojangAuth");
VelocityProxy.enabled = true;
VelocityProxy.key = new SecretKeySpec(secret.getBytes(), MAC_ALGORITHM);
}

View File

@ -19,10 +19,11 @@ import net.minestom.server.network.packet.client.login.ClientLoginPluginResponse
import net.minestom.server.network.packet.client.login.ClientLoginStartPacket;
import net.minestom.server.network.packet.server.login.EncryptionRequestPacket;
import net.minestom.server.network.packet.server.login.LoginDisconnectPacket;
import net.minestom.server.network.packet.server.login.LoginPluginRequestPacket;
import net.minestom.server.network.player.GameProfile;
import net.minestom.server.network.player.PlayerConnection;
import net.minestom.server.network.player.PlayerSocketConnection;
import net.minestom.server.network.plugin.LoginPluginMessageProcessor;
import net.minestom.server.network.plugin.LoginPluginResponse;
import net.minestom.server.utils.async.AsyncUtils;
import org.jetbrains.annotations.NotNull;
@ -44,6 +45,7 @@ public final class LoginListener {
private static final Gson GSON = new Gson();
private static final Component ALREADY_CONNECTED = Component.text("You are already on this server", NamedTextColor.RED);
private static final Component ERROR_DURING_LOGIN = Component.text("Error during login!", NamedTextColor.RED);
public static final Component INVALID_PROXY_RESPONSE = Component.text("Invalid proxy response!", NamedTextColor.RED);
public static void loginStartListener(@NotNull ClientLoginStartPacket packet, @NotNull PlayerConnection connection) {
@ -54,11 +56,8 @@ public final class LoginListener {
socketConnection.UNSAFE_setLoginUsername(packet.username());
// Velocity support
if (VelocityProxy.isEnabled()) {
final int messageId = ThreadLocalRandom.current().nextInt();
final String channel = VelocityProxy.PLAYER_INFO_CHANNEL;
// Important in order to retrieve the channel in the response packet
socketConnection.addPluginRequestEntry(messageId, channel);
connection.sendPacket(new LoginPluginRequestPacket(messageId, channel, null));
connection.loginPluginMessageProcessor().request(VelocityProxy.PLAYER_INFO_CHANNEL, null)
.thenAccept(response -> handleVelocityProxyResponse(socketConnection, response));
return;
}
}
@ -163,48 +162,50 @@ public final class LoginListener {
return MojangCrypt.decryptByteToSecretKey(MojangAuth.getKeyPair().getPrivate(), sharedSecret);
}
public static void loginPluginResponseListener(@NotNull ClientLoginPluginResponsePacket packet, @NotNull PlayerConnection connection) {
// Proxy support
if (connection instanceof PlayerSocketConnection socketConnection) {
final String channel = socketConnection.getPluginRequestChannel(packet.messageId());
if (channel != null) {
boolean success = false;
private static void handleVelocityProxyResponse(PlayerSocketConnection socketConnection, LoginPluginResponse response) {
byte[] data = response.getPayload();
SocketAddress socketAddress = null;
GameProfile gameProfile = null;
// Velocity
if (VelocityProxy.isEnabled() && channel.equals(VelocityProxy.PLAYER_INFO_CHANNEL)) {
byte[] data = packet.data();
if (data != null && data.length > 0) {
NetworkBuffer buffer = new NetworkBuffer(ByteBuffer.wrap(data));
success = VelocityProxy.checkIntegrity(buffer);
if (success) {
// Get the real connection address
final InetAddress address;
try {
address = InetAddress.getByName(buffer.read(STRING));
} catch (UnknownHostException e) {
MinecraftServer.getExceptionManager().handleException(e);
return;
}
final int port = ((java.net.InetSocketAddress) connection.getRemoteAddress()).getPort();
socketAddress = new InetSocketAddress(address, port);
gameProfile = new GameProfile(buffer);
}
}
}
if (success) {
socketConnection.setRemoteAddress(socketAddress);
socketConnection.UNSAFE_setProfile(gameProfile);
CONNECTION_MANAGER.createPlayer(connection, gameProfile.uuid(), gameProfile.name());
} else {
LoginDisconnectPacket disconnectPacket = new LoginDisconnectPacket(INVALID_PROXY_RESPONSE);
socketConnection.sendPacket(disconnectPacket);
SocketAddress socketAddress = null;
GameProfile gameProfile = null;
boolean success = false;
if (data != null && data.length > 0) {
NetworkBuffer buffer = new NetworkBuffer(ByteBuffer.wrap(data));
success = VelocityProxy.checkIntegrity(buffer);
if (success) {
// Get the real connection address
final InetAddress address;
try {
address = InetAddress.getByName(buffer.read(STRING));
} catch (UnknownHostException e) {
MinecraftServer.getExceptionManager().handleException(e);
return;
}
final int port = ((java.net.InetSocketAddress) socketConnection.getRemoteAddress()).getPort();
socketAddress = new InetSocketAddress(address, port);
gameProfile = new GameProfile(buffer);
}
}
if (success) {
socketConnection.setRemoteAddress(socketAddress);
socketConnection.UNSAFE_setProfile(gameProfile);
CONNECTION_MANAGER.createPlayer(socketConnection, gameProfile.uuid(), gameProfile.name());
} else {
LoginDisconnectPacket disconnectPacket = new LoginDisconnectPacket(INVALID_PROXY_RESPONSE);
socketConnection.sendPacket(disconnectPacket);
}
}
public static void loginPluginResponseListener(@NotNull ClientLoginPluginResponsePacket packet, @NotNull PlayerConnection connection) {
try {
LoginPluginMessageProcessor messageProcessor = connection.loginPluginMessageProcessor();
messageProcessor.handleResponse(packet.messageId(), packet.data());
} catch (Throwable t) {
MinecraftServer.LOGGER.error("Error handling Login Plugin Response", t);
LoginDisconnectPacket disconnectPacket = new LoginDisconnectPacket(ERROR_DURING_LOGIN);
connection.sendPacket(disconnectPacket);
connection.disconnect();
}
}
public static void loginAckListener(@NotNull ClientLoginAcknowledgedPacket ignored, @NotNull PlayerConnection connection) {

View File

@ -3,12 +3,14 @@ package net.minestom.server.network;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.MinecraftServer;
import net.minestom.server.ServerFlag;
import net.minestom.server.entity.Player;
import net.minestom.server.entity.damage.DamageType;
import net.minestom.server.event.EventDispatcher;
import net.minestom.server.event.player.AsyncPlayerConfigurationEvent;
import net.minestom.server.event.player.AsyncPlayerPreLoginEvent;
import net.minestom.server.instance.Instance;
import net.minestom.server.listener.preplay.LoginListener;
import net.minestom.server.message.Messenger;
import net.minestom.server.network.packet.client.login.ClientLoginStartPacket;
import net.minestom.server.network.packet.server.common.KeepAlivePacket;
@ -20,6 +22,7 @@ import net.minestom.server.network.packet.server.login.LoginSuccessPacket;
import net.minestom.server.network.packet.server.play.StartConfigurationPacket;
import net.minestom.server.network.player.PlayerConnection;
import net.minestom.server.network.player.PlayerSocketConnection;
import net.minestom.server.network.plugin.LoginPluginMessageProcessor;
import net.minestom.server.utils.StringUtils;
import net.minestom.server.utils.async.AsyncUtils;
import net.minestom.server.utils.debug.DebugUtils;
@ -30,26 +33,20 @@ import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jglrxavpok.hephaistos.nbt.NBT;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* Manages the connected clients.
*/
public final class ConnectionManager {
private static final Logger logger = LoggerFactory.getLogger(ConnectionManager.class);
private static final long KEEP_ALIVE_DELAY = 10_000;
private static final long KEEP_ALIVE_KICK = 30_000;
private static final Component TIMEOUT_TEXT = Component.text("Timeout", NamedTextColor.RED);
// All players once their Player object has been instantiated.
private final Map<PlayerConnection, Player> connectionPlayerMap = new ConcurrentHashMap<>();
// Players waiting to be spawned (post configuration state)
@ -225,7 +222,8 @@ public final class ConnectionManager {
}
// Call pre login event
AsyncPlayerPreLoginEvent asyncPlayerPreLoginEvent = new AsyncPlayerPreLoginEvent(player);
LoginPluginMessageProcessor pluginMessageProcessor = playerConnection.loginPluginMessageProcessor();
AsyncPlayerPreLoginEvent asyncPlayerPreLoginEvent = new AsyncPlayerPreLoginEvent(player, pluginMessageProcessor);
EventDispatcher.call(asyncPlayerPreLoginEvent);
if (!player.isOnline())
return; // Player has been kicked
@ -242,6 +240,14 @@ public final class ConnectionManager {
}
}
// Wait for pending login plugin messages
try {
pluginMessageProcessor.awaitReplies(ServerFlag.LOGIN_PLUGIN_MESSAGE_TIMEOUT, TimeUnit.MILLISECONDS);
} catch (Throwable t) {
player.kick(LoginListener.INVALID_PROXY_RESPONSE);
throw new RuntimeException("Error getting replies for login plugin messages", t);
}
// Send login success packet (and switch to configuration phase)
LoginSuccessPacket loginSuccessPacket = new LoginSuccessPacket(player.getUuid(), player.getUsername(), 0);
playerConnection.sendPacket(loginSuccessPacket);
@ -367,10 +373,10 @@ public final class ConnectionManager {
final KeepAlivePacket keepAlivePacket = new KeepAlivePacket(tickStart);
for (Player player : playerGroup) {
final long lastKeepAlive = tickStart - player.getLastKeepAlive();
if (lastKeepAlive > KEEP_ALIVE_DELAY && player.didAnswerKeepAlive()) {
if (lastKeepAlive > ServerFlag.KEEP_ALIVE_DELAY && player.didAnswerKeepAlive()) {
player.refreshKeepAlive(tickStart);
player.sendPacket(keepAlivePacket);
} else if (lastKeepAlive >= KEEP_ALIVE_KICK) {
} else if (lastKeepAlive >= ServerFlag.KEEP_ALIVE_KICK) {
player.kick(TIMEOUT_TEXT);
}
}

View File

@ -6,6 +6,7 @@ import net.minestom.server.entity.Entity;
import net.minestom.server.entity.Player;
import net.minestom.server.network.ConnectionState;
import net.minestom.server.network.packet.server.SendablePacket;
import net.minestom.server.network.plugin.LoginPluginMessageProcessor;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@ -13,6 +14,7 @@ import org.jetbrains.annotations.Nullable;
import java.net.SocketAddress;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
/**
* A PlayerConnection is an object needed for all created {@link Player}.
@ -24,6 +26,8 @@ public abstract class PlayerConnection {
private PlayerPublicKey playerPublicKey;
volatile boolean online;
private LoginPluginMessageProcessor loginPluginMessageProcessor = new LoginPluginMessageProcessor(this);
public PlayerConnection() {
this.online = true;
this.connectionState = ConnectionState.HANDSHAKE;
@ -141,6 +145,10 @@ public abstract class PlayerConnection {
public void setConnectionState(@NotNull ConnectionState connectionState) {
this.connectionState = connectionState;
if (connectionState == ConnectionState.CONFIGURATION) {
// Clear the plugin request map (it is not used beyond login)
this.loginPluginMessageProcessor = null;
}
}
/**
@ -160,6 +168,15 @@ public abstract class PlayerConnection {
this.playerPublicKey = playerPublicKey;
}
/**
* Gets the login plugin message processor, only available during the login state.
*/
@ApiStatus.Internal
public @NotNull LoginPluginMessageProcessor loginPluginMessageProcessor() {
return Objects.requireNonNull(this.loginPluginMessageProcessor,
"Login plugin message processor is only available during the login state.");
}
@Override
public String toString() {
return "PlayerConnection{" +

View File

@ -7,7 +7,6 @@ import net.minestom.server.event.EventDispatcher;
import net.minestom.server.event.ListenerHandle;
import net.minestom.server.event.player.PlayerPacketOutEvent;
import net.minestom.server.extras.mojangAuth.MojangCrypt;
import net.minestom.server.network.ConnectionState;
import net.minestom.server.network.PacketProcessor;
import net.minestom.server.network.packet.client.ClientPacket;
import net.minestom.server.network.packet.client.handshake.ClientHandshakePacket;
@ -34,7 +33,6 @@ import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.SocketChannel;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.DataFormatException;
@ -66,10 +64,6 @@ public class PlayerSocketConnection extends PlayerConnection {
private int serverPort;
private int protocolVersion;
// Used for the login plugin request packet, to retrieve the channel from a message id,
// cleared once the player enters the play state
private final Map<Integer, String> pluginRequestMap = new ConcurrentHashMap<>();
private final List<BinaryBuffer> waitingBuffers = new ArrayList<>();
private final AtomicReference<BinaryBuffer> tickBuffer = new AtomicReference<>(POOL.get());
private BinaryBuffer cacheBuffer;
@ -287,44 +281,6 @@ public class PlayerSocketConnection extends PlayerConnection {
this.protocolVersion = protocolVersion;
}
/**
* Adds an entry to the plugin request map.
* <p>
* Only working if {@link #getConnectionState()} is {@link net.minestom.server.network.ConnectionState#LOGIN}.
*
* @param messageId the message id
* @param channel the packet channel
* @throws IllegalStateException if a messageId with the value {@code messageId} already exists for this connection
*/
public void addPluginRequestEntry(int messageId, @NotNull String channel) {
if (!getConnectionState().equals(ConnectionState.LOGIN)) {
return;
}
Check.stateCondition(pluginRequestMap.containsKey(messageId), "You cannot have two messageId with the same value");
this.pluginRequestMap.put(messageId, channel);
}
/**
* Gets a request channel from a message id, previously cached using {@link #addPluginRequestEntry(int, String)}.
* <p>
* Be aware that the internal map is cleared once the player enters the play state.
*
* @param messageId the message id
* @return the channel linked to the message id, null if not found
*/
public @Nullable String getPluginRequestChannel(int messageId) {
return pluginRequestMap.get(messageId);
}
@Override
public void setConnectionState(@NotNull ConnectionState connectionState) {
super.setConnectionState(connectionState);
// Clear the plugin request map (since it is not used anymore)
if (connectionState.equals(ConnectionState.PLAY)) {
this.pluginRequestMap.clear();
}
}
public byte[] getNonce() {
return nonce;
}

View File

@ -0,0 +1,64 @@
package net.minestom.server.network.plugin;
import net.minestom.server.network.packet.server.login.LoginPluginRequestPacket;
import net.minestom.server.network.player.PlayerConnection;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@ApiStatus.Internal
public class LoginPluginMessageProcessor {
private static final AtomicInteger REQUEST_ID = new AtomicInteger(0);
private final Map<Integer, LoginPluginRequest> requestByMsgId = new ConcurrentHashMap<>();
private final PlayerConnection connection;
public LoginPluginMessageProcessor(@NotNull PlayerConnection connection) {
this.connection = connection;
}
public @NotNull CompletableFuture<LoginPluginResponse> request(@NotNull String channel, byte @Nullable [] requestPayload) {
LoginPluginRequest request = new LoginPluginRequest(channel, requestPayload);
int messageId = getNextMessageId();
requestByMsgId.put(messageId, request);
connection.sendPacket(new LoginPluginRequestPacket(messageId, request.getChannel(), request.getRequestPayload()));
return request.getResponseFuture();
}
public void handleResponse(int messageId, byte[] responseData) throws Exception {
LoginPluginRequest request = requestByMsgId.remove(messageId);
if (request == null) {
throw new Exception("Received unexpected Login Plugin Response id " + messageId + " of " + responseData.length + " bytes");
}
try {
LoginPluginResponse response = LoginPluginResponse.fromPayload(request.getChannel(), responseData);
request.getResponseFuture().complete(response);
} catch (Throwable t) {
throw new Exception("Error handling Login Plugin Response on channel '" + request.getChannel() + "'", t);
}
}
public void awaitReplies(long timeout, @NotNull TimeUnit timeUnit) throws Exception {
if (requestByMsgId.isEmpty()) {
return;
}
CompletableFuture[] futures = requestByMsgId.values().stream()
.map(LoginPluginRequest::getResponseFuture)
.toArray(CompletableFuture[]::new);
CompletableFuture.allOf(futures).get(timeout, timeUnit);
}
private static int getNextMessageId() {
return REQUEST_ID.getAndIncrement();
}
}

View File

@ -0,0 +1,28 @@
package net.minestom.server.network.plugin;
import org.jetbrains.annotations.Nullable;
import java.util.concurrent.CompletableFuture;
public class LoginPluginRequest {
private final String channel;
private final byte[] requestPayload;
private final CompletableFuture<LoginPluginResponse> responseFuture = new CompletableFuture<>();
public LoginPluginRequest(String channel, @Nullable byte[] requestPayload) {
this.channel = channel;
this.requestPayload = requestPayload;
}
public String getChannel() {
return channel;
}
public @Nullable byte[] getRequestPayload() {
return requestPayload;
}
public CompletableFuture<LoginPluginResponse> getResponseFuture() {
return responseFuture;
}
}

View File

@ -0,0 +1,30 @@
package net.minestom.server.network.plugin;
public class LoginPluginResponse {
private final String channel;
private final boolean understood;
private final byte[] payload;
private LoginPluginResponse(String channel, boolean understood, byte[] payload) {
this.channel = channel;
this.understood = understood;
this.payload = payload;
}
public String getChannel() {
return channel;
}
public boolean isUnderstood() {
return understood;
}
public byte[] getPayload() {
return payload;
}
public static LoginPluginResponse fromPayload(String channel, byte[] payload) {
boolean understood = payload != null;
return new LoginPluginResponse(channel, understood, payload);
}
}