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 dfd007acd..6e4e2cb84 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -93,6 +93,8 @@ public class VelocityConfiguration implements ProxyConfig { private @Nullable Favicon favicon; @Expose private boolean forceKeyAuthentication = true; // Added in 1.19 + @Expose + private PacketLimiterConfig packetLimiterConfig = PacketLimiterConfig.DEFAULT; private VelocityConfiguration(Servers servers, ForcedHosts forcedHosts, Advanced advanced, Query query, Metrics metrics) { @@ -109,7 +111,7 @@ public class VelocityConfiguration implements ProxyConfig { boolean onlineModeKickExistingPlayers, PingPassthroughMode pingPassthrough, boolean samplePlayersInPing, boolean enablePlayerAddressLogging, Servers servers, ForcedHosts forcedHosts, Advanced advanced, Query query, Metrics metrics, - boolean forceKeyAuthentication) { + boolean forceKeyAuthentication, PacketLimiterConfig packetLimiterConfig) { this.bind = bind; this.motd = motd; this.showMaxPlayers = showMaxPlayers; @@ -128,6 +130,7 @@ public class VelocityConfiguration implements ProxyConfig { this.query = query; this.metrics = metrics; this.forceKeyAuthentication = forceKeyAuthentication; + this.packetLimiterConfig = packetLimiterConfig; } /** @@ -449,6 +452,10 @@ public class VelocityConfiguration implements ProxyConfig { return advanced.isEnableReusePort(); } + public PacketLimiterConfig getPacketLimiterConfig() { + return packetLimiterConfig; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -466,6 +473,7 @@ public class VelocityConfiguration implements ProxyConfig { .add("favicon", favicon) .add("enablePlayerAddressLogging", enablePlayerAddressLogging) .add("forceKeyAuthentication", forceKeyAuthentication) + .add("packetLimiterConfig", packetLimiterConfig) .toString(); } @@ -557,6 +565,7 @@ public class VelocityConfiguration implements ProxyConfig { final boolean kickExisting = config.getOrElse("kick-existing-players", false); final boolean enablePlayerAddressLogging = config.getOrElse( "enable-player-address-logging", true); + final PacketLimiterConfig packetLimiterConfig = PacketLimiterConfig.fromConfig(config.get("packet-limiter")); // Throw an exception if the forwarding-secret file is empty and the proxy is using a // forwarding mode that requires it. @@ -584,7 +593,8 @@ public class VelocityConfiguration implements ProxyConfig { new Advanced(advancedConfig), new Query(queryConfig), new Metrics(metricsConfig), - forceKeyAuthentication + forceKeyAuthentication, + packetLimiterConfig ); } } @@ -987,4 +997,27 @@ public class VelocityConfiguration implements ProxyConfig { return enabled; } } + + /** + * Configuration for packet limiting. + * + * @param interval the interval in seconds to measure packets over + * @param pps the maximum number of packets per second allowed + * @param bytes the maximum number of bytes per second allowed + */ + public record PacketLimiterConfig(int interval, int pps, int bytes) { + public static PacketLimiterConfig DEFAULT = new PacketLimiterConfig(7, 500, -1); + + public static PacketLimiterConfig fromConfig(CommentedConfig config) { + if (config != null) { + return new PacketLimiterConfig( + config.getIntOrElse("interval", DEFAULT.interval()), + config.getIntOrElse("pps", DEFAULT.pps()), + config.getIntOrElse("bytes", DEFAULT.bytes()) + ); + } else { + return DEFAULT; + } + } + } } 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..048d5d085 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java @@ -26,8 +26,10 @@ import static com.velocitypowered.proxy.network.Connections.MINECRAFT_ENCODER; import static com.velocitypowered.proxy.network.Connections.READ_TIMEOUT; import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.config.VelocityConfiguration; import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.connection.client.HandshakeSessionHandler; +import com.velocitypowered.proxy.network.limiter.SimpleBytesPerSecondLimiter; import com.velocitypowered.proxy.protocol.ProtocolUtils; import com.velocitypowered.proxy.protocol.StateRegistry; import com.velocitypowered.proxy.protocol.netty.LegacyPingDecoder; @@ -72,6 +74,17 @@ public class ServerChannelInitializer extends ChannelInitializer { new HandshakeSessionHandler(connection, this.server)); ch.pipeline().addLast(Connections.HANDLER, connection); + VelocityConfiguration.PacketLimiterConfig packetLimiterConfig = + server.getConfiguration().getPacketLimiterConfig(); + int configuredInterval = packetLimiterConfig.interval(); + int configuredPPS = packetLimiterConfig.pps(); + int configuredBytes = packetLimiterConfig.bytes(); + + if (configuredInterval > 0 && (configuredBytes > 0 || configuredPPS > 0)) { + ch.pipeline().get(MinecraftVarintFrameDecoder.class).setPacketLimiter( + new SimpleBytesPerSecondLimiter(configuredPPS, configuredBytes, configuredInterval) + ); + } if (this.server.getConfiguration().isProxyProtocol()) { ch.pipeline().addFirst(new HAProxyMessageDecoder()); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/limiter/PacketLimiter.java b/proxy/src/main/java/com/velocitypowered/proxy/network/limiter/PacketLimiter.java new file mode 100644 index 000000000..e63a0c8bf --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/limiter/PacketLimiter.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2025 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.limiter; + +/** + * PacketLimiter enforces a limit on the number of bytes processed over a time window. + * Implementations should be thread-safe. + */ +public interface PacketLimiter { + /** + * Attempts to record the specified number of bytes within the current window. + * + * @param bytes the number of bytes to record + * @return true if the bytes are allowed and recorded; false if the limit would be exceeded + */ + boolean account(int bytes); +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/limiter/SimpleBytesPerSecondLimiter.java b/proxy/src/main/java/com/velocitypowered/proxy/network/limiter/SimpleBytesPerSecondLimiter.java new file mode 100644 index 000000000..0551e7c27 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/limiter/SimpleBytesPerSecondLimiter.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2025 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.limiter; + +import com.velocitypowered.proxy.util.IntervalledCounter; +import org.jspecify.annotations.Nullable; + +/** + * A moving-window limiter over a configurable number of seconds. + * It enforces both packets-per-second and average bytes-per-second limits. + * The effective cap over the full window equals limitPerSecond * windowSeconds. + */ +public final class SimpleBytesPerSecondLimiter implements PacketLimiter { + @Nullable + private final IntervalledCounter bytesCounter; + @Nullable + private final IntervalledCounter packetsCounter; + private final int packetsPerSecond; + private final int bytesPerSecond; + + /** + * Creates a new SimpleBytesPerSecondLimiter. + * + * @param packetsPerSecond maximum average packets per second allowed (> 0) + * @param bytesPerSecond maximum average bytes per second allowed (> 0) + * @param windowSeconds number of seconds in the moving window (> 0) + */ + public SimpleBytesPerSecondLimiter(int packetsPerSecond, int bytesPerSecond, int windowSeconds) { + this.packetsPerSecond = packetsPerSecond; + if (windowSeconds <= 0) { + throw new IllegalArgumentException("windowSeconds must be > 0"); + } + this.bytesPerSecond = bytesPerSecond; + this.packetsCounter = packetsPerSecond > 0 ? new IntervalledCounter(windowSeconds) : null; + this.bytesCounter = bytesPerSecond > 0 ? new IntervalledCounter(windowSeconds) : null; + + } + + /** + * Records the given payload length as one packet and returns whether it is allowed. + */ + @SuppressWarnings("RedundantIfStatement") + @Override + public boolean account(int bytes) { + long currTime = System.nanoTime(); + if (packetsCounter != null) { + packetsCounter.updateAndAdd(1, currTime); + if (packetsCounter.getRate() > packetsPerSecond) { + return false; + } + } + + if (bytesCounter != null) { + bytesCounter.updateAndAdd(bytes, currTime); + if (bytesCounter.getRate() > bytesPerSecond) { + return false; + } + } + + return true; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftVarintFrameDecoder.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftVarintFrameDecoder.java index 5450390be..620d87a87 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftVarintFrameDecoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftVarintFrameDecoder.java @@ -20,6 +20,7 @@ package com.velocitypowered.proxy.protocol.netty; import static io.netty.util.ByteProcessor.FIND_NON_NUL; import com.velocitypowered.api.network.ProtocolVersion; +import com.velocitypowered.proxy.network.limiter.PacketLimiter; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.ProtocolUtils; import com.velocitypowered.proxy.protocol.StateRegistry; @@ -32,6 +33,7 @@ import io.netty.handler.codec.CorruptedFrameException; import java.util.List; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jspecify.annotations.Nullable; /** * Frames Minecraft server packets which are prefixed by a 21-bit VarInt encoding. @@ -52,6 +54,8 @@ public class MinecraftVarintFrameDecoder extends ByteToMessageDecoder { private final ProtocolUtils.Direction direction; private final StateRegistry.PacketRegistry.ProtocolRegistry registry; private StateRegistry state; + @Nullable + private PacketLimiter packetLimiter; /** * Creates a new {@code MinecraftVarintFrameDecoder} decoding packets from the specified {@code Direction}. @@ -131,6 +135,15 @@ public class MinecraftVarintFrameDecoder extends ByteToMessageDecoder { // note that zero-length packets are ignored if (length > 0) { + // If enabled, rate-limit serverbound payload bytes based on frame length + if (packetLimiter != null) { + if (!packetLimiter.account(length)) { + throw new QuietDecoderException( + "Rate limit exceeded while processing packets for %s".formatted( + ctx.channel().remoteAddress())); + } + } + if (in.readableBytes() < length) { in.resetReaderIndex(); } else { @@ -240,4 +253,8 @@ public class MinecraftVarintFrameDecoder extends ByteToMessageDecoder { public void setState(StateRegistry stateRegistry) { this.state = stateRegistry; } + + public void setPacketLimiter(@Nullable PacketLimiter packetLimiter) { + this.packetLimiter = packetLimiter; + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/IntervalledCounter.java b/proxy/src/main/java/com/velocitypowered/proxy/util/IntervalledCounter.java new file mode 100644 index 000000000..24bfe4193 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/IntervalledCounter.java @@ -0,0 +1,251 @@ +/* + * Copyright (C) 2025 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.util; + +/** + * IntervalledCounter maintains a rolling sum of values associated with timestamps, keeping + * only those entries that fall within a fixed time interval from the most recent timestamp. + * + *

Time values must be provided in the same unit as {@link System#nanoTime()} (nanoseconds), + * and the configured interval is also expressed in nanoseconds. Callers are expected to + * periodically advance the counter to the current time using {@link #updateCurrentTime()} or + * {@link #updateCurrentTime(long)} to evict expired entries before adding new ones via + * {@link #addTime(long)} or {@link #addTime(long, long)}.

+ * + *

This class is not thread-safe. If multiple threads access an instance concurrently, + * external synchronization is required.

+ */ +public final class IntervalledCounter { + + private static final int INITIAL_SIZE = 8; + + /** + * Ring buffer holding the timestamp (in nanoseconds) for each data point. + */ + protected long[] times; + /** + * Ring buffer holding the count associated with each timestamp. + */ + protected long[] counts; + /** + * The sliding window size in nanoseconds. Only entries with time >= (currentTime - interval) + * are considered part of the window. + */ + protected final long interval; + /** + * Cached lower bound of the window (in nanoseconds) after the last update. + */ + protected long minTime; + /** + * Running sum of all counts currently within the window. + */ + protected long sum; + /** + * Head index (inclusive) of the ring buffer. + */ + protected int head; // inclusive + /** + * Tail index (exclusive) of the ring buffer. + */ + protected int tail; // exclusive + + /** + * Creates a new counter with the specified interval. + * + * @param interval the window size in nanoseconds (compatible with {@link System#nanoTime()}) + */ + public IntervalledCounter(final long interval) { + this.times = new long[INITIAL_SIZE]; + this.counts = new long[INITIAL_SIZE]; + this.interval = interval; + } + + /** + * Advances the window to the current time using {@link System#nanoTime()}, evicting any + * data points that have fallen outside of the interval and updating the running sum. + */ + public void updateCurrentTime() { + this.updateCurrentTime(System.nanoTime()); + } + + /** + * Advances the window to the provided time, evicting any data points older than + * {@code currentTime - interval} and updating the running sum. + * + * @param currentTime the current time in nanoseconds (as from {@link System#nanoTime()}) + */ + public void updateCurrentTime(final long currentTime) { + long sum = this.sum; + int head = this.head; + final int tail = this.tail; + final long minTime = currentTime - this.interval; + + final int arrayLen = this.times.length; + + // guard against overflow by using subtraction + while (head != tail && this.times[head] - minTime < 0) { + sum -= this.counts[head]; + // there are two ways we can do this: + // 1. free the count when adding + // 2. free it now + // option #2 + this.counts[head] = 0; + if (++head >= arrayLen) { + head = 0; + } + } + + this.sum = sum; + this.head = head; + this.minTime = minTime; + } + + /** + * Adds a single unit at the specified timestamp, assuming the timestamp is within the current + * window. If the timestamp is older than the current window lower bound, the value is ignored. + * This method does not automatically advance the window; callers should invoke + * {@link #updateCurrentTime()} or {@link #updateCurrentTime(long)} beforehand. + * + * @param currTime the timestamp in nanoseconds + */ + public void addTime(final long currTime) { + this.addTime(currTime, 1L); + } + + /** + * Adds {@code count} units at the specified timestamp, assuming the timestamp is within the + * current window. If the timestamp is older than {@code minTime}, the value is ignored. + * This method does not automatically advance the window; callers should invoke + * {@link #updateCurrentTime()} or {@link #updateCurrentTime(long)} beforehand. + * + * @param currTime the timestamp in nanoseconds + * @param count the amount to add (non-negative) + */ + public void addTime(final long currTime, final long count) { + // guard against overflow by using subtraction + if (currTime - this.minTime < 0) { + return; + } + int nextTail = (this.tail + 1) % this.times.length; + if (nextTail == this.head) { + this.resize(); + nextTail = (this.tail + 1) % this.times.length; + } + + this.times[this.tail] = currTime; + this.counts[this.tail] += count; + this.sum += count; + this.tail = nextTail; + } + + /** + * Convenience method that advances the window to the current time and then adds {@code count} + * units at that time. + * + * @param count the amount to add (non-negative) + */ + public void updateAndAdd(final long count) { + final long currTime = System.nanoTime(); + this.updateCurrentTime(currTime); + this.addTime(currTime, count); + } + + /** + * Convenience method that advances the window to {@code currTime} and then adds {@code count} + * units at that time. + * + * @param count the amount to add (non-negative) + * @param currTime the timestamp in nanoseconds + */ + public void updateAndAdd(final long count, final long currTime) { + this.updateCurrentTime(currTime); + this.addTime(currTime, count); + } + + /** + * Doubles the capacity of the internal ring buffers, preserving the order of existing data. + */ + private void resize() { + final long[] oldElements = this.times; + final long[] oldCounts = this.counts; + final long[] newElements = new long[this.times.length * 2]; + final long[] newCounts = new long[this.times.length * 2]; + this.times = newElements; + this.counts = newCounts; + + final int head = this.head; + final int tail = this.tail; + final int size = tail >= head ? (tail - head) : (tail + (oldElements.length - head)); + this.head = 0; + this.tail = size; + + if (tail >= head) { + // sequentially ordered from [head, tail) + System.arraycopy(oldElements, head, newElements, 0, size); + System.arraycopy(oldCounts, head, newCounts, 0, size); + } else { + // ordered from [head, length) + // then followed by [0, tail) + + System.arraycopy(oldElements, head, newElements, 0, oldElements.length - head); + System.arraycopy(oldElements, 0, newElements, oldElements.length - head, tail); + + System.arraycopy(oldCounts, head, newCounts, 0, oldCounts.length - head); + System.arraycopy(oldCounts, 0, newCounts, oldCounts.length - head, tail); + } + } + + /** + * Returns the current rate in units per second based on the rolling sum and the configured + * interval. Specifically: {@code sum / (intervalSeconds)} where {@code intervalSeconds} + * equals {@code interval / 1e9}. + * + * @return the rate in units per second for the current window + */ + public double getRate() { + return (double)this.sum / ((double)this.interval * 1.0E-9); + } + + /** + * Returns the configured interval size in nanoseconds. + * + * @return the interval size in nanoseconds + */ + public long getInterval() { + return this.interval; + } + + /** + * Returns the rolling sum of all counts currently within the window. + * + * @return the rolling sum + */ + public long getSum() { + return this.sum; + } + + /** + * Returns the number of data points currently stored in the internal ring buffer. This may be + * less than or equal to the number of points added since older entries may have been evicted. + * + * @return the number of stored data points + */ + public int totalDataPoints() { + return this.tail >= this.head ? (this.tail - this.head) : (this.tail + (this.counts.length - this.head)); + } +} diff --git a/proxy/src/main/resources/default-velocity.toml b/proxy/src/main/resources/default-velocity.toml index 4d71e589b..a742fe60f 100644 --- a/proxy/src/main/resources/default-velocity.toml +++ b/proxy/src/main/resources/default-velocity.toml @@ -74,6 +74,11 @@ sample-players-in-ping = false # If not enabled (default is true) player IP addresses will be replaced by in logs enable-player-address-logging = true +[packet-limiter] +interval = 3000 +pps = 100 +bytes = 1000 + [servers] # Configure your servers here. Each key represents the server's name, and the value # represents the IP address of the server to connect to.