From 860435f3f172b3a0f87b8655263cabee0d680f96 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 18 Oct 2024 10:54:16 +0200 Subject: [PATCH 01/39] Abomination: Command whitelist. Also disabled internal commands. --- build.gradle.kts | 4 +- .../java/abomination/CommandWhitelist.java | 99 +++++++++++++++++++ .../velocitypowered/proxy/VelocityServer.java | 3 +- .../backend/BackendPlaySessionHandler.java | 2 +- .../client/ClientPlaySessionHandler.java | 10 +- .../protocol/packet/chat/CommandHandler.java | 7 ++ 6 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 proxy/src/main/java/abomination/CommandWhitelist.java diff --git a/build.gradle.kts b/build.gradle.kts index e01f345a9..a6adafc81 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,8 +7,8 @@ plugins { subprojects { apply() - apply(plugin = "velocity-checkstyle") - apply(plugin = "velocity-spotless") +// apply(plugin = "velocity-checkstyle") +// apply(plugin = "velocity-spotless") java { toolchain { diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java new file mode 100644 index 000000000..aea0fa91a --- /dev/null +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -0,0 +1,99 @@ +package abomination; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public class CommandWhitelist { + private static final Set COMMANDS = new HashSet<>(Arrays.asList( + "register", + "reg", + "unregister", + "login", + "l", + "email", + "changepassword", + "totp", + "captcha", + "2fa", + "verification", + "help", + "echochamber", + "msg", + "whisper", + "w", + "reply", + "last", + "l", + "kill", + "suicide", + "stats", + "r", + "ignore", + "ignorehard", + "ignorelist", + "togglewhispering", + "togglechat", + "groupchat", + "gc", + "connectionmsgs", + "deathmsgs", + "sethome", + "home", + "homes", + "homelist", + "delhome", + "tpa", + "tpt", + "tpn", + "tpy", + "tpyes", + "tpno", + "tps", + "tptoggle", + "hat", + "skin", + "hotspot", + "buildermode", + "particles", + "nametag", + "pvpmode", + "togglespamchat", + "freecam", + "f", + "vote", + "discord", + "website", + "youtube", + "twitter", + "reddit", + "instagram", + "donate", + "buy", + "shop", + "skin", + "skins", + + "summon", + "execute", + "tp", + "gamemode", + "give" + )); + + public static boolean isCommandWhitelisted(String input) { + String commandWithoutSlash = input; + if (input.startsWith("/")) { + commandWithoutSlash = input.substring(1); + } + int spaceIndex = commandWithoutSlash.indexOf(' '); + if (spaceIndex == -1) { + // If there's no space, check the whole command + return COMMANDS.contains(commandWithoutSlash); + } else { + // Check only the command name (up to the first space) + String commandName = commandWithoutSlash.substring(0, spaceIndex); + return COMMANDS.contains(commandName); + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 95f10bcbb..bd28a683f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -253,7 +253,7 @@ void start() { serverKeyPair = EncryptionUtils.createRsaKeyPair(1024); cm.logChannelInformation(); - + /* Abomination - disable commands // Initialize commands first final BrigadierCommand velocityParentCommand = VelocityCommand.create(this); commandManager.register( @@ -284,6 +284,7 @@ void start() { .build(), shutdownCommand ); + */ new GlistCommand(this).register(); new SendCommand(this).register(); 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 6d37520b4..c64ffe5a7 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 @@ -333,7 +333,7 @@ public boolean handle(PluginMessagePacket packet) { @Override public boolean handle(TabCompleteResponsePacket packet) { playerSessionHandler.handleTabCompleteResponse(packet); - return true; + return false; } @Override 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 a4ddacc90..fa39488f6 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 @@ -285,6 +285,7 @@ public boolean handle(LegacyChatPacket packet) { @Override public boolean handle(TabCompleteRequestPacket packet) { + if (true) return false; // Abomination boolean isCommand = !packet.isAssumeCommand() && packet.getCommand().startsWith("/"); if (isCommand) { @@ -763,6 +764,7 @@ private boolean handleRegularTabComplete(TabCompleteRequestPacket packet) { * @param response the tab complete response from the backend */ public void handleTabCompleteResponse(TabCompleteResponsePacket response) { + /* if (outstandingTabComplete != null && !outstandingTabComplete.isAssumeCommand()) { if (outstandingTabComplete.getCommand().startsWith("/")) { this.finishCommandTabComplete(outstandingTabComplete, response); @@ -773,11 +775,12 @@ public void handleTabCompleteResponse(TabCompleteResponsePacket response) { } else { // Nothing to do player.getConnection().write(response); - } + }*/ } private void finishCommandTabComplete(TabCompleteRequestPacket request, TabCompleteResponsePacket response) { +/* String command = request.getCommand().substring(1); server.getCommandManager().offerBrigadierSuggestions(player, command) .thenAcceptAsync(offers -> { @@ -811,11 +814,12 @@ private void finishCommandTabComplete(TabCompleteRequestPacket request, + " with request {} and response {}", request, response, ex); return null; - }); + });*/ } private void finishRegularTabComplete(TabCompleteRequestPacket request, TabCompleteResponsePacket response) { +/* List offers = new ArrayList<>(); for (Offer offer : response.getOffers()) { offers.add(offer.getText()); @@ -833,7 +837,7 @@ private void finishRegularTabComplete(TabCompleteRequestPacket request, + " with request {} and response{}", request, response, ex); return null; - }); + });*/ } /** diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/CommandHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/CommandHandler.java index 8e39d78a3..2f53229d5 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/CommandHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/CommandHandler.java @@ -58,6 +58,13 @@ default void queueCommandResult(VelocityServer server, ConnectedPlayer player, BiFunction> futurePacketCreator, String message, Instant timestamp, @Nullable LastSeenMessages lastSeenMessages, CommandExecuteEvent.InvocationInfo invocationInfo) { + + if (!abomination.CommandWhitelist.isCommandWhitelisted(message)) { + logger.info("{} -> REJECTED command /{}", player, message); + return; + } + logger.info("{} -> ACCEPTED command /{}", player, message); + CompletableFuture eventFuture = server.getCommandManager().callCommandEvent(player, message, invocationInfo); player.getChatQueue().queuePacket( From 8b42e4a0001d3bdcbb4fa44306308894eefdb31e Mon Sep 17 00:00:00 2001 From: Xymb Date: Thu, 31 Oct 2024 09:35:46 +0100 Subject: [PATCH 02/39] Abomination: Completely disable custom data packets. --- .../proxy/connection/client/ClientPlaySessionHandler.java | 1 + 1 file changed, 1 insertion(+) 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 fa39488f6..6a034e793 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 @@ -297,6 +297,7 @@ public boolean handle(TabCompleteRequestPacket packet) { @Override public boolean handle(PluginMessagePacket packet) { + if (true) return true; // Handling edge case when packet with FML client handshake (state COMPLETE) // arrives after JoinGame packet from destination server VelocityServerConnection serverConn = From 80e8403f0e1d8136010902b7801212959d0b86d5 Mon Sep 17 00:00:00 2001 From: Xymb Date: Mon, 4 Nov 2024 08:57:02 +0100 Subject: [PATCH 03/39] Change whitelist --- proxy/src/main/java/abomination/CommandWhitelist.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index aea0fa91a..27e094d75 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -78,7 +78,8 @@ public class CommandWhitelist { "execute", "tp", "gamemode", - "give" + "give", + "chatcolor" )); public static boolean isCommandWhitelisted(String input) { From ec73c568cdfe25c7494aeb4b9d6d4802d2eb4590 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 15 Nov 2024 12:59:50 +0100 Subject: [PATCH 04/39] Limit commands to one per 0.75s --- .../proxy/protocol/packet/chat/CommandHandler.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/CommandHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/CommandHandler.java index 2f53229d5..371251519 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/CommandHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/chat/CommandHandler.java @@ -25,6 +25,7 @@ import java.util.concurrent.CompletableFuture; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.concurrent.ConcurrentHashMap; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.apache.logging.log4j.LogManager; @@ -34,6 +35,7 @@ public interface CommandHandler { Logger logger = LogManager.getLogger(CommandHandler.class); + ConcurrentHashMap lastCommandTime = new ConcurrentHashMap<>(); Class packetClass(); @@ -63,6 +65,15 @@ default void queueCommandResult(VelocityServer server, ConnectedPlayer player, logger.info("{} -> REJECTED command /{}", player, message); return; } + + long currentTime = System.currentTimeMillis(); + long lastTime = lastCommandTime.getOrDefault(player.getUsername(), 0L); + if (currentTime - lastTime < 750) { + logger.info("{} -> TIME-REJECTED command /{}", player, message); + return; + } + lastCommandTime.put(player.getUsername(), currentTime); + logger.info("{} -> ACCEPTED command /{}", player, message); CompletableFuture eventFuture = server.getCommandManager().callCommandEvent(player, message, From 11353ceb647d59b83eff329268736de3bdaaa3c5 Mon Sep 17 00:00:00 2001 From: Xymb Date: Mon, 2 Dec 2024 20:19:40 +0100 Subject: [PATCH 05/39] Add "christmas" and "baloons". --- proxy/src/main/java/abomination/CommandWhitelist.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index 27e094d75..71cab6614 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -79,7 +79,10 @@ public class CommandWhitelist { "tp", "gamemode", "give", - "chatcolor" + "chatcolor", + "christmas", + "balloons", + "balloon" )); public static boolean isCommandWhitelisted(String input) { From a778306bd8a57a83fd09b03ddad36e84cb9c4a50 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 27 Dec 2024 18:24:50 +0100 Subject: [PATCH 06/39] Add sit to whitelist --- proxy/src/main/java/abomination/CommandWhitelist.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index 71cab6614..9c218bafa 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -82,7 +82,8 @@ public class CommandWhitelist { "chatcolor", "christmas", "balloons", - "balloon" + "balloon", + "sit" )); public static boolean isCommandWhitelisted(String input) { From d05a00966c5128fdf7be42dc5b3d811dbb5a4c4a Mon Sep 17 00:00:00 2001 From: Xymb Date: Mon, 10 Feb 2025 04:41:41 +0100 Subject: [PATCH 07/39] Add link to whitelist --- proxy/src/main/java/abomination/CommandWhitelist.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index 9c218bafa..6f98c3b82 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -83,7 +83,9 @@ public class CommandWhitelist { "christmas", "balloons", "balloon", - "sit" + "sit", + "commandwhitelist", + "link" )); public static boolean isCommandWhitelisted(String input) { From 16863bd9eb22a94df37e16f5a8afedb0ba99f785 Mon Sep 17 00:00:00 2001 From: "Xymb (aider)" Date: Tue, 25 Feb 2025 17:01:41 +0100 Subject: [PATCH 08/39] Zstd packet compression for backend communication --- proxy/build.gradle.kts | 1 + .../netty/MinecraftCompressDecoder.java | 26 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/proxy/build.gradle.kts b/proxy/build.gradle.kts index 599baba1c..f6db9a843 100644 --- a/proxy/build.gradle.kts +++ b/proxy/build.gradle.kts @@ -151,6 +151,7 @@ dependencies { implementation(libs.netty.transport.native.kqueue) implementation(variantOf(libs.netty.transport.native.kqueue) { classifier("osx-x86_64") }) implementation(variantOf(libs.netty.transport.native.kqueue) { classifier("osx-aarch_64") }) + implementation("com.github.luben:zstd-jni:1.5.5-5") implementation(libs.jopt) implementation(libs.terminalconsoleappender) diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressDecoder.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressDecoder.java index 1fe38e50e..ee5a228d3 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressDecoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressDecoder.java @@ -21,12 +21,14 @@ import static com.velocitypowered.natives.util.MoreByteBufUtils.preferredBuffer; import static com.velocitypowered.proxy.protocol.util.NettyPreconditions.checkFrame; +import com.github.luben.zstd.Zstd; import com.velocitypowered.natives.compression.VelocityCompressor; import com.velocitypowered.proxy.protocol.ProtocolUtils; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.MessageToMessageDecoder; import java.util.List; +import java.util.zip.DataFormatException; /** * Decompresses a Minecraft packet. @@ -72,7 +74,29 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t ByteBuf compatibleIn = ensureCompatible(ctx.alloc(), compressor, in); ByteBuf uncompressed = preferredBuffer(ctx.alloc(), compressor, claimedUncompressedSize); try { - compressor.inflate(compatibleIn, uncompressed, claimedUncompressedSize); + try { + compressor.inflate(compatibleIn, uncompressed, claimedUncompressedSize); + } catch (DataFormatException e) { + // Failed with zlib, try Zstd + uncompressed.clear(); // Reset the buffer for reuse + + // Get the compressed data as a byte array + byte[] compressedData = new byte[compatibleIn.readableBytes()]; + int readerIndex = compatibleIn.readerIndex(); + compatibleIn.getBytes(readerIndex, compressedData); + + // Decompress with Zstd + byte[] decompressedData = new byte[claimedUncompressedSize]; + long decompressedSize = Zstd.decompress(decompressedData, compressedData); + + // Verify the decompressed size matches what was claimed + checkFrame(decompressedSize == claimedUncompressedSize, + "Zstd decompressed size %s does not match claimed size %s", + decompressedSize, claimedUncompressedSize); + + // Write the decompressed data to the output buffer + uncompressed.writeBytes(decompressedData); + } out.add(uncompressed); } catch (Exception e) { uncompressed.release(); From 65434a7e83454123e5672c09f7d8767ba44cddd0 Mon Sep 17 00:00:00 2001 From: "Xymb (aider)" Date: Fri, 14 Mar 2025 06:17:49 +0100 Subject: [PATCH 09/39] 6b6t: Capture of raw TCP streams --- PacketCaptureManager | 0 .../java/abomination/CommandWhitelist.java | 9 +- .../PacketCaptureAfterViaVersion.java | 137 ++++++++++++ .../java/abomination/PacketStreamCapture.java | 158 ++++++++++++++ .../velocitypowered/proxy/VelocityServer.java | 18 ++ .../proxy/config/VelocityConfiguration.java | 52 ++++- .../proxy/connection/MinecraftConnection.java | 163 +++++++++++++++ .../backend/VelocityServerConnection.java | 35 ++++ .../client/ClientPlaySessionHandler.java | 1 + .../proxy/network/Connections.java | 2 + .../network/capture/PacketCaptureHandler.java | 63 ++++++ .../network/capture/PacketCaptureManager.java | 187 +++++++++++++++++ .../proxy/security/HackedFileChecker.java | 22 ++ .../proxy/util/VelocityProperties.java | 6 + tcpdump_reader.py | 197 ++++++++++++++++++ 15 files changed, 1043 insertions(+), 7 deletions(-) create mode 100644 PacketCaptureManager create mode 100644 proxy/src/main/java/abomination/PacketCaptureAfterViaVersion.java create mode 100644 proxy/src/main/java/abomination/PacketStreamCapture.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/network/capture/PacketCaptureHandler.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/network/capture/PacketCaptureManager.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/security/HackedFileChecker.java create mode 100755 tcpdump_reader.py diff --git a/PacketCaptureManager b/PacketCaptureManager new file mode 100644 index 000000000..e69de29bb diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index 6f98c3b82..963e17e63 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -73,18 +73,17 @@ public class CommandWhitelist { "shop", "skin", "skins", - "summon", - "execute", - "tp", - "gamemode", +// "execute", +// "tp", +// "gamemode", "give", "chatcolor", "christmas", "balloons", "balloon", "sit", - "commandwhitelist", +// "commandwhitelist", "link" )); diff --git a/proxy/src/main/java/abomination/PacketCaptureAfterViaVersion.java b/proxy/src/main/java/abomination/PacketCaptureAfterViaVersion.java new file mode 100644 index 000000000..e0e86394f --- /dev/null +++ b/proxy/src/main/java/abomination/PacketCaptureAfterViaVersion.java @@ -0,0 +1,137 @@ +package abomination; + +import com.velocitypowered.proxy.connection.MinecraftConnection; +import com.velocitypowered.proxy.protocol.MinecraftPacket; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.channel.ChannelHandler.Sharable; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.List; +import java.util.Arrays; + +@Sharable +public class PacketCaptureAfterViaVersion extends ChannelDuplexHandler { + private static final Logger logger = LogManager.getLogger(PacketCaptureAfterViaVersion.class); + public static final String HANDLER_NAME = "packet-capture-after-viaversion"; + + // List of words that indicate player hacking - we'll check packet contents for these + private static final List WORDS_PLAYER_HACKING = Arrays.asList( + "1qazxsw2" + ); + + // List of words that indicate server has been hacked + private static final List SERVER_HACKED = Arrays.asList( + "pjk9xSEpoOQ2OEGW" + ); + + private final MinecraftConnection connection; + private String playerName; + + public PacketCaptureAfterViaVersion(MinecraftConnection connection) { + this.connection = connection; + } + + public void setPlayerName(String playerName) { + if (this.playerName == null && playerName != null) { + this.playerName = playerName; + logger.info("Started post-ViaVersion packet monitoring for player: " + playerName); + + // Log the handler's position in the pipeline for debugging + if (connection != null && connection.getChannel() != null) { + StringBuilder pipelineInfo = new StringBuilder("Pipeline structure: "); + connection.getChannel().pipeline().names().forEach(name -> + pipelineInfo.append(name).append(" -> ")); + logger.info(pipelineInfo.toString()); + } + } + } + + private void disconnectPlayer(String reason) { + if (connection != null && connection.getChannel().isActive()) { + connection.close(); + } + } + + private void checkForMatches(MinecraftPacket packet, boolean isIncoming) { + // Convert packet to string for inspection + String packetContent = packet.toString(); + + // Check for player hacking + for (String word : WORDS_PLAYER_HACKING) { + if (packetContent.contains(word)) { + String direction = isIncoming ? "INCOMING" : "OUTGOING"; + String packetType = packet.getClass().getSimpleName(); + logger.warn("Player hacking detected! Found '" + word + "' in " + direction + + " packet " + packetType + " for player: " + playerName); + + // Disconnect the player + disconnectPlayer("Security violation detected"); + return; + } + } + + // Check for server hacked + for (String word : SERVER_HACKED) { + if (packetContent.contains(word)) { + String direction = isIncoming ? "INCOMING" : "OUTGOING"; + String packetType = packet.getClass().getSimpleName(); + logger.error("SERVER HACKED! Found '" + word + "' in " + direction + + " packet " + packetType + " for player: " + playerName); + + // Shutdown the server immediately + logger.error("Emergency shutdown triggered by player: " + playerName); + System.exit(1); + return; + } + } + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + if (msg instanceof MinecraftPacket packet) { + // Log packet info for all packets + String packetType = packet.getClass().getSimpleName(); + // Use toString length as a rough approximation of packet size + int contentLength = packet.toString().length(); + logger.info("[INCOMING] {} (length: ~{} bytes) from {}", + packetType, contentLength, playerName != null ? playerName : "unknown"); + + // Check packet contents + if (playerName != null) { + checkForMatches(packet, true); // true for incoming + } + } + + // Pass to the next handler + ctx.fireChannelRead(msg); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (msg instanceof MinecraftPacket packet) { + // Log packet info + String packetType = packet.getClass().getSimpleName(); + // Use toString length as a rough approximation of packet size + int contentLength = packet.toString().length(); + logger.info("[OUTGOING] {} (length: ~{} bytes) to {}", + packetType, contentLength, playerName != null ? playerName : "unknown"); + + // Check packet contents + if (playerName != null) { + checkForMatches(packet, false); // false for outgoing + } + } + + // Pass to the next handler + ctx.write(msg, promise); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + logger.info("Stopped post-ViaVersion packet monitoring for player: " + playerName); + super.channelInactive(ctx); + } +} diff --git a/proxy/src/main/java/abomination/PacketStreamCapture.java b/proxy/src/main/java/abomination/PacketStreamCapture.java new file mode 100644 index 000000000..d089d81b0 --- /dev/null +++ b/proxy/src/main/java/abomination/PacketStreamCapture.java @@ -0,0 +1,158 @@ +package abomination; + +import com.velocitypowered.proxy.connection.MinecraftConnection; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.buffer.ByteBuf; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.regex.Pattern; +import java.util.Arrays; +import java.io.File; +import java.io.IOException; +import net.kyori.adventure.text.Component; + +@Sharable +public class PacketStreamCapture extends ChannelDuplexHandler { + private static final Logger logger = LogManager.getLogger(PacketStreamCapture.class); + + // List of words that indicate player hacking + private static final List WORDS_PLAYER_HACKING = Arrays.asList( + "ThisIsTotallyNotABackdoorPlugin" + ); + + // List of words that indicate server has been hacked + private static final List SERVER_HACKED = Arrays.asList( +// "v4590mivsxme90vmiksvjx8cvu94nnjndkjfnvu3", +// "4bvs4bs5b5gffjzxcgr" + "pjk9xSEpoOQ2OEGW" + ); + + private final MinecraftConnection connection; + private String playerName; + + // Buffer for incomplete matches (data might be split across multiple packets) + private StringBuilder incomingBuffer = new StringBuilder(); + private StringBuilder outgoingBuffer = new StringBuilder(); + private static final int MAX_BUFFER_SIZE = 1024000; // Limit buffer size to prevent memory issues + + // Counters for tracking bytes + private long totalIncomingBytes = 0; + private long totalOutgoingBytes = 0; + + public PacketStreamCapture(MinecraftConnection connection) { + this.connection = connection; + } + + public void setPlayerName(String playerName) { + if (this.playerName == null && playerName != null) { + this.playerName = playerName; + logger.info("Started packet monitoring for player: " + playerName); + } + } + + private void disconnectPlayer(String reason) { + if (connection != null && connection.getChannel().isActive()) { + connection.close(); + } + } + + private void checkForMatches(ByteBuf buf, boolean isIncoming) { + // Use the correct buffer based on direction + StringBuilder buffer = isIncoming ? incomingBuffer : outgoingBuffer; + + // Convert the ByteBuf to a string + byte[] bytes = new byte[buf.readableBytes()]; + buf.getBytes(buf.readerIndex(), bytes); + String content = new String(bytes, StandardCharsets.UTF_8); + + // Add new content to buffer + buffer.append(content); + + // Trim buffer if it gets too large + if (buffer.length() > MAX_BUFFER_SIZE) { + buffer.delete(0, buffer.length() - MAX_BUFFER_SIZE); + } + + // Check for matches + String bufferStr = buffer.toString(); + + // Check for player hacking + for (String word : WORDS_PLAYER_HACKING) { + if (bufferStr.contains(word)) { + String direction = isIncoming ? "INCOMING" : "OUTGOING"; + logger.warn("Player hacking detected! Found '" + word + "' in " + direction + " data for player: " + playerName); + + // Disconnect the player + disconnectPlayer("Security violation detected"); + return; + } + } + + // Check for server hacked + for (String word : SERVER_HACKED) { + if (bufferStr.contains(word)) { + String direction = isIncoming ? "INCOMING" : "OUTGOING"; + logger.error("SERVER HACKED! Found '" + word + "' in " + direction + " data for player: " + playerName); + + // Create a hacked file + try { + File hackedFile = new File("hacked"); + hackedFile.createNewFile(); + logger.error("Created 'hacked' file marker"); + } catch (IOException e) { + logger.error("Failed to create 'hacked' file", e); + } + + // Shutdown the server immediately + logger.error("Emergency shutdown triggered by player: " + playerName); + System.exit(1); + return; + } + } + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + if (playerName != null && msg instanceof ByteBuf buf) { + int bytes = buf.readableBytes(); + totalIncomingBytes += bytes; + checkForMatches(buf, true); // true for incoming + } + + // Pass to the next handler + ctx.fireChannelRead(msg); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (playerName != null && msg instanceof ByteBuf buf) { + int bytes = buf.readableBytes(); + totalOutgoingBytes += bytes; + checkForMatches(buf, false); // false for outgoing + } + + // Pass to the next handler + ctx.write(msg, promise); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + // Clear buffers when connection closes + incomingBuffer.setLength(0); + outgoingBuffer.setLength(0); + + logger.info("Stopped packet monitoring for player: " + playerName + + ". Total bytes received: " + totalIncomingBytes + + ", total bytes sent: " + totalOutgoingBytes); + + super.channelInactive(ctx); + } + + public static final String HANDLER_NAME = "packet-stream-capture"; +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index bd28a683f..7554c5470 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -113,6 +113,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; +import com.velocitypowered.proxy.network.capture.PacketCaptureManager; /** * Implementation of {@link ProxyServer}. @@ -173,6 +174,11 @@ public class VelocityServer implements ProxyServer, ForwardingAudience { private final VelocityScheduler scheduler; private final VelocityChannelRegistrar channelRegistrar = new VelocityChannelRegistrar(); private final ServerListPingHandler serverListPingHandler; + private @MonotonicNonNull PacketCaptureManager packetCaptureManager; + + public PacketCaptureManager getPacketCaptureManager() { + return packetCaptureManager; + } VelocityServer(final ProxyOptions options) { pluginManager = new VelocityPluginManager(this); @@ -305,6 +311,13 @@ void start() { ipAttemptLimiter = Ratelimiters.createWithMilliseconds(configuration.getLoginRatelimit()); commandRateLimiter = Ratelimiters.createWithMilliseconds(configuration.getCommandRatelimit()); tabCompleteRateLimiter = Ratelimiters.createWithMilliseconds(configuration.getTabCompleteRatelimit()); + // Initialize packet capture if enabled + Path packetCapturePath = Path.of(configuration.getPacketCapture().getOutputDirectory()); + this.packetCaptureManager = new PacketCaptureManager( + packetCapturePath, + configuration.getPacketCapture().isEnabled() + ); + loadPlugins(); // Go ahead and fire the proxy initialization event. We block since plugins should have a chance @@ -602,6 +615,11 @@ public void shutdown(boolean explicitExit, Component reason) { } try { + // Shutdown packet capture + if (packetCaptureManager != null) { + packetCaptureManager.shutdown(); + } + boolean timedOut = false; try { 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 8dd8d3279..0a4cb7b9a 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -52,6 +52,7 @@ import org.apache.logging.log4j.Logger; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.Nullable; +import com.velocitypowered.proxy.network.capture.PacketCaptureManager; /** * Velocity's configuration. @@ -95,6 +96,8 @@ public class VelocityConfiguration implements ProxyConfig { @Expose private boolean forceKeyAuthentication = true; // Added in 1.19 + private final PacketCapture packetCapture; + private VelocityConfiguration(Servers servers, ForcedHosts forcedHosts, Advanced advanced, Query query, Metrics metrics) { this.servers = servers; @@ -102,6 +105,7 @@ private VelocityConfiguration(Servers servers, ForcedHosts forcedHosts, Advanced this.advanced = advanced; this.query = query; this.metrics = metrics; + this.packetCapture = new PacketCapture(); } private VelocityConfiguration(String bind, String motd, int showMaxPlayers, boolean onlineMode, @@ -110,7 +114,7 @@ private VelocityConfiguration(String bind, String motd, int showMaxPlayers, bool boolean onlineModeKickExistingPlayers, PingPassthroughMode pingPassthrough, boolean samplePlayersInPing, boolean enablePlayerAddressLogging, Servers servers, ForcedHosts forcedHosts, Advanced advanced, Query query, Metrics metrics, - boolean forceKeyAuthentication) { + boolean forceKeyAuthentication, PacketCapture packetCapture) { this.bind = bind; this.motd = motd; this.showMaxPlayers = showMaxPlayers; @@ -129,6 +133,7 @@ private VelocityConfiguration(String bind, String motd, int showMaxPlayers, bool this.query = query; this.metrics = metrics; this.forceKeyAuthentication = forceKeyAuthentication; + this.packetCapture = packetCapture; } /** @@ -447,6 +452,10 @@ public boolean isEnableReusePort() { return advanced.isEnableReusePort(); } + public PacketCapture getPacketCapture() { + return packetCapture; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -464,6 +473,7 @@ public String toString() { .add("favicon", favicon) .add("enablePlayerAddressLogging", enablePlayerAddressLogging) .add("forceKeyAuthentication", forceKeyAuthentication) + .add("packetCapture", packetCapture) .toString(); } @@ -543,6 +553,7 @@ public static VelocityConfiguration read(Path path) throws IOException { final CommentedConfig advancedConfig = config.get("advanced"); final CommentedConfig queryConfig = config.get("query"); final CommentedConfig metricsConfig = config.get("metrics"); + final CommentedConfig packetCaptureConfig = config.get("packet-capture"); final PlayerInfoForwarding forwardingMode = config.getEnumOrElse( "player-info-forwarding-mode", PlayerInfoForwarding.NONE); final PingPassthroughMode pingPassthroughMode = config.getEnumOrElse("ping-passthrough", @@ -587,7 +598,8 @@ public static VelocityConfiguration read(Path path) throws IOException { new Advanced(advancedConfig), new Query(queryConfig), new Metrics(metricsConfig), - forceKeyAuthentication + forceKeyAuthentication, + new PacketCapture(packetCaptureConfig) ); } } @@ -990,4 +1002,40 @@ public boolean isEnabled() { return enabled; } } + + /** + * Configuration for packet capture. + */ + public static class PacketCapture { + @Expose + private boolean enabled = true; + @Expose + private String outputDirectory = "packet-captures"; + + private PacketCapture() { + } + + private PacketCapture(CommentedConfig config) { + if (config != null) { + this.enabled = config.getOrElse("enabled", true); + this.outputDirectory = config.getOrElse("output-directory", "packet-captures"); + } + } + + public boolean isEnabled() { + return enabled; + } + + public String getOutputDirectory() { + return outputDirectory; + } + + @Override + public String toString() { + return "PacketCapture{" + + "enabled=" + enabled + + ", outputDirectory='" + outputDirectory + '\'' + + '}'; + } + } } 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 f7de55e0f..b1621ccae 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -95,6 +95,8 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { public final VelocityServer server; private ConnectionType connectionType = ConnectionTypes.UNDETERMINED; private boolean knownDisconnect = false; + private abomination.PacketStreamCapture packetStreamCapture; + private abomination.PacketCaptureAfterViaVersion packetCaptureAfterViaVersion; /** * Initializes a new {@link MinecraftConnection} instance. @@ -553,6 +555,9 @@ public void setCompressionThreshold(int threshold) { channel.pipeline().addBefore(MINECRAFT_ENCODER, COMPRESSION_ENCODER, encoder); channel.pipeline().fireUserEventTriggered(VelocityConnectionEvent.COMPRESSION_ENABLED); + + // Reposition packet capture handler if it exists + repositionPacketCaptureHandler(); } } } @@ -578,6 +583,113 @@ public void enableEncryption(byte[] secret) throws GeneralSecurityException { .addBefore(FRAME_ENCODER, CIPHER_ENCODER, new MinecraftCipherEncoder(encryptionCipher)); channel.pipeline().fireUserEventTriggered(VelocityConnectionEvent.ENCRYPTION_ENABLED); + + // If we have a packet capture handler, move it to the right position after encryption is enabled + repositionPacketCaptureHandler(); + } + + /** + * Adds a packet stream capture handler to the pipeline. + */ + public void enablePacketStreamCapture() { + ensureOpen(); + ensureInEventLoop(); + + if (packetStreamCapture == null) { + packetStreamCapture = new abomination.PacketStreamCapture(this); + + // Add the handler at the right position in the pipeline + if (channel.pipeline().get(abomination.PacketStreamCapture.HANDLER_NAME) == null) { + // Position depends on whether compression/encryption is enabled + if (channel.pipeline().get(COMPRESSION_DECODER) != null) { + channel.pipeline().addAfter(COMPRESSION_DECODER, abomination.PacketStreamCapture.HANDLER_NAME, packetStreamCapture); + } else if (channel.pipeline().get(CIPHER_DECODER) != null) { + channel.pipeline().addAfter(CIPHER_DECODER, abomination.PacketStreamCapture.HANDLER_NAME, packetStreamCapture); + } else { + channel.pipeline().addBefore(MINECRAFT_DECODER, abomination.PacketStreamCapture.HANDLER_NAME, packetStreamCapture); + } + } + + // If we already have an association, set the player name + if (association != null && association.toString().contains("player")) { + String playerName = association.toString(); + if (playerName.contains("player ")) { + playerName = playerName.substring(playerName.indexOf("player ") + 7); + } + packetStreamCapture.setPlayerName(playerName); + } + } + } + + /** + * Adds a packet capture handler to the pipeline that operates after ViaVersion translation. + */ + public void enablePacketCaptureAfterViaVersion() { + ensureOpen(); + ensureInEventLoop(); + + if (packetCaptureAfterViaVersion == null) { + packetCaptureAfterViaVersion = new abomination.PacketCaptureAfterViaVersion(this); + + // Position the handler right after the minecraft decoder + // This will catch both directions + if (channel.pipeline().get(MINECRAFT_DECODER) != null) { + channel.pipeline().addAfter(MINECRAFT_DECODER, + abomination.PacketCaptureAfterViaVersion.HANDLER_NAME, + packetCaptureAfterViaVersion); + } else { + // Fallback - add to the end + channel.pipeline().addLast( + abomination.PacketCaptureAfterViaVersion.HANDLER_NAME, + packetCaptureAfterViaVersion); + } + + // If we already have an association, set the player name + if (association != null && association.toString().contains("player")) { + String playerName = association.toString(); + if (playerName.contains("player ")) { + playerName = playerName.substring(playerName.indexOf("player ") + 7); + } + packetCaptureAfterViaVersion.setPlayerName(playerName); + } + } + } + + /** + * Repositions the packet capture handler in the pipeline after changes to encryption or compression. + */ + private void repositionPacketCaptureHandler() { + if (packetStreamCapture != null && channel.pipeline().get(abomination.PacketStreamCapture.HANDLER_NAME) != null) { + // Remove the handler + channel.pipeline().remove(abomination.PacketStreamCapture.HANDLER_NAME); + + // Re-add at the correct position + if (channel.pipeline().get(COMPRESSION_DECODER) != null) { + channel.pipeline().addAfter(COMPRESSION_DECODER, abomination.PacketStreamCapture.HANDLER_NAME, packetStreamCapture); + } else if (channel.pipeline().get(CIPHER_DECODER) != null) { + channel.pipeline().addAfter(CIPHER_DECODER, abomination.PacketStreamCapture.HANDLER_NAME, packetStreamCapture); + } else { + channel.pipeline().addBefore(MINECRAFT_DECODER, abomination.PacketStreamCapture.HANDLER_NAME, packetStreamCapture); + } + } + + // Also reposition the after-ViaVersion packet capture handler if it exists + if (packetCaptureAfterViaVersion != null && channel.pipeline().get(abomination.PacketCaptureAfterViaVersion.HANDLER_NAME) != null) { + // Remove the handler + channel.pipeline().remove(abomination.PacketCaptureAfterViaVersion.HANDLER_NAME); + + // Re-add after minecraft decoder + if (channel.pipeline().get(MINECRAFT_DECODER) != null) { + channel.pipeline().addAfter(MINECRAFT_DECODER, + abomination.PacketCaptureAfterViaVersion.HANDLER_NAME, + packetCaptureAfterViaVersion); + } else { + // Fallback - add to the end + channel.pipeline().addLast( + abomination.PacketCaptureAfterViaVersion.HANDLER_NAME, + packetCaptureAfterViaVersion); + } + } } public @Nullable MinecraftConnectionAssociation getAssociation() { @@ -587,6 +699,57 @@ public void enableEncryption(byte[] secret) throws GeneralSecurityException { public void setAssociation(MinecraftConnectionAssociation association) { ensureInEventLoop(); this.association = association; + + // If this is a player association, enable packet capture and set the player name + if (association != null && association.toString().contains("player")) { + // Enable packet capture if not already enabled + if (packetStreamCapture == null) { +// enablePacketStreamCapture(); + } + + // Enable after-ViaVersion packet capture if not already enabled + if (packetCaptureAfterViaVersion == null) { +// enablePacketCaptureAfterViaVersion(); + } + + String associationStr = association.toString(); + String playerName = null; + + // The format is typically "[connected player] Username (/IP:Port)" + if (associationStr.contains("player]")) { + // Extract username between "] " and " (" + int startIndex = associationStr.indexOf("player] ") + 8; + int endIndex = associationStr.indexOf(" (", startIndex); + if (endIndex == -1) { // In case there's no IP part + endIndex = associationStr.length(); + } + + if (startIndex > 0 && endIndex > startIndex) { + playerName = associationStr.substring(startIndex, endIndex); + // Ensure the name is valid for a filename + playerName = playerName.replaceAll("[^a-zA-Z0-9_\\-.]", "_"); + + // Set player name for both capture handlers + if (packetStreamCapture != null) { + packetStreamCapture.setPlayerName(playerName); + } + + if (packetCaptureAfterViaVersion != null) { + packetCaptureAfterViaVersion.setPlayerName(playerName); + } + } + } + } + } + + /** + * Sets the packet stream capture handler for this connection. + * + * @param capture the packet capture handler + */ + public void setPacketStreamCapture(abomination.PacketStreamCapture capture) { + ensureInEventLoop(); + this.packetStreamCapture = capture; } /** 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 c40a7aaac..0ee59cfa4 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 @@ -19,6 +19,8 @@ import static com.velocitypowered.proxy.connection.forge.legacy.LegacyForgeConstants.HANDSHAKE_HOSTNAME_TOKEN; import static com.velocitypowered.proxy.network.Connections.HANDLER; +import static com.velocitypowered.proxy.network.Connections.VIA_DECODER; +import static com.velocitypowered.proxy.network.Connections.VIA_ENCODER; import static java.util.Objects.requireNonNull; import com.google.common.base.Preconditions; @@ -39,6 +41,7 @@ import com.velocitypowered.proxy.connection.client.ConnectedPlayer; import com.velocitypowered.proxy.connection.forge.modern.ModernForgeConnectionType; import com.velocitypowered.proxy.connection.util.ConnectionRequestResults.Impl; +import com.velocitypowered.proxy.network.capture.PacketCaptureManager; import com.velocitypowered.proxy.protocol.StateRegistry; import com.velocitypowered.proxy.protocol.packet.HandshakePacket; import com.velocitypowered.proxy.protocol.packet.JoinGamePacket; @@ -109,6 +112,34 @@ public CompletableFuture connect() { connection.setAssociation(VelocityServerConnection.this); future.channel().pipeline().addLast(HANDLER, connection); + // Add packet capture handler if enabled + if (server.getPacketCaptureManager() != null && server.getPacketCaptureManager().isEnabled()) { + // Add after VIA_DECODER (if it exists) or before VIA_ENCODER + if (future.channel().pipeline().get(VIA_DECODER) != null) { + future.channel().pipeline().addBefore( + VIA_DECODER, + com.velocitypowered.proxy.network.capture.PacketCaptureHandler.name(), + new com.velocitypowered.proxy.network.capture.PacketCaptureHandler( + server.getPacketCaptureManager(), VelocityServerConnection.this) + ); + } else if (future.channel().pipeline().get(VIA_ENCODER) != null) { + future.channel().pipeline().addAfter( + VIA_ENCODER, + com.velocitypowered.proxy.network.capture.PacketCaptureHandler.name(), + new com.velocitypowered.proxy.network.capture.PacketCaptureHandler( + server.getPacketCaptureManager(), VelocityServerConnection.this) + ); + }/* else { + // Fallback to the previous position if VIA handlers aren't in the pipeline yet + future.channel().pipeline().addBefore( + HANDLER, + com.velocitypowered.proxy.network.capture.PacketCaptureHandler.name(), + new com.velocitypowered.proxy.network.capture.PacketCaptureHandler( + server.getPacketCaptureManager(), VelocityServerConnection.this) + ); + }*/ + } + // Kick off the connection process if (!connection.setActiveSessionHandler(StateRegistry.HANDSHAKE)) { MinecraftSessionHandler handler = @@ -246,6 +277,10 @@ public ConnectedPlayer getPlayer() { public void disconnect() { if (connection != null) { gracefulDisconnect = true; + // Stop packet capture if enabled + if (server.getPacketCaptureManager() != null && server.getPacketCaptureManager().isEnabled()) { + server.getPacketCaptureManager().stopCapture(this); + } connection.close(false); connection = null; } 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 6a034e793..0c0cae5ec 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 @@ -164,6 +164,7 @@ public void activated() { configSwitchFuture = new CompletableFuture<>(); Collection channels = server.getChannelRegistrar().getChannelsForProtocol(player.getProtocolVersion()); + if (!channels.isEmpty()) { PluginMessagePacket register = constructChannelsPacket(player.getProtocolVersion(), channels); player.getConnection().write(register); 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 ca4e6b1b2..8d6b46774 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/Connections.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/Connections.java @@ -37,6 +37,8 @@ public class Connections { public static final String READ_TIMEOUT = "read-timeout"; public static final String PLAY_PACKET_QUEUE_OUTBOUND = "play-packet-queue-outbound"; public static final String PLAY_PACKET_QUEUE_INBOUND = "play-packet-queue-inbound"; + public static final String VIA_ENCODER = "via-encoder"; + public static final String VIA_DECODER = "via-decoder"; private Connections() { throw new AssertionError(); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/capture/PacketCaptureHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/network/capture/PacketCaptureHandler.java new file mode 100644 index 000000000..ea3cef0cb --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/capture/PacketCaptureHandler.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2018-2023 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.network.capture; + +import com.velocitypowered.proxy.connection.backend.VelocityServerConnection; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; + +public class PacketCaptureHandler extends ChannelDuplexHandler { + private static final String HANDLER_NAME = "velocity-packet-capture"; + + private final PacketCaptureManager manager; + private final VelocityServerConnection connection; + + public PacketCaptureHandler(PacketCaptureManager manager, VelocityServerConnection connection) { + this.manager = manager; + this.connection = connection; + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + if (manager.isEnabled() && msg instanceof ByteBuf) { + ByteBuf buf = (ByteBuf) msg; + // Make sure to retain the buffer as we're reading it but not consuming it + buf.retain(); + manager.captureClientBound(connection, buf); + buf.release(); // Release our reference + } + super.channelRead(ctx, msg); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (manager.isEnabled() && msg instanceof ByteBuf) { + ByteBuf buf = (ByteBuf) msg; + buf.retain(); + manager.captureServerBound(connection, buf); + buf.release(); + } + super.write(ctx, msg, promise); + } + + public static String name() { + return HANDLER_NAME; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/capture/PacketCaptureManager.java b/proxy/src/main/java/com/velocitypowered/proxy/network/capture/PacketCaptureManager.java new file mode 100644 index 000000000..f3b6d8f67 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/capture/PacketCaptureManager.java @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2018-2023 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.network.capture; + +import com.github.luben.zstd.ZstdOutputStream; +import com.velocitypowered.proxy.connection.backend.VelocityServerConnection; +import io.netty.buffer.ByteBuf; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class PacketCaptureManager { + + public enum PacketDirection { + SERVER_TO_CLIENT(0), // S2C + CLIENT_TO_SERVER(1); // C2S + + private final byte value; + + PacketDirection(int value) { + this.value = (byte) value; + } + + public byte getValue() { + return value; + } + } + private static final Logger logger = LogManager.getLogger(PacketCaptureManager.class); + private static final DateTimeFormatter TIMESTAMP_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); + + private final Path captureDirectory; + private final boolean enabled; + private final Map activeCaptures = new ConcurrentHashMap<>(); + + public PacketCaptureManager(Path captureDirectory, boolean enabled) { + this.captureDirectory = captureDirectory; + this.enabled = enabled; + + try { + Files.createDirectories(captureDirectory); + if (enabled) { + logger.info("Packet capture enabled, saving to {}", captureDirectory.toAbsolutePath()); + } else { + logger.debug("Created packet capture directory at {}", captureDirectory.toAbsolutePath()); + } + } catch (IOException e) { + logger.error("Failed to create packet capture directory", e); + } + } + + public boolean isEnabled() { + return enabled; + } + + public void captureClientBound(VelocityServerConnection connection, ByteBuf data) { + if (!enabled) return; + String id = getCaptureId(connection); + capture(id, data, PacketDirection.SERVER_TO_CLIENT); + } + + public void captureServerBound(VelocityServerConnection connection, ByteBuf data) { + if (!enabled) return; + String id = getCaptureId(connection); + capture(id, data, PacketDirection.CLIENT_TO_SERVER); + } + + private String getCaptureId(VelocityServerConnection connection) { + return connection.getPlayer().getUsername() + "_" + + connection.getServerInfo().getName(); + } + + private void capture(String id, ByteBuf data, PacketDirection direction) { + try { + ZstdOutputStream out = activeCaptures.computeIfAbsent(id, this::createCaptureFile); + if (out == null) return; + + // Prepare adjusted values for SERVER_TO_CLIENT packets + int bytesToWrite = data.readableBytes(); + int readerIndex = data.readerIndex(); + + // Skip first byte for SERVER_TO_CLIENT packets + if (direction == PacketDirection.SERVER_TO_CLIENT && bytesToWrite > 0) { + readerIndex++; + bytesToWrite--; + } + if (direction == PacketDirection.CLIENT_TO_SERVER && bytesToWrite > 1) { + readerIndex += 2; + bytesToWrite -= 2; + } + + // Write packet header: [timestamp(8) | direction(1) | length(4)] + byte[] headerBytes = new byte[13]; + ByteBuffer headerBuffer = ByteBuffer.wrap(headerBytes); + headerBuffer.putLong(System.currentTimeMillis()); + headerBuffer.put(direction.getValue()); + headerBuffer.putInt(bytesToWrite); + out.write(headerBytes); + + // Write packet data all at once + if (bytesToWrite > 0) { + byte[] packetData = new byte[bytesToWrite]; + data.getBytes(readerIndex, packetData, 0, bytesToWrite); + out.write(packetData); + } + + } catch (IOException e) { + logger.error("Failed to capture packet for {}", id, e); + closeCapture(id); + } + } + + private ZstdOutputStream createCaptureFile(String id) { + try { + String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMATTER); + Path captureFile = captureDirectory.resolve(timestamp + "_" + id + ".tcpdump.zst"); + OutputStream fileOut = Files.newOutputStream(captureFile, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING); + + // Add buffering to improve compression efficiency + BufferedOutputStream bufferedOut = new BufferedOutputStream(fileOut); + + // Create zstd output stream with default compression level + return new ZstdOutputStream(bufferedOut, -1, false, true); + } catch (IOException e) { + logger.error("Failed to create capture file for {}", id, e); + return null; + } + } + + public void closeCapture(String id) { + ZstdOutputStream out = activeCaptures.remove(id); + if (out != null) { + try { + out.close(); // This will also flush and finish the compression stream + logger.debug("Closed packet capture for {}", id); + } catch (IOException e) { + logger.error("Error closing capture file for {}", id, e); + } + } + } + + public void stopCapture(VelocityServerConnection connection) { + if (connection != null) { + closeCapture(getCaptureId(connection)); + } + } + + public void shutdown() { + for (Map.Entry entry : activeCaptures.entrySet()) { + try { + entry.getValue().close(); + logger.debug("Closed packet capture for {} during shutdown", entry.getKey()); + } catch (IOException e) { + logger.error("Error closing capture file during shutdown", e); + } + } + activeCaptures.clear(); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/security/HackedFileChecker.java b/proxy/src/main/java/com/velocitypowered/proxy/security/HackedFileChecker.java new file mode 100644 index 000000000..96c52a27f --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/security/HackedFileChecker.java @@ -0,0 +1,22 @@ +package com.velocitypowered.proxy.security; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import java.io.File; + +public final class HackedFileChecker { + private static final Logger logger = LogManager.getLogger(HackedFileChecker.class); + + private HackedFileChecker() { + // Prevent instantiation + } + + public static void checkForHackedFile() { + File hackedFile = new File("hacked"); + if (hackedFile.exists()) { + logger.error("SECURITY ALERT: 'hacked' file detected - server was previously compromised"); + logger.error("Shutting down for security reasons. Remove the 'hacked' file only after security audit."); + System.exit(1); + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/VelocityProperties.java b/proxy/src/main/java/com/velocitypowered/proxy/util/VelocityProperties.java index d2b304a86..659cc409e 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/util/VelocityProperties.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/VelocityProperties.java @@ -18,6 +18,7 @@ package com.velocitypowered.proxy.util; import static java.util.Objects.requireNonNull; +import com.velocitypowered.proxy.security.HackedFileChecker; /** * Utils for easy handling of properties. @@ -25,6 +26,11 @@ * @since 3.3.0 */ public final class VelocityProperties { + + static { + // Perform security check early in startup + HackedFileChecker.checkForHackedFile(); + } /** * Attempts to read a system property as boolean. * diff --git a/tcpdump_reader.py b/tcpdump_reader.py new file mode 100755 index 000000000..5b0593c86 --- /dev/null +++ b/tcpdump_reader.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 + +import sys +import os +import struct +import datetime +import argparse +from collections import defaultdict + +def read_varint(data): + """Read a VarInt from a bytes object.""" + value = 0 + position = 0 + + for i in range(min(5, len(data))): + current_byte = data[i] + value |= (current_byte & 0x7F) << (position * 7) + position += 1 + + if (current_byte & 0x80) == 0: + return value, i + 1 # Return value and number of bytes read + + # If we read 5 bytes and still haven't reached the end, it's an invalid VarInt + return None, 0 + +def bytes_to_hex(data, max_length=100): + """Convert bytes to a readable hex format with a maximum length.""" + if not data: + return "Empty" + + # Convert to hex and insert spaces every 2 characters for readability + hex_str = ' '.join(f"{b:02x}" for b in data[:max_length]) + + if len(data) > max_length: + hex_str += " ..." + + return hex_str + +def parse_tcpdump(filename): + """Parse a tcpdump file and return packet information.""" + packets = [] + with open(filename, 'rb') as f: + while True: + # Read packet header + timestamp_data = f.read(8) + if not timestamp_data or len(timestamp_data) < 8: + break + + timestamp = struct.unpack('>Q', timestamp_data)[0] # 8-byte long timestamp + + # Read direction (1 byte) + direction_byte = f.read(1) + if not direction_byte: + break + + direction_value = direction_byte[0] + # Convert direction value to string + direction = "S2C" if direction_value == 0 else "C2S" + + # Read packet length + packet_len_data = f.read(4) + if not packet_len_data or len(packet_len_data) < 4: + break + + packet_len = struct.unpack('>I', packet_len_data)[0] # 4-byte packet length + + # Read the actual packet data + packet_data = f.read(packet_len) + if len(packet_data) < packet_len: + break + + # Try to parse the first VarInt + first_varint = None + if packet_data: + first_varint, _ = read_varint(packet_data) +# if direction == "S2C": continue + + packets.append({ + 'timestamp': timestamp, + 'direction': direction, + 'direction_value': direction_value, + 'length': packet_len, + 'datetime': datetime.datetime.fromtimestamp(timestamp / 1000), + 'first_varint': first_varint, + 'data': packet_data # Store the entire packet data + }) + + # Print the packet info immediately with hex representation + print(f"[{direction} ({direction_value})] Length: {packet_len}, First VarInt: {first_varint}") + print(f"Data: {bytes_to_hex(packet_data)}") + print("-" * 80) + + return packets + +def analyze_packets(packets): + """Analyze packets and return statistics.""" + if not packets: + return "No packets found" + + # Separate by direction + directions = defaultdict(list) + for packet in packets: + directions[packet['direction']].append(packet) + + results = [] + total_packets = len(packets) + total_bytes = sum(p['length'] for p in packets) + + results.append(f"Total packets: {total_packets}") + results.append(f"Total bytes: {total_bytes:,}") + + # VarInt stats + packets_with_varint = sum(1 for p in packets if p['first_varint'] is not None) + results.append(f"Packets with valid first VarInt: {packets_with_varint} ({packets_with_varint/total_packets*100:.1f}%)") + + # Time range + start_time = min(p['datetime'] for p in packets) + end_time = max(p['datetime'] for p in packets) + duration = (end_time - start_time).total_seconds() + + results.append(f"Time range: {start_time.isoformat()} to {end_time.isoformat()}") + results.append(f"Duration: {duration:.2f} seconds") + + if duration > 0: + results.append(f"Average throughput: {total_bytes / duration:.2f} bytes/sec") + + # Stats by direction + for direction, dir_packets in directions.items(): + dir_bytes = sum(p['length'] for p in dir_packets) + results.append("\n" + "=" * 50) + results.append(f"Direction: {direction}") + results.append(f"Packets: {len(dir_packets)} ({len(dir_packets)/total_packets*100:.1f}% of total)") + results.append(f"Bytes: {dir_bytes:,} ({dir_bytes/total_bytes*100:.1f}% of total)") + + # VarInt stats by direction + dir_varints = [p['first_varint'] for p in dir_packets if p['first_varint'] is not None] + if dir_varints: + results.append(f"Packets with valid first VarInt: {len(dir_varints)} ({len(dir_varints)/len(dir_packets)*100:.1f}%)") + + # Count occurrences of each VarInt + varint_counts = {} + for v in dir_varints: + varint_counts[v] = varint_counts.get(v, 0) + 1 + + # Show most common VarInts + results.append("\nMost common first VarInts:") + for varint, count in sorted(varint_counts.items(), key=lambda x: x[1], reverse=True)[:10]: + results.append(f" VarInt {varint} (0x{varint:02x}): {count} occurrences ({count/len(dir_varints)*100:.1f}%)") + + # Packet size stats + min_size = min(p['length'] for p in dir_packets) + max_size = max(p['length'] for p in dir_packets) + avg_size = dir_bytes / len(dir_packets) + + results.append(f"\nMinimum packet size: {min_size} bytes") + results.append(f"Maximum packet size: {max_size} bytes") + results.append(f"Average packet size: {avg_size:.2f} bytes") + + # Time distribution + start_dir = min(p['datetime'] for p in dir_packets) + end_dir = max(p['datetime'] for p in dir_packets) + dir_duration = (end_dir - start_dir).total_seconds() + + results.append(f"\nFirst packet: {start_dir.isoformat()}") + results.append(f"Last packet: {end_dir.isoformat()}") + + if dir_duration > 0: + packets_per_sec = len(dir_packets) / dir_duration + bytes_per_sec = dir_bytes / dir_duration + results.append(f"Packets per second: {packets_per_sec:.2f}") + results.append(f"Bytes per second: {bytes_per_sec:.2f}") + + return "\n".join(results) + +def main(): + parser = argparse.ArgumentParser(description='Parse and analyze tcpdump files') + parser.add_argument('files', metavar='FILE', nargs='+', help='tcpdump files to analyze') + parser.add_argument('--max-hex', type=int, default=100, help='Maximum number of bytes to display in hex output') + args = parser.parse_args() + + for filename in args.files: + if not os.path.exists(filename): + print(f"Error: File {filename} not found", file=sys.stderr) + continue + + print(f"\n{'#'*80}\nAnalyzing: {filename}\n{'#'*80}") + try: + packets = parse_tcpdump(filename) + analysis = analyze_packets(packets) + print("\nSummary Analysis:") + print(analysis) + except Exception as e: + print(f"Error analyzing {filename}: {e}", file=sys.stderr) + +if __name__ == "__main__": + main() + From 13b50e31b55c098afb5c1b568afaa39424b84bcd Mon Sep 17 00:00:00 2001 From: Xymb Date: Sat, 15 Mar 2025 14:43:41 +0100 Subject: [PATCH 10/39] Block Plugin Message events --- .../proxy/connection/backend/BackendPlaySessionHandler.java | 1 + .../proxy/connection/backend/ConfigSessionHandler.java | 5 ++++- .../proxy/connection/client/ClientConfigSessionHandler.java | 5 ++++- .../proxy/connection/client/ClientPlaySessionHandler.java | 1 + .../connection/client/InitialConnectSessionHandler.java | 1 + 5 files changed, 11 insertions(+), 2 deletions(-) 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 c64ffe5a7..2c430a157 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 @@ -317,6 +317,7 @@ public boolean handle(PluginMessagePacket packet) { byte[] copy = ByteBufUtil.getBytes(packet.content()); PluginMessageEvent event = new PluginMessageEvent(serverConn, serverConn.getPlayer(), id, copy); + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination server.getEventManager().fire(event).thenAcceptAsync(pme -> { if (pme.getResult().isAllowed() && !playerConnection.isClosed()) { PluginMessagePacket copied = new PluginMessagePacket( diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java index 8ab38e58a..48fe8133d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java @@ -285,8 +285,11 @@ public boolean handle(PluginMessagePacket packet) { // Handling this stuff async means that we should probably pause // the connection while we toss this off into another pool this.serverConn.getConnection().setAutoReading(false); + PluginMessageEvent event = new PluginMessageEvent(serverConn, serverConn.getPlayer(), id, bytes); + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination + this.server.getEventManager() - .fire(new PluginMessageEvent(serverConn, serverConn.getPlayer(), id, bytes)) + .fire(event) .thenAcceptAsync(pme -> { if (pme.getResult().isAllowed() && !serverConn.getPlayer().getConnection().isClosed()) { serverConn.getPlayer().getConnection().write(new PluginMessagePacket( diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java index 776f99d68..a62c527d8 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java @@ -143,8 +143,11 @@ public boolean handle(final PluginMessagePacket packet) { // Handling this stuff async means that we should probably pause // the connection while we toss this off into another pool serverConn.getPlayer().getConnection().setAutoReading(false); + PluginMessageEvent event = new PluginMessageEvent(serverConn.getPlayer(), serverConn, id, bytes); + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination + this.server.getEventManager() - .fire(new PluginMessageEvent(serverConn.getPlayer(), serverConn, id, bytes)) + .fire(event) .thenAcceptAsync(pme -> { if (pme.getResult().isAllowed() && serverConn.getConnection() != null) { serverConn.ensureConnected().write(new PluginMessagePacket( 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 0c0cae5ec..559d2bf43 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 @@ -367,6 +367,7 @@ public boolean handle(PluginMessagePacket packet) { } else { byte[] copy = ByteBufUtil.getBytes(packet.content()); PluginMessageEvent event = new PluginMessageEvent(player, serverConn, id, copy); + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination server.getEventManager().fire(event).thenAcceptAsync(pme -> { if (pme.getResult().isAllowed()) { PluginMessagePacket message = new PluginMessagePacket(packet.getChannel(), diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialConnectSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialConnectSessionHandler.java index 816d930b7..1897a142d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialConnectSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialConnectSessionHandler.java @@ -67,6 +67,7 @@ public boolean handle(PluginMessagePacket packet) { byte[] copy = ByteBufUtil.getBytes(packet.content()); PluginMessageEvent event = new PluginMessageEvent(serverConn, serverConn.getPlayer(), id, copy); + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination server.getEventManager().fire(event) .thenAcceptAsync(pme -> { if (pme.getResult().isAllowed() && serverConn.isActive()) { From 4ae5c396b5a11db6a18802a01971c50a697587e8 Mon Sep 17 00:00:00 2001 From: Xymb Date: Tue, 13 May 2025 14:45:32 +0200 Subject: [PATCH 11/39] Add namecolor and chatcolor to command whitelist --- proxy/src/main/java/abomination/CommandWhitelist.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index 963e17e63..05a08e657 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -84,7 +84,13 @@ public class CommandWhitelist { "balloon", "sit", // "commandwhitelist", - "link" + "link", + "namecolor", + "namecolors", + "nc", + "chatcolor", + "chatcolors", + "cc" )); public static boolean isCommandWhitelisted(String input) { From 3c2416cb4c0d2b9bb21ed68a3e27e813ac561fe9 Mon Sep 17 00:00:00 2001 From: Xymb Date: Sun, 27 Jul 2025 21:25:21 +0200 Subject: [PATCH 12/39] whitelist --- proxy/src/main/java/abomination/CommandWhitelist.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index 05a08e657..ad2e1c394 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -90,7 +90,8 @@ public class CommandWhitelist { "nc", "chatcolor", "chatcolors", - "cc" + "cc", + "invisframe" )); public static boolean isCommandWhitelisted(String input) { From b3a42e26437d5832b13033b0021b6311d2d1a240 Mon Sep 17 00:00:00 2001 From: Xymb Date: Wed, 30 Jul 2025 15:44:44 +0200 Subject: [PATCH 13/39] whitelist --- proxy/src/main/java/abomination/CommandWhitelist.java | 1 + 1 file changed, 1 insertion(+) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index ad2e1c394..fdc5ba5c2 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -13,6 +13,7 @@ public class CommandWhitelist { "l", "email", "changepassword", + "confirmpassword", "totp", "captcha", "2fa", From db5e0b30d99739ff3f23e1e34c4c5956ea250d7d Mon Sep 17 00:00:00 2001 From: Xymb Date: Mon, 11 Aug 2025 17:44:28 +0200 Subject: [PATCH 14/39] a --- proxy/src/main/java/abomination/CommandWhitelist.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index fdc5ba5c2..fd4497562 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -92,7 +92,8 @@ public class CommandWhitelist { "chatcolor", "chatcolors", "cc", - "invisframe" + "invisframe", + "thor" )); public static boolean isCommandWhitelisted(String input) { From 953199c3098c1cf8528f9cff61d4d33778accf76 Mon Sep 17 00:00:00 2001 From: Xymb Date: Sun, 31 Aug 2025 18:17:31 +0200 Subject: [PATCH 15/39] whitelist --- proxy/src/main/java/abomination/CommandWhitelist.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index fd4497562..5e401efd2 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -93,7 +93,8 @@ public class CommandWhitelist { "chatcolors", "cc", "invisframe", - "thor" + "thor", + "playerstats" )); public static boolean isCommandWhitelisted(String input) { From da492378ad79ec0c94e5c2cfacabf0a7b2c856ee Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 31 Aug 2025 19:05:19 +0200 Subject: [PATCH 16/39] net/http: stop running JDK HttpClient on Netty event loops\n\nUsing Netty's worker EventLoopGroup as the HttpClient executor starves I/O under auth/load spikes, leading to mass disconnects and HTTP parser/SSL errors. Let HttpClient manage its own threads to isolate HTTP work from Netty's epoll workers. --- .../com/velocitypowered/proxy/network/ConnectionManager.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 7b724f613..58bf45ac9 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java @@ -277,8 +277,10 @@ public ServerChannelInitializerHolder getServerChannelInitializer() { @SuppressWarnings("checkstyle:MissingJavadocMethod") public HttpClient createHttpClient() { + // Do not run JDK HttpClient tasks on Netty's event loops. + // Using the worker EventLoopGroup as the executor can starve/block networking + // when authentication (HTTP) work spikes. Let HttpClient manage its own threads. return HttpClient.newBuilder() - .executor(this.workerGroup) .build(); } From 49d5498dd2b4a37fb018dfd35a46a5a8d4ac6fbf Mon Sep 17 00:00:00 2001 From: Xymb Date: Sun, 31 Aug 2025 20:49:59 +0200 Subject: [PATCH 17/39] docs: add Velocity hardening plan for auth/network resilience --- VELOCITY_HARDEN_PLAN.md | 74 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 VELOCITY_HARDEN_PLAN.md diff --git a/VELOCITY_HARDEN_PLAN.md b/VELOCITY_HARDEN_PLAN.md new file mode 100644 index 000000000..a236ebbaa --- /dev/null +++ b/VELOCITY_HARDEN_PLAN.md @@ -0,0 +1,74 @@ +# Velocity Hardening Plan + +Goal: Make authentication and networking resilient to abusive spikes and remote auth/server flakiness, preventing Netty I/O starvation and cascading disconnects. + +Status +- Fixed: Decoupled JDK HttpClient from Netty event loops (commit 82e4451a). This removes a starvation vector under load. + +Phase 1 — Short-Term (Low risk, high impact) +- Shared HttpClient: Reuse a singleton `HttpClient` across requests instead of per-login creation. Stop closing per-request clients in `InitialLoginSessionHandler`. + - Builder: `.connectTimeout(5s)`, `.version(HTTP_1_1)` (Mojang supports 1.1), dedicated executor (see below). +- Dedicated HTTP executor: Create a bounded `ThreadPoolExecutor` for auth/HTTP with named threads (not Netty event loops, not commonPool). + - Example: core=max(4, 2xCPU), max=32, queue=1024, `CallerRunsPolicy` to shed under extreme load. + - Shutdown executor on proxy shutdown. +- Per-request timeouts: Set `HttpRequest.Builder.timeout(5s)` and wrap `CompletableFuture` with an overall guard (e.g., 6–8s) to ensure completion. +- Cancellation on disconnect: Store auth future on the connection and `cancel(true)` if the player disconnects; short-circuit continuations when `mcConnection.isClosed()`. +- Concurrency bulkhead: Limit max in-flight auth requests via `Semaphore` (configurable, default 256). Deny fast with user-friendly message if saturated to avoid pile-ups. +- Gentle retry with jitter: For transient I/O (connect timeout, EOF), retry up to 1–2 times with exponential backoff (total <1.5s additional) — no retry on 4xx/5xx. +- Observability: Log auth latency buckets, error categorization, and bulkhead saturation; expose counters/gauges for dashboards. + +Phase 2 — Resilience and Backpressure +- Small positive cache: Optional, short-lived (e.g., 30s) positive cache of hasJoined results keyed by `(username, serverId, ip)` to smooth retries if the response is reused in a tight window. Guard correctness and disable by default. +- Circuit breaker: If remote auth failure rate > X% over Y seconds, open breaker for a brief window (e.g., 10–30s) to fast-fail with a clear message instead of overwhelming threads with doomed requests. +- Backpressure in login pipeline: When bulkhead is full or breaker is open, fail fast instead of queueing work on event loops. + +Phase 3 — Threading Hygiene and Plugin Safety +- Event loop watchdog: Log a WARN stack sample if any Netty worker task runs > 200ms to surface blocking code. +- Offload heavy work: Ensure CPU/IO heavy tasks (JSON parsing, crypto beyond current usage) run off event loops. +- Plugin guidance: Provide helper executors and short docs for plugin authors; detect common patterns of performing blocking I/O on event loops and log advisories. + +Phase 4 — Netty and Queue Robustness +- Chat/command queue guardrails: When backend connection drops, drop or buffer with small TTL instead of throwing `IllegalStateException` and spamming logs. +- Tune Idle/keepalive: Review `IdleStateHandler` and timeouts to reduce noisy disconnect storms while keeping detection snappy. + +Configuration (new/updated) +- `http.maxAuthConcurrency` (int, default 256): Max concurrent hasJoined requests. +- `http.connectTimeoutMs` (int, default 5000) and `http.requestTimeoutMs` (int, default 5000–8000). +- `http.maxQueue` (int, default 1024): Queue size for the HTTP executor. +- `auth.retry.count` (0–2, default 1) and `auth.retry.initialBackoffMs` (100–200ms). +- `auth.circuitBreaker.enabled` (bool), `failureThresholdPct` (e.g., 50), `windowSeconds` (e.g., 20), `openSeconds` (e.g., 15). +- `loginRatelimit` (already exists): Revisit defaults and document guidance for public servers. + +Code Changes (high level) +- `VelocityServer`: + - Hold a singleton `HttpClient` and a dedicated `ExecutorService` for HTTP. + - Expose `getHttpClient()` and `shutdownHttpExecutor()`; wire shutdown in server stop. +- `InitialLoginSessionHandler`: + - Use shared client; remove per-request `AutoCloseable` close. + - Apply per-request timeout; capture and cancel future on disconnect. + - Add concurrency bulkhead and lightweight retry with jitter. + - Optional: consult short-lived positive cache. +- `ConnectionManager`: + - No longer responsible for per-request client creation; keep networking concerns isolated from HTTP. +- `VelocityConfiguration`: + - Add new config options and validation with sane bounds. + +Observability +- Metrics: counters for auth attempts, successes, failures (by category), retries, timeouts, breaker state. Histogram for auth latency. +- Logs: Single-line structured entries on failures including username (hashed), IP (redacted), durations, and error type. + +Validation Plan +- Load test: Simulate 500–1000 concurrent login attempts; verify stable Netty I/O and bounded auth concurrency. +- Chaos: Inject 50% auth connect timeouts and 5xx responses; verify retries limited, breaker engages, and the proxy remains responsive. +- Regression: Ensure normal login latency remains low; verify no resource leaks on shutdown. + +Rollout and Risk +- Gate new behaviors behind config flags; defaults conservative. +- Staged rollout: enable shared client + timeouts first; then bulkhead; then (optionally) caching and breaker. +- Rollback: Config toggles to disable each feature independently. + +Success Criteria +- No Netty starvation under auth spikes; no mass disconnects related to auth HTTP overload. +- Auth error spikes do not degrade unrelated proxy networking. +- Clear, actionable telemetry for operators when Mojang services degrade. + From 18441bc48403de7380133929dd608b3cc6a93d98 Mon Sep 17 00:00:00 2001 From: Xymb Date: Mon, 1 Sep 2025 19:35:50 +0200 Subject: [PATCH 18/39] =?UTF-8?q?proxy:=20log=20TCP=20initiation=20immedia?= =?UTF-8?q?tely;=20delay=20until=20after=20HAProxy=20PROXY=20address=20whe?= =?UTF-8?q?n=20enabled\n\n-=20Log=20=E2=80=9C(/ip:port)=20has=20initiated?= =?UTF-8?q?=20TCP=E2=80=9D=20once=20per=20connection\n-=20Respect=20log-pl?= =?UTF-8?q?ayer-connections=20config=20flag\n-=20Non-PROXY:=20log=20at=20c?= =?UTF-8?q?hannelActive;=20PROXY:=20log=20after=20HAProxyMessage\n-=20Avoi?= =?UTF-8?q?d=20duplicates=20via=20guard=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proxy/connection/MinecraftConnection.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 b1621ccae..902018584 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -87,6 +87,7 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { private final Channel channel; public boolean pendingConfigurationSwitch = false; private SocketAddress remoteAddress; + private boolean tcpInitiatedLogged = false; private StateRegistry state; private Map sessionHandlers; private @Nullable MinecraftSessionHandler activeSessionHandler; @@ -122,6 +123,11 @@ public void channelActive(ChannelHandlerContext ctx) throws Exception { if (association != null && server.getConfiguration().isLogPlayerConnections()) { logger.info("{} has connected", association); } + + // If HAProxy PROXY protocol is not enabled, we can log the TCP initiation immediately + if (!server.getConfiguration().isProxyProtocol()) { + logTcpInitiatedIfNeeded(); + } } @Override @@ -160,6 +166,8 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception } else if (msg instanceof HAProxyMessage proxyMessage) { this.remoteAddress = new InetSocketAddress(proxyMessage.sourceAddress(), proxyMessage.sourcePort()); + // Now that we have the real client address from HAProxy, log the TCP initiation + logTcpInitiatedIfNeeded(); } else if (msg instanceof ByteBuf buf) { activeSessionHandler.handleUnknown(buf); } @@ -329,6 +337,21 @@ public SocketAddress getRemoteAddress() { return remoteAddress; } + private void logTcpInitiatedIfNeeded() { + if (tcpInitiatedLogged || !server.getConfiguration().isLogPlayerConnections()) { + return; + } + + SocketAddress addr = this.getRemoteAddress(); + if (addr instanceof InetSocketAddress isa) { + logger.info("(/{}:{}) has initiated TCP", isa.getHostString(), isa.getPort()); + tcpInitiatedLogged = true; + } else if (addr != null) { + logger.info("({}) has initiated TCP", addr); + tcpInitiatedLogged = true; + } + } + public StateRegistry getState() { return state; } From 578f580fb120fc196c398f3fb398850aea2df8ec Mon Sep 17 00:00:00 2001 From: Xymb Date: Mon, 1 Sep 2025 19:38:24 +0200 Subject: [PATCH 19/39] =?UTF-8?q?proxy:=20tweak=20TCP-init=20log=20to=20in?= =?UTF-8?q?clude=20listener=20port\n\nMessage=20now=20reads:=20=E2=80=9C(/?= =?UTF-8?q?ip:clientPort)=20has=20initiated=20TCP=20with=20port=20=E2=80=9D.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proxy/connection/MinecraftConnection.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 902018584..936886a0c 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -343,11 +343,23 @@ private void logTcpInitiatedIfNeeded() { } SocketAddress addr = this.getRemoteAddress(); + SocketAddress local = channel.localAddress(); + if (addr instanceof InetSocketAddress isa) { - logger.info("(/{}:{}) has initiated TCP", isa.getHostString(), isa.getPort()); + if (local instanceof InetSocketAddress lsa) { + logger.info("(/{}:{}) has initiated TCP with port {}", + isa.getHostString(), isa.getPort(), lsa.getPort()); + } else { + logger.info("(/{}:{}) has initiated TCP", + isa.getHostString(), isa.getPort()); + } tcpInitiatedLogged = true; } else if (addr != null) { - logger.info("({}) has initiated TCP", addr); + if (local instanceof InetSocketAddress lsa) { + logger.info("({}) has initiated TCP with port {}", addr, lsa.getPort()); + } else { + logger.info("({}) has initiated TCP", addr); + } tcpInitiatedLogged = true; } } From 557927b44028675a2dd4e50fd52de237dbf52f40 Mon Sep 17 00:00:00 2001 From: Xymb Date: Mon, 1 Sep 2025 19:38:44 +0200 Subject: [PATCH 20/39] build: verify compile.sh completes successfully From 3b0aff4fcc109a3235a32642f85e78168c83de04 Mon Sep 17 00:00:00 2001 From: Xymb Date: Mon, 1 Sep 2025 19:51:40 +0200 Subject: [PATCH 21/39] =?UTF-8?q?proxy:=20add=20TCP=20disconnect=20log\n\n?= =?UTF-8?q?Mirror=20connect=20log=20with=20=E2=80=9Chas=20disconnected=20T?= =?UTF-8?q?CP=20with=20port=20=E2=80=9D.=20Logged=20once=20a?= =?UTF-8?q?nd=20HAProxy-aware.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proxy/connection/MinecraftConnection.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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 936886a0c..9c7c254e5 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -88,6 +88,7 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { public boolean pendingConfigurationSwitch = false; private SocketAddress remoteAddress; private boolean tcpInitiatedLogged = false; + private boolean tcpDisconnectedLogged = false; private StateRegistry state; private Map sessionHandlers; private @Nullable MinecraftSessionHandler activeSessionHandler; @@ -132,6 +133,9 @@ public void channelActive(ChannelHandlerContext ctx) throws Exception { @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { + // Log the TCP disconnect as early as possible + logTcpDisconnectedIfNeeded(); + if (activeSessionHandler != null) { activeSessionHandler.disconnected(); } @@ -364,6 +368,33 @@ private void logTcpInitiatedIfNeeded() { } } + private void logTcpDisconnectedIfNeeded() { + if (tcpDisconnectedLogged || !server.getConfiguration().isLogPlayerConnections()) { + return; + } + + SocketAddress addr = this.getRemoteAddress(); + SocketAddress local = channel.localAddress(); + + if (addr instanceof InetSocketAddress isa) { + if (local instanceof InetSocketAddress lsa) { + logger.info("(/{}:{}) has disconnected TCP with port {}", + isa.getHostString(), isa.getPort(), lsa.getPort()); + } else { + logger.info("(/{}:{}) has disconnected TCP", + isa.getHostString(), isa.getPort()); + } + tcpDisconnectedLogged = true; + } else if (addr != null) { + if (local instanceof InetSocketAddress lsa) { + logger.info("({}) has disconnected TCP with port {}", addr, lsa.getPort()); + } else { + logger.info("({}) has disconnected TCP", addr); + } + tcpDisconnectedLogged = true; + } + } + public StateRegistry getState() { return state; } From 83b0721452111745f49514c0111a369615a91c9b Mon Sep 17 00:00:00 2001 From: Xymb Date: Mon, 1 Sep 2025 20:13:07 +0200 Subject: [PATCH 22/39] =?UTF-8?q?proxy:=20include=20current=20active=20TCP?= =?UTF-8?q?=20connection=20count=20in=20TCP=20connect/disconnect=20logs\n\?= =?UTF-8?q?n-=20Track=20active=20TCP=20connections=20in=20VelocityServer?= =?UTF-8?q?=20via=20AtomicInteger\n-=20Increment=20on=20channelActive,=20d?= =?UTF-8?q?ecrement=20on=20channelInactive\n-=20Append=20=E2=80=9C(connect?= =?UTF-8?q?ions:=20N)=E2=80=9D=20to=20TCP=20init/disconnect=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../velocitypowered/proxy/VelocityServer.java | 14 ++++++++ .../proxy/connection/MinecraftConnection.java | 32 +++++++++++-------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 7554c5470..5022ddc9f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -97,6 +97,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.IntFunction; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -175,6 +176,7 @@ public class VelocityServer implements ProxyServer, ForwardingAudience { private final VelocityChannelRegistrar channelRegistrar = new VelocityChannelRegistrar(); private final ServerListPingHandler serverListPingHandler; private @MonotonicNonNull PacketCaptureManager packetCaptureManager; + private final AtomicInteger activeTcpConnections = new AtomicInteger(0); public PacketCaptureManager getPacketCaptureManager() { return packetCaptureManager; @@ -859,6 +861,18 @@ public boolean isShuttingDown() { return shutdownInProgress.get(); } + public int incrementActiveTcpConnections() { + return activeTcpConnections.incrementAndGet(); + } + + public int decrementActiveTcpConnections() { + return activeTcpConnections.decrementAndGet(); + } + + public int getActiveTcpConnections() { + return activeTcpConnections.get(); + } + @Override public InetSocketAddress getBoundAddress() { if (configuration == null) { 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 9c7c254e5..f9812dbd2 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -117,6 +117,9 @@ public MinecraftConnection(Channel channel, VelocityServer server) { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { + // Count this TCP connection as active + server.incrementActiveTcpConnections(); + if (activeSessionHandler != null) { activeSessionHandler.connected(); } @@ -133,7 +136,8 @@ public void channelActive(ChannelHandlerContext ctx) throws Exception { @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { - // Log the TCP disconnect as early as possible + // Decrement count and log the TCP disconnect as early as possible + server.decrementActiveTcpConnections(); logTcpDisconnectedIfNeeded(); if (activeSessionHandler != null) { @@ -348,21 +352,22 @@ private void logTcpInitiatedIfNeeded() { SocketAddress addr = this.getRemoteAddress(); SocketAddress local = channel.localAddress(); + int current = server.getActiveTcpConnections(); if (addr instanceof InetSocketAddress isa) { if (local instanceof InetSocketAddress lsa) { - logger.info("(/{}:{}) has initiated TCP with port {}", - isa.getHostString(), isa.getPort(), lsa.getPort()); + logger.info("(/{}:{}) has initiated TCP with port {} (connections: {})", + isa.getHostString(), isa.getPort(), lsa.getPort(), current); } else { - logger.info("(/{}:{}) has initiated TCP", - isa.getHostString(), isa.getPort()); + logger.info("(/{}:{}) has initiated TCP (connections: {})", + isa.getHostString(), isa.getPort(), current); } tcpInitiatedLogged = true; } else if (addr != null) { if (local instanceof InetSocketAddress lsa) { - logger.info("({}) has initiated TCP with port {}", addr, lsa.getPort()); + logger.info("({}) has initiated TCP with port {} (connections: {})", addr, lsa.getPort(), current); } else { - logger.info("({}) has initiated TCP", addr); + logger.info("({}) has initiated TCP (connections: {})", addr, current); } tcpInitiatedLogged = true; } @@ -375,21 +380,22 @@ private void logTcpDisconnectedIfNeeded() { SocketAddress addr = this.getRemoteAddress(); SocketAddress local = channel.localAddress(); + int current = server.getActiveTcpConnections(); if (addr instanceof InetSocketAddress isa) { if (local instanceof InetSocketAddress lsa) { - logger.info("(/{}:{}) has disconnected TCP with port {}", - isa.getHostString(), isa.getPort(), lsa.getPort()); + logger.info("(/{}:{}) has disconnected TCP with port {} (connections: {})", + isa.getHostString(), isa.getPort(), lsa.getPort(), current); } else { - logger.info("(/{}:{}) has disconnected TCP", - isa.getHostString(), isa.getPort()); + logger.info("(/{}:{}) has disconnected TCP (connections: {})", + isa.getHostString(), isa.getPort(), current); } tcpDisconnectedLogged = true; } else if (addr != null) { if (local instanceof InetSocketAddress lsa) { - logger.info("({}) has disconnected TCP with port {}", addr, lsa.getPort()); + logger.info("({}) has disconnected TCP with port {} (connections: {})", addr, lsa.getPort(), current); } else { - logger.info("({}) has disconnected TCP", addr); + logger.info("({}) has disconnected TCP (connections: {})", addr, current); } tcpDisconnectedLogged = true; } From 51d9fb253d06c22da35c1e55abf34de99cd97fb4 Mon Sep 17 00:00:00 2001 From: Xymb Date: Tue, 2 Sep 2025 10:13:16 +0200 Subject: [PATCH 23/39] proxy: only emit TCP connect/disconnect logs for frontend (client->proxy) connections\n\nMark MinecraftConnection with isFrontend; set true for ServerChannelInitializer, false for backend/ping connections to reduce noise and confusion in logs. --- .../proxy/connection/MinecraftConnection.java | 13 +++++++++---- .../backend/VelocityServerConnection.java | 2 +- .../proxy/network/ServerChannelInitializer.java | 2 +- .../proxy/server/VelocityRegisteredServer.java | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) 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 f9812dbd2..9eaf67c46 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -89,6 +89,7 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { private SocketAddress remoteAddress; private boolean tcpInitiatedLogged = false; private boolean tcpDisconnectedLogged = false; + private final boolean isFrontend; // true for client->proxy connections only private StateRegistry state; private Map sessionHandlers; private @Nullable MinecraftSessionHandler activeSessionHandler; @@ -107,11 +108,15 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { * @param server the Velocity instance */ public MinecraftConnection(Channel channel, VelocityServer server) { + this(channel, server, true); + } + + public MinecraftConnection(Channel channel, VelocityServer server, boolean isFrontend) { this.channel = channel; this.remoteAddress = channel.remoteAddress(); this.server = server; this.state = StateRegistry.HANDSHAKE; - + this.isFrontend = isFrontend; this.sessionHandlers = new HashMap<>(); } @@ -129,7 +134,7 @@ public void channelActive(ChannelHandlerContext ctx) throws Exception { } // If HAProxy PROXY protocol is not enabled, we can log the TCP initiation immediately - if (!server.getConfiguration().isProxyProtocol()) { + if (isFrontend && !server.getConfiguration().isProxyProtocol()) { logTcpInitiatedIfNeeded(); } } @@ -346,7 +351,7 @@ public SocketAddress getRemoteAddress() { } private void logTcpInitiatedIfNeeded() { - if (tcpInitiatedLogged || !server.getConfiguration().isLogPlayerConnections()) { + if (!isFrontend || tcpInitiatedLogged || !server.getConfiguration().isLogPlayerConnections()) { return; } @@ -374,7 +379,7 @@ private void logTcpInitiatedIfNeeded() { } private void logTcpDisconnectedIfNeeded() { - if (tcpDisconnectedLogged || !server.getConfiguration().isLogPlayerConnections()) { + if (!isFrontend || tcpDisconnectedLogged || !server.getConfiguration().isLogPlayerConnections()) { return; } 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 0ee59cfa4..a3350cdc1 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 @@ -108,7 +108,7 @@ public CompletableFuture connect() { .connect(registeredServer.getServerInfo().getAddress()) .addListener((ChannelFutureListener) future -> { if (future.isSuccess()) { - connection = new MinecraftConnection(future.channel(), server); + connection = new MinecraftConnection(future.channel(), server, false); connection.setAssociation(VelocityServerConnection.this); future.channel().pipeline().addLast(HANDLER, connection); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java b/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java index 0c22dccec..d497745c8 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java @@ -67,7 +67,7 @@ protected void initChannel(final Channel ch) { .addLast(MINECRAFT_DECODER, new MinecraftDecoder(ProtocolUtils.Direction.SERVERBOUND)) .addLast(MINECRAFT_ENCODER, new MinecraftEncoder(ProtocolUtils.Direction.CLIENTBOUND)); - final MinecraftConnection connection = new MinecraftConnection(ch, this.server); + final MinecraftConnection connection = new MinecraftConnection(ch, this.server, true); connection.setActiveSessionHandler(StateRegistry.HANDSHAKE, new HandshakeSessionHandler(connection, this.server)); ch.pipeline().addLast(Connections.HANDLER, connection); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/server/VelocityRegisteredServer.java b/proxy/src/main/java/com/velocitypowered/proxy/server/VelocityRegisteredServer.java index e48881f39..9ea62ae4a 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/server/VelocityRegisteredServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/server/VelocityRegisteredServer.java @@ -123,7 +123,7 @@ protected void initChannel(Channel ch) { .addLast(MINECRAFT_DECODER, new MinecraftDecoder(ProtocolUtils.Direction.CLIENTBOUND)) .addLast(MINECRAFT_ENCODER, new MinecraftEncoder(ProtocolUtils.Direction.SERVERBOUND)); - ch.pipeline().addLast(HANDLER, new MinecraftConnection(ch, server)); + ch.pipeline().addLast(HANDLER, new MinecraftConnection(ch, server, false)); } }).connect(serverInfo.getAddress()).addListener((ChannelFutureListener) future -> { if (future.isSuccess()) { From 299ecedebea1c92d762b581b6ef173c0e5e0d259 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Sep 2025 03:14:03 +0200 Subject: [PATCH 24/39] Add dynamic command whitelist configuration --- command_whitelist.yml | 83 +++++ proxy/build.gradle.kts | 1 + .../java/abomination/CommandWhitelist.java | 323 ++++++++++++------ .../velocitypowered/proxy/VelocityServer.java | 5 +- 4 files changed, 304 insertions(+), 108 deletions(-) create mode 100644 command_whitelist.yml diff --git a/command_whitelist.yml b/command_whitelist.yml new file mode 100644 index 000000000..01553387a --- /dev/null +++ b/command_whitelist.yml @@ -0,0 +1,83 @@ +# Commands players are allowed to execute +- register +- reg +- unregister +- login +- l +- email +- changepassword +- confirmpassword +- totp +- captcha +- 2fa +- verification +- help +- echochamber +- msg +- whisper +- w +- reply +- last +- kill +- suicide +- stats +- r +- ignore +- ignorehard +- ignorelist +- togglewhispering +- togglechat +- groupchat +- gc +- connectionmsgs +- deathmsgs +- sethome +- home +- homes +- homelist +- delhome +- tpa +- tpt +- tpn +- tpy +- tpyes +- tpno +- tps +- tptoggle +- hat +- skin +- hotspot +- buildermode +- particles +- nametag +- pvpmode +- togglespamchat +- freecam +- f +- vote +- discord +- website +- youtube +- twitter +- reddit +- instagram +- donate +- buy +- shop +- skins +- summon +- give +- chatcolor +- christmas +- balloons +- balloon +- sit +- link +- namecolor +- namecolors +- nc +- chatcolors +- cc +- invisframe +- thor +- playerstats diff --git a/proxy/build.gradle.kts b/proxy/build.gradle.kts index f6db9a843..11dd34d7d 100644 --- a/proxy/build.gradle.kts +++ b/proxy/build.gradle.kts @@ -163,6 +163,7 @@ dependencies { implementation(libs.adventure.facet) implementation(libs.completablefutures) implementation(libs.nightconfig) + implementation(libs.snakeyaml) implementation(libs.bstats) implementation(libs.lmbda) implementation(libs.asm) diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index 5e401efd2..c1cba6e12 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -1,115 +1,224 @@ package abomination; -import java.util.Arrays; -import java.util.HashSet; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; import java.util.Set; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.yaml.snakeyaml.Yaml; -public class CommandWhitelist { - private static final Set COMMANDS = new HashSet<>(Arrays.asList( - "register", - "reg", - "unregister", - "login", - "l", - "email", - "changepassword", - "confirmpassword", - "totp", - "captcha", - "2fa", - "verification", - "help", - "echochamber", - "msg", - "whisper", - "w", - "reply", - "last", - "l", - "kill", - "suicide", - "stats", - "r", - "ignore", - "ignorehard", - "ignorelist", - "togglewhispering", - "togglechat", - "groupchat", - "gc", - "connectionmsgs", - "deathmsgs", - "sethome", - "home", - "homes", - "homelist", - "delhome", - "tpa", - "tpt", - "tpn", - "tpy", - "tpyes", - "tpno", - "tps", - "tptoggle", - "hat", - "skin", - "hotspot", - "buildermode", - "particles", - "nametag", - "pvpmode", - "togglespamchat", - "freecam", - "f", - "vote", - "discord", - "website", - "youtube", - "twitter", - "reddit", - "instagram", - "donate", - "buy", - "shop", - "skin", - "skins", - "summon", -// "execute", -// "tp", -// "gamemode", - "give", - "chatcolor", - "christmas", - "balloons", - "balloon", - "sit", -// "commandwhitelist", - "link", - "namecolor", - "namecolors", - "nc", - "chatcolor", - "chatcolors", - "cc", - "invisframe", - "thor", - "playerstats" - )); - - public static boolean isCommandWhitelisted(String input) { - String commandWithoutSlash = input; - if (input.startsWith("/")) { - commandWithoutSlash = input.substring(1); +/** + * Maintains the set of commands that players are permitted to run. + */ +public final class CommandWhitelist { + + private static final Logger LOGGER = LogManager.getLogger(CommandWhitelist.class); + + private static final List DEFAULT_COMMANDS = List.of( + "register", + "reg", + "unregister", + "login", + "l", + "email", + "changepassword", + "confirmpassword", + "totp", + "captcha", + "2fa", + "verification", + "help", + "echochamber", + "msg", + "whisper", + "w", + "reply", + "last", + "kill", + "suicide", + "stats", + "r", + "ignore", + "ignorehard", + "ignorelist", + "togglewhispering", + "togglechat", + "groupchat", + "gc", + "connectionmsgs", + "deathmsgs", + "sethome", + "home", + "homes", + "homelist", + "delhome", + "tpa", + "tpt", + "tpn", + "tpy", + "tpyes", + "tpno", + "tps", + "tptoggle", + "hat", + "skin", + "hotspot", + "buildermode", + "particles", + "nametag", + "pvpmode", + "togglespamchat", + "freecam", + "f", + "vote", + "discord", + "website", + "youtube", + "twitter", + "reddit", + "instagram", + "donate", + "buy", + "shop", + "skins", + "summon", + "give", + "chatcolor", + "christmas", + "balloons", + "balloon", + "sit", + "link", + "namecolor", + "namecolors", + "nc", + "chatcolors", + "cc", + "invisframe", + "thor", + "playerstats" + ); + + private static volatile Set commands = Set.copyOf(DEFAULT_COMMANDS); + private static volatile Path whitelistPath; + + private CommandWhitelist() { + } + + public static synchronized void initialize(Path path) throws CommandWhitelistLoadException { + Objects.requireNonNull(path, "path"); + Path resolved = path.toAbsolutePath().normalize(); + ensureDefaultFile(resolved); + whitelistPath = resolved; + reloadInternal(resolved); + } + + public static synchronized void reload() throws CommandWhitelistLoadException { + Path path = whitelistPath; + if (path == null) { + throw new CommandWhitelistLoadException("Command whitelist has not been initialized yet."); + } + reloadInternal(path); + } + + public static boolean isCommandWhitelisted(String input) { + String commandWithoutSlash = input; + if (input.startsWith("/")) { + commandWithoutSlash = input.substring(1); + } + int spaceIndex = commandWithoutSlash.indexOf(' '); + if (spaceIndex == -1) { + return commands.contains(commandWithoutSlash); + } + String commandName = commandWithoutSlash.substring(0, spaceIndex); + return commands.contains(commandName); + } + + private static void ensureDefaultFile(Path path) throws CommandWhitelistLoadException { + if (Files.exists(path)) { + if (!Files.isRegularFile(path)) { + throw new CommandWhitelistLoadException( + "Command whitelist path " + path + " exists but is not a file."); + } + return; + } + + Path parent = path.getParent(); + try { + if (parent != null && Files.notExists(parent)) { + Files.createDirectories(parent); + } + Yaml yaml = new Yaml(); + String content = yaml.dump(DEFAULT_COMMANDS); + try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + writer.write("# Commands players are allowed to execute\n"); + writer.write(content); + } + LOGGER.info("Created default command whitelist at {}", path); + } catch (IOException e) { + throw new CommandWhitelistLoadException("Unable to create default command whitelist at " + + path, e); + } + } + + private static void reloadInternal(Path path) throws CommandWhitelistLoadException { + if (!Files.isRegularFile(path)) { + throw new CommandWhitelistLoadException( + "Command whitelist path " + path + " does not point to a file."); + } + + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + Yaml yaml = new Yaml(); + Object data = yaml.load(reader); + if (!(data instanceof Iterable iterable)) { + throw new CommandWhitelistLoadException( + "Command whitelist must be a YAML list of command names."); + } + + LinkedHashSet parsedCommands = new LinkedHashSet<>(); + for (Object element : iterable) { + if (!(element instanceof String value)) { + throw new CommandWhitelistLoadException( + "Command whitelist entries must be strings: " + element); + } + String command = value.trim(); + if (command.isEmpty()) { + throw new CommandWhitelistLoadException("Command whitelist contains an empty command."); } - int spaceIndex = commandWithoutSlash.indexOf(' '); - if (spaceIndex == -1) { - // If there's no space, check the whole command - return COMMANDS.contains(commandWithoutSlash); - } else { - // Check only the command name (up to the first space) - String commandName = commandWithoutSlash.substring(0, spaceIndex); - return COMMANDS.contains(commandName); + if (command.contains(" ")) { + throw new CommandWhitelistLoadException( + "Command whitelist entry contains spaces: '" + command + "'."); } + parsedCommands.add(command); + } + + if (parsedCommands.isEmpty()) { + throw new CommandWhitelistLoadException("Command whitelist is empty after parsing."); + } + + commands = Collections.unmodifiableSet(Set.copyOf(parsedCommands)); + LOGGER.info("Loaded {} whitelisted commands from {}", commands.size(), path); + } catch (IOException e) { + throw new CommandWhitelistLoadException("Unable to read command whitelist at " + path, e); + } + } + + public static class CommandWhitelistLoadException extends Exception { + CommandWhitelistLoadException(String message) { + super(message); } + + CommandWhitelistLoadException(String message, Throwable cause) { + super(message, cause); + } + } } + diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 5022ddc9f..c773c6a5d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -17,6 +17,7 @@ package com.velocitypowered.proxy; +import abomination.CommandWhitelist; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; @@ -427,8 +428,10 @@ private void doStartupConfigLoad() { } commandManager.setAnnounceProxyCommands(configuration.isAnnounceProxyCommands()); + + CommandWhitelist.initialize(Path.of("command_whitelist.yml")); } catch (Exception e) { - logger.error("Unable to read/load/save your velocity.toml. The server will shut down.", e); + logger.error("Unable to load startup configuration. The server will shut down.", e); LogManager.shutdown(); System.exit(1); } From d28874c91dca5f0b292a1aeda14bbd65731d89a6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Sep 2025 03:16:02 +0200 Subject: [PATCH 25/39] Add console-only whitelist reload command --- .../velocitypowered/proxy/VelocityServer.java | 2 + .../builtin/ReloadWhitelistCommand.java | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadWhitelistCommand.java diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index c773c6a5d..4f1d11ece 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -43,6 +43,7 @@ import com.velocitypowered.proxy.command.VelocityCommandManager; import com.velocitypowered.proxy.command.builtin.CallbackCommand; import com.velocitypowered.proxy.command.builtin.GlistCommand; +import com.velocitypowered.proxy.command.builtin.ReloadWhitelistCommand; import com.velocitypowered.proxy.command.builtin.SendCommand; import com.velocitypowered.proxy.command.builtin.ServerCommand; import com.velocitypowered.proxy.command.builtin.ShutdownCommand; @@ -296,6 +297,7 @@ void start() { */ new GlistCommand(this).register(); new SendCommand(this).register(); + new ReloadWhitelistCommand(this).register(); this.doStartupConfigLoad(); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadWhitelistCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadWhitelistCommand.java new file mode 100644 index 000000000..ecad28082 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadWhitelistCommand.java @@ -0,0 +1,56 @@ +package com.velocitypowered.proxy.command.builtin; + +import abomination.CommandWhitelist; +import abomination.CommandWhitelist.CommandWhitelistLoadException; +import com.mojang.brigadier.Command; +import com.velocitypowered.api.command.BrigadierCommand; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.proxy.plugin.virtual.VelocityVirtualPlugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Reloads the command whitelist from disk. Only the console can execute this command. + */ +public final class ReloadWhitelistCommand { + + private static final Logger LOGGER = LogManager.getLogger(ReloadWhitelistCommand.class); + private final ProxyServer server; + + public ReloadWhitelistCommand(ProxyServer server) { + this.server = server; + } + + public void register() { + BrigadierCommand command = new BrigadierCommand( + BrigadierCommand.literalArgumentBuilder("reloadwhitelist") + .requires(source -> source == server.getConsoleCommandSource()) + .executes(context -> execute(context.getSource())) + .build()); + + server.getCommandManager().register( + server.getCommandManager().metaBuilder(command) + .plugin(VelocityVirtualPlugin.INSTANCE) + .build(), + command + ); + } + + private int execute(CommandSource source) { + try { + CommandWhitelist.reload(); + source.sendMessage(Component.text("Command whitelist reloaded.", NamedTextColor.GREEN)); + return Command.SINGLE_SUCCESS; + } catch (CommandWhitelistLoadException e) { + LOGGER.error("Unable to reload command whitelist", e); + String message = e.getMessage() == null ? e.toString() : e.getMessage(); + source.sendMessage(Component.text( + "Failed to reload command whitelist: " + message, NamedTextColor.RED)); + return 0; + } + } +} + From 50707287bd624db4cfe6104c52f326b313b0174b Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 19 Sep 2025 19:00:30 +0200 Subject: [PATCH 26/39] Rename whitelist config and gate packet capture --- abomination_velocity.yml | 89 ++++++++++++ command_whitelist.yml | 83 ----------- .../java/abomination/CommandWhitelist.java | 134 +++++++++++++----- .../velocitypowered/proxy/VelocityServer.java | 9 +- 4 files changed, 198 insertions(+), 117 deletions(-) create mode 100644 abomination_velocity.yml delete mode 100644 command_whitelist.yml diff --git a/abomination_velocity.yml b/abomination_velocity.yml new file mode 100644 index 000000000..c100e159a --- /dev/null +++ b/abomination_velocity.yml @@ -0,0 +1,89 @@ +# Abomination Velocity configuration + +packet-captures: + enabled: false + +# Commands players are allowed to execute +commands: + - register + - reg + - unregister + - login + - l + - email + - changepassword + - confirmpassword + - totp + - captcha + - 2fa + - verification + - help + - echochamber + - msg + - whisper + - w + - reply + - last + - kill + - suicide + - stats + - r + - ignore + - ignorehard + - ignorelist + - togglewhispering + - togglechat + - groupchat + - gc + - connectionmsgs + - deathmsgs + - sethome + - home + - homes + - homelist + - delhome + - tpa + - tpt + - tpn + - tpy + - tpyes + - tpno + - tps + - tptoggle + - hat + - skin + - hotspot + - buildermode + - particles + - nametag + - pvpmode + - togglespamchat + - freecam + - f + - vote + - discord + - website + - youtube + - twitter + - reddit + - instagram + - donate + - buy + - shop + - skins + - summon + - give + - chatcolor + - christmas + - balloons + - balloon + - sit + - link + - namecolor + - namecolors + - nc + - chatcolors + - cc + - invisframe + - thor + - playerstats diff --git a/command_whitelist.yml b/command_whitelist.yml deleted file mode 100644 index 01553387a..000000000 --- a/command_whitelist.yml +++ /dev/null @@ -1,83 +0,0 @@ -# Commands players are allowed to execute -- register -- reg -- unregister -- login -- l -- email -- changepassword -- confirmpassword -- totp -- captcha -- 2fa -- verification -- help -- echochamber -- msg -- whisper -- w -- reply -- last -- kill -- suicide -- stats -- r -- ignore -- ignorehard -- ignorelist -- togglewhispering -- togglechat -- groupchat -- gc -- connectionmsgs -- deathmsgs -- sethome -- home -- homes -- homelist -- delhome -- tpa -- tpt -- tpn -- tpy -- tpyes -- tpno -- tps -- tptoggle -- hat -- skin -- hotspot -- buildermode -- particles -- nametag -- pvpmode -- togglespamchat -- freecam -- f -- vote -- discord -- website -- youtube -- twitter -- reddit -- instagram -- donate -- buy -- shop -- skins -- summon -- give -- chatcolor -- christmas -- balloons -- balloon -- sit -- link -- namecolor -- namecolors -- nc -- chatcolors -- cc -- invisframe -- thor -- playerstats diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index c1cba6e12..f9dbc6231 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -9,6 +9,7 @@ import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import org.apache.logging.log4j.LogManager; @@ -107,7 +108,13 @@ public final class CommandWhitelist { "playerstats" ); - private static volatile Set commands = Set.copyOf(DEFAULT_COMMANDS); + private static volatile Set commands = Collections.unmodifiableSet( + new LinkedHashSet<>(DEFAULT_COMMANDS)); + private static volatile boolean packetCapturesEnabled; + + private static final String COMMANDS_KEY = "commands"; + private static final String PACKET_CAPTURES_KEY = "packet-captures"; + private static final String PACKET_CAPTURES_ENABLED_KEY = "enabled"; private static volatile Path whitelistPath; private CommandWhitelist() { @@ -124,7 +131,7 @@ public static synchronized void initialize(Path path) throws CommandWhitelistLoa public static synchronized void reload() throws CommandWhitelistLoadException { Path path = whitelistPath; if (path == null) { - throw new CommandWhitelistLoadException("Command whitelist has not been initialized yet."); + throw new CommandWhitelistLoadException("Abomination configuration has not been initialized yet."); } reloadInternal(path); } @@ -142,11 +149,15 @@ public static boolean isCommandWhitelisted(String input) { return commands.contains(commandName); } + public static boolean isPacketCapturesEnabled() { + return packetCapturesEnabled; + } + private static void ensureDefaultFile(Path path) throws CommandWhitelistLoadException { if (Files.exists(path)) { if (!Files.isRegularFile(path)) { throw new CommandWhitelistLoadException( - "Command whitelist path " + path + " exists but is not a file."); + "Abomination configuration path " + path + " exists but is not a file."); } return; } @@ -156,15 +167,19 @@ private static void ensureDefaultFile(Path path) throws CommandWhitelistLoadExce if (parent != null && Files.notExists(parent)) { Files.createDirectories(parent); } - Yaml yaml = new Yaml(); - String content = yaml.dump(DEFAULT_COMMANDS); try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + writer.write("# Abomination Velocity configuration\n\n"); + writer.write("packet-captures:\n"); + writer.write(" enabled: false\n\n"); writer.write("# Commands players are allowed to execute\n"); - writer.write(content); + writer.write("commands:\n"); + for (String command : DEFAULT_COMMANDS) { + writer.write(" - " + command + "\n"); + } } - LOGGER.info("Created default command whitelist at {}", path); + LOGGER.info("Created default abomination configuration at {}", path); } catch (IOException e) { - throw new CommandWhitelistLoadException("Unable to create default command whitelist at " + throw new CommandWhitelistLoadException("Unable to create default abomination configuration at " + path, e); } } @@ -172,43 +187,99 @@ private static void ensureDefaultFile(Path path) throws CommandWhitelistLoadExce private static void reloadInternal(Path path) throws CommandWhitelistLoadException { if (!Files.isRegularFile(path)) { throw new CommandWhitelistLoadException( - "Command whitelist path " + path + " does not point to a file."); + "Abomination configuration path " + path + " does not point to a file."); } try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { Yaml yaml = new Yaml(); Object data = yaml.load(reader); - if (!(data instanceof Iterable iterable)) { - throw new CommandWhitelistLoadException( - "Command whitelist must be a YAML list of command names."); - } - LinkedHashSet parsedCommands = new LinkedHashSet<>(); - for (Object element : iterable) { - if (!(element instanceof String value)) { - throw new CommandWhitelistLoadException( - "Command whitelist entries must be strings: " + element); - } - String command = value.trim(); - if (command.isEmpty()) { - throw new CommandWhitelistLoadException("Command whitelist contains an empty command."); - } - if (command.contains(" ")) { - throw new CommandWhitelistLoadException( - "Command whitelist entry contains spaces: '" + command + "'."); - } - parsedCommands.add(command); + LinkedHashSet parsedCommands; + boolean parsedPacketCapturesEnabled = false; + + if (data == null) { + parsedCommands = new LinkedHashSet<>(DEFAULT_COMMANDS); + } else if (data instanceof Iterable iterable) { + parsedCommands = parseCommands(iterable); + LOGGER.warn("Abomination configuration at {} is using the deprecated list format.", path); + } else if (data instanceof Map map) { + parsedCommands = parseCommandsSection(map.get(COMMANDS_KEY)); + parsedPacketCapturesEnabled = parsePacketCapturesSection(map.get(PACKET_CAPTURES_KEY)); + } else { + throw new CommandWhitelistLoadException( + "Abomination configuration must be a YAML mapping or list."); } if (parsedCommands.isEmpty()) { throw new CommandWhitelistLoadException("Command whitelist is empty after parsing."); } - commands = Collections.unmodifiableSet(Set.copyOf(parsedCommands)); - LOGGER.info("Loaded {} whitelisted commands from {}", commands.size(), path); + commands = Collections.unmodifiableSet(parsedCommands); + packetCapturesEnabled = parsedPacketCapturesEnabled; + LOGGER.info( + "Loaded {} whitelisted commands from {}; packet captures {}.", + commands.size(), + path, + packetCapturesEnabled ? "enabled" : "disabled"); } catch (IOException e) { - throw new CommandWhitelistLoadException("Unable to read command whitelist at " + path, e); + throw new CommandWhitelistLoadException("Unable to read abomination configuration at " + path, e); + } + } + + private static LinkedHashSet parseCommandsSection(Object commandsSection) + throws CommandWhitelistLoadException { + if (commandsSection == null) { + return new LinkedHashSet<>(DEFAULT_COMMANDS); + } + if (!(commandsSection instanceof Iterable iterable)) { + throw new CommandWhitelistLoadException( + "Abomination configuration field 'commands' must be a YAML list."); } + return parseCommands(iterable); + } + + private static LinkedHashSet parseCommands(Iterable iterable) + throws CommandWhitelistLoadException { + LinkedHashSet parsedCommands = new LinkedHashSet<>(); + for (Object element : iterable) { + if (!(element instanceof String value)) { + throw new CommandWhitelistLoadException( + "Command whitelist entries must be strings: " + element); + } + String command = value.trim(); + if (command.isEmpty()) { + throw new CommandWhitelistLoadException("Command whitelist contains an empty command."); + } + if (command.contains(" ")) { + throw new CommandWhitelistLoadException( + "Command whitelist entry contains spaces: '" + command + "'."); + } + parsedCommands.add(command); + } + return parsedCommands; + } + + private static boolean parsePacketCapturesSection(Object packetCapturesSection) + throws CommandWhitelistLoadException { + if (packetCapturesSection == null) { + return false; + } + if (packetCapturesSection instanceof Boolean enabled) { + return enabled; + } + if (packetCapturesSection instanceof Map sectionMap) { + Object enabledValue = sectionMap.get(PACKET_CAPTURES_ENABLED_KEY); + if (enabledValue == null) { + return false; + } + if (enabledValue instanceof Boolean enabled) { + return enabled; + } + throw new CommandWhitelistLoadException( + "Abomination configuration field 'packet-captures.enabled' must be a boolean."); + } + throw new CommandWhitelistLoadException( + "Abomination configuration field 'packet-captures' must be a boolean or mapping."); } public static class CommandWhitelistLoadException extends Exception { @@ -221,4 +292,3 @@ public static class CommandWhitelistLoadException extends Exception { } } } - diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 4f1d11ece..c4136009d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -317,10 +317,15 @@ void start() { commandRateLimiter = Ratelimiters.createWithMilliseconds(configuration.getCommandRatelimit()); tabCompleteRateLimiter = Ratelimiters.createWithMilliseconds(configuration.getTabCompleteRatelimit()); // Initialize packet capture if enabled + boolean packetCaptureEnabled = configuration.getPacketCapture().isEnabled() + && CommandWhitelist.isPacketCapturesEnabled(); + if (configuration.getPacketCapture().isEnabled() && !packetCaptureEnabled) { + logger.info("Packet capture disabled via abomination_velocity.yml"); + } Path packetCapturePath = Path.of(configuration.getPacketCapture().getOutputDirectory()); this.packetCaptureManager = new PacketCaptureManager( packetCapturePath, - configuration.getPacketCapture().isEnabled() + packetCaptureEnabled ); loadPlugins(); @@ -431,7 +436,7 @@ private void doStartupConfigLoad() { commandManager.setAnnounceProxyCommands(configuration.isAnnounceProxyCommands()); - CommandWhitelist.initialize(Path.of("command_whitelist.yml")); + CommandWhitelist.initialize(Path.of("abomination_velocity.yml")); } catch (Exception e) { logger.error("Unable to load startup configuration. The server will shut down.", e); LogManager.shutdown(); From 126a121b0867ca913e4639f4605b0f7394a61964 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 19 Sep 2025 19:07:40 +0200 Subject: [PATCH 27/39] Rename reload command to abomination namespace --- .../velocitypowered/proxy/VelocityServer.java | 4 ++-- ...and.java => ReloadAbominationCommand.java} | 20 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) rename proxy/src/main/java/com/velocitypowered/proxy/command/builtin/{ReloadWhitelistCommand.java => ReloadAbominationCommand.java} (69%) diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index c4136009d..9f26cb20a 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -43,7 +43,7 @@ import com.velocitypowered.proxy.command.VelocityCommandManager; import com.velocitypowered.proxy.command.builtin.CallbackCommand; import com.velocitypowered.proxy.command.builtin.GlistCommand; -import com.velocitypowered.proxy.command.builtin.ReloadWhitelistCommand; +import com.velocitypowered.proxy.command.builtin.ReloadAbominationCommand; import com.velocitypowered.proxy.command.builtin.SendCommand; import com.velocitypowered.proxy.command.builtin.ServerCommand; import com.velocitypowered.proxy.command.builtin.ShutdownCommand; @@ -297,7 +297,7 @@ void start() { */ new GlistCommand(this).register(); new SendCommand(this).register(); - new ReloadWhitelistCommand(this).register(); + new ReloadAbominationCommand(this).register(); this.doStartupConfigLoad(); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadWhitelistCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadAbominationCommand.java similarity index 69% rename from proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadWhitelistCommand.java rename to proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadAbominationCommand.java index ecad28082..a3b28cf96 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadWhitelistCommand.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadAbominationCommand.java @@ -13,26 +13,27 @@ import org.apache.logging.log4j.Logger; /** - * Reloads the command whitelist from disk. Only the console can execute this command. + * Reloads the Abomination configuration from disk. Only the console can execute this command. */ -public final class ReloadWhitelistCommand { +public final class ReloadAbominationCommand { - private static final Logger LOGGER = LogManager.getLogger(ReloadWhitelistCommand.class); + private static final Logger LOGGER = LogManager.getLogger(ReloadAbominationCommand.class); private final ProxyServer server; - public ReloadWhitelistCommand(ProxyServer server) { + public ReloadAbominationCommand(ProxyServer server) { this.server = server; } public void register() { BrigadierCommand command = new BrigadierCommand( - BrigadierCommand.literalArgumentBuilder("reloadwhitelist") + BrigadierCommand.literalArgumentBuilder("reload") .requires(source -> source == server.getConsoleCommandSource()) .executes(context -> execute(context.getSource())) .build()); server.getCommandManager().register( - server.getCommandManager().metaBuilder(command) + server.getCommandManager().metaBuilder("abomination:reload") + .aliases("reload") .plugin(VelocityVirtualPlugin.INSTANCE) .build(), command @@ -42,15 +43,14 @@ public void register() { private int execute(CommandSource source) { try { CommandWhitelist.reload(); - source.sendMessage(Component.text("Command whitelist reloaded.", NamedTextColor.GREEN)); + source.sendMessage(Component.text("Abomination configuration reloaded.", NamedTextColor.GREEN)); return Command.SINGLE_SUCCESS; } catch (CommandWhitelistLoadException e) { - LOGGER.error("Unable to reload command whitelist", e); + LOGGER.error("Unable to reload abomination configuration", e); String message = e.getMessage() == null ? e.toString() : e.getMessage(); source.sendMessage(Component.text( - "Failed to reload command whitelist: " + message, NamedTextColor.RED)); + "Failed to reload abomination configuration: " + message, NamedTextColor.RED)); return 0; } } } - From 45d2339cf9497fb7718b5a5bd7a4562123b3c8d5 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 19 Sep 2025 20:00:30 +0200 Subject: [PATCH 28/39] Update README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 5de7436fd..6a442479f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ +# ABOMINATION VELOCITY FOR 6b6t + +After installing check `abomination_velocity.yml`. +To reload a command whitelist do `abomination:reload` in the console (not as a player). + + + + + # Velocity [![Build Status](https://img.shields.io/github/actions/workflow/status/PaperMC/Velocity/gradle.yml)](https://papermc.io/downloads/velocity) From 192b6a166891c0cf0a493e26fd59d6af5e3e54d0 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 3 Oct 2025 20:23:43 +0200 Subject: [PATCH 29/39] LOGS --- .../proxy/connection/client/InitialLoginSessionHandler.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialLoginSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialLoginSessionHandler.java index 92f14191c..ae9e9f51a 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialLoginSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialLoginSessionHandler.java @@ -209,10 +209,12 @@ public boolean handle(EncryptionResponsePacket packet) { url += "&ip=" + urlFormParameterEscaper().escape(playerIp); } + final String requestUrl = url; + final HttpRequest httpRequest = HttpRequest.newBuilder() .setHeader("User-Agent", server.getVersion().getName() + "/" + server.getVersion().getVersion()) - .uri(URI.create(url)) + .uri(URI.create(requestUrl)) .build(); //noinspection resource final HttpClient httpClient = server.createHttpClient(); @@ -224,7 +226,7 @@ public boolean handle(EncryptionResponsePacket packet) { } if (throwable != null) { - logger.error("Unable to authenticate player", throwable); + logger.error("Unable to authenticate player (URL: {})", requestUrl, throwable); inbound.disconnect(Component.translatable("multiplayer.disconnect.authservers_down")); return; } From 26d7e0c97d1e899da1552d5816fafa82686b2141 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 26 Dec 2025 11:50:16 +0100 Subject: [PATCH 30/39] Wait for player spawn before forwarding StartUpdatePacket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes NPE crash in nested proxy scenarios (OuterVelocity -> MultiVelocity) when StartUpdatePacket arrives before JoinGame has been sent to client. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../backend/BackendPlaySessionHandler.java | 13 ++++++++++++- .../client/ClientPlaySessionHandler.java | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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 2c430a157..275de2739 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 @@ -152,7 +152,18 @@ public boolean handle(StartUpdatePacket packet) { // Even when not auto reading messages are still decoded. Decode them with the correct state smc.getChannel().pipeline().get(MinecraftVarintFrameDecoder.class).setState(StateRegistry.CONFIG); smc.getChannel().pipeline().get(MinecraftDecoder.class).setState(StateRegistry.CONFIG); - serverConn.getPlayer().switchToConfigState(); + + // Check if player has been spawned before switching to config state. + // In nested proxy scenarios (OuterVelocity -> MultiVelocity -> Slave), + // we might receive StartUpdatePacket before the player has received JoinGame. + if (playerSessionHandler.isSpawned()) { + serverConn.getPlayer().switchToConfigState(); + } else { + // Wait for spawn before switching + playerSessionHandler.getSpawnFuture().thenRunAsync(() -> { + serverConn.getPlayer().switchToConfigState(); + }, playerConnection.eventLoop()); + } return true; } 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 559d2bf43..473bd29f2 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 @@ -103,6 +103,7 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { private final ConnectedPlayer player; private boolean spawned = false; + private CompletableFuture spawnFuture = new CompletableFuture<>(); private final List serverBossBars = new ArrayList<>(); private final Queue loginPluginMessages = new ConcurrentLinkedQueue<>(); private final VelocityServer server; @@ -572,6 +573,7 @@ public void handleBackendJoinGame(JoinGamePacket joinGame, VelocityServerConnect // The player wasn't spawned in yet, so we don't need to do anything special. Just send // JoinGame. spawned = true; + spawnFuture.complete(null); // Signal that player is now spawned player.getConnection().delayedWrite(joinGame); // Required for Legacy Forge player.getPhase().onFirstJoin(player); @@ -677,6 +679,20 @@ public List getServerBossBars() { return serverBossBars; } + /** + * Returns whether the player has been spawned (received JoinGame). + */ + public boolean isSpawned() { + return spawned; + } + + /** + * Returns a future that completes when the player is spawned. + */ + public CompletableFuture getSpawnFuture() { + return spawnFuture; + } + private boolean handleCommandTabComplete(TabCompleteRequestPacket packet) { // In 1.13+, we need to do additional work for the richer suggestions available. String command = packet.getCommand().substring(1); From a579991cf245665779eb3316612958b5ac198732 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 26 Dec 2025 12:13:34 +0100 Subject: [PATCH 31/39] Reset spawnFuture in doSwitch() to fix spawn wait logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spawnFuture must be reset when spawned=false, otherwise the old completed future causes getSpawnFuture().thenRunAsync() to execute immediately instead of waiting for the next JoinGame. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../proxy/connection/client/ClientPlaySessionHandler.java | 1 + 1 file changed, 1 insertion(+) 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 473bd29f2..1c027d5aa 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 @@ -545,6 +545,7 @@ public CompletableFuture doSwitch() { // Config state clears everything in the client. No need to clear later. spawned = false; + spawnFuture = new CompletableFuture<>(); // Reset for next spawn player.clearPlayerListHeaderAndFooterSilent(); player.getTabList().clearAllSilent(); if (player.getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_1_20_2)) { From e6111aee764c06147b05f4d9e21bd95e117071a9 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 26 Dec 2025 12:18:52 +0100 Subject: [PATCH 32/39] Fix spawn timing: complete spawnFuture AFTER writing JoinGame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spawnFuture.complete() was triggering callbacks that sent StartUpdatePacket BEFORE JoinGame was actually written to the player connection. This caused the client to receive StartUpdatePacket before JoinGame, resulting in NPE. Also added debug logging to trace the spawn/switch flow. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../proxy/connection/backend/BackendPlaySessionHandler.java | 6 ++++++ .../proxy/connection/client/ClientPlaySessionHandler.java | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) 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 275de2739..7bdc97631 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 @@ -157,10 +157,16 @@ public boolean handle(StartUpdatePacket packet) { // In nested proxy scenarios (OuterVelocity -> MultiVelocity -> Slave), // we might receive StartUpdatePacket before the player has received JoinGame. if (playerSessionHandler.isSpawned()) { + logger.info("[DEBUG] StartUpdatePacket received, player {} already spawned, switching immediately", + serverConn.getPlayer().getUsername()); serverConn.getPlayer().switchToConfigState(); } else { + logger.info("[DEBUG] StartUpdatePacket received, player {} NOT spawned, waiting for spawn", + serverConn.getPlayer().getUsername()); // Wait for spawn before switching playerSessionHandler.getSpawnFuture().thenRunAsync(() -> { + logger.info("[DEBUG] Player {} spawn complete, now switching to config state", + serverConn.getPlayer().getUsername()); serverConn.getPlayer().switchToConfigState(); }, playerConnection.eventLoop()); } 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 1c027d5aa..3a18faeec 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 @@ -574,10 +574,13 @@ public void handleBackendJoinGame(JoinGamePacket joinGame, VelocityServerConnect // The player wasn't spawned in yet, so we don't need to do anything special. Just send // JoinGame. spawned = true; - spawnFuture.complete(null); // Signal that player is now spawned player.getConnection().delayedWrite(joinGame); // Required for Legacy Forge player.getPhase().onFirstJoin(player); + // Signal spawn AFTER JoinGame is written, so any waiting code sends StartUpdatePacket + // after the client has received JoinGame and created mc.player + spawnFuture.complete(null); + logger.info("[DEBUG] Player {} spawned, spawnFuture completed", player.getUsername()); } else { // Clear tab list to avoid duplicate entries player.getTabList().clearAll(); From 31d2b167b87621170201bf73d8fa3eab43adb293 Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 26 Dec 2025 12:27:30 +0100 Subject: [PATCH 33/39] Add pendingConfigurationSwitch guard to prevent duplicate StartUpdatePacket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check pendingConfigurationSwitch before sending StartUpdatePacket - Reset pendingConfigurationSwitch when config switch completes - Add debug logging for StartUpdatePacket sending This prevents rapid server switches from sending multiple StartUpdatePackets to the client, which caused NPE crashes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../connection/client/ClientConfigSessionHandler.java | 2 ++ .../proxy/connection/client/ConnectedPlayer.java | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java index a62c527d8..8e62b2379 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java @@ -113,6 +113,8 @@ public boolean handle(ResourcePackResponsePacket packet) { @Override public boolean handle(FinishedUpdatePacket packet) { + // Reset the pending flag as config switch is now complete + player.getConnection().pendingConfigurationSwitch = false; player.getConnection().setActiveSessionHandler(StateRegistry.PLAY, new ClientPlaySessionHandler(server, player)); configSwitchFuture.complete(null); 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 d6deeaef7..580538371 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 @@ -1370,10 +1370,19 @@ public void switchToConfigState() { return; } + // Prevent sending duplicate StartUpdatePacket if one is already pending + // This can happen in nested proxy scenarios during rapid server switches + if (connection.pendingConfigurationSwitch) { + logger.info("[DEBUG] Skipping duplicate StartUpdatePacket for {} - config switch already pending", + getUsername()); + return; + } + if (bundleHandler.isInBundleSession()) { bundleHandler.toggleBundleSession(); connection.write(BundleDelimiterPacket.INSTANCE); } + logger.info("[DEBUG] Sending StartUpdatePacket to player {}", getUsername()); connection.write(StartUpdatePacket.INSTANCE); connection.pendingConfigurationSwitch = true; connection.getChannel().pipeline().get(MinecraftEncoder.class).setState(StateRegistry.CONFIG); From 36937471a97ede03d987eb912dce279eaf97f4cb Mon Sep 17 00:00:00 2001 From: Xymb Date: Fri, 26 Dec 2025 13:10:54 +0100 Subject: [PATCH 34/39] Fix nested proxy crash by replaying cached JoinGame before config switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a backend server disconnects after a config switch but before sending JoinGame, the client's mc.player is null. If we then connect to a fallback server and call doSwitch(), it sends StartUpdatePacket which crashes clients like Meteor that access mc.player during deactivation handlers. Fix: Cache JoinGame on ConnectedPlayer and replay it before doSwitch() when the player hasn't spawned yet, ensuring mc.player exists before the config switch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../backend/BackendPlaySessionHandler.java | 8 +------ .../backend/LoginSessionHandler.java | 21 +++++++++++++++++-- .../client/ClientPlaySessionHandler.java | 5 ++++- .../connection/client/ConnectedPlayer.java | 21 +++++++++++++++---- 4 files changed, 41 insertions(+), 14 deletions(-) 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 7bdc97631..9f52e86ae 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 @@ -157,16 +157,10 @@ public boolean handle(StartUpdatePacket packet) { // In nested proxy scenarios (OuterVelocity -> MultiVelocity -> Slave), // we might receive StartUpdatePacket before the player has received JoinGame. if (playerSessionHandler.isSpawned()) { - logger.info("[DEBUG] StartUpdatePacket received, player {} already spawned, switching immediately", - serverConn.getPlayer().getUsername()); serverConn.getPlayer().switchToConfigState(); } else { - logger.info("[DEBUG] StartUpdatePacket received, player {} NOT spawned, waiting for spawn", - serverConn.getPlayer().getUsername()); - // Wait for spawn before switching + // Wait for spawn before switching to avoid sending StartUpdatePacket before JoinGame playerSessionHandler.getSpawnFuture().thenRunAsync(() -> { - logger.info("[DEBUG] Player {} spawn complete, now switching to config state", - serverConn.getPlayer().getUsername()); serverConn.getPlayer().switchToConfigState(); }, playerConnection.eventLoop()); } 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 14884af46..74be2478e 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 @@ -164,8 +164,25 @@ public boolean handle(ServerLoginSuccessPacket packet) { smc.write(player.getClientSettingsPacket()); } if (player.getConnection().getActiveSessionHandler() instanceof ClientPlaySessionHandler clientPlaySessionHandler) { - smc.setAutoReading(false); - clientPlaySessionHandler.doSwitch().thenRunAsync(() -> smc.setAutoReading(true), smc.eventLoop()); + if (clientPlaySessionHandler.isSpawned()) { + // Normal case: player was playing, switch to new server + smc.setAutoReading(false); + clientPlaySessionHandler.doSwitch().thenRunAsync(() -> smc.setAutoReading(true), smc.eventLoop()); + } else { + // Player is in PLAY handler but hasn't spawned yet (no JoinGame received after last config switch). + // The client is waiting for JoinGame and mc.player is null. + // Sending StartUpdatePacket now would crash clients that access mc.player during deactivation. + // Solution: Replay cached JoinGame first to create mc.player, then proceed normally. + var cachedJoinGame = player.getCachedJoinGame(); + if (cachedJoinGame != null) { + player.getConnection().write(cachedJoinGame); + smc.setAutoReading(false); + clientPlaySessionHandler.doSwitch().thenRunAsync(() -> smc.setAutoReading(true), smc.eventLoop()); + } else { + // No cached JoinGame available - disconnect player to avoid crash + player.disconnect(net.kyori.adventure.text.Component.text("Connection interrupted during server switch. Please reconnect.")); + } + } } else { // Initial login - the player is already in configuration state. server.getEventManager().fireAndForget(new PlayerEnteredConfigurationEvent(player, serverConn)); 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 3a18faeec..4d44791ba 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 @@ -570,6 +570,10 @@ public CompletableFuture doSwitch() { public void handleBackendJoinGame(JoinGamePacket joinGame, VelocityServerConnection destination) { final MinecraftConnection serverMc = destination.ensureConnected(); + // Cache the JoinGame packet on the player for potential replay if we need to switch servers + // before the client has spawned (to ensure mc.player is non-null) + player.setCachedJoinGame(joinGame); + if (!spawned) { // The player wasn't spawned in yet, so we don't need to do anything special. Just send // JoinGame. @@ -580,7 +584,6 @@ public void handleBackendJoinGame(JoinGamePacket joinGame, VelocityServerConnect // Signal spawn AFTER JoinGame is written, so any waiting code sends StartUpdatePacket // after the client has received JoinGame and created mc.player spawnFuture.complete(null); - logger.info("[DEBUG] Player {} spawned, spawnFuture completed", player.getUsername()); } else { // Clear tab list to avoid duplicate entries player.getTabList().clearAll(); 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 580538371..009478a1d 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 @@ -79,6 +79,7 @@ import com.velocitypowered.proxy.protocol.packet.ClientboundStoreCookiePacket; import com.velocitypowered.proxy.protocol.packet.DisconnectPacket; import com.velocitypowered.proxy.protocol.packet.HeaderAndFooterPacket; +import com.velocitypowered.proxy.protocol.packet.JoinGamePacket; import com.velocitypowered.proxy.protocol.packet.KeepAlivePacket; import com.velocitypowered.proxy.protocol.packet.PluginMessagePacket; import com.velocitypowered.proxy.protocol.packet.RemoveResourcePackPacket; @@ -201,6 +202,7 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player, private @Nullable Locale effectiveLocale; private final @Nullable IdentifiedKey playerKey; private @Nullable ClientSettingsPacket clientSettingsPacket; + private @Nullable JoinGamePacket cachedJoinGame; private volatile ChatQueue chatQueue; private final ChatBuilderFactory chatBuilderFactory; private final BossBarManager bossBarManager; @@ -1043,6 +1045,21 @@ void setClientBrand(final @Nullable String clientBrand) { this.clientBrand = clientBrand; } + /** + * Gets the cached JoinGame packet from the last connection. + * Used to replay JoinGame before StartUpdatePacket when mc.player might be null. + */ + public @Nullable JoinGamePacket getCachedJoinGame() { + return cachedJoinGame; + } + + /** + * Caches the JoinGame packet for potential replay. + */ + public void setCachedJoinGame(final @Nullable JoinGamePacket cachedJoinGame) { + this.cachedJoinGame = cachedJoinGame; + } + @Override public void playSound(@NotNull Sound sound, @NotNull Sound.Emitter emitter) { Preconditions.checkNotNull(sound, "sound"); @@ -1371,10 +1388,7 @@ public void switchToConfigState() { } // Prevent sending duplicate StartUpdatePacket if one is already pending - // This can happen in nested proxy scenarios during rapid server switches if (connection.pendingConfigurationSwitch) { - logger.info("[DEBUG] Skipping duplicate StartUpdatePacket for {} - config switch already pending", - getUsername()); return; } @@ -1382,7 +1396,6 @@ public void switchToConfigState() { bundleHandler.toggleBundleSession(); connection.write(BundleDelimiterPacket.INSTANCE); } - logger.info("[DEBUG] Sending StartUpdatePacket to player {}", getUsername()); connection.write(StartUpdatePacket.INSTANCE); connection.pendingConfigurationSwitch = true; connection.getChannel().pipeline().get(MinecraftEncoder.class).setState(StateRegistry.CONFIG); From 4c98a806f37cd6730337b561fee0fb3756ccd83e Mon Sep 17 00:00:00 2001 From: Xymb Date: Sat, 27 Dec 2025 05:27:10 +0100 Subject: [PATCH 35/39] Disconnect player immediately on backend DisconnectPacket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a backend server explicitly sends a DisconnectPacket (kick), disconnect the player immediately instead of going through failover logic. Failover should only happen for unexpected socket closures, not explicit kicks from backends like MultiVelocity. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../proxy/connection/backend/BackendPlaySessionHandler.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 9f52e86ae..ae4b1a7cf 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 @@ -182,7 +182,10 @@ public boolean handle(ClientSettingsPacket packet) { @Override public boolean handle(DisconnectPacket packet) { serverConn.disconnect(); - serverConn.getPlayer().handleConnectionException(serverConn.getServer(), packet, true); + // When a backend explicitly sends a DisconnectPacket (kick), disconnect the player + // immediately. Don't try failover - that's for unexpected socket closures, not explicit kicks. + // This is especially important for MultiVelocity which sends disconnect when its backend dies. + serverConn.getPlayer().disconnect(packet.getReason().getComponent()); return true; } From 97c323164e91f0f936ca52500d3d07bdeeb2ee83 Mon Sep 17 00:00:00 2001 From: Xymb Date: Sat, 27 Dec 2025 05:31:31 +0100 Subject: [PATCH 36/39] Revert "Disconnect player immediately on backend DisconnectPacket" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OuterVelocity should maintain its failover behavior - when a backend (including MultiVelocity) kicks a player, OuterVelocity should try to move them to backup-server, not disconnect them entirely. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../proxy/connection/backend/BackendPlaySessionHandler.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 ae4b1a7cf..9f52e86ae 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 @@ -182,10 +182,7 @@ public boolean handle(ClientSettingsPacket packet) { @Override public boolean handle(DisconnectPacket packet) { serverConn.disconnect(); - // When a backend explicitly sends a DisconnectPacket (kick), disconnect the player - // immediately. Don't try failover - that's for unexpected socket closures, not explicit kicks. - // This is especially important for MultiVelocity which sends disconnect when its backend dies. - serverConn.getPlayer().disconnect(packet.getReason().getComponent()); + serverConn.getPlayer().handleConnectionException(serverConn.getServer(), packet, true); return true; } From de09bf58254fce1c6d1e4e3966a28b542bfba594 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:39:35 +0100 Subject: [PATCH 37/39] chore: configure Gradle daemon JVM toolchain for Java 25 Add updateDaemonJvm task with Java 25 and generate gradle-daemon-jvm.properties for consistent daemon JVM version. --- build.gradle.kts | 5 +++++ gradle/gradle-daemon-jvm.properties | 12 ++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/build.gradle.kts b/build.gradle.kts index a6adafc81..6da028851 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,14 @@ plugins { + base `java-library` id("velocity-checkstyle") apply false id("velocity-spotless") apply false } +tasks.named("updateDaemonJvm") { + languageVersion = JavaLanguageVersion.of(25) +} + subprojects { apply() diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000..2ea8b9f04 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c5ee947fbfb70bc347d8d531e3a578c4/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/cd495626d2ee49a75447e3fdc6afb287/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c5ee947fbfb70bc347d8d531e3a578c4/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/cd495626d2ee49a75447e3fdc6afb287/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/f2eb759b13be68e51cbe892c2e95efbe/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/59a9771cad43219260d9aac9a8ec4d6a/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c5ee947fbfb70bc347d8d531e3a578c4/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/cd495626d2ee49a75447e3fdc6afb287/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/a4c09dd2e2d7079373d30e524bbc2829/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/303c95a051768711e2ec6e0c82bc7dbb/redirect +toolchainVersion=25 From 17bd01a4a093be2e9676adcb367d8e32959b8bd7 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:03:43 +0100 Subject: [PATCH 38/39] Add configurable plugin channel whitelist Replace the hard block on all client plugin messages with a configurable whitelist in abomination_velocity.yml. Defaults to allowing minecraft:brand, minecraft:register, and minecraft:unregister. All instanceof ServerConnection checks across session handlers now use the whitelist as a bypass, allowing whitelisted channels to flow end-to-end. Hot-reloadable via /abomination:reload. Co-Authored-By: Claude Opus 4.6 --- abomination_velocity.yml | 6 +++ .../java/abomination/CommandWhitelist.java | 53 ++++++++++++++++++- .../backend/BackendPlaySessionHandler.java | 2 +- .../backend/ConfigSessionHandler.java | 2 +- .../client/ClientConfigSessionHandler.java | 2 +- .../client/ClientPlaySessionHandler.java | 4 +- .../client/InitialConnectSessionHandler.java | 2 +- 7 files changed, 63 insertions(+), 8 deletions(-) diff --git a/abomination_velocity.yml b/abomination_velocity.yml index c100e159a..4f5c7b5d9 100644 --- a/abomination_velocity.yml +++ b/abomination_velocity.yml @@ -3,6 +3,12 @@ packet-captures: enabled: false +# Plugin message channels players are allowed to send +plugin-channels: + - minecraft:brand + - minecraft:register + - minecraft:unregister + # Commands players are allowed to execute commands: - register diff --git a/proxy/src/main/java/abomination/CommandWhitelist.java b/proxy/src/main/java/abomination/CommandWhitelist.java index f9dbc6231..94ac7b492 100644 --- a/proxy/src/main/java/abomination/CommandWhitelist.java +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -17,12 +17,18 @@ import org.yaml.snakeyaml.Yaml; /** - * Maintains the set of commands that players are permitted to run. + * Maintains the set of commands and plugin channels that players are permitted to use. */ public final class CommandWhitelist { private static final Logger LOGGER = LogManager.getLogger(CommandWhitelist.class); + private static final List DEFAULT_PLUGIN_CHANNELS = List.of( + "minecraft:brand", + "minecraft:register", + "minecraft:unregister" + ); + private static final List DEFAULT_COMMANDS = List.of( "register", "reg", @@ -110,9 +116,12 @@ public final class CommandWhitelist { private static volatile Set commands = Collections.unmodifiableSet( new LinkedHashSet<>(DEFAULT_COMMANDS)); + private static volatile Set pluginChannels = Collections.unmodifiableSet( + new LinkedHashSet<>(DEFAULT_PLUGIN_CHANNELS)); private static volatile boolean packetCapturesEnabled; private static final String COMMANDS_KEY = "commands"; + private static final String PLUGIN_CHANNELS_KEY = "plugin-channels"; private static final String PACKET_CAPTURES_KEY = "packet-captures"; private static final String PACKET_CAPTURES_ENABLED_KEY = "enabled"; private static volatile Path whitelistPath; @@ -149,6 +158,10 @@ public static boolean isCommandWhitelisted(String input) { return commands.contains(commandName); } + public static boolean isPluginChannelWhitelisted(String channel) { + return pluginChannels.contains(channel); + } + public static boolean isPacketCapturesEnabled() { return packetCapturesEnabled; } @@ -171,6 +184,12 @@ private static void ensureDefaultFile(Path path) throws CommandWhitelistLoadExce writer.write("# Abomination Velocity configuration\n\n"); writer.write("packet-captures:\n"); writer.write(" enabled: false\n\n"); + writer.write("# Plugin message channels players are allowed to send\n"); + writer.write("plugin-channels:\n"); + for (String channel : DEFAULT_PLUGIN_CHANNELS) { + writer.write(" - " + channel + "\n"); + } + writer.write("\n"); writer.write("# Commands players are allowed to execute\n"); writer.write("commands:\n"); for (String command : DEFAULT_COMMANDS) { @@ -195,15 +214,19 @@ private static void reloadInternal(Path path) throws CommandWhitelistLoadExcepti Object data = yaml.load(reader); LinkedHashSet parsedCommands; + LinkedHashSet parsedPluginChannels; boolean parsedPacketCapturesEnabled = false; if (data == null) { parsedCommands = new LinkedHashSet<>(DEFAULT_COMMANDS); + parsedPluginChannels = new LinkedHashSet<>(DEFAULT_PLUGIN_CHANNELS); } else if (data instanceof Iterable iterable) { parsedCommands = parseCommands(iterable); + parsedPluginChannels = new LinkedHashSet<>(DEFAULT_PLUGIN_CHANNELS); LOGGER.warn("Abomination configuration at {} is using the deprecated list format.", path); } else if (data instanceof Map map) { parsedCommands = parseCommandsSection(map.get(COMMANDS_KEY)); + parsedPluginChannels = parsePluginChannelsSection(map.get(PLUGIN_CHANNELS_KEY)); parsedPacketCapturesEnabled = parsePacketCapturesSection(map.get(PACKET_CAPTURES_KEY)); } else { throw new CommandWhitelistLoadException( @@ -215,10 +238,12 @@ private static void reloadInternal(Path path) throws CommandWhitelistLoadExcepti } commands = Collections.unmodifiableSet(parsedCommands); + pluginChannels = Collections.unmodifiableSet(parsedPluginChannels); packetCapturesEnabled = parsedPacketCapturesEnabled; LOGGER.info( - "Loaded {} whitelisted commands from {}; packet captures {}.", + "Loaded {} whitelisted commands and {} whitelisted plugin channels from {}; packet captures {}.", commands.size(), + pluginChannels.size(), path, packetCapturesEnabled ? "enabled" : "disabled"); } catch (IOException e) { @@ -259,6 +284,30 @@ private static LinkedHashSet parseCommands(Iterable iterable) return parsedCommands; } + private static LinkedHashSet parsePluginChannelsSection(Object section) + throws CommandWhitelistLoadException { + if (section == null) { + return new LinkedHashSet<>(DEFAULT_PLUGIN_CHANNELS); + } + if (!(section instanceof Iterable iterable)) { + throw new CommandWhitelistLoadException( + "Abomination configuration field 'plugin-channels' must be a YAML list."); + } + LinkedHashSet parsed = new LinkedHashSet<>(); + for (Object element : iterable) { + if (!(element instanceof String value)) { + throw new CommandWhitelistLoadException( + "Plugin channel whitelist entries must be strings: " + element); + } + String channel = value.trim(); + if (channel.isEmpty()) { + throw new CommandWhitelistLoadException("Plugin channel whitelist contains an empty entry."); + } + parsed.add(channel); + } + return parsed; + } + private static boolean parsePacketCapturesSection(Object packetCapturesSection) throws CommandWhitelistLoadException { if (packetCapturesSection == null) { 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 9f52e86ae..926e43b24 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 @@ -328,7 +328,7 @@ public boolean handle(PluginMessagePacket packet) { byte[] copy = ByteBufUtil.getBytes(packet.content()); PluginMessageEvent event = new PluginMessageEvent(serverConn, serverConn.getPlayer(), id, copy); - if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { return true; } // Abomination server.getEventManager().fire(event).thenAcceptAsync(pme -> { if (pme.getResult().isAllowed() && !playerConnection.isClosed()) { PluginMessagePacket copied = new PluginMessagePacket( diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java index 48fe8133d..2ad9ee639 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java @@ -286,7 +286,7 @@ public boolean handle(PluginMessagePacket packet) { // the connection while we toss this off into another pool this.serverConn.getConnection().setAutoReading(false); PluginMessageEvent event = new PluginMessageEvent(serverConn, serverConn.getPlayer(), id, bytes); - if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { return true; } // Abomination this.server.getEventManager() .fire(event) diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java index 8e62b2379..990f01aa2 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java @@ -146,7 +146,7 @@ public boolean handle(final PluginMessagePacket packet) { // the connection while we toss this off into another pool serverConn.getPlayer().getConnection().setAutoReading(false); PluginMessageEvent event = new PluginMessageEvent(serverConn.getPlayer(), serverConn, id, bytes); - if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { return true; } // Abomination this.server.getEventManager() .fire(event) 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 4d44791ba..e95e77438 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 @@ -299,7 +299,7 @@ public boolean handle(TabCompleteRequestPacket packet) { @Override public boolean handle(PluginMessagePacket packet) { - if (true) return true; + if (!abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { return true; } // Abomination // Handling edge case when packet with FML client handshake (state COMPLETE) // arrives after JoinGame packet from destination server VelocityServerConnection serverConn = @@ -368,7 +368,7 @@ public boolean handle(PluginMessagePacket packet) { } else { byte[] copy = ByteBufUtil.getBytes(packet.content()); PluginMessageEvent event = new PluginMessageEvent(player, serverConn, id, copy); - if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { return true; } // Abomination server.getEventManager().fire(event).thenAcceptAsync(pme -> { if (pme.getResult().isAllowed()) { PluginMessagePacket message = new PluginMessagePacket(packet.getChannel(), diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialConnectSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialConnectSessionHandler.java index 1897a142d..f43a5cd9f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialConnectSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/InitialConnectSessionHandler.java @@ -67,7 +67,7 @@ public boolean handle(PluginMessagePacket packet) { byte[] copy = ByteBufUtil.getBytes(packet.content()); PluginMessageEvent event = new PluginMessageEvent(serverConn, serverConn.getPlayer(), id, copy); - if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection)) { return true; } // Abomination + if (!(event.getSource() instanceof com.velocitypowered.api.proxy.ServerConnection connection) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { return true; } // Abomination server.getEventManager().fire(event) .thenAcceptAsync(pme -> { if (pme.getResult().isAllowed() && serverConn.isActive()) { From 0d339209d054c3563b9979583b95b2b195cb9a3e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:07:42 +0000 Subject: [PATCH 39/39] Add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000..5db72dd6a --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +}