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; private @Nullable Favicon favicon;
@Expose @Expose
private boolean forceKeyAuthentication = true; // Added in 1.19 private boolean forceKeyAuthentication = true; // Added in 1.19
@Expose
private PacketLimiterConfig packetLimiterConfig = PacketLimiterConfig.DEFAULT;
private VelocityConfiguration(Servers servers, ForcedHosts forcedHosts, Advanced advanced, private VelocityConfiguration(Servers servers, ForcedHosts forcedHosts, Advanced advanced,
Query query, Metrics metrics) { Query query, Metrics metrics) {
@@ -109,7 +111,7 @@ public class VelocityConfiguration implements ProxyConfig {
boolean onlineModeKickExistingPlayers, PingPassthroughMode pingPassthrough, boolean onlineModeKickExistingPlayers, PingPassthroughMode pingPassthrough,
boolean samplePlayersInPing, boolean enablePlayerAddressLogging, Servers servers, boolean samplePlayersInPing, boolean enablePlayerAddressLogging, Servers servers,
ForcedHosts forcedHosts, Advanced advanced, Query query, Metrics metrics, ForcedHosts forcedHosts, Advanced advanced, Query query, Metrics metrics,
boolean forceKeyAuthentication) { boolean forceKeyAuthentication, PacketLimiterConfig packetLimiterConfig) {
this.bind = bind; this.bind = bind;
this.motd = motd; this.motd = motd;
this.showMaxPlayers = showMaxPlayers; this.showMaxPlayers = showMaxPlayers;
@@ -128,6 +130,7 @@ public class VelocityConfiguration implements ProxyConfig {
this.query = query; this.query = query;
this.metrics = metrics; this.metrics = metrics;
this.forceKeyAuthentication = forceKeyAuthentication; this.forceKeyAuthentication = forceKeyAuthentication;
this.packetLimiterConfig = packetLimiterConfig;
} }
/** /**
@@ -449,6 +452,10 @@ public class VelocityConfiguration implements ProxyConfig {
return advanced.isEnableReusePort(); return advanced.isEnableReusePort();
} }
public PacketLimiterConfig getPacketLimiterConfig() {
return packetLimiterConfig;
}
@Override @Override
public String toString() { public String toString() {
return MoreObjects.toStringHelper(this) return MoreObjects.toStringHelper(this)
@@ -466,6 +473,7 @@ public class VelocityConfiguration implements ProxyConfig {
.add("favicon", favicon) .add("favicon", favicon)
.add("enablePlayerAddressLogging", enablePlayerAddressLogging) .add("enablePlayerAddressLogging", enablePlayerAddressLogging)
.add("forceKeyAuthentication", forceKeyAuthentication) .add("forceKeyAuthentication", forceKeyAuthentication)
.add("packetLimiterConfig", packetLimiterConfig)
.toString(); .toString();
} }
@@ -557,6 +565,7 @@ public class VelocityConfiguration implements ProxyConfig {
final boolean kickExisting = config.getOrElse("kick-existing-players", false); final boolean kickExisting = config.getOrElse("kick-existing-players", false);
final boolean enablePlayerAddressLogging = config.getOrElse( final boolean enablePlayerAddressLogging = config.getOrElse(
"enable-player-address-logging", true); "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 // Throw an exception if the forwarding-secret file is empty and the proxy is using a
// forwarding mode that requires it. // forwarding mode that requires it.
@@ -584,7 +593,8 @@ public class VelocityConfiguration implements ProxyConfig {
new Advanced(advancedConfig), new Advanced(advancedConfig),
new Query(queryConfig), new Query(queryConfig),
new Metrics(metricsConfig), new Metrics(metricsConfig),
forceKeyAuthentication forceKeyAuthentication,
packetLimiterConfig
); );
} }
} }
@@ -987,4 +997,27 @@ public class VelocityConfiguration implements ProxyConfig {
return enabled; 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 static com.velocitypowered.proxy.network.Connections.READ_TIMEOUT;
import com.velocitypowered.proxy.VelocityServer; import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.config.VelocityConfiguration;
import com.velocitypowered.proxy.connection.MinecraftConnection; import com.velocitypowered.proxy.connection.MinecraftConnection;
import com.velocitypowered.proxy.connection.client.HandshakeSessionHandler; 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.ProtocolUtils;
import com.velocitypowered.proxy.protocol.StateRegistry; import com.velocitypowered.proxy.protocol.StateRegistry;
import com.velocitypowered.proxy.protocol.netty.LegacyPingDecoder; import com.velocitypowered.proxy.protocol.netty.LegacyPingDecoder;
@@ -72,6 +74,17 @@ public class ServerChannelInitializer extends ChannelInitializer<Channel> {
new HandshakeSessionHandler(connection, this.server)); new HandshakeSessionHandler(connection, this.server));
ch.pipeline().addLast(Connections.HANDLER, connection); 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()) { if (this.server.getConfiguration().isProxyProtocol()) {
ch.pipeline().addFirst(new HAProxyMessageDecoder()); 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 static io.netty.util.ByteProcessor.FIND_NON_NUL;
import com.velocitypowered.api.network.ProtocolVersion; import com.velocitypowered.api.network.ProtocolVersion;
import com.velocitypowered.proxy.network.limiter.PacketLimiter;
import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.MinecraftPacket;
import com.velocitypowered.proxy.protocol.ProtocolUtils; import com.velocitypowered.proxy.protocol.ProtocolUtils;
import com.velocitypowered.proxy.protocol.StateRegistry; import com.velocitypowered.proxy.protocol.StateRegistry;
@@ -32,6 +33,7 @@ import io.netty.handler.codec.CorruptedFrameException;
import java.util.List; import java.util.List;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.jspecify.annotations.Nullable;
/** /**
* Frames Minecraft server packets which are prefixed by a 21-bit VarInt encoding. * 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 ProtocolUtils.Direction direction;
private final StateRegistry.PacketRegistry.ProtocolRegistry registry; private final StateRegistry.PacketRegistry.ProtocolRegistry registry;
private StateRegistry state; private StateRegistry state;
@Nullable
private PacketLimiter packetLimiter;
/** /**
* Creates a new {@code MinecraftVarintFrameDecoder} decoding packets from the specified {@code Direction}. * 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 // note that zero-length packets are ignored
if (length > 0) { 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) { if (in.readableBytes() < length) {
in.resetReaderIndex(); in.resetReaderIndex();
} else { } else {
@@ -240,4 +253,8 @@ public class MinecraftVarintFrameDecoder extends ByteToMessageDecoder {
public void setState(StateRegistry stateRegistry) { public void setState(StateRegistry stateRegistry) {
this.state = 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 # If not enabled (default is true) player IP addresses will be replaced by <ip address withheld> in logs
enable-player-address-logging = true enable-player-address-logging = true
[packet-limiter]
interval = 3000
pps = 100
bytes = 1000
[servers] [servers]
# Configure your servers here. Each key represents the server's name, and the value # Configure your servers here. Each key represents the server's name, and the value
# represents the IP address of the server to connect to. # represents the IP address of the server to connect to.