Add basic packet limiter

This commit is contained in:
Shane Freeder
2025-09-20 17:00:44 +01:00
parent 6e80f57739
commit 347972f34d
7 changed files with 430 additions and 2 deletions

View File

@@ -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;
}
}
}
}

View File

@@ -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<Channel> {
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());
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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.
*
* <p>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)}.</p>
*
* <p>This class is not thread-safe. If multiple threads access an instance concurrently,
* external synchronization is required.</p>
*/
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));
}
}

View File

@@ -74,6 +74,11 @@ sample-players-in-ping = false
# If not enabled (default is true) player IP addresses will be replaced by <ip address withheld> 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.