diff --git a/bukkit/loader/build.gradle.kts b/bukkit/loader/build.gradle.kts index 2465542bb..901f5444a 100644 --- a/bukkit/loader/build.gradle.kts +++ b/bukkit/loader/build.gradle.kts @@ -85,5 +85,6 @@ tasks { relocate("io.netty.handler.codec.rtsp", "net.momirealms.craftengine.libraries.netty.handler.codec.rtsp") relocate("io.netty.handler.codec.spdy", "net.momirealms.craftengine.libraries.netty.handler.codec.spdy") relocate("io.netty.handler.codec.http2", "net.momirealms.craftengine.libraries.netty.handler.codec.http2") + relocate("io.github.bucket4j", "net.momirealms.craftengine.libraries.bucket4j") } } diff --git a/bukkit/paper-loader/build.gradle.kts b/bukkit/paper-loader/build.gradle.kts index 17432d821..ce8513840 100644 --- a/bukkit/paper-loader/build.gradle.kts +++ b/bukkit/paper-loader/build.gradle.kts @@ -172,6 +172,7 @@ tasks { relocate("io.netty.handler.codec.rtsp", "net.momirealms.craftengine.libraries.netty.handler.codec.rtsp") relocate("io.netty.handler.codec.spdy", "net.momirealms.craftengine.libraries.netty.handler.codec.spdy") relocate("io.netty.handler.codec.http2", "net.momirealms.craftengine.libraries.netty.handler.codec.http2") + relocate("io.github.bucket4j", "net.momirealms.craftengine.libraries.bucket4j") } } diff --git a/common-files/src/main/resources/config.yml b/common-files/src/main/resources/config.yml index bd8b101a0..44c64fa80 100644 --- a/common-files/src/main/resources/config.yml +++ b/common-files/src/main/resources/config.yml @@ -195,9 +195,13 @@ resource-pack: protocol: "http" deny-non-minecraft-request: true one-time-token: true - rate-limit: + rate-limitation-per-ip: + enable: true max-requests: 10 reset-interval: 30 + token-bucket: + enable: true + qps: 1000 item: # [Premium Exclusive] diff --git a/common-files/src/main/resources/craft-engine.properties b/common-files/src/main/resources/craft-engine.properties index 23c13b14f..1b396c4fe 100644 --- a/common-files/src/main/resources/craft-engine.properties +++ b/common-files/src/main/resources/craft-engine.properties @@ -34,4 +34,5 @@ reactive-streams=${reactive_streams_version} amazon-sdk-s3=${amazon_awssdk_version} amazon-sdk-eventstream=${amazon_awssdk_eventstream_version} evalex=${evalex_version} -jimfs=${jimfs_version} \ No newline at end of file +jimfs=${jimfs_version} +bucket4j=${bucket4j_version} \ No newline at end of file diff --git a/common-files/src/main/resources/translations/en.yml b/common-files/src/main/resources/translations/en.yml index 899854b70..761e46f38 100644 --- a/common-files/src/main/resources/translations/en.yml +++ b/common-files/src/main/resources/translations/en.yml @@ -80,6 +80,7 @@ warning.config.yaml.duplicated_key: "Issue found in file - Found du warning.config.yaml.inconsistent_value_type: "Issue found in file - Found duplicated key '' at line with different value types, this might cause unexpected results." warning.config.type.int: "Issue found in file - Failed to load '': Cannot cast '' to integer type for option ''." warning.config.type.boolean: "Issue found in file - Failed to load '': Cannot cast '' to boolean type for option ''." +warning.config.type.long: "Issue found in file - Failed to load '': Cannot cast '' to long type for option ''." warning.config.type.float: "Issue found in file - Failed to load '': Cannot cast '' to float type for option ''." warning.config.type.double: "Issue found in file - Failed to load '': Cannot cast '' to double type for option ''." warning.config.type.quaternionf: "Issue found in file - Failed to load '': Cannot cast '' to Quaternionf type for option ''." diff --git a/common-files/src/main/resources/translations/zh_cn.yml b/common-files/src/main/resources/translations/zh_cn.yml index 038b7d04d..3de669d54 100644 --- a/common-files/src/main/resources/translations/zh_cn.yml +++ b/common-files/src/main/resources/translations/zh_cn.yml @@ -80,6 +80,7 @@ warning.config.yaml.duplicated_key: "在文件 发现问题 - 在 warning.config.yaml.inconsistent_value_type: "在文件 发现问题 - 在第行发现重复且值类型不同的键 '', 这可能会导致一些意料之外的问题" warning.config.type.int: "在文件 发现问题 - 无法加载 '': 无法将 '' 转换为整数类型 (选项 '')" warning.config.type.boolean: "在文件 发现问题 - 无法加载 '': 无法将 '' 转换为布尔类型 (选项 '')" +warning.config.type.long: "在文件 发现问题 - 无法加载 '': 无法将 '' 转换为长整型类型 (选项 '')" warning.config.type.float: "在文件 发现问题 - 无法加载 '': 无法将 '' 转换为浮点数类型 (选项 '')" warning.config.type.double: "在文件 发现问题 - 无法加载 '': 无法将 '' 转换为双精度类型 (选项 '')" warning.config.type.quaternionf: "在文件 发现问题 - 无法加载 '': 无法将 '' 转换为四元数类型 (选项 '')" diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 9ad35c52d..be15d9371 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -21,7 +21,7 @@ dependencies { implementation("net.momirealms:sparrow-nbt-codec:${rootProject.properties["sparrow_nbt_version"]}") implementation("net.momirealms:sparrow-nbt-legacy-codec:${rootProject.properties["sparrow_nbt_version"]}") // S3 - implementation("net.momirealms:craft-engine-s3:0.8") + implementation("net.momirealms:craft-engine-s3:0.9") // Util compileOnly("net.momirealms:sparrow-util:${rootProject.properties["sparrow_util_version"]}") // Adventure @@ -69,6 +69,8 @@ dependencies { compileOnly("com.mojang:authlib:${rootProject.properties["authlib_version"]}") // concurrentutil compileOnly("ca.spottedleaf:concurrentutil:${rootProject.properties["concurrent_util_version"]}") + // bucket4j + compileOnly("com.bucket4j:bucket4j_jdk17-core:${rootProject.properties["bucket4j_version"]}") } java { @@ -107,6 +109,7 @@ tasks { relocate("io.netty.handler.codec.rtsp", "net.momirealms.craftengine.libraries.netty.handler.codec.rtsp") relocate("io.netty.handler.codec.spdy", "net.momirealms.craftengine.libraries.netty.handler.codec.spdy") relocate("io.netty.handler.codec.http2", "net.momirealms.craftengine.libraries.netty.handler.codec.http2") + relocate("io.github.bucket4j", "net.momirealms.craftengine.libraries.bucket4j") // bucket4j } } diff --git a/core/src/main/java/net/momirealms/craftengine/core/entity/furniture/AbstractFurnitureManager.java b/core/src/main/java/net/momirealms/craftengine/core/entity/furniture/AbstractFurnitureManager.java index ab16f7a96..52ea8252d 100644 --- a/core/src/main/java/net/momirealms/craftengine/core/entity/furniture/AbstractFurnitureManager.java +++ b/core/src/main/java/net/momirealms/craftengine/core/entity/furniture/AbstractFurnitureManager.java @@ -132,7 +132,7 @@ public abstract class AbstractFurnitureManager implements FurnitureManager { .item(Key.of(ResourceConfigUtils.requireNonEmptyStringOrThrow(element.get("item"), "warning.config.furniture.element.missing_item"))) .applyDyedColor(ResourceConfigUtils.getAsBoolean(element.getOrDefault("apply-dyed-color", true), "apply-dyed-color")) .billboard(ResourceConfigUtils.getOrDefault(element.get("billboard"), o -> Billboard.valueOf(o.toString().toUpperCase(Locale.ENGLISH)), Billboard.FIXED)) - .transform(ResourceConfigUtils.getOrDefault(element.get("transform"), o -> ItemDisplayContext.valueOf(o.toString().toUpperCase(Locale.ENGLISH)), ItemDisplayContext.NONE)) + .transform(ResourceConfigUtils.getOrDefault(ResourceConfigUtils.get(element, "transform", "display-transform"), o -> ItemDisplayContext.valueOf(o.toString().toUpperCase(Locale.ENGLISH)), ItemDisplayContext.NONE)) .scale(ResourceConfigUtils.getAsVector3f(element.getOrDefault("scale", "1"), "scale")) .position(ResourceConfigUtils.getAsVector3f(element.getOrDefault("position", "0"), "position")) .translation(ResourceConfigUtils.getAsVector3f(element.getOrDefault("translation", "0"), "translation")) diff --git a/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/SelfHost.java b/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/SelfHost.java index 4f93508c7..c0958173d 100644 --- a/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/SelfHost.java +++ b/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/SelfHost.java @@ -1,5 +1,7 @@ package net.momirealms.craftengine.core.pack.host.impl; +import com.google.common.util.concurrent.RateLimiter; +import io.github.bucket4j.Bandwidth; import net.momirealms.craftengine.core.pack.host.ResourcePackDownloadData; import net.momirealms.craftengine.core.pack.host.ResourcePackHost; import net.momirealms.craftengine.core.pack.host.ResourcePackHostFactory; @@ -12,10 +14,12 @@ import net.momirealms.craftengine.core.util.MiscUtils; import net.momirealms.craftengine.core.util.ResourceConfigUtils; import java.nio.file.Path; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; public class SelfHost implements ResourcePackHost { public static final Factory FACTORY = new Factory(); @@ -58,6 +62,7 @@ public class SelfHost implements ResourcePackHost { public static class Factory implements ResourcePackHostFactory { + @SuppressWarnings("UnstableApiUsage") @Override public ResourcePackHost create(Map arguments) { SelfHostHttpServer selfHostHttpServer = SelfHostHttpServer.instance(); @@ -76,14 +81,30 @@ public class SelfHost implements ResourcePackHost { boolean oneTimeToken = ResourceConfigUtils.getAsBoolean(arguments.getOrDefault("one-time-token", true), "one-time-token"); String protocol = arguments.getOrDefault("protocol", "http").toString(); boolean denyNonMinecraftRequest = ResourceConfigUtils.getAsBoolean(arguments.getOrDefault("deny-non-minecraft-request", true), "deny-non-minecraft-request"); - Map rateMap = MiscUtils.castToMap(arguments.get("rate-map"), true); - int maxRequests = 5; - int resetInterval = 20_000; - if (rateMap != null) { - maxRequests = ResourceConfigUtils.getAsInt(rateMap.getOrDefault("max-requests", 5), "max-requests"); - resetInterval = ResourceConfigUtils.getAsInt(rateMap.getOrDefault("reset-interval", 20), "reset-interval") * 1000; + Map rateLimitPerIp = MiscUtils.castToMap(arguments.get("rate-limitation-per-ip"), true); + boolean enabledLimitPerIp = false; + Bandwidth limit = null; + out: + if (rateLimitPerIp != null) { + enabledLimitPerIp = ResourceConfigUtils.getAsBoolean(rateLimitPerIp.getOrDefault("enable", false), "enable"); + if (!enabledLimitPerIp) break out; + int maxRequests = Math.max(ResourceConfigUtils.getAsInt(rateLimitPerIp.getOrDefault("max-requests", 5), "max-requests"), 1); + int resetInterval = Math.max(ResourceConfigUtils.getAsInt(rateLimitPerIp.getOrDefault("reset-interval", 20), "reset-interval"), 1); + limit = Bandwidth.builder() + .capacity(maxRequests) + .refillGreedy(maxRequests, Duration.ofSeconds(resetInterval)) + .build(); } - selfHostHttpServer.updateProperties(ip, port, url, denyNonMinecraftRequest, protocol, maxRequests, resetInterval, oneTimeToken); + Map tokenBucket = MiscUtils.castToMap(arguments.get("token-bucket"), true); + boolean enabledTokenBucket = false; + RateLimiter globalLimiter = null; + out: + if (tokenBucket != null) { + enabledTokenBucket = ResourceConfigUtils.getAsBoolean(tokenBucket.getOrDefault("enable", false), "enable"); + if (!enabledTokenBucket) break out; + globalLimiter = RateLimiter.create(ResourceConfigUtils.getAsDouble(tokenBucket.getOrDefault("qps", 1000), "qps")); + } + selfHostHttpServer.updateProperties(ip, port, url, denyNonMinecraftRequest, protocol, limit, enabledLimitPerIp, enabledTokenBucket, globalLimiter, oneTimeToken); return INSTANCE; } } diff --git a/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/SelfHostHttpServer.java b/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/SelfHostHttpServer.java index c320f80df..7004f90fa 100644 --- a/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/SelfHostHttpServer.java +++ b/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/SelfHostHttpServer.java @@ -3,6 +3,9 @@ package net.momirealms.craftengine.core.pack.host.impl; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.util.concurrent.RateLimiter; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; import io.netty.bootstrap.ServerBootstrap; import io.netty.buffer.Unpooled; import io.netty.channel.*; @@ -23,10 +26,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Duration; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +@SuppressWarnings("UnstableApiUsage") public class SelfHostHttpServer { private static SelfHostHttpServer instance; private final Cache oneTimePackUrls = Caffeine.newBuilder() @@ -34,17 +39,23 @@ public class SelfHostHttpServer { .scheduler(Scheduler.systemScheduler()) .expireAfterWrite(1, TimeUnit.MINUTES) .build(); - private final Cache ipAccessCache = Caffeine.newBuilder() + private final Cache ipRateLimiters = Caffeine.newBuilder() .maximumSize(256) .scheduler(Scheduler.systemScheduler()) - .expireAfterWrite(10, TimeUnit.MINUTES) + .expireAfterAccess(10, TimeUnit.MINUTES) .build(); private final AtomicLong totalRequests = new AtomicLong(); private final AtomicLong blockedRequests = new AtomicLong(); - private int rateLimit = 1; - private long rateLimitInterval = 1000; + private Bandwidth limitPerIp = Bandwidth.builder() + .capacity(1) + .refillGreedy(1, Duration.ofSeconds(1)) + .initialTokens(1) + .build(); + private RateLimiter globalLimiter = RateLimiter.create(1); + private boolean enabledLimitPerIp = false; + private boolean enabledGlobalLimit = false; private String ip = "localhost"; private int port = -1; private String protocol = "http"; @@ -72,15 +83,19 @@ public class SelfHostHttpServer { String url, boolean denyNonMinecraft, String protocol, - int maxRequests, - int resetInterval, + Bandwidth limitPerIp, + boolean enabledLimitPerIp, + boolean enabledGlobalLimit, + RateLimiter globalLimiter, boolean token) { this.ip = ip; this.url = url; this.denyNonMinecraft = denyNonMinecraft; this.protocol = protocol; - this.rateLimit = maxRequests; - this.rateLimitInterval = resetInterval; + this.limitPerIp = limitPerIp; + this.enabledLimitPerIp = enabledLimitPerIp; + this.enabledGlobalLimit = enabledGlobalLimit; + this.globalLimiter = globalLimiter; this.useToken = token; if (port <= 0 || port > 65535) { @@ -136,7 +151,13 @@ public class SelfHostHttpServer { String clientIp = ((InetSocketAddress) ctx.channel().remoteAddress()) .getAddress().getHostAddress(); - if (checkRateLimit(clientIp)) { + if (enabledGlobalLimit && !globalLimiter.tryAcquire()) { + sendError(ctx, HttpResponseStatus.TOO_MANY_REQUESTS, "Rate limit exceeded"); + blockedRequests.incrementAndGet(); + return; + } + + if (enabledLimitPerIp && !checkIpRateLimit(clientIp)) { sendError(ctx, HttpResponseStatus.TOO_MANY_REQUESTS, "Rate limit exceeded"); blockedRequests.incrementAndGet(); return; @@ -213,23 +234,12 @@ public class SelfHostHttpServer { ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); } - private boolean checkRateLimit(String clientIp) { - IpAccessRecord record = ipAccessCache.getIfPresent(clientIp); - long now = System.currentTimeMillis(); - - if (record == null) { - record = new IpAccessRecord(now, 1); - ipAccessCache.put(clientIp, record); - return false; - } - - if (now - record.lastAccessTime > rateLimitInterval) { - record.lastAccessTime = now; - record.accessCount = 1; - return false; - } - - return ++record.accessCount > rateLimit; + private boolean checkIpRateLimit(String clientIp) { + Bucket rateLimiter = ipRateLimiters.get(clientIp, k -> + Bucket.builder().addLimit(limitPerIp).build() + ); + assert rateLimiter != null; + return rateLimiter.tryConsume(1); } private boolean validateToken(String token) { @@ -312,14 +322,4 @@ public class SelfHostHttpServer { CraftEngine.instance().logger().severe("SHA-1 algorithm not available", e); } } - - private static class IpAccessRecord { - long lastAccessTime; - int accessCount; - - IpAccessRecord(long lastAccessTime, int accessCount) { - this.lastAccessTime = lastAccessTime; - this.accessCount = accessCount; - } - } } \ No newline at end of file diff --git a/core/src/main/java/net/momirealms/craftengine/core/plugin/CraftEngine.java b/core/src/main/java/net/momirealms/craftengine/core/plugin/CraftEngine.java index 8bb7f61d3..8bbfb01c0 100644 --- a/core/src/main/java/net/momirealms/craftengine/core/plugin/CraftEngine.java +++ b/core/src/main/java/net/momirealms/craftengine/core/plugin/CraftEngine.java @@ -419,7 +419,8 @@ public abstract class CraftEngine implements Plugin { Dependencies.LZ4, Dependencies.EVALEX, Dependencies.NETTY_HTTP, - Dependencies.JIMFS + Dependencies.JIMFS, + Dependencies.BUCKET_4_J ); } diff --git a/core/src/main/java/net/momirealms/craftengine/core/plugin/dependency/Dependencies.java b/core/src/main/java/net/momirealms/craftengine/core/plugin/dependency/Dependencies.java index 0d44161bb..bd66b18cb 100644 --- a/core/src/main/java/net/momirealms/craftengine/core/plugin/dependency/Dependencies.java +++ b/core/src/main/java/net/momirealms/craftengine/core/plugin/dependency/Dependencies.java @@ -372,6 +372,13 @@ public class Dependencies { List.of(Relocation.of("jimfs", "com{}google{}common{}jimfs")) ); + public static final Dependency BUCKET_4_J = new Dependency( + "bucket4j", + "com{}bucket4j", + "bucket4j_jdk17-core", + List.of(Relocation.of("bucket4j", "io{}github{}bucket4j")) + ); + public static final Dependency NETTY_HTTP = new Dependency( "netty-codec-http", "io{}netty", diff --git a/core/src/main/java/net/momirealms/craftengine/core/util/ResourceConfigUtils.java b/core/src/main/java/net/momirealms/craftengine/core/util/ResourceConfigUtils.java index 3c21da4ce..c399e7b65 100644 --- a/core/src/main/java/net/momirealms/craftengine/core/util/ResourceConfigUtils.java +++ b/core/src/main/java/net/momirealms/craftengine/core/util/ResourceConfigUtils.java @@ -218,6 +218,30 @@ public final class ResourceConfigUtils { } } + public static long getAsLong(Object o, String option) { + switch (o) { + case null -> { + return 0; + } + case Long l -> { + return l; + } + case Number number -> { + return number.longValue(); + } + case String s -> { + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + throw new LocalizedResourceConfigException("warning.config.type.long", e, s, option); + } + } + default -> { + throw new LocalizedResourceConfigException("warning.config.type.long", o.toString(), option); + } + } + } + @SuppressWarnings("unchecked") public static Map getAsMap(Object obj, String option) { if (obj instanceof Map map) { diff --git a/gradle.properties b/gradle.properties index 5ec8f0c20..68950dd75 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,8 +2,8 @@ org.gradle.jvmargs=-Xmx1G # Project settings project_version=0.0.65.11 -config_version=57 -lang_version=39 +config_version=58 +lang_version=40 project_group=net.momirealms latest_supported_version=1.21.10 @@ -56,6 +56,7 @@ amazon_awssdk_eventstream_version=1.0.1 jimfs_version=1.3.1 authlib_version=7.0.60 concurrent_util_version=0.0.3 +bucket4j_version=8.15.0 # Proxy settings #systemProp.socks.proxyHost=127.0.0.1