9
0
mirror of https://github.com/Xiao-MoMi/craft-engine.git synced 2025-12-19 15:09:15 +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
- /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

View File

@@ -39,7 +39,6 @@ public class BukkitCommandManager extends AbstractCommandManager<CommandSender>
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),

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"]}")
// 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

View File

@@ -15,7 +15,6 @@ public class S3HostFactory implements ResourcePackHostFactory {
public ResourcePackHost create(Map<String, Object> arguments) {
CraftEngine.instance().dependencyManager().loadDependencies(
List.of(
Dependencies.NETTY_HTTP,
Dependencies.NETTY_HTTP2,
Dependencies.REACTIVE_STREAMS,
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.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<String, Boolean> oneTimePackUrls = Caffeine.newBuilder()
.maximumSize(256)
.scheduler(Scheduler.systemScheduler())
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
private final Cache<String, IpAccessRecord> 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<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) {
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<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 {
final long lastAccessTime;
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.LZ4,
Dependencies.EVALEX,
Dependencies.NETTY_HTTP,
Dependencies.JIMFS,
Dependencies.COMMONS_IMAGING
);

View File

@@ -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