fix(rcon): rate limit remote command bursts

This commit is contained in:
simoleo89
2026-06-14 18:27:11 +02:00
parent 39d21daeff
commit 994d539caf
3 changed files with 88 additions and 0 deletions
@@ -153,6 +153,10 @@ public final class Emulator {
Emulator.config.register("camera.price.points.type", "5");
Emulator.config.register("camera.render.delay", "5");
Emulator.config.register("hotel.timezone", java.time.ZoneId.systemDefault().getId());
Emulator.config.register("rcon.rate_limit.enabled", "1");
Emulator.config.register("rcon.rate_limit.limit_for_period", "60");
Emulator.config.register("rcon.rate_limit.refresh_period_ms", "1000");
Emulator.config.register("rcon.rate_limit.timeout_ms", "0");
String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId());
System.out.println();
LOGGER.info("https://github.com/duckietm/Arcturus-Morningstar-Extended, ");
@@ -3,15 +3,21 @@ package com.eu.habbo.networking.rconserver;
import com.eu.habbo.Emulator;
import com.eu.habbo.messages.rcon.*;
import com.eu.habbo.networking.Server;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import gnu.trove.map.hash.THashMap;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.SocketAddress;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -23,6 +29,8 @@ public class RCONServer extends Server {
private final THashMap<String, Class<? extends RCONMessage>> messages;
private final GsonBuilder gsonBuilder;
private final boolean rateLimitEnabled;
private final LoadingCache<String, RateLimiter> rateLimiters;
List<String> allowedAdresses = new ArrayList<>();
public RCONServer(String host, int port) throws Exception {
@@ -32,6 +40,16 @@ public class RCONServer extends Server {
this.gsonBuilder = new GsonBuilder();
this.gsonBuilder.registerTypeAdapter(RCONMessage.class, new RCONMessage.RCONMessageSerializer());
this.rateLimitEnabled = Emulator.getConfig().getBoolean("rcon.rate_limit.enabled", true);
RateLimiterConfig rateLimiterConfig = RateLimiterConfig.custom()
.limitForPeriod(Math.max(1, Emulator.getConfig().getInt("rcon.rate_limit.limit_for_period", 60)))
.limitRefreshPeriod(Duration.ofMillis(Math.max(100, Emulator.getConfig().getInt("rcon.rate_limit.refresh_period_ms", 1000))))
.timeoutDuration(Duration.ofMillis(Math.max(0, Emulator.getConfig().getInt("rcon.rate_limit.timeout_ms", 0))))
.build();
this.rateLimiters = Caffeine.newBuilder()
.maximumSize(512)
.expireAfterAccess(Duration.ofMinutes(10))
.build(address -> RateLimiter.of("rcon-" + address, rateLimiterConfig));
this.addRCONMessage("alertuser", AlertUser.class);
this.addRCONMessage("disconnect", DisconnectUser.class);
@@ -89,6 +107,11 @@ public class RCONServer extends Server {
}
public String handle(ChannelHandlerContext ctx, String key, String body) throws Exception {
if (!this.acquirePermit(ctx)) {
LOGGER.warn("RCON rate limit exceeded for {}", remoteAddress(ctx));
return "RATE_LIMITED";
}
Class<? extends RCONMessage> message = this.messages.get(key.replace("_", "").toLowerCase());
String result;
@@ -118,4 +141,17 @@ public class RCONServer extends Server {
public List<String> getCommands() {
return new ArrayList<>(this.messages.keySet());
}
private boolean acquirePermit(ChannelHandlerContext ctx) {
return !this.rateLimitEnabled || this.rateLimiters.get(remoteAddress(ctx)).acquirePermission();
}
private static String remoteAddress(ChannelHandlerContext ctx) {
if (ctx == null || ctx.channel() == null) {
return "unknown";
}
SocketAddress address = ctx.channel().remoteAddress();
return address == null ? "unknown" : address.toString();
}
}
@@ -0,0 +1,48 @@
package com.eu.habbo.networking.rconserver;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RCONServerHandlerContractTest {
private static String serverSource() throws Exception {
return Files.readString(Path.of("src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java"));
}
private static String emulatorSource() throws Exception {
return Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java"));
}
@Test
void rconRequestsAreRateLimitedPerRemoteAddress() throws Exception {
String source = serverSource();
assertTrue(source.contains("LoadingCache<String, RateLimiter>"),
"RCON server must keep per-remote-address rate limiters");
assertTrue(source.contains("Caffeine.newBuilder()"),
"RCON rate limiters must expire instead of growing forever");
assertTrue(source.contains(".acquirePermission()"),
"RCON handler must consume a Resilience4j permit before dispatching commands");
assertTrue(source.contains("RATE_LIMITED"),
"RCON callers need a deterministic response when the rate limit rejects a request");
}
@Test
void rconRateLimitDefaultsAreRegisteredBeforeServerStarts() throws Exception {
String source = emulatorSource();
int registerIndex = source.indexOf("register(\"rcon.rate_limit.enabled\", \"1\")");
int serverIndex = source.indexOf("new RCONServer");
assertTrue(registerIndex >= 0, "RCON rate limiting must have a registered default toggle");
assertTrue(source.contains("register(\"rcon.rate_limit.limit_for_period\", \"60\")"),
"RCON rate limit must have a registered default limit");
assertTrue(source.contains("register(\"rcon.rate_limit.refresh_period_ms\", \"1000\")"),
"RCON rate limit must have a registered default refresh period");
assertTrue(source.contains("register(\"rcon.rate_limit.timeout_ms\", \"0\")"),
"RCON rate limit must reject immediately by default instead of blocking event loops");
assertTrue(registerIndex < serverIndex, "RCON rate limit defaults must be registered before RCONServer is constructed");
}
}