diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index de1a4e86..86ceca74 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -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, "); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java index 9eaeeef4..95d14d99 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServer.java @@ -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> messages; private final GsonBuilder gsonBuilder; + private final boolean rateLimitEnabled; + private final LoadingCache rateLimiters; List 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 message = this.messages.get(key.replace("_", "").toLowerCase()); String result; @@ -118,4 +141,17 @@ public class RCONServer extends Server { public List 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(); + } } diff --git a/Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java b/Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java new file mode 100644 index 00000000..7888b0d0 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/networking/rconserver/RCONServerHandlerContractTest.java @@ -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"), + "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"); + } +}