diff --git a/build.gradle b/build.gradle index 1d0cf21..ada0eb1 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,7 @@ sourceCompatibility = targetCompatibility = compileJava.sourceCompatibility = co minecraft { mappings channel: 'snapshot', version: "20210215-1.16.3" + accessTransformer = project.file('src/main/resources/META-INF/accesstransformer.cfg') runs { client { workingDirectory project.file('run') diff --git a/src/main/java/com/ixnah/mc/protocol/CustomProtocol.java b/src/main/java/com/ixnah/mc/protocol/CustomProtocol.java new file mode 100644 index 0000000..48fa27f --- /dev/null +++ b/src/main/java/com/ixnah/mc/protocol/CustomProtocol.java @@ -0,0 +1,74 @@ +package com.ixnah.mc.protocol; + +import com.ixnah.mc.websocket.WebSocketConsumer; +import io.netty.channel.Channel; +import io.netty.util.AttributeKey; + +import java.net.URI; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/12 16:19 + */ +public class CustomProtocol { + + public static final AttributeKey HANDSHAKE_COMPLETED_KEY = AttributeKey.valueOf("CustomProtocolHandshakeCompleted"); + public static final AttributeKey SERVER_URI_KEY = AttributeKey.valueOf("CustomProtocolServerUri"); + public static final String URI_REGEX = "[a-zA-z]+://[^\\s]*"; + + private static final Map>> protocolMap = new ConcurrentHashMap<>(16); + private static final Map defaultPortMap = new ConcurrentHashMap<>(16); + + public static void accept(String name, Channel channel) { + Consumer channelConsumer = (ch) -> { }; + try { + Class> channelConsumerClass = protocolMap.get(name); + if (channelConsumerClass != null) { + channelConsumer = channelConsumerClass.newInstance(); + } + } catch (InstantiationException | IllegalAccessException e) { + e.printStackTrace(); + } + channelConsumer.accept(channel); + } + + public static void register(Class> channelConsumer, String... names) { + for (String name : names) { + protocolMap.put(name, channelConsumer); + } + } + + public static Class> unregister(String name) { + return protocolMap.remove(name); + } + + public static void setDefaultPort(int port, String... names) { + for (String name : names) { + defaultPortMap.put(name, port); + } + } + + public static int getServerUriPort(URI serverUri) { + int uriPort = serverUri.getPort(); + if (uriPort != -1) { + return uriPort; + } + String name = serverUri.getScheme(); + if (name != null) { + name = name.toLowerCase(Locale.ROOT); + } + return defaultPortMap.getOrDefault(name, 25565); + } + + static { + register(WebSocketConsumer.class, "ws", "wss", "http", "https"); + setDefaultPort(80, "ws", "http"); + setDefaultPort(443, "wss", "https"); + setDefaultPort(25565, "tcp", "mc", "minecraft"); + } +} diff --git a/src/main/java/com/ixnah/mc/protocol/UriServerAddress.java b/src/main/java/com/ixnah/mc/protocol/UriServerAddress.java new file mode 100644 index 0000000..46544ec --- /dev/null +++ b/src/main/java/com/ixnah/mc/protocol/UriServerAddress.java @@ -0,0 +1,24 @@ +package com.ixnah.mc.protocol; + +import net.minecraft.client.multiplayer.ServerAddress; + +import java.net.URI; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/12 10:55 + */ +public class UriServerAddress extends ServerAddress { + + private final URI address; + + public UriServerAddress(URI address) { + super(address.getHost(), CustomProtocol.getServerUriPort(address)); + this.address = address.normalize(); + } + + public URI getAddress() { + return address; + } +} diff --git a/src/main/java/com/ixnah/mc/protocol/bridge/CustomProtocolBridge.java b/src/main/java/com/ixnah/mc/protocol/bridge/CustomProtocolBridge.java new file mode 100644 index 0000000..d17a983 --- /dev/null +++ b/src/main/java/com/ixnah/mc/protocol/bridge/CustomProtocolBridge.java @@ -0,0 +1,12 @@ +package com.ixnah.mc.protocol.bridge; + +import java.net.URI; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/12 12:05 + */ +public interface CustomProtocolBridge { + void setServerUri(URI uri); +} diff --git a/src/main/java/com/ixnah/mc/protocol/mixin/client/gui/screen/ConnectingScreenMixin.java b/src/main/java/com/ixnah/mc/protocol/mixin/client/gui/screen/ConnectingScreenMixin.java new file mode 100644 index 0000000..545a010 --- /dev/null +++ b/src/main/java/com/ixnah/mc/protocol/mixin/client/gui/screen/ConnectingScreenMixin.java @@ -0,0 +1,133 @@ +package com.ixnah.mc.protocol.mixin.client.gui.screen; + +import com.ixnah.mc.protocol.UriServerAddress; +import com.ixnah.mc.protocol.bridge.CustomProtocolBridge; +import net.minecraft.client.gui.DialogTexts; +import net.minecraft.client.gui.screen.ConnectingScreen; +import net.minecraft.client.gui.screen.DisconnectedScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.multiplayer.ServerAddress; +import net.minecraft.client.network.login.ClientLoginNetHandler; +import net.minecraft.network.NetworkManager; +import net.minecraft.network.ProtocolType; +import net.minecraft.network.handshake.client.CHandshakePacket; +import net.minecraft.network.login.client.CLoginStartPacket; +import net.minecraft.util.DefaultUncaughtExceptionHandler; +import net.minecraft.util.text.ITextComponent; +import net.minecraft.util.text.TranslationTextComponent; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.ixnah.mc.protocol.CustomProtocol.URI_REGEX; +import static java.util.Objects.requireNonNull; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/12 22:47 + */ +@Mixin(ConnectingScreen.class) +public abstract class ConnectingScreenMixin extends Screen { + @Shadow + @Final + private static final AtomicInteger CONNECTION_ID = new AtomicInteger(0); + @Shadow + @Final + private static final Logger LOGGER = LogManager.getLogger(); + @Shadow + private NetworkManager networkManager; + @Shadow + private boolean cancel; + @Shadow + @Final + private Screen previousGuiScreen; + + private URI serverUri; + + @Shadow + private void func_209514_a(ITextComponent p_209514_1_) { + } + + protected ConnectingScreenMixin(ITextComponent titleIn) { + super(titleIn); + } + + @Redirect(method = "(Lnet/minecraft/client/gui/screen/Screen;Lnet/minecraft/client/Minecraft;Lnet/minecraft/client/multiplayer/ServerData;)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/multiplayer/ServerAddress;getIP()Ljava/lang/String;")) + private String getServerIP(ServerAddress serverAddress) { + if (serverAddress instanceof UriServerAddress) { + return ((UriServerAddress) serverAddress).getAddress().toString(); + } + return serverAddress.getIP(); + } + + @Redirect(method = "*", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/screen/ConnectingScreen;connect(Ljava/lang/String;I)V")) + private void redirectConnect(ConnectingScreen connectingScreen, String ip, int port) { + if (ip.matches(URI_REGEX)) { + try { + UriServerAddress uriServerAddress = new UriServerAddress(new URI(ip)); + serverUri = uriServerAddress.getAddress(); + this.connect(uriServerAddress.getIP(), uriServerAddress.getPort()); + return; + } catch (URISyntaxException ignored) { + } + } + this.connect(ip, port); + } + + /** + * @author 寒兮 + * @reason Mixin不支持非static class + * static class无法访问外部类的变量 + */ + @Overwrite + private void connect(final String ip, final int port) { + requireNonNull(this.minecraft, "Minecraft must not be null!"); + LOGGER.info("Connecting to {}, {}", ip, port); + Thread thread = new Thread(() -> { + InetAddress inetaddress = null; + + try { + if (this.cancel) { + return; + } + + inetaddress = InetAddress.getByName(ip); + this.networkManager = NetworkManager.createNetworkManagerAndConnect(inetaddress, port, this.minecraft.gameSettings.isUsingNativeTransport()); + ((CustomProtocolBridge) this.networkManager).setServerUri(serverUri); + this.networkManager.setNetHandler(new ClientLoginNetHandler(this.networkManager, this.minecraft, this.previousGuiScreen, this::func_209514_a)); + this.networkManager.sendPacket(new CHandshakePacket(ip, port, ProtocolType.LOGIN)); + this.networkManager.sendPacket(new CLoginStartPacket(this.minecraft.getSession().getProfile())); + } catch (UnknownHostException unknownhostexception) { + if (this.cancel) { + return; + } + + LOGGER.error("Couldn't connect to server", unknownhostexception); + this.minecraft.execute(() -> this.minecraft.displayGuiScreen(new DisconnectedScreen(this.previousGuiScreen, DialogTexts.CONNECTION_FAILED, new TranslationTextComponent("disconnect.genericReason", "Unknown host")))); + } catch (Exception exception) { + if (this.cancel) { + return; + } + + LOGGER.error("Couldn't connect to server", exception); + String s = inetaddress == null ? exception.toString() : exception.toString().replaceAll(inetaddress + ":" + port, ""); + this.minecraft.execute(() -> this.minecraft.displayGuiScreen(new DisconnectedScreen(this.previousGuiScreen, DialogTexts.CONNECTION_FAILED, new TranslationTextComponent("disconnect.genericReason", s)))); + } + + }, "Server Connector #" + CONNECTION_ID.incrementAndGet()); + thread.setUncaughtExceptionHandler(new DefaultUncaughtExceptionHandler(LOGGER)); + thread.start(); + } +} diff --git a/src/main/java/com/ixnah/mc/protocol/mixin/client/multiplayer/ServerAddressMixin.java b/src/main/java/com/ixnah/mc/protocol/mixin/client/multiplayer/ServerAddressMixin.java new file mode 100644 index 0000000..a224f05 --- /dev/null +++ b/src/main/java/com/ixnah/mc/protocol/mixin/client/multiplayer/ServerAddressMixin.java @@ -0,0 +1,31 @@ +package com.ixnah.mc.protocol.mixin.client.multiplayer; + +import com.ixnah.mc.protocol.UriServerAddress; +import net.minecraft.client.multiplayer.ServerAddress; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.net.URI; + +import static com.ixnah.mc.protocol.CustomProtocol.URI_REGEX; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/12 10:53 + */ +@Mixin(ServerAddress.class) +public class ServerAddressMixin { + + @Inject(method = "fromString", cancellable = true, at = @At("HEAD")) + private static void parseCustomProtocol(String addrString, CallbackInfoReturnable cir) { + try { + if (addrString.matches(URI_REGEX)) + cir.setReturnValue(new UriServerAddress(new URI(addrString.trim()))); + } catch (Throwable e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/java/com/ixnah/mc/protocol/mixin/client/network/ServerPingerMixin.java b/src/main/java/com/ixnah/mc/protocol/mixin/client/network/ServerPingerMixin.java new file mode 100644 index 0000000..ff9ffe2 --- /dev/null +++ b/src/main/java/com/ixnah/mc/protocol/mixin/client/network/ServerPingerMixin.java @@ -0,0 +1,30 @@ +package com.ixnah.mc.protocol.mixin.client.network; + +import com.ixnah.mc.protocol.UriServerAddress; +import com.ixnah.mc.protocol.bridge.CustomProtocolBridge; +import net.minecraft.client.multiplayer.ServerAddress; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.client.network.ServerPinger; +import net.minecraft.network.NetworkManager; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.LocalCapture; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/12 8:44 + */ +@Mixin(ServerPinger.class) +public class ServerPingerMixin { + + @Inject(method = "ping", locals = LocalCapture.CAPTURE_FAILSOFT, at = @At(value = "INVOKE_ASSIGN", target = "Lnet/minecraft/network/NetworkManager;createNetworkManagerAndConnect(Ljava/net/InetAddress;IZ)Lnet/minecraft/network/NetworkManager;")) + private void connectCustomProtocol(ServerData server, Runnable p_147224_2_, CallbackInfo ci, ServerAddress serveraddress, NetworkManager networkmanager) { + if (serveraddress instanceof UriServerAddress) { + UriServerAddress uriServerAddress = (UriServerAddress) serveraddress; + ((CustomProtocolBridge) networkmanager).setServerUri(uriServerAddress.getAddress()); + } + } +} diff --git a/src/main/java/com/ixnah/mc/protocol/mixin/network/NetworkManagerMixin.java b/src/main/java/com/ixnah/mc/protocol/mixin/network/NetworkManagerMixin.java new file mode 100644 index 0000000..44b2326 --- /dev/null +++ b/src/main/java/com/ixnah/mc/protocol/mixin/network/NetworkManagerMixin.java @@ -0,0 +1,45 @@ +package com.ixnah.mc.protocol.mixin.network; + +import com.ixnah.mc.protocol.CustomProtocol; +import com.ixnah.mc.protocol.bridge.CustomProtocolBridge; +import io.netty.channel.Channel; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.GenericFutureListener; +import net.minecraft.network.IPacket; +import net.minecraft.network.NetworkManager; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.net.URI; +import java.util.Locale; + +import static com.ixnah.mc.protocol.util.SpinUtil.spinRequireNonNull; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/12 11:56 + */ +@Mixin(NetworkManager.class) +public class NetworkManagerMixin implements CustomProtocolBridge { + @Shadow private Channel channel; + + @Inject(method = "dispatchPacket", at = @At("HEAD")) + public void onDispatchPacket(IPacket inPacket, GenericFutureListener> futureListeners, CallbackInfo ci) { + Boolean handshakeCompleted = channel.attr(CustomProtocol.HANDSHAKE_COMPLETED_KEY).get(); + URI serverUri = channel.attr(CustomProtocol.SERVER_URI_KEY).get(); + if (serverUri != null && (handshakeCompleted == null || !handshakeCompleted)) { + CustomProtocol.accept(serverUri.getScheme().toLowerCase(Locale.ROOT), channel); + } + channel.attr(CustomProtocol.HANDSHAKE_COMPLETED_KEY).set(true); + } + + @Override + public void setServerUri(URI uri) { + spinRequireNonNull(this, "channel", "channel can't be null!"); + channel.attr(CustomProtocol.SERVER_URI_KEY).set(uri); + } +} diff --git a/src/main/java/com/ixnah/mc/protocol/util/SpinUtil.java b/src/main/java/com/ixnah/mc/protocol/util/SpinUtil.java new file mode 100644 index 0000000..433bfa9 --- /dev/null +++ b/src/main/java/com/ixnah/mc/protocol/util/SpinUtil.java @@ -0,0 +1,34 @@ +package com.ixnah.mc.protocol.util; + +import net.minecraftforge.fml.unsafe.UnsafeHacks; + +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; + +import static java.util.Objects.requireNonNull; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/19 15:21 + */ +public class SpinUtil { + + private SpinUtil() { + } + + public static T spinRequireNonNull(Object object, String fieldName, String message) { + try { + Field field = object.getClass().getDeclaredField(fieldName); + AtomicInteger count = new AtomicInteger(0); + T result; + while ((result = UnsafeHacks.getField(field, object)) == null && count.incrementAndGet() < 1000) { + LockSupport.parkNanos("SpinRequireNonNull", 1000L); // 获取时可能为null, 自旋等待 + } + return requireNonNull(result, "channel can't be null!"); + } catch (NoSuchFieldException e) { + throw new NullPointerException(message); + } + } +} diff --git a/src/main/java/com/ixnah/mc/websocket/WebSocketConsumer.java b/src/main/java/com/ixnah/mc/websocket/WebSocketConsumer.java new file mode 100644 index 0000000..812cffb --- /dev/null +++ b/src/main/java/com/ixnah/mc/websocket/WebSocketConsumer.java @@ -0,0 +1,172 @@ +package com.ixnah.mc.websocket; + +import com.ixnah.mc.protocol.CustomProtocol; +import com.ixnah.mc.websocket.handler.EncoderWrapper; +import com.ixnah.mc.websocket.handler.HttpClientHandler; +import com.ixnah.mc.websocket.handler.PacketToFrameHandler; +import com.ixnah.mc.websocket.handler.WebSocketClientHandler; +import com.ixnah.mc.websocket.util.ManifestUtil; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.handler.codec.MessageToByteEncoder; +import io.netty.handler.codec.http.*; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import io.netty.util.concurrent.Future; +import net.minecraft.client.Minecraft; +import net.minecraft.util.MinecraftVersion; + +import javax.net.ssl.SSLException; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.function.Consumer; + +import static io.netty.handler.codec.http.HttpHeaderNames.*; +import static io.netty.handler.codec.http.HttpHeaderValues.KEEP_ALIVE; +import static io.netty.handler.codec.http.HttpHeaderValues.*; +import static io.netty.handler.codec.http.HttpMethod.GET; +import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/12 13:07 + */ +public class WebSocketConsumer implements Consumer { + public static final String USER_AGENT_FORMAT = "LightfallClient/1.0.0 Minecraft/%s Netty/%s Java/%s"; + private static final String PACKET_HANDLER_NAME = "packet_handler"; + private final Map removedEncoder = new LinkedHashMap<>(8); + private Channel channel; + + /** + * 由于 {@link WebSocketClientHandshaker#handshake(io.netty.channel.Channel, io.netty.channel.ChannelPromise)} + * 发送HTTP握手包会直接调用 {@link Channel#writeAndFlush(java.lang.Object)}, 所以说必须在WebSocket握手前 + * 把 {@link MessageToByteEncoder} 从pipeline中临时移除. + */ + @Override + public void accept(Channel channel) { + this.channel = channel; + cleanPipeline(); + handshake(); + resetPipeline(); + } + + private void cleanPipeline() { + channel.pipeline().toMap().forEach((name, handler) -> { + if (handler instanceof MessageToByteEncoder) { + MessageToByteEncoder encoder = (MessageToByteEncoder) handler; + if (encoder.isSharable()) { + removedEncoder.put(name, encoder); + } else { + try { + MethodType methodType = MethodType.methodType(void.class); + MethodHandle handle = MethodHandles.lookup().findConstructor(MessageToByteEncoder.class, methodType); + removedEncoder.put(name, handle); + } catch (NoSuchMethodException | IllegalAccessException exception) { + removedEncoder.put(name, new EncoderWrapper<>(encoder)); + } + } + channel.pipeline().remove(encoder); + } + }); + } + + private void resetPipeline() { + removedEncoder.forEach((name, encoder) -> { + if (encoder instanceof ChannelHandler) { + channel.pipeline().addBefore(PACKET_HANDLER_NAME, name, (ChannelHandler) encoder); + } else { + MethodHandle encoderConstructor = (MethodHandle) encoder; + try { + MessageToByteEncoder newEncoder = (MessageToByteEncoder) encoderConstructor.invoke(); + channel.pipeline().addBefore(PACKET_HANDLER_NAME, name, newEncoder); + } catch (Throwable throwable) { + throwable.printStackTrace(); + } + } + }); + removedEncoder.clear(); + } + + private void handshake() { + URI serverUri = channel.attr(CustomProtocol.SERVER_URI_KEY).get(); + String scheme = serverUri.getScheme().toLowerCase(Locale.ROOT).replace("ws", "http"); + boolean useSSL = scheme.equals("https"); + if (useSSL) { + try { + SslContext sslContext = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build(); + channel.pipeline().addFirst("SSL", sslContext.newHandler(channel.alloc())); // TODO: SSL证书验证 + } catch (SSLException e) { + e.printStackTrace(); + } + } + HttpClientHandler httpClient = new HttpClientHandler(); + channel.pipeline() + .addAfter("timeout", "HttpClientHandler", httpClient) + .addAfter("timeout", "HttpObjectAggregator", new HttpObjectAggregator(8192)) + .addAfter("timeout", "HttpClientCodec", new HttpClientCodec()); + + String host = serverUri.getHost(); + if (serverUri.getPort() != -1) { + host = host + ":" + serverUri.getPort(); + } + String minecraftVersion = MinecraftVersion.GAME_VERSION.getName(); + String nettyVersion = ManifestUtil.getValue(Channel.class, "Implementation-Version"); + String javaVersion = System.getProperty("java.version"); + String userAgent = String.format(USER_AGENT_FORMAT, minecraftVersion, nettyVersion, javaVersion); + String playerId = Minecraft.getInstance().getSession().getPlayerID(); + + String serverPath = serverUri.getPath(); + if (serverPath == null || serverPath.trim().isEmpty()) { + serverPath = "/"; + } else { + serverPath = serverUri.toASCIIString().split(host, 2)[1]; + } + HttpRequest request = new DefaultFullHttpRequest(HTTP_1_1, GET, serverPath); + request.headers() + .add(HOST, host) + .add(ACCEPT, "*/*") + .add(USER_AGENT, userAgent) + .add(PRAGMA, NO_CACHE) + .add(CACHE_CONTROL, NO_STORE) // 设置CDN不缓存 + .add(CONNECTION, KEEP_ALIVE) // 设置长链接 + .add(AUTHORIZATION, playerId); // 传递UUID + + Future responseFuture = httpClient.sendRequest(request).syncUninterruptibly(); + if (!responseFuture.isSuccess()) { + responseFuture.cause().printStackTrace(); + throw new RuntimeException(responseFuture.cause()); + } + HttpResponse response = responseFuture.getNow(); + if (response.status().code() >= HttpResponseStatus.BAD_REQUEST.code()) { // TODO: 可能出现的重定向 301 302 + RuntimeException exception = new RuntimeException(response.status().toString()); + exception.printStackTrace(); + throw exception; + } + + String token = ((FullHttpResponse) response).content().toString(Charset.defaultCharset()); + HttpHeaders headers = new DefaultHttpHeaders(); + headers.add(USER_AGENT, userAgent).add(AUTHORIZATION, token); + WebSocketClientHandshaker handshaker = WebSocketClientHandshakerFactory + .newHandshaker(serverUri, WebSocketVersion.V13, "Minecraft", true, headers); + WebSocketClientHandler webSocketClientHandler = new WebSocketClientHandler(handshaker); + channel.pipeline().replace("HttpClientHandler", "WebSocketClientHandler", webSocketClientHandler); // 发送WebSocket握手请求 + Future handshakeFuture = webSocketClientHandler.handshakeFuture().syncUninterruptibly(); // 阻塞: 等待握手结束 + if (!handshakeFuture.isSuccess()) { // 握手失败 抛出异常退出连接 + handshakeFuture.cause().printStackTrace(); + throw new RuntimeException(handshakeFuture.cause()); + } + // 握手成功 + channel.pipeline().addBefore(PACKET_HANDLER_NAME, "PacketToFrameHandler", new PacketToFrameHandler()); + } +} diff --git a/src/main/java/com/ixnah/mc/websocket/handler/EncoderWrapper.java b/src/main/java/com/ixnah/mc/websocket/handler/EncoderWrapper.java new file mode 100644 index 0000000..2bbc054 --- /dev/null +++ b/src/main/java/com/ixnah/mc/websocket/handler/EncoderWrapper.java @@ -0,0 +1,29 @@ +package com.ixnah.mc.websocket.handler; + +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelOutboundHandlerAdapter; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.MessageToByteEncoder; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/19 13:07 + * + * 如 {@link net.minecraft.network.NettyPacketEncoder} 等Encoder没有使用注解Sharable, 在移除一次后不能再次添加, 所以使用包装类解决 + */ +@Sharable +public class EncoderWrapper extends ChannelOutboundHandlerAdapter { + + private final MessageToByteEncoder encoder; + + public EncoderWrapper(MessageToByteEncoder encoder) { + this.encoder = encoder; + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + encoder.write(ctx, msg, promise); + } +} diff --git a/src/main/java/com/ixnah/mc/websocket/handler/HttpClientHandler.java b/src/main/java/com/ixnah/mc/websocket/handler/HttpClientHandler.java new file mode 100644 index 0000000..1b6bd1d --- /dev/null +++ b/src/main/java/com/ixnah/mc/websocket/handler/HttpClientHandler.java @@ -0,0 +1,70 @@ +package com.ixnah.mc.websocket.handler; + +import com.ixnah.mc.websocket.util.HttpClient; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.util.concurrent.DefaultPromise; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.Promise; + +import java.util.LinkedList; +import java.util.Queue; + +import static com.ixnah.mc.protocol.util.SpinUtil.spinRequireNonNull; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2020/7/8 13:19 + */ +public class HttpClientHandler extends SimpleChannelInboundHandler implements HttpClient { + + private ChannelHandlerContext ctx; + private final Queue> responsePromiseQueue = new LinkedList<>(); + + public HttpClientHandler() { + super(false); + } + + @Override + public void handlerAdded(ChannelHandlerContext ctx) { + if (this.ctx == null) + this.ctx = ctx; + } + + @Override + public void handlerRemoved(ChannelHandlerContext ctx) { + synchronized (responsePromiseQueue) { + try { + for (Promise promise : responsePromiseQueue) { + promise.tryFailure(new RuntimeException("handlerRemoved")); + } + } catch (Throwable t) { + t.printStackTrace(); + } + } + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof HttpResponse && !responsePromiseQueue.isEmpty()) { + responsePromiseQueue.poll().setSuccess((HttpResponse) msg); + } + } + + @Override + public Future sendRequest(HttpRequest request) { + spinRequireNonNull(this, "ctx", "HttpClientHandler must add to pipeline"); + Promise responsePromise = new DefaultPromise<>(ctx.executor()); + ctx.channel().writeAndFlush(request).addListener(writeFuture -> { + if (!writeFuture.isSuccess()) { + responsePromise.setFailure(writeFuture.cause()); + } else { + responsePromiseQueue.offer(responsePromise); + } + }); + return responsePromise; + } +} diff --git a/src/main/java/com/ixnah/mc/websocket/handler/PacketToFrameHandler.java b/src/main/java/com/ixnah/mc/websocket/handler/PacketToFrameHandler.java new file mode 100644 index 0000000..fa7cc9f --- /dev/null +++ b/src/main/java/com/ixnah/mc/websocket/handler/PacketToFrameHandler.java @@ -0,0 +1,31 @@ +package com.ixnah.mc.websocket.handler; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.MessageToMessageEncoder; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import io.netty.handler.codec.http.websocketx.ContinuationWebSocketFrame; + +import java.util.List; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2020/3/29 21:05 + */ +public class PacketToFrameHandler extends MessageToMessageEncoder { + + @Override + protected void encode(ChannelHandlerContext ctx, ByteBuf msg, List out) { + int maxFrameSize = 65536, length = msg.readableBytes(); + if (length < maxFrameSize) { + out.add(new BinaryWebSocketFrame(msg.retain())); + } else { + out.add(new BinaryWebSocketFrame(false, 0, msg.readSlice(maxFrameSize).retain())); + for (int i = 1, size = (int) Math.ceil(length / (double) maxFrameSize) - 1; i < size; i++) { + out.add(new ContinuationWebSocketFrame(false, 0, msg.readSlice(maxFrameSize).retain())); + } + out.add(new ContinuationWebSocketFrame(true, 0, msg.readSlice(msg.readableBytes()).retain())); + } + } +} diff --git a/src/main/java/com/ixnah/mc/websocket/handler/WebSocketClientHandler.java b/src/main/java/com/ixnah/mc/websocket/handler/WebSocketClientHandler.java new file mode 100644 index 0000000..f893f44 --- /dev/null +++ b/src/main/java/com/ixnah/mc/websocket/handler/WebSocketClientHandler.java @@ -0,0 +1,91 @@ +package com.ixnah.mc.websocket.handler; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.websocketx.*; +import io.netty.util.CharsetUtil; +import io.netty.util.concurrent.Future; + +import java.util.ArrayList; +import java.util.List; + +import static com.ixnah.mc.protocol.util.SpinUtil.spinRequireNonNull; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2020/3/29 18:00 + */ +public class WebSocketClientHandler extends SimpleChannelInboundHandler { + + private ChannelPromise handshakeFuture; + private final WebSocketClientHandshaker handshaker; + private final List frameContents = new ArrayList<>(); + + public WebSocketClientHandler(WebSocketClientHandshaker handshaker) { + super(false); + this.handshaker = handshaker; + } + + public Future handshakeFuture() { + return spinRequireNonNull(this, "handshakeFuture", "WebSocketClientHandler must add to pipeline"); + } + + // 被添加到pipeline后开始进行Websocket握手 + @Override + public void handlerAdded(ChannelHandlerContext ctx) { + handshakeFuture = ctx.newPromise(); + handshaker.handshake(ctx.channel()); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) { + frameContents.clear(); + ctx.fireChannelInactive(); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Object msg) { + if (!handshaker.isHandshakeComplete()) { + try { + handshaker.finishHandshake(ctx.channel(), (FullHttpResponse) msg); + handshakeFuture.trySuccess(); + } catch (Throwable t) { + t.printStackTrace(); + handshakeFuture.tryFailure(t); + } + return; + } + + if (msg instanceof FullHttpResponse) { + FullHttpResponse response = (FullHttpResponse) msg; + throw new IllegalStateException("Unexpected FullHttpResponse (getStatus=" + response.status() + + ", content=" + response.content().toString(CharsetUtil.UTF_8) + ')'); + } + + handleWebSocketFrame(ctx, (WebSocketFrame) msg); + } + + private void handleWebSocketFrame(ChannelHandlerContext ctx, WebSocketFrame frame) { + if (frame instanceof CloseWebSocketFrame) { + handshaker.close(ctx.channel(), (CloseWebSocketFrame) frame.retain()); + return; + } + if (frame instanceof PingWebSocketFrame) { + ctx.channel().write(new PongWebSocketFrame(frame.content().retain())); + return; + } + + synchronized (frameContents) { + frameContents.add(frame.content()); + if (frame.isFinalFragment()) { + ctx.fireChannelRead(Unpooled.wrappedBuffer(frameContents.toArray(new ByteBuf[0]))); + frameContents.clear(); + } + } + } +} diff --git a/src/main/java/com/ixnah/mc/websocket/util/HttpClient.java b/src/main/java/com/ixnah/mc/websocket/util/HttpClient.java new file mode 100644 index 0000000..4eec52b --- /dev/null +++ b/src/main/java/com/ixnah/mc/websocket/util/HttpClient.java @@ -0,0 +1,14 @@ +package com.ixnah.mc.websocket.util; + +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.util.concurrent.Future; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2020/7/10 9:07 + */ +public interface HttpClient { + Future sendRequest(HttpRequest request); +} diff --git a/src/main/java/com/ixnah/mc/websocket/util/ManifestUtil.java b/src/main/java/com/ixnah/mc/websocket/util/ManifestUtil.java new file mode 100644 index 0000000..991c4a0 --- /dev/null +++ b/src/main/java/com/ixnah/mc/websocket/util/ManifestUtil.java @@ -0,0 +1,29 @@ +package com.ixnah.mc.websocket.util; + +import java.io.IOException; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; + +/** + * @author 寒兮 + * @version 1.0 + * @date 2021/7/19 11:07 + */ +public class ManifestUtil { + + private ManifestUtil() { + } + + public static Manifest getManifest(Class class_) { + try (JarInputStream jis = new JarInputStream(class_.getProtectionDomain().getCodeSource().getLocation().openStream())) { + return jis.getManifest(); + } catch (IOException e) { + e.printStackTrace(); + return new Manifest(); + } + } + + public static String getValue(Class class_, String key) { + return getManifest(class_).getMainAttributes().getValue(key); + } +} diff --git a/src/main/java/io/izzel/lightfall/client/LightfallConnector.java b/src/main/java/io/izzel/lightfall/client/LightfallConnector.java index a9ed79c..01f4077 100644 --- a/src/main/java/io/izzel/lightfall/client/LightfallConnector.java +++ b/src/main/java/io/izzel/lightfall/client/LightfallConnector.java @@ -8,5 +8,6 @@ public class LightfallConnector implements IMixinConnector { @Override public void connect() { Mixins.addConfiguration("mixins.lightfall.json"); + Mixins.addConfiguration("mixins.protocol.json"); } } diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 0000000..2b14988 --- /dev/null +++ b/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,2 @@ +# CustomProtocol +public net.minecraft.client.multiplayer.ServerAddress (Ljava/lang/String;I)V \ No newline at end of file diff --git a/src/main/resources/mixins.protocol.json b/src/main/resources/mixins.protocol.json new file mode 100644 index 0000000..194daa8 --- /dev/null +++ b/src/main/resources/mixins.protocol.json @@ -0,0 +1,16 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.ixnah.mc.protocol.mixin", + "target": "@env(DEFAULT)", + "refmap": "mixins.lightfall.refmap.json", + "injectors": { + "defaultRequire": 1 + }, + "mixins": [ + "client.gui.screen.ConnectingScreenMixin", + "client.multiplayer.ServerAddressMixin", + "client.network.ServerPingerMixin", + "network.NetworkManagerMixin" + ] +} \ No newline at end of file