Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ public boolean isSupported() {
MINECRAFT_1_21_11(774, "1.21.11"),
MINECRAFT_26_1(775, "26.1", "26.1.1", "26.1.2"),
MINECRAFT_26_2(776, "26.2"),
MINECRAFT_26_3(777, "26.3");
MINECRAFT_26_3(777, "26.3"),
MINECRAFT_26_4(-1, 344, "26.4"); // TODO

private static final int SNAPSHOT_BIT = 30;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.velocitypowered.proxy.protocol.packet.BossBarPacket;
import com.velocitypowered.proxy.protocol.packet.BundleDelimiterPacket;
import com.velocitypowered.proxy.protocol.packet.ClientSettingsPacket;
import com.velocitypowered.proxy.protocol.packet.ClientboundApplyOptionsPacket;
import com.velocitypowered.proxy.protocol.packet.ClientboundCookieRequestPacket;
import com.velocitypowered.proxy.protocol.packet.ClientboundPostEffectsPacket;
import com.velocitypowered.proxy.protocol.packet.ClientboundSoundEntityPacket;
Expand Down Expand Up @@ -54,6 +55,7 @@
import com.velocitypowered.proxy.protocol.packet.ServerLoginSuccessPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCustomClickActionPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundOptionsAppliedPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket;
import com.velocitypowered.proxy.protocol.packet.SetCompressionPacket;
import com.velocitypowered.proxy.protocol.packet.StatusPingPacket;
Expand Down Expand Up @@ -409,4 +411,12 @@ default boolean handle(ClientboundStopSoundPacket packet) {
default boolean handle(ClientboundPostEffectsPacket packet) {
return false;
}

default boolean handle(ClientboundApplyOptionsPacket packet) {
return false;
}

default boolean handle(ServerboundOptionsAppliedPacket packet) {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,8 @@ public boolean handle(TransferPacket packet) {
resultedAddress = originalAddress;
}
this.playerConnection.write(new TransferPacket(
resultedAddress.getHostName(), resultedAddress.getPort()));
resultedAddress.getHostName(), resultedAddress.getPort(),
packet.properties()));
}
}, playerConnection.eventLoop());
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,8 @@ public boolean handle(TransferPacket packet) {
resultedAddress = originalAddress;
}
serverConn.getPlayer().getConnection().write(new TransferPacket(
resultedAddress.getHostName(), resultedAddress.getPort()));
resultedAddress.getHostName(), resultedAddress.getPort(),
packet.properties()));
}
}, serverConn.ensureConnected().eventLoop());
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import com.velocitypowered.proxy.protocol.StateRegistry;
import com.velocitypowered.proxy.protocol.packet.ClientboundCookieRequestPacket;
import com.velocitypowered.proxy.protocol.packet.ClientboundStoreCookiePacket;
import com.velocitypowered.proxy.protocol.packet.ConnectionOptions;
import com.velocitypowered.proxy.protocol.packet.DisconnectPacket;
import com.velocitypowered.proxy.protocol.packet.EncryptionRequestPacket;
import com.velocitypowered.proxy.protocol.packet.LoginAcknowledgedPacket;
Expand Down Expand Up @@ -66,6 +67,7 @@ public class LoginSessionHandler implements MinecraftSessionHandler {
private final VelocityServerConnection serverConn;
private final CompletableFuture<Impl> resultFuture;
private boolean informationForwarded;
private int pendingCompressionThreshold = -1; // 26.4+

LoginSessionHandler(VelocityServer server, VelocityServerConnection serverConn,
CompletableFuture<Impl> resultFuture) {
Expand Down Expand Up @@ -137,7 +139,13 @@ public boolean handle(DisconnectPacket packet) {

@Override
public boolean handle(SetCompressionPacket packet) {
serverConn.ensureConnected().setCompressionThreshold(packet.getThreshold());
MinecraftConnection smc = serverConn.ensureConnected();
if (smc.getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_26_4)) {
// Only announces the threshold; LoginSuccess says whether to switch it on.
pendingCompressionThreshold = packet.getThreshold();
} else {
smc.setCompressionThreshold(packet.getThreshold());
}
return true;
}

Expand All @@ -158,6 +166,16 @@ public boolean handle(ServerLoginSuccessPacket packet) {
smc.setActiveSessionHandler(StateRegistry.PLAY, new TransitionSessionHandler(server, serverConn, resultFuture));
} else {
smc.write(new LoginAcknowledgedPacket());
if (smc.getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_26_4)
&& ConnectionOptions.isCompression(packet.getOptions())) {
if (pendingCompressionThreshold < 0) {
throw new QuietRuntimeException("Backend server asked for compression in LoginSuccess"
+ " without sending a compression threshold first");
}
// LoginAcknowledged has been encoded uncompressed above (we are on the event loop); the
// server only replies once it has received it, so both directions switch over here.
smc.setCompressionThreshold(pendingCompressionThreshold);
}
smc.setActiveSessionHandler(StateRegistry.CONFIG, new ConfigSessionHandler(server, serverConn, resultFuture));
ConnectedPlayer player = serverConn.getPlayer();
if (player.getClientSettingsPacket() != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package com.velocitypowered.proxy.connection.client;

import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_1_8;
import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_26_4;

import com.google.common.base.Preconditions;
import com.velocitypowered.api.event.connection.DisconnectEvent;
Expand All @@ -40,6 +41,7 @@
import com.velocitypowered.proxy.connection.MinecraftSessionHandler;
import com.velocitypowered.proxy.crypto.IdentifiedKeyImpl;
import com.velocitypowered.proxy.protocol.StateRegistry;
import com.velocitypowered.proxy.protocol.packet.ConnectionOptions;
import com.velocitypowered.proxy.protocol.packet.LoginAcknowledgedPacket;
import com.velocitypowered.proxy.protocol.packet.ServerLoginSuccessPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket;
Expand Down Expand Up @@ -70,6 +72,9 @@ public class AuthSessionHandler implements MinecraftSessionHandler {
private final boolean onlineMode;
private State loginState = State.START; // 1.20.2+
private final String serverIdHash;
// 26.4+: SetCompression only announces the threshold; the client switches compression on
// once LoginSuccess (with the compression option) is acknowledged.
private int pendingCompressionThreshold = -1;

AuthSessionHandler(VelocityServer server, LoginInboundConnection inbound,
GameProfile profile, boolean onlineMode, String serverIdHash) {
Expand Down Expand Up @@ -139,7 +144,11 @@ private void startLoginCompletion(ConnectedPlayer player) {
int threshold = server.getConfiguration().getCompressionThreshold();
if (threshold >= 0 && mcConnection.getProtocolVersion().noLessThan(MINECRAFT_1_8)) {
mcConnection.write(new SetCompressionPacket(threshold));
mcConnection.setCompressionThreshold(threshold);
if (mcConnection.getProtocolVersion().noLessThan(MINECRAFT_26_4)) {
pendingCompressionThreshold = threshold;
} else {
mcConnection.setCompressionThreshold(threshold);
}
}
VelocityConfiguration configuration = server.getConfiguration();
UUID playerUniqueId = player.getUniqueId();
Expand Down Expand Up @@ -182,6 +191,12 @@ public boolean handle(LoginAcknowledgedPacket packet) {
inbound.disconnect(Component.translatable("multiplayer.disconnect.invalid_player_data"));
} else {
loginState = State.ACKNOWLEDGED;
if (pendingCompressionThreshold >= 0) {
// LoginAcknowledged was the last uncompressed packet in both directions. We are still
// inside the frame decoder's read loop, so the next frame already goes through the
// decompressor.
mcConnection.setCompressionThreshold(pendingCompressionThreshold);
}
mcConnection.setActiveSessionHandler(StateRegistry.CONFIG, new ClientConfigSessionHandler(server, connectedPlayer));

server.getEventManager().fire(new PostLoginEvent(connectedPlayer)).thenCompose(ignored -> {
Expand Down Expand Up @@ -239,6 +254,9 @@ private void completeLoginProtocolPhaseAndInitialize(ConnectedPlayer player) {
if (inbound.getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_26_2)) {
success.setSessionId(server.getSessionId());
}
if (pendingCompressionThreshold >= 0) {
success.setOptions(ConnectionOptions.COMPRESSION);
}
mcConnection.write(success);

loginState = State.SUCCESS_SENT;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,22 @@ public boolean handle(final PluginMessagePacket packet) {
brandChannel = packet.getChannel();
// Client sends `minecraft:brand` packet immediately after Login,
// but at this time the backend server may not be ready
} else if (PluginMessageUtil.isModList(packet)) {
// 26.4+ clients send `minecraft:mod_list` right after the brand, once per session.
player.setClientModList(ByteBufUtil.getBytes(packet.content()));
} else if (BungeeCordMessageResponder.isBungeeCordMessage(packet)) {
return true;
} else if (serverConn != null) {
ChannelIdentifier id = this.server.getChannelRegistrar().getFromId(packet.getChannel());

if (serverConn.getConnection() == null) {
// The backend connection is still being set up; there is nowhere to send this yet.
// TODO: Do we queue this instead?
logger.warn("Dropping plugin message on {} from {}: backend connection to {} not ready",
packet.getChannel(), player, serverConn.getServerInfo().getName());
return true;
}

if (id == null) {
serverConn.ensureConnected().write(packet.retain());
return true;
Expand Down Expand Up @@ -338,6 +349,12 @@ public CompletableFuture<Void> handleBackendFinishUpdate(VelocityServerConnectio
smc.write(brandPacket);
}

final byte[] modList = serverConn.getPlayer().getClientModList();
if (modList != null) {
smc.write(new PluginMessagePacket(PluginMessageUtil.MOD_LIST_CHANNEL,
Unpooled.wrappedBuffer(modList)));
}

callConfigurationEvent().thenCompose(v -> {
return server.getEventManager().fire(new PlayerFinishConfigurationEvent(player, serverConn))
.completeOnTimeout(null, 5, TimeUnit.SECONDS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player,
private final BundleDelimiterHandler bundleHandler = new BundleDelimiterHandler(this);

private @Nullable String clientBrand;
private byte @Nullable [] clientModList; // 26.4+, minecraft:mod_list payload
private @Nullable Locale effectiveLocale;
private final @Nullable IdentifiedKey playerKey;
private @Nullable ClientSettingsPacket clientSettingsPacket;
Expand Down Expand Up @@ -1028,6 +1029,20 @@ void setClientBrand(final @Nullable String clientBrand) {
this.clientBrand = clientBrand;
}

/**
* Returns the raw {@code minecraft:mod_list} payload the client sent after login (26.4+).
* The client only sends it once; its is replayed to every backend we connect to.
*
* @return the payload, or {@code null} if the client didn't send one
*/
byte @Nullable [] getClientModList() {
return clientModList;
}

void setClientModList(final byte @Nullable [] clientModList) {
this.clientModList = clientModList;
}

@Override
public void playSound(@NotNull Sound sound, @NotNull Sound.Emitter emitter) {
Preconditions.checkNotNull(sound, "sound");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,13 @@ static String cleanVhost(String hostname) {
cleaned = hostname.substring(0, zeroIdx);
}

// Minecraft 26.4+ can append query properties to the host ("host?_o=original:port&_id=...").
// A '?' can never be part of a hostname, so strip them before the forced host lookup.
int queryIdx = cleaned.indexOf('?');
if (queryIdx > -1) {
cleaned = cleaned.substring(0, queryIdx);
}

// If we connect through an SRV record, there will be a period at the end (DNS usually elides
// this ending octet).
if (!cleaned.isEmpty() && cleaned.charAt(cleaned.length() - 1) == '.') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@
import com.velocitypowered.proxy.crypto.IdentifiedKeyImpl;
import com.velocitypowered.proxy.protocol.StateRegistry;
import com.velocitypowered.proxy.protocol.netty.MinecraftDecoder;
import com.velocitypowered.proxy.protocol.packet.ClientboundApplyOptionsPacket;
import com.velocitypowered.proxy.protocol.packet.ConnectionOptions;
import com.velocitypowered.proxy.protocol.packet.EncryptionRequestPacket;
import com.velocitypowered.proxy.protocol.packet.EncryptionResponsePacket;
import com.velocitypowered.proxy.protocol.packet.LoginPluginResponsePacket;
import com.velocitypowered.proxy.protocol.packet.ServerLoginPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundOptionsAppliedPacket;
import com.velocitypowered.proxy.util.VelocityProperties;
import io.netty.buffer.ByteBuf;
import java.net.InetSocketAddress;
Expand All @@ -58,6 +61,7 @@
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.Nullable;

/**
* Handles authenticating the player to Mojang's servers.
Expand All @@ -77,6 +81,9 @@ public class InitialLoginSessionHandler implements MinecraftSessionHandler {
private @MonotonicNonNull ServerLoginPacket login;
private byte[] verify = EMPTY_BYTE_ARRAY;
private LoginState currentState = LoginState.LOGIN_PACKET_EXPECTED;
// 26.4+: encryption waits until the client confirms ApplyOptions.
private byte @Nullable [] pendingSharedSecret;
private @Nullable AuthSessionHandler pendingAuthSessionHandler;
private final boolean forceKeyAuthentication;

InitialLoginSessionHandler(VelocityServer server, MinecraftConnection mcConnection,
Expand Down Expand Up @@ -201,9 +208,14 @@ public boolean handle(EncryptionResponsePacket packet) {

byte[] decryptedSharedSecret = decryptRsa(serverKeyPair, packet.getSharedSecret());

// Go ahead and enable encryption. Once the client sends EncryptionResponse, encryption
// is enabled.
mcConnection.enableEncryption(decryptedSharedSecret);
if (mcConnection.getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_26_4)) {
// 26.4+ clients keep talking in plaintext until we send ApplyOptions after authenticating.
pendingSharedSecret = decryptedSharedSecret;
} else {
// Go ahead and enable encryption. Once the client sends EncryptionResponse, encryption
// is enabled.
mcConnection.enableEncryption(decryptedSharedSecret);
}

String serverId = generateServerId(decryptedSharedSecret, serverKeyPair.getPublic());
String playerIp = ((InetSocketAddress) mcConnection.getRemoteAddress()).getHostString();
Expand Down Expand Up @@ -247,8 +259,16 @@ public boolean handle(EncryptionResponsePacket packet) {
}
}
// All went well, initialize the session.
mcConnection.setActiveSessionHandler(StateRegistry.LOGIN,
new AuthSessionHandler(server, inbound, profile, true, serverId));
final AuthSessionHandler authSessionHandler =
new AuthSessionHandler(server, inbound, profile, true, serverId);
if (pendingSharedSecret != null) {
// 26.4+: ask the client to switch encryption on and wait for its confirmation.
pendingAuthSessionHandler = authSessionHandler;
currentState = LoginState.APPLY_OPTIONS_SENT;
mcConnection.write(new ClientboundApplyOptionsPacket(ConnectionOptions.ENCRYPTION));
} else {
mcConnection.setActiveSessionHandler(StateRegistry.LOGIN, authSessionHandler);
}
} else if (response.statusCode() == 204) {
// Apparently an offline-mode user logged onto this online-mode proxy.
inbound.disconnect(
Expand All @@ -271,6 +291,31 @@ public boolean handle(EncryptionResponsePacket packet) {
return true;
}

@Override
public boolean handle(ServerboundOptionsAppliedPacket packet) {
assertState(LoginState.APPLY_OPTIONS_SENT);
byte[] sharedSecret = pendingSharedSecret;
AuthSessionHandler authSessionHandler = pendingAuthSessionHandler;
if (mcConnection.isClosed() || sharedSecret == null || authSessionHandler == null) {
return true;
}
this.currentState = LoginState.OPTIONS_APPLIED;
this.pendingSharedSecret = null;
this.pendingAuthSessionHandler = null;

try {
// OptionsApplied was the client's last plaintext packet. The client sends nothing else
// until we answer, so no encrypted bytes can be buffered behind it yet.
mcConnection.enableEncryption(sharedSecret);
} catch (GeneralSecurityException e) {
logger.error("Unable to enable encryption", e);
mcConnection.close(true);
return true;
}
mcConnection.setActiveSessionHandler(StateRegistry.LOGIN, authSessionHandler);
return true;
}

private EncryptionRequestPacket generateEncryptionRequest() {
byte[] verify = new byte[4];
SECURE_RANDOM.nextBytes(verify);
Expand Down Expand Up @@ -306,6 +351,8 @@ private enum LoginState {
LOGIN_PACKET_EXPECTED,
LOGIN_PACKET_RECEIVED,
ENCRYPTION_REQUEST_SENT,
ENCRYPTION_RESPONSE_RECEIVED
ENCRYPTION_RESPONSE_RECEIVED,
APPLY_OPTIONS_SENT, // 26.4+
OPTIONS_APPLIED // 26.4+
}
}
Loading