diff --git a/PacketCaptureManager b/PacketCaptureManager new file mode 100644 index 000000000..e69de29bb 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) 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. + diff --git a/abomination_velocity.yml b/abomination_velocity.yml new file mode 100644 index 000000000..4f5c7b5d9 --- /dev/null +++ b/abomination_velocity.yml @@ -0,0 +1,95 @@ +# Abomination Velocity configuration + +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 + - 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/build.gradle.kts b/build.gradle.kts index e01f345a9..6da028851 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,14 +1,19 @@ plugins { + base `java-library` id("velocity-checkstyle") apply false id("velocity-spotless") apply false } +tasks.named("updateDaemonJvm") { + languageVersion = JavaLanguageVersion.of(25) +} + subprojects { apply() - apply(plugin = "velocity-checkstyle") - apply(plugin = "velocity-spotless") +// apply(plugin = "velocity-checkstyle") +// apply(plugin = "velocity-spotless") java { toolchain { 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 diff --git a/proxy/build.gradle.kts b/proxy/build.gradle.kts index 599baba1c..11dd34d7d 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) @@ -162,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 new file mode 100644 index 000000000..94ac7b492 --- /dev/null +++ b/proxy/src/main/java/abomination/CommandWhitelist.java @@ -0,0 +1,343 @@ +package abomination; + +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.Map; +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; + +/** + * 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", + "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 = 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; + + 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("Abomination configuration 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); + } + + public static boolean isPluginChannelWhitelisted(String channel) { + return pluginChannels.contains(channel); + } + + 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( + "Abomination configuration path " + path + " exists but is not a file."); + } + return; + } + + Path parent = path.getParent(); + try { + if (parent != null && Files.notExists(parent)) { + Files.createDirectories(parent); + } + 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("# 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) { + writer.write(" - " + command + "\n"); + } + } + LOGGER.info("Created default abomination configuration at {}", path); + } catch (IOException e) { + throw new CommandWhitelistLoadException("Unable to create default abomination configuration at " + + path, e); + } + } + + private static void reloadInternal(Path path) throws CommandWhitelistLoadException { + if (!Files.isRegularFile(path)) { + throw new CommandWhitelistLoadException( + "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); + + 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( + "Abomination configuration must be a YAML mapping or list."); + } + + if (parsedCommands.isEmpty()) { + throw new CommandWhitelistLoadException("Command whitelist is empty after parsing."); + } + + commands = Collections.unmodifiableSet(parsedCommands); + pluginChannels = Collections.unmodifiableSet(parsedPluginChannels); + packetCapturesEnabled = parsedPacketCapturesEnabled; + LOGGER.info( + "Loaded {} whitelisted commands and {} whitelisted plugin channels from {}; packet captures {}.", + commands.size(), + pluginChannels.size(), + path, + packetCapturesEnabled ? "enabled" : "disabled"); + } catch (IOException 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 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) { + 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 { + CommandWhitelistLoadException(String message) { + super(message); + } + + CommandWhitelistLoadException(String message, Throwable cause) { + super(message, cause); + } + } +} 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 95f10bcbb..9f26cb20a 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; @@ -42,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.ReloadAbominationCommand; import com.velocitypowered.proxy.command.builtin.SendCommand; import com.velocitypowered.proxy.command.builtin.ServerCommand; import com.velocitypowered.proxy.command.builtin.ShutdownCommand; @@ -97,6 +99,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; @@ -113,6 +116,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 +177,12 @@ public class VelocityServer implements ProxyServer, ForwardingAudience { private final VelocityScheduler scheduler; 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; + } VelocityServer(final ProxyOptions options) { pluginManager = new VelocityPluginManager(this); @@ -253,7 +263,7 @@ void start() { serverKeyPair = EncryptionUtils.createRsaKeyPair(1024); cm.logChannelInformation(); - + /* Abomination - disable commands // Initialize commands first final BrigadierCommand velocityParentCommand = VelocityCommand.create(this); commandManager.register( @@ -284,8 +294,10 @@ void start() { .build(), shutdownCommand ); + */ new GlistCommand(this).register(); new SendCommand(this).register(); + new ReloadAbominationCommand(this).register(); this.doStartupConfigLoad(); @@ -304,6 +316,18 @@ void start() { ipAttemptLimiter = Ratelimiters.createWithMilliseconds(configuration.getLoginRatelimit()); 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, + packetCaptureEnabled + ); + loadPlugins(); // Go ahead and fire the proxy initialization event. We block since plugins should have a chance @@ -411,8 +435,10 @@ private void doStartupConfigLoad() { } commandManager.setAnnounceProxyCommands(configuration.isAnnounceProxyCommands()); + + CommandWhitelist.initialize(Path.of("abomination_velocity.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); } @@ -601,6 +627,11 @@ public void shutdown(boolean explicitExit, Component reason) { } try { + // Shutdown packet capture + if (packetCaptureManager != null) { + packetCaptureManager.shutdown(); + } + boolean timedOut = false; try { @@ -840,6 +871,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/command/builtin/ReloadAbominationCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadAbominationCommand.java new file mode 100644 index 000000000..a3b28cf96 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ReloadAbominationCommand.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 Abomination configuration from disk. Only the console can execute this command. + */ +public final class ReloadAbominationCommand { + + private static final Logger LOGGER = LogManager.getLogger(ReloadAbominationCommand.class); + private final ProxyServer server; + + public ReloadAbominationCommand(ProxyServer server) { + this.server = server; + } + + public void register() { + BrigadierCommand command = new BrigadierCommand( + BrigadierCommand.literalArgumentBuilder("reload") + .requires(source -> source == server.getConsoleCommandSource()) + .executes(context -> execute(context.getSource())) + .build()); + + server.getCommandManager().register( + server.getCommandManager().metaBuilder("abomination:reload") + .aliases("reload") + .plugin(VelocityVirtualPlugin.INSTANCE) + .build(), + command + ); + } + + private int execute(CommandSource source) { + try { + CommandWhitelist.reload(); + source.sendMessage(Component.text("Abomination configuration reloaded.", NamedTextColor.GREEN)); + return Command.SINGLE_SUCCESS; + } catch (CommandWhitelistLoadException 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 abomination configuration: " + message, NamedTextColor.RED)); + return 0; + } + } +} 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..9eaf67c46 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,9 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { private final Channel channel; public boolean pendingConfigurationSwitch = false; 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; @@ -95,6 +98,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. @@ -103,16 +108,23 @@ 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<>(); } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { + // Count this TCP connection as active + server.incrementActiveTcpConnections(); + if (activeSessionHandler != null) { activeSessionHandler.connected(); } @@ -120,10 +132,19 @@ 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 (isFrontend && !server.getConfiguration().isProxyProtocol()) { + logTcpInitiatedIfNeeded(); + } } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { + // Decrement count and log the TCP disconnect as early as possible + server.decrementActiveTcpConnections(); + logTcpDisconnectedIfNeeded(); + if (activeSessionHandler != null) { activeSessionHandler.disconnected(); } @@ -158,6 +179,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); } @@ -327,6 +350,62 @@ public SocketAddress getRemoteAddress() { return remoteAddress; } + private void logTcpInitiatedIfNeeded() { + if (!isFrontend || tcpInitiatedLogged || !server.getConfiguration().isLogPlayerConnections()) { + return; + } + + 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 {} (connections: {})", + isa.getHostString(), isa.getPort(), lsa.getPort(), current); + } else { + 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 {} (connections: {})", addr, lsa.getPort(), current); + } else { + logger.info("({}) has initiated TCP (connections: {})", addr, current); + } + tcpInitiatedLogged = true; + } + } + + private void logTcpDisconnectedIfNeeded() { + if (!isFrontend || tcpDisconnectedLogged || !server.getConfiguration().isLogPlayerConnections()) { + return; + } + + 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 {} (connections: {})", + isa.getHostString(), isa.getPort(), lsa.getPort(), current); + } else { + 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 {} (connections: {})", addr, lsa.getPort(), current); + } else { + logger.info("({}) has disconnected TCP (connections: {})", addr, current); + } + tcpDisconnectedLogged = true; + } + } + public StateRegistry getState() { return state; } @@ -553,6 +632,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 +660,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 +776,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/BackendPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java index 6d37520b4..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 @@ -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 to avoid sending StartUpdatePacket before JoinGame + playerSessionHandler.getSpawnFuture().thenRunAsync(() -> { + serverConn.getPlayer().switchToConfigState(); + }, playerConnection.eventLoop()); + } return true; } @@ -317,6 +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) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { return true; } // Abomination server.getEventManager().fire(event).thenAcceptAsync(pme -> { if (pme.getResult().isAllowed() && !playerConnection.isClosed()) { PluginMessagePacket copied = new PluginMessagePacket( @@ -333,7 +345,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/backend/ConfigSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java index 8ab38e58a..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 @@ -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) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { 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/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/backend/VelocityServerConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java index c40a7aaac..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 @@ -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; @@ -105,10 +108,38 @@ 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); + // 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/ClientConfigSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java index 776f99d68..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 @@ -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); @@ -143,8 +145,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) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { 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 a4ddacc90..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 @@ -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; @@ -164,6 +165,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); @@ -285,6 +287,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) { @@ -296,6 +299,7 @@ public boolean handle(TabCompleteRequestPacket packet) { @Override public boolean handle(PluginMessagePacket packet) { + 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 = @@ -364,6 +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) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { return true; } // Abomination server.getEventManager().fire(event).thenAcceptAsync(pme -> { if (pme.getResult().isAllowed()) { PluginMessagePacket message = new PluginMessagePacket(packet.getChannel(), @@ -540,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)) { @@ -564,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. @@ -571,6 +581,9 @@ public void handleBackendJoinGame(JoinGamePacket joinGame, VelocityServerConnect 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); } else { // Clear tab list to avoid duplicate entries player.getTabList().clearAll(); @@ -673,6 +686,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); @@ -763,6 +790,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 +801,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 +840,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 +863,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/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java index d6deeaef7..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"); @@ -1370,6 +1387,11 @@ public void switchToConfigState() { return; } + // Prevent sending duplicate StartUpdatePacket if one is already pending + if (connection.pendingConfigurationSwitch) { + return; + } + if (bundleHandler.isInBundleSession()) { bundleHandler.toggleBundleSession(); connection.write(BundleDelimiterPacket.INSTANCE); 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..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,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) && !abomination.CommandWhitelist.isPluginChannelWhitelisted(packet.getChannel())) { return true; } // Abomination server.getEventManager().fire(event) .thenAcceptAsync(pme -> { if (pme.getResult().isAllowed() && serverConn.isActive()) { 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; } 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(); } 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/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/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/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(); 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..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(); @@ -58,6 +60,22 @@ 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; + } + + 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, invocationInfo); player.getChatQueue().queuePacket( 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/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()) { 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/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" + ] +} 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() +