9
0
mirror of https://github.com/Xiao-MoMi/craft-engine.git synced 2025-12-20 23:49:27 +00:00

使用netty实现self host

This commit is contained in:
XiaoMoMi
2025-05-23 22:51:25 +08:00
parent d0e2429008
commit 3530be68a4
8 changed files with 225 additions and 259 deletions

View File

@@ -175,13 +175,6 @@ debug_is_section_injected:
- /craftengine debug is-section-injected - /craftengine debug is-section-injected
- /ce 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: debug_test:
enable: true enable: true
permission: ce.command.debug.test permission: ce.command.debug.test

View File

@@ -39,7 +39,6 @@ public class BukkitCommandManager extends AbstractCommandManager<CommandSender>
new SearchUsageAdminCommand(this, plugin), new SearchUsageAdminCommand(this, plugin),
new TestCommand(this, plugin), new TestCommand(this, plugin),
new DebugGetBlockStateRegistryIdCommand(this, plugin), new DebugGetBlockStateRegistryIdCommand(this, plugin),
new DebugHostStatusCommand(this, plugin),
new DebugGetBlockInternalIdCommand(this, plugin), new DebugGetBlockInternalIdCommand(this, plugin),
new DebugAppearanceStateUsageCommand(this, plugin), new DebugAppearanceStateUsageCommand(this, plugin),
new DebugRealStateUsageCommand(this, plugin), new DebugRealStateUsageCommand(this, plugin),

View File

@@ -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<CommandSender> {
public DebugHostStatusCommand(CraftEngineCommandManager<CommandSender> commandManager, CraftEngine plugin) {
super(commandManager, plugin);
}
@Override
public Command.Builder<? extends CommandSender> assembleCommand(org.incendo.cloud.CommandManager<CommandSender> manager, Command.Builder<CommandSender> 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";
}
}

View File

@@ -41,6 +41,7 @@ dependencies {
compileOnly("org.apache.logging.log4j:log4j-core:${rootProject.properties["log4j_version"]}") compileOnly("org.apache.logging.log4j:log4j-core:${rootProject.properties["log4j_version"]}")
// Netty // Netty
compileOnly("io.netty:netty-all:${rootProject.properties["netty_version"]}") compileOnly("io.netty:netty-all:${rootProject.properties["netty_version"]}")
compileOnly("io.netty:netty-codec-http:${rootProject.properties["netty_version"]}")
// Cache // Cache
compileOnly("com.github.ben-manes.caffeine:caffeine:${rootProject.properties["caffeine_version"]}") compileOnly("com.github.ben-manes.caffeine:caffeine:${rootProject.properties["caffeine_version"]}")
// Compression // Compression

View File

@@ -15,7 +15,6 @@ public class S3HostFactory implements ResourcePackHostFactory {
public ResourcePackHost create(Map<String, Object> arguments) { public ResourcePackHost create(Map<String, Object> arguments) {
CraftEngine.instance().dependencyManager().loadDependencies( CraftEngine.instance().dependencyManager().loadDependencies(
List.of( List.of(
Dependencies.NETTY_HTTP,
Dependencies.NETTY_HTTP2, Dependencies.NETTY_HTTP2,
Dependencies.REACTIVE_STREAMS, Dependencies.REACTIVE_STREAMS,
Dependencies.AMAZON_AWSSDK_S3, Dependencies.AMAZON_AWSSDK_S3,

View File

@@ -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.Cache;
import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Scheduler; import com.github.benmanes.caffeine.cache.Scheduler;
import com.sun.net.httpserver.HttpExchange; import io.netty.bootstrap.ServerBootstrap;
import com.sun.net.httpserver.HttpHandler; import io.netty.buffer.Unpooled;
import com.sun.net.httpserver.HttpServer; 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.pack.host.ResourcePackDownloadData;
import net.momirealms.craftengine.core.plugin.CraftEngine; import net.momirealms.craftengine.core.plugin.CraftEngine;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -19,30 +23,26 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
public class SelfHostHttpServer { public class SelfHostHttpServer {
private static SelfHostHttpServer instance; private static SelfHostHttpServer instance;
// Caffeine缓存和统计计数器
private final Cache<String, Boolean> oneTimePackUrls = Caffeine.newBuilder() private final Cache<String, Boolean> oneTimePackUrls = Caffeine.newBuilder()
.maximumSize(256) .maximumSize(256)
.scheduler(Scheduler.systemScheduler()) .scheduler(Scheduler.systemScheduler())
.expireAfterWrite(1, TimeUnit.MINUTES) .expireAfterWrite(1, TimeUnit.MINUTES)
.build(); .build();
private final Cache<String, IpAccessRecord> ipAccessCache = Caffeine.newBuilder() private final Cache<String, IpAccessRecord> ipAccessCache = Caffeine.newBuilder()
.maximumSize(256) .maximumSize(256)
.scheduler(Scheduler.systemScheduler()) .scheduler(Scheduler.systemScheduler())
.expireAfterWrite(10, TimeUnit.MINUTES) .expireAfterWrite(10, TimeUnit.MINUTES)
.build(); .build();
private ExecutorService threadPool;
private HttpServer server;
private final AtomicLong totalRequests = new AtomicLong(); private final AtomicLong totalRequests = new AtomicLong();
private final AtomicLong blockedRequests = new AtomicLong(); private final AtomicLong blockedRequests = new AtomicLong();
@@ -59,47 +59,9 @@ public class SelfHostHttpServer {
private String packHash; private String packHash;
private UUID packUUID; private UUID packUUID;
public void updateProperties(String ip, private EventLoopGroup bossGroup;
int port, private EventLoopGroup workerGroup;
String url, private Channel serverChannel;
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;
}
public static SelfHostHttpServer instance() { public static SelfHostHttpServer instance() {
if (instance == null) { if (instance == null) {
@@ -108,21 +70,31 @@ public class SelfHostHttpServer {
return instance; return instance;
} }
@Nullable public void updateProperties(String ip,
public ResourcePackDownloadData generateOneTimeUrl() { int port,
if (this.resourcePackBytes == null) { String url,
return null; 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); if (this.port == port && serverChannel != null) return;
} disable();
String token = UUID.randomUUID().toString();
this.oneTimePackUrls.put(token, true); this.port = port;
return new ResourcePackDownloadData( initializeServer();
url() + "download?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8),
this.packUUID,
this.packHash
);
} }
public String url() { public String url() {
@@ -132,6 +104,189 @@ public class SelfHostHttpServer {
return this.protocol + "://" + this.ip + ":" + this.port + "/"; 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<SocketChannel>() {
@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<FullHttpRequest> {
@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) { public void readResourcePack(Path path) {
try { try {
if (Files.exists(path)) { 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<String, String> 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<String, String> parseQuery(String query) {
Map<String, String> 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 { private static class IpAccessRecord {
final long lastAccessTime; long lastAccessTime;
int accessCount; int accessCount;
IpAccessRecord(long lastAccessTime, int accessCount) { IpAccessRecord(long lastAccessTime, int accessCount) {

View File

@@ -315,6 +315,7 @@ public abstract class CraftEngine implements Plugin {
Dependencies.AHO_CORASICK, Dependencies.AHO_CORASICK,
Dependencies.LZ4, Dependencies.LZ4,
Dependencies.EVALEX, Dependencies.EVALEX,
Dependencies.NETTY_HTTP,
Dependencies.JIMFS, Dependencies.JIMFS,
Dependencies.COMMONS_IMAGING Dependencies.COMMONS_IMAGING
); );

View File

@@ -42,7 +42,7 @@ commons_imaging_version=1.0.0-alpha6
sparrow_nbt_version=0.7.3 sparrow_nbt_version=0.7.3
sparrow_util_version=0.47 sparrow_util_version=0.47
fastutil_version=8.5.15 fastutil_version=8.5.15
netty_version=4.1.119.Final netty_version=4.1.121.Final
joml_version=1.10.8 joml_version=1.10.8
datafixerupper_version=6.0.8 datafixerupper_version=6.0.8
mojang_brigadier_version=1.0.18 mojang_brigadier_version=1.0.18