diff --git a/bukkit/loader/src/main/resources/commands.yml b/bukkit/loader/src/main/resources/commands.yml index 0a4a2616f..fdf4e1613 100644 --- a/bukkit/loader/src/main/resources/commands.yml +++ b/bukkit/loader/src/main/resources/commands.yml @@ -175,13 +175,6 @@ debug_is_section_injected: - /craftengine debug is-section-injected - /ce debug is-section-injected -debug_host_status: - enable: true - permission: ce.command.debug.host_status - usage: - - /craftengine debug host-status - - /ce debug host-status - debug_test: enable: true permission: ce.command.debug.test diff --git a/bukkit/src/main/java/net/momirealms/craftengine/bukkit/plugin/command/BukkitCommandManager.java b/bukkit/src/main/java/net/momirealms/craftengine/bukkit/plugin/command/BukkitCommandManager.java index 0da8fe6dd..c1f1c3f39 100644 --- a/bukkit/src/main/java/net/momirealms/craftengine/bukkit/plugin/command/BukkitCommandManager.java +++ b/bukkit/src/main/java/net/momirealms/craftengine/bukkit/plugin/command/BukkitCommandManager.java @@ -39,7 +39,6 @@ public class BukkitCommandManager extends AbstractCommandManager new SearchUsageAdminCommand(this, plugin), new TestCommand(this, plugin), new DebugGetBlockStateRegistryIdCommand(this, plugin), - new DebugHostStatusCommand(this, plugin), new DebugGetBlockInternalIdCommand(this, plugin), new DebugAppearanceStateUsageCommand(this, plugin), new DebugRealStateUsageCommand(this, plugin), diff --git a/bukkit/src/main/java/net/momirealms/craftengine/bukkit/plugin/command/feature/DebugHostStatusCommand.java b/bukkit/src/main/java/net/momirealms/craftengine/bukkit/plugin/command/feature/DebugHostStatusCommand.java deleted file mode 100644 index 6402c8f98..000000000 --- a/bukkit/src/main/java/net/momirealms/craftengine/bukkit/plugin/command/feature/DebugHostStatusCommand.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.momirealms.craftengine.bukkit.plugin.command.feature; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.md_5.bungee.chat.ComponentSerializer; -import net.momirealms.craftengine.bukkit.nms.FastNMS; -import net.momirealms.craftengine.bukkit.plugin.command.BukkitCommandFeature; -import net.momirealms.craftengine.bukkit.plugin.injector.BukkitInjector; -import net.momirealms.craftengine.core.pack.host.impl.SelfHostHttpServer; -import net.momirealms.craftengine.core.plugin.CraftEngine; -import net.momirealms.craftengine.core.plugin.command.CraftEngineCommandManager; -import net.momirealms.craftengine.core.plugin.command.sender.Sender; -import org.bukkit.Chunk; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.incendo.cloud.Command; - -public class DebugHostStatusCommand extends BukkitCommandFeature { - - public DebugHostStatusCommand(CraftEngineCommandManager commandManager, CraftEngine plugin) { - super(commandManager, plugin); - } - - @Override - public Command.Builder assembleCommand(org.incendo.cloud.CommandManager manager, Command.Builder builder) { - return builder - .handler(context -> { - Sender sender = plugin().senderFactory().wrap(context.sender()); - sender.sendMessage(Component.text("Self Host status: " + (SelfHostHttpServer.instance().isAlive() ? "on" : "off"))); - byte[] pack = SelfHostHttpServer.instance().resourcePackBytes(); - sender.sendMessage(Component.text("Resource Pack Bytes: " + (pack == null ? "null" : pack.length))); - }); - } - - @Override - public String getFeatureID() { - return "debug_host_status"; - } -} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b4b762304..7af5d3bbe 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { compileOnly("org.apache.logging.log4j:log4j-core:${rootProject.properties["log4j_version"]}") // Netty compileOnly("io.netty:netty-all:${rootProject.properties["netty_version"]}") + compileOnly("io.netty:netty-codec-http:${rootProject.properties["netty_version"]}") // Cache compileOnly("com.github.ben-manes.caffeine:caffeine:${rootProject.properties["caffeine_version"]}") // Compression diff --git a/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/S3HostFactory.java b/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/S3HostFactory.java index 5de2fa3b0..3f52ff790 100644 --- a/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/S3HostFactory.java +++ b/core/src/main/java/net/momirealms/craftengine/core/pack/host/impl/S3HostFactory.java @@ -15,7 +15,6 @@ public class S3HostFactory implements ResourcePackHostFactory { public ResourcePackHost create(Map arguments) { CraftEngine.instance().dependencyManager().loadDependencies( List.of( - Dependencies.NETTY_HTTP, Dependencies.NETTY_HTTP2, Dependencies.REACTIVE_STREAMS, Dependencies.AMAZON_AWSSDK_S3, 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 0d617ce0c..8a42bb490 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,15 +3,19 @@ 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.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.Unpooled; +import io.netty.channel.*; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.*; +import io.netty.util.CharsetUtil; import net.momirealms.craftengine.core.pack.host.ResourcePackDownloadData; import net.momirealms.craftengine.core.plugin.CraftEngine; import org.jetbrains.annotations.Nullable; import java.io.IOException; -import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -19,30 +23,26 @@ import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.HashMap; -import java.util.Map; import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; public class SelfHostHttpServer { private static SelfHostHttpServer instance; + + // Caffeine缓存和统计计数器 private final Cache oneTimePackUrls = Caffeine.newBuilder() .maximumSize(256) .scheduler(Scheduler.systemScheduler()) .expireAfterWrite(1, TimeUnit.MINUTES) .build(); + private final Cache ipAccessCache = Caffeine.newBuilder() .maximumSize(256) .scheduler(Scheduler.systemScheduler()) .expireAfterWrite(10, TimeUnit.MINUTES) .build(); - private ExecutorService threadPool; - private HttpServer server; - private final AtomicLong totalRequests = new AtomicLong(); private final AtomicLong blockedRequests = new AtomicLong(); @@ -59,47 +59,9 @@ public class SelfHostHttpServer { private String packHash; private UUID packUUID; - public void updateProperties(String ip, - int port, - String url, - boolean denyNonMinecraft, - String protocol, - int maxRequests, - int resetInternal, - boolean token) { - this.ip = ip; - this.url = url; - this.denyNonMinecraft = denyNonMinecraft; - this.protocol = protocol; - this.rateLimit = maxRequests; - this.rateLimitInterval = resetInternal; - this.useToken = token; - if (port <= 0 || port > 65535) { - throw new IllegalArgumentException("Invalid port number: " + port); - } - if (port == this.port && this.server != null) return; - if (this.server != null) disable(); - this.port = port; - try { - this.threadPool = Executors.newFixedThreadPool(1); - this.server = HttpServer.create(new InetSocketAddress("::", port), 0); - this.server.createContext("/download", new ResourcePackHandler()); -// this.server.createContext("/metrics", this::handleMetrics); - this.server.setExecutor(this.threadPool); - this.server.start(); - CraftEngine.instance().logger().info("HTTP server started on port: " + port); - } catch (IOException e) { - CraftEngine.instance().logger().warn("Failed to start HTTP server", e); - } - } - - public boolean isAlive() { - return this.server != null; - } - - public byte[] resourcePackBytes() { - return resourcePackBytes; - } + private EventLoopGroup bossGroup; + private EventLoopGroup workerGroup; + private Channel serverChannel; public static SelfHostHttpServer instance() { if (instance == null) { @@ -108,21 +70,31 @@ public class SelfHostHttpServer { return instance; } - @Nullable - public ResourcePackDownloadData generateOneTimeUrl() { - if (this.resourcePackBytes == null) { - return null; + public void updateProperties(String ip, + int port, + String url, + boolean denyNonMinecraft, + String protocol, + int maxRequests, + int resetInterval, + boolean token) { + this.ip = ip; + this.url = url; + this.denyNonMinecraft = denyNonMinecraft; + this.protocol = protocol; + this.rateLimit = maxRequests; + this.rateLimitInterval = resetInterval; + this.useToken = token; + + if (port <= 0 || port > 65535) { + throw new IllegalArgumentException("Invalid port: " + port); } - if (!this.useToken) { - return new ResourcePackDownloadData(url() + "download", this.packUUID, this.packHash); - } - String token = UUID.randomUUID().toString(); - this.oneTimePackUrls.put(token, true); - return new ResourcePackDownloadData( - url() + "download?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8), - this.packUUID, - this.packHash - ); + + if (this.port == port && serverChannel != null) return; + disable(); + + this.port = port; + initializeServer(); } public String url() { @@ -132,6 +104,189 @@ public class SelfHostHttpServer { return this.protocol + "://" + this.ip + ":" + this.port + "/"; } + private void initializeServer() { + bossGroup = new NioEventLoopGroup(1); + workerGroup = new NioEventLoopGroup(); + + ServerBootstrap b = new ServerBootstrap(); + b.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ChannelPipeline pipeline = ch.pipeline(); + pipeline.addLast(new HttpServerCodec()); + pipeline.addLast(new HttpObjectAggregator(1048576)); + pipeline.addLast(new RequestHandler()); + } + }); + try { + serverChannel = b.bind(port).sync().channel(); + CraftEngine.instance().logger().info("Netty HTTP server started on port: " + port); + } catch (InterruptedException e) { + CraftEngine.instance().logger().warn("Failed to start Netty server", e); + Thread.currentThread().interrupt(); + } + } + + @ChannelHandler.Sharable + private class RequestHandler extends SimpleChannelInboundHandler { + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) { + totalRequests.incrementAndGet(); + + try { + String clientIp = ((InetSocketAddress) ctx.channel().remoteAddress()) + .getAddress().getHostAddress(); + + if (checkRateLimit(clientIp)) { + sendError(ctx, HttpResponseStatus.TOO_MANY_REQUESTS, "Rate limit exceeded"); + blockedRequests.incrementAndGet(); + return; + } + + QueryStringDecoder queryDecoder = new QueryStringDecoder(request.uri()); + String path = queryDecoder.path(); + + if ("/download".equals(path)) { + handleDownload(ctx, request, queryDecoder); + } else if ("/metrics".equals(path)) { + handleMetrics(ctx); + } else { + sendError(ctx, HttpResponseStatus.NOT_FOUND, "Not Found"); + } + } catch (Exception e) { + CraftEngine.instance().logger().warn("Request handling failed", e); + sendError(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR, "Internal Error"); + } + } + + private void handleDownload(ChannelHandlerContext ctx, FullHttpRequest request, QueryStringDecoder queryDecoder) { + if (useToken) { + String token = queryDecoder.parameters().getOrDefault("token", java.util.Collections.emptyList()).stream().findFirst().orElse(null); + if (!validateToken(token)) { + sendError(ctx, HttpResponseStatus.FORBIDDEN, "Invalid token"); + blockedRequests.incrementAndGet(); + return; + } + } + + if (denyNonMinecraft) { + String userAgent = request.headers().get(HttpHeaderNames.USER_AGENT); + if (userAgent == null || !userAgent.startsWith("Minecraft Java/")) { + sendError(ctx, HttpResponseStatus.FORBIDDEN, "Invalid client"); + blockedRequests.incrementAndGet(); + return; + } + } + + if (resourcePackBytes == null) { + sendError(ctx, HttpResponseStatus.NOT_FOUND, "Resource pack missing"); + blockedRequests.incrementAndGet(); + return; + } + + FullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, + HttpResponseStatus.OK, + Unpooled.wrappedBuffer(resourcePackBytes) + ); + response.headers() + .set(HttpHeaderNames.CONTENT_TYPE, "application/zip") + .set(HttpHeaderNames.CONTENT_LENGTH, resourcePackBytes.length); + + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + private void handleMetrics(ChannelHandlerContext ctx) { + String metrics = "# TYPE total_requests counter\n" + + "total_requests " + totalRequests.get() + "\n" + + "# TYPE blocked_requests counter\n" + + "blocked_requests " + blockedRequests.get(); + + FullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, + HttpResponseStatus.OK, + Unpooled.copiedBuffer(metrics, CharsetUtil.UTF_8) + ); + response.headers() + .set(HttpHeaderNames.CONTENT_TYPE, "text/plain") + .set(HttpHeaderNames.CONTENT_LENGTH, metrics.length()); + + 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 validateToken(String token) { + if (token == null || token.length() != 36) return false; + Boolean valid = oneTimePackUrls.getIfPresent(token); + if (valid != null) { + oneTimePackUrls.invalidate(token); + return true; + } + return false; + } + + private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status, String message) { + FullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, + status, + Unpooled.copiedBuffer(message, CharsetUtil.UTF_8) + ); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + CraftEngine.instance().logger().warn("Channel error", cause); + ctx.close(); + } + } + + @Nullable + public ResourcePackDownloadData generateOneTimeUrl() { + if (this.resourcePackBytes == null) return null; + + if (!this.useToken) { + return new ResourcePackDownloadData(url() + "download", this.packUUID, this.packHash); + } + + String token = UUID.randomUUID().toString(); + oneTimePackUrls.put(token, true); + return new ResourcePackDownloadData( + url() + "download?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8), + packUUID, + packHash + ); + } + + public void disable() { + if (serverChannel != null) { + serverChannel.close().awaitUninterruptibly(); + bossGroup.shutdownGracefully(); + workerGroup.shutdownGracefully(); + serverChannel = null; + } + } + public void readResourcePack(Path path) { try { if (Files.exists(path)) { @@ -162,151 +317,8 @@ public class SelfHostHttpServer { } } - private void handleMetrics(HttpExchange exchange) throws IOException { - String metrics = "# TYPE total_requests counter\n" - + "total_requests " + totalRequests.get() + "\n" - + "# TYPE blocked_requests counter\n" - + "blocked_requests " + blockedRequests.get(); - - exchange.getResponseHeaders().set("Content-Type", "text/plain"); - exchange.sendResponseHeaders(200, metrics.length()); - try (OutputStream os = exchange.getResponseBody()) { - os.write(metrics.getBytes(StandardCharsets.UTF_8)); - } - } - - public void disable() { - if (this.server != null) { - this.server.stop(0); - this.server = null; - if (this.threadPool != null) { - this.threadPool.shutdownNow(); - this.threadPool = null; - } - } - } - - private class ResourcePackHandler implements HttpHandler { - @Override - public void handle(HttpExchange exchange) throws IOException { - try { - totalRequests.incrementAndGet(); - - String clientIp = getClientIp(exchange); - if (checkRateLimit(clientIp)) { - handleBlockedRequest(exchange, 429, "Rate limit exceeded"); - return; - } - if (useToken) { - String token = parseToken(exchange); - if (!validateToken(token)) { - handleBlockedRequest(exchange, 403, "Invalid token"); - return; - } - } - if (!validateClient(exchange)) { - handleBlockedRequest(exchange, 403, "Invalid client"); - return; - } - if (resourcePackBytes == null) { - handleBlockedRequest(exchange, 404, "Resource pack missing"); - return; - } - sendResourcePack(exchange); - } catch (Exception e) { - handleBlockedRequest(exchange, 500, "Internal error"); - CraftEngine.instance().logger().warn("Request handling failed", e); - } - } - - private String getClientIp(HttpExchange exchange) { - return exchange.getRemoteAddress().getAddress().getHostAddress(); - } - - 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); - } else { - if (now - record.lastAccessTime > rateLimitInterval) { - record = new IpAccessRecord(now, 1); - ipAccessCache.put(clientIp, record); - } else { - record.accessCount++; - } - } - return record.accessCount > rateLimit; - } - - private String parseToken(HttpExchange exchange) { - Map params = parseQuery(exchange.getRequestURI().getQuery()); - return params.get("token"); - } - - private boolean validateToken(String token) { - if (token == null || token.length() != 36) return false; - - Boolean valid = oneTimePackUrls.getIfPresent(token); - if (valid != null) { - oneTimePackUrls.invalidate(token); - return true; - } - return false; - } - - private boolean validateClient(HttpExchange exchange) { - if (!denyNonMinecraft) return true; - - String userAgent = exchange.getRequestHeaders().getFirst("User-Agent"); - return userAgent != null && userAgent.startsWith("Minecraft Java/"); - } - - private void sendResourcePack(HttpExchange exchange) throws IOException { - exchange.getResponseHeaders().set("Content-Type", "application/zip"); - exchange.getResponseHeaders().set("Content-Length", String.valueOf(resourcePackBytes.length)); - exchange.sendResponseHeaders(200, resourcePackBytes.length); - - try (OutputStream os = exchange.getResponseBody()) { - os.write(resourcePackBytes); - } catch (IOException e) { - if (!e.getMessage().contains("abort") && !e.getMessage().contains("reset")) { - CraftEngine.instance().logger().warn("Failed to send resource pack", e); - throw e; - } - CraftEngine.instance().debug(() -> "Client aborted resource pack download: " + e.getMessage()); - } - } - - private void handleBlockedRequest(HttpExchange exchange, int code, String reason) throws IOException { - blockedRequests.incrementAndGet(); - CraftEngine.instance().debug(() -> - String.format("Blocked request [%s] %s: %s", - code, - exchange.getRemoteAddress(), - reason) - ); - exchange.sendResponseHeaders(code, -1); - exchange.close(); - } - - private Map parseQuery(String query) { - Map params = new HashMap<>(); - if (query == null) return params; - - for (String pair : query.split("&")) { - int idx = pair.indexOf("="); - String key = idx > 0 ? pair.substring(0, idx) : pair; - String value = idx > 0 ? pair.substring(idx + 1) : ""; - params.put(key, value); - } - return params; - } - } - private static class IpAccessRecord { - final long lastAccessTime; + long lastAccessTime; int accessCount; IpAccessRecord(long lastAccessTime, int accessCount) { 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 c9c1be83b..5996289c3 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 @@ -315,6 +315,7 @@ public abstract class CraftEngine implements Plugin { Dependencies.AHO_CORASICK, Dependencies.LZ4, Dependencies.EVALEX, + Dependencies.NETTY_HTTP, Dependencies.JIMFS, Dependencies.COMMONS_IMAGING ); diff --git a/gradle.properties b/gradle.properties index 03cc96751..ccb8b0586 100644 --- a/gradle.properties +++ b/gradle.properties @@ -42,7 +42,7 @@ commons_imaging_version=1.0.0-alpha6 sparrow_nbt_version=0.7.3 sparrow_util_version=0.47 fastutil_version=8.5.15 -netty_version=4.1.119.Final +netty_version=4.1.121.Final joml_version=1.10.8 datafixerupper_version=6.0.8 mojang_brigadier_version=1.0.18