diff --git a/.gitignore b/.gitignore index b809f65..bed6ff2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,4 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store diff --git a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java index a13ed70..cf0bf93 100644 --- a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java +++ b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java @@ -67,6 +67,11 @@ private void onPlayerSetupConnect(PlayerSetupConnectEvent event) { if (message == null) { event.setCancelled(true); event.setReason(Message.raw("invalid player info message (is your proxy secret and backend id valid?)")); + getLogger().at(Level.WARNING).log( + "failed to parse player info message, likely an invalid secret or backend (secret=<%d bytes>, backend=%s)", + this.getProxySecret().length, + this.getBackendName() + ); return; } @@ -76,7 +81,7 @@ private void onPlayerSetupConnect(PlayerSetupConnectEvent event) { event.setReason(Message.raw("internal error while verifying player information")); } } - + private byte[] getProxySecret() { byte[] proxySecret = System.getenv("HYPROXY_SECRET") != null ? System.getenv("HYPROXY_SECRET").getBytes(StandardCharsets.UTF_8) : null; diff --git a/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java b/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java index 629eaa7..b655527 100644 --- a/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java +++ b/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java @@ -19,8 +19,9 @@ public class ProtocolUtil { public static final ChannelFutureListener CLOSE_ON_COMPLETE = ProtocolUtil::closeApplicationOnComplete; public void writeVarString(ByteBuf buf, String str) { - writeVarString(buf, str, StandardCharsets.US_ASCII); + writeVarString(buf, str, StandardCharsets.UTF_8); } + public void writeVarString(ByteBuf buf, String str, Charset charset) { byte[] bytes = str.getBytes(charset); VarIntUtil.write(buf, bytes.length); @@ -28,7 +29,7 @@ public void writeVarString(ByteBuf buf, String str, Charset charset) { } public String readVarString(ByteBuf buf, int maxLength) { - return readVarString(buf, maxLength, StandardCharsets.US_ASCII); + return readVarString(buf, maxLength, StandardCharsets.UTF_8); } public Pair readVarString(ByteBuf buf, int offset, int maxLength) { @@ -43,7 +44,7 @@ public Pair readVarString(ByteBuf buf, int offset, int maxLengt data[i] = buf.getByte(offset + varIntLength + i); } - return Pair.of(new String(data, StandardCharsets.US_ASCII), varIntLength + data.length); + return Pair.of(new String(data, StandardCharsets.UTF_8), varIntLength + data.length); } public String readVarString(ByteBuf buf, int maxLength, Charset charset) { diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java b/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java index b8c4a2f..f1f653b 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java +++ b/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java @@ -439,10 +439,13 @@ public void registerPlayer(HyProxyPlayer player) { */ public void unregisterPlayer(HyProxyPlayer player) { if (this.getPlayerByProfileId(player.getProfileId(), true) == null) { - throw new IllegalArgumentException("player profile id " + player.getProfileId() + " not registered"); + return; } + this.playersByProfileId.remove(player.getProfileId()); - this.playersByUsername.remove(player.getUsername().toLowerCase(Locale.ROOT)); + if (player.getUsername() != null) { + this.playersByUsername.remove(player.getUsername().toLowerCase(Locale.ROOT)); + } } public String getServerCertFingerprint() { diff --git a/proxy/src/main/java/ac/eva/hyproxy/auth/JWTVerifier.java b/proxy/src/main/java/ac/eva/hyproxy/auth/JWTVerifier.java index 1d265b4..324f331 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/auth/JWTVerifier.java +++ b/proxy/src/main/java/ac/eva/hyproxy/auth/JWTVerifier.java @@ -16,6 +16,7 @@ import java.security.cert.X509Certificate; import java.text.ParseException; import java.time.Instant; +import java.util.Map; import java.util.UUID; @Slf4j @@ -107,6 +108,12 @@ public class JWTVerifier { } JWTClaimsSet claimsSet = jwt.getJWTClaimsSet(); + + // todo: this is stupid, and should be cleaned up + Map profile = claimsSet.getJSONObjectClaim("profile"); + Object skinClaim = profile != null ? profile.get("skin") : null; + String skin = skinClaim != null ? skinClaim.toString() : null; + IdentityTokenClaims claims = new IdentityTokenClaims( claimsSet.getIssuer(), claimsSet.getSubject(), @@ -114,7 +121,8 @@ public class JWTVerifier { claimsSet.getIssueTime() != null ? claimsSet.getIssueTime().toInstant().getEpochSecond() : null, claimsSet.getExpirationTime() != null ? claimsSet.getExpirationTime().toInstant().getEpochSecond() : null, claimsSet.getNotBeforeTime() != null ? claimsSet.getNotBeforeTime().toInstant().getEpochSecond() : null, - claimsSet.getStringClaim("scope") + claimsSet.getStringClaim("scope"), + skin ); if (!claims.issuer().equals(HytaleSessionServiceClient.SESSIONS_ISSUER)) { @@ -176,7 +184,8 @@ public record IdentityTokenClaims( @Nullable Long issuedAt, @Nullable Long expiresAt, @Nullable Long notBefore, - @Nullable String scope + @Nullable String scope, + @Nullable String skin ) { public @Nullable UUID getSubjectAsUUID() { if (this.subject == null) return null; diff --git a/proxy/src/main/java/ac/eva/hyproxy/config/HyProxyConfiguration.java b/proxy/src/main/java/ac/eva/hyproxy/config/HyProxyConfiguration.java index 2631e79..d6c3485 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/config/HyProxyConfiguration.java +++ b/proxy/src/main/java/ac/eva/hyproxy/config/HyProxyConfiguration.java @@ -43,6 +43,13 @@ public class HyProxyConfiguration { private Map backends; private Map> permissions; + // QUIC UDP datagram sizing. maxUdpPayloadSize caps datagrams in both directions so + // they survive the path MTU; discoverPmtu toggles DPLPMTUD probing. See MHPL-615: + // both are tuned down when the proxy sits behind Cloudflare Spectrum, which drops + // oversized/fragmented UDP instead of forwarding it. + private int maxUdpPayloadSize; + private boolean discoverPmtu; + public InetSocketAddress getBind() { return AddressUtil.parseAndResolveAddress(bind); } @@ -184,6 +191,9 @@ public static HyProxyConfiguration load(HyProxy proxy, Path configFilePath) thro String initialBackend = config.getOrElse("initial-backend", "main"); boolean proxyCommunicationEnabled = config.getOrElse("proxy-communication", true); + int maxUdpPayloadSize = config.getIntOrElse("max-udp-payload-size", 1200); + boolean discoverPmtu = config.getOrElse("discover-pmtu", false); + CommentedConfig backendConfig = config.get("backends"); Map backends = backendConfig.valueMap() .entrySet() @@ -208,7 +218,9 @@ public static HyProxyConfiguration load(HyProxy proxy, Path configFilePath) thro initialBackend, proxyCommunicationEnabled, backends, - permissions + permissions, + maxUdpPayloadSize, + discoverPmtu ); } } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/HytaleConnection.java b/proxy/src/main/java/ac/eva/hyproxy/io/HytaleConnection.java index caf0b78..1d16b3a 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/HytaleConnection.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/HytaleConnection.java @@ -82,6 +82,35 @@ public void channelInactive(ChannelHandlerContext ctx) { } } + @Override + public void channelWritabilityChanged(ChannelHandlerContext ctx) { + QuicStreamChannel streamChannel = (QuicStreamChannel) ctx.channel(); + NetworkChannel networkChannel = this.channelsByStreamId.getOrDefault(streamChannel.streamId(), NetworkChannel.DEFAULT); + + QuicStreamChannel peerStream = this.getPeerStream(networkChannel); + if (peerStream != null && peerStream.isActive()) { + peerStream.config().setAutoRead(streamChannel.isWritable()); + } + + ctx.fireChannelWritabilityChanged(); + } + + private @Nullable QuicStreamChannel getPeerStream(NetworkChannel networkChannel) { + if (this.player == null) { + return null; + } + + HytaleConnection peer = this.player.getInboundConnection() == this + ? this.player.getOutboundConnection() + : this.player.getInboundConnection(); + + if (peer == null) { + return null; + } + + return peer.streams.get(networkChannel); + } + public String getIdentifier() { if (this.hasPlayer()) { return player.getIdentifier(); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/HytalePacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/HytalePacketHandler.java index 932e07d..8f5ba63 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/HytalePacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/HytalePacketHandler.java @@ -38,6 +38,12 @@ default boolean handle(ServerAuthToken serverAuthToken) { default boolean handle(ConnectAccept connectAccept) { return false; } + default boolean handle(RequestInsecurePlayerOptions requestInsecurePlayerOptions) { + return false; + } + default boolean handle(InsecurePlayerOptions insecurePlayerOptions) { + return false; + } default boolean handle(ClientReferral referral) { return false; } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java index 38a6fde..f4621ba 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java @@ -4,6 +4,7 @@ import ac.eva.hyproxy.io.packet.Packet; import ac.eva.hyproxy.io.packet.PacketRegistry; import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; import lombok.extern.slf4j.Slf4j; @@ -12,6 +13,7 @@ @Slf4j public class PacketDecoder extends ByteToMessageDecoder { + private static final boolean DEBUG_PACKETS = Boolean.getBoolean("hyproxy.debugBytes"); private static final int MAX_PAYLOAD_LENGTH = 1677721600; @Override @@ -36,6 +38,15 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { return; } + if (DEBUG_PACKETS) { + int frameLength = 8 + payloadLength; + log.info( + "INBOUND frame ({}B):\n{}", + frameLength, + ByteBufUtil.prettyHexDump(in, originalReaderIndex, Math.min(frameLength, 256)) + ); + } + if (packetInfo == null) { out.add(in.copy(originalReaderIndex, 8 + payloadLength)); in.skipBytes(payloadLength); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/QuicChannelInboundHandlerAdapter.java b/proxy/src/main/java/ac/eva/hyproxy/io/QuicChannelInboundHandlerAdapter.java index 4df89d7..480374c 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/QuicChannelInboundHandlerAdapter.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/QuicChannelInboundHandlerAdapter.java @@ -12,6 +12,7 @@ import org.jspecify.annotations.Nullable; import ac.eva.hyproxy.HyProxy; import ac.eva.hyproxy.common.util.ProtocolUtil; +import ac.eva.hyproxy.config.HyProxyConfiguration; import ac.eva.hyproxy.io.channel.InboundChannelInitializer; import ac.eva.hyproxy.io.proto.DisconnectType; @@ -33,6 +34,9 @@ public boolean isSharable() { @Override public void channelActive(ChannelHandlerContext ctx) { + HyProxyConfiguration config = this.proxy.getConfiguration(); + int maxUdpPayloadSize = config.getMaxUdpPayloadSize(); + ChannelHandler handler = new QuicServerCodecBuilder() .sslContext(this.sslContext) .tokenHandler(InsecureQuicTokenHandler.INSTANCE) @@ -45,7 +49,15 @@ public void channelActive(ChannelHandlerContext ctx) { .initialMaxStreamDataBidirectionalLocal(128 * 1024) .initialMaxStreamDataBidirectionalRemote(128 * 1024) .initialMaxStreamsBidirectional(8) - .discoverPmtu(true) + // MHPL-615: cap the QUIC datagram size so packets survive the path MTU. + // maxRecvUdpPayloadSize is advertised to the client as our + // max_udp_payload_size, forcing it to never send us datagrams larger than + // this (the inbound direction that Cloudflare Spectrum blackholes); the + // send cap bounds our outbound datagrams. discoverPmtu is off behind + // Spectrum so DPLPMTUD does not grow datagrams past the forwardable size. + .maxRecvUdpPayloadSize(maxUdpPayloadSize) + .maxSendUdpPayloadSize(maxUdpPayloadSize) + .discoverPmtu(config.isDiscoverPmtu()) .congestionControlAlgorithm(QuicCongestionControlAlgorithm.BBR) .handler(new ChannelInboundHandlerAdapter() { @Override diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java index e7cfde7..7bb906f 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java @@ -103,11 +103,7 @@ public boolean handle(AuthToken authToken) { return true; } - if (!tokenUsername.equals(player.getUsername())) { - connection.disconnect("Invalid token claims: username mismatch"); - return true; - } - + player.setUsername(tokenUsername); String serverAuthGrant = authToken.getServerAuthorizationGrant(); if (serverAuthGrant == null || serverAuthGrant.isEmpty()) { @@ -146,6 +142,7 @@ private void onAuthenticated(String serverAccessToken) { return; } + connection.getProxy().registerPlayer(player); PlayerAuthSuccessEvent event = connection.getProxy().getEventBus().fire(new PlayerAuthSuccessEvent( player, false diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java index 9e52f7e..7c43647 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java @@ -1,5 +1,6 @@ package ac.eva.hyproxy.io.handler.inbound; +import ac.eva.hyproxy.auth.JWTVerifier; import ac.eva.hyproxy.io.packet.impl.ClientDisconnect; import io.netty.buffer.Unpooled; import lombok.RequiredArgsConstructor; @@ -10,9 +11,11 @@ import ac.eva.hyproxy.io.HytaleConnection; import ac.eva.hyproxy.io.HytalePacketHandler; import ac.eva.hyproxy.io.packet.impl.auth.Connect; +import ac.eva.hyproxy.io.proto.PlayerSkin; import ac.eva.hyproxy.player.HyProxyPlayer; import java.util.Locale; +import java.util.UUID; @Slf4j @RequiredArgsConstructor @@ -21,7 +24,30 @@ public class InboundInitialPacketHandler implements HytalePacketHandler { @Override public boolean handle(Connect connect) { - if (connection.getProxy().getPlayerByProfileId(connect.getUuid()) != null) { + if (connect.getClientType() == null) { + connection.disconnect("invalid client type"); + return true; + } + + String identityToken = connect.getIdentityToken(); + if (identityToken == null) { + connection.disconnect("This proxy only supports online mode players!"); + return true; + } + + JWTVerifier.IdentityTokenClaims claims = connection.getProxy().getJwtVerifier().validateIdentityToken(identityToken); + if (claims == null) { + connection.disconnect("invalid or expired identity token"); + return true; + } + + UUID profileId = claims.getSubjectAsUUID(); + if (profileId == null) { + connection.disconnect("invalid identity token: missing or malformed subject"); + return true; + } + + if (connection.getProxy().getPlayerByProfileId(profileId) != null) { connection.disconnect("You are already connected to this proxy!"); return true; } @@ -31,17 +57,17 @@ public boolean handle(Connect connect) { player.setProtocolCrc(connect.getProtocolCrc()); player.setProtocolBuildNumber(connect.getProtocolBuildNumber()); player.setClientVersion(connect.getClientVersion()); - player.setProfileId(connect.getUuid()); - player.setUsername(connect.getUsername()); - player.setIdentityToken(connect.getIdentityToken()); + player.setProfileId(profileId); + player.setIdentityToken(identityToken); player.setLanguage(connect.getLanguage()); player.setClientType(connect.getClientType()); + player.setSkin(PlayerSkin.fromJson(claims.skin())); byte[] referralData = connect.getReferralData(); if (referralData != null) { SecretMessageUtil.BackendReferralMessage referralMessage = SecretMessageUtil.validateAndDecodeReferralData( Unpooled.copiedBuffer(referralData), - connect.getUuid(), + profileId, connection.getProxy().getConfiguration().getProxySecret() ); @@ -80,8 +106,6 @@ public boolean handle(Connect connect) { connection.setPlayer(player); - connection.getProxy().registerPlayer(player); - log.info("authenticating player {}", this.connection.getIdentifier()); connection.setPacketHandler(new InboundAuthPacketHandler(this.connection)); return true; diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java index 4716737..547a5ac 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java @@ -9,6 +9,8 @@ import ac.eva.hyproxy.io.HytalePacketHandler; import ac.eva.hyproxy.io.packet.impl.auth.Connect; import ac.eva.hyproxy.io.packet.impl.auth.ConnectAccept; +import ac.eva.hyproxy.io.packet.impl.auth.InsecurePlayerOptions; +import ac.eva.hyproxy.io.packet.impl.auth.RequestInsecurePlayerOptions; import ac.eva.hyproxy.player.HyProxyPlayer; import ac.eva.hyproxy.util.NettyUtil; @@ -30,10 +32,8 @@ public void connected() { player.getProtocolBuildNumber(), player.getClientVersion(), player.getClientType(), - player.getProfileId(), - player.getLanguage(), null, - player.getUsername(), + player.getLanguage(), SecretMessageUtil.generatePlayerInfoReferral(new SecretMessageUtil.BackendPlayerInfoMessage( player.getProfileId(), player.getUsername(), @@ -45,6 +45,13 @@ public void connected() { )); } + @Override + public boolean handle(RequestInsecurePlayerOptions request) { + HyProxyPlayer player = connection.ensurePlayer(); + connection.send(new InsecurePlayerOptions(player.getProfileId(), player.getUsername(), player.getSkin())); + return true; + } + @Override public boolean handle(ConnectAccept connectAccept) { log.info("starting forwarding for {} to backend {}", connection.getIdentifier(), backend.getInfo().id()); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/PacketRegistry.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/PacketRegistry.java index d61e9cc..f5b4777 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/PacketRegistry.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/PacketRegistry.java @@ -27,6 +27,8 @@ public class PacketRegistry { register(new PacketInfo(13, ServerAuthToken.class, ServerAuthToken::deserialize)); register(new PacketInfo(14, ConnectAccept.class, ConnectAccept::deserialize)); register(new PacketInfo(18, ClientReferral.class, ClientReferral::deserialize)); + register(new PacketInfo(363, InsecurePlayerOptions.class, InsecurePlayerOptions::deserialize)); + register(new PacketInfo(364, RequestInsecurePlayerOptions.class, RequestInsecurePlayerOptions::deserialize)); register(new PacketInfo(210, ServerMessage.class, ServerMessage::deserialize)); register(new PacketInfo(211, ChatMessage.class, ChatMessage::deserialize)); register(new PacketInfo(223, ServerInfo.class, ServerInfo::deserialize)); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/AuthGrant.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/AuthGrant.java index 68c5fcf..c85d12d 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/AuthGrant.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/AuthGrant.java @@ -34,7 +34,7 @@ public static AuthGrant deserialize(ByteBuf buf) { if ((nullBits & 0x1) != 0) { int offset = varsOffset + authorizationGrantOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 128); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); authorizationGrant = varString.left(); readViaOffsets += varString.right(); } @@ -43,7 +43,7 @@ public static AuthGrant deserialize(ByteBuf buf) { if ((nullBits & 0x2) != 0) { int offset = varsOffset + serverIdentityTokenOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 128); + Pair varString = ProtocolUtil.readVarString(buf, offset, 8192); serverIdentityToken = varString.left(); readViaOffsets += varString.right(); } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java index 132cb38..4ed1733 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java @@ -14,7 +14,6 @@ import ac.eva.hyproxy.common.util.VarIntUtil; import java.nio.charset.StandardCharsets; -import java.util.UUID; @Getter @RequiredArgsConstructor @@ -24,10 +23,8 @@ public class Connect implements Packet { private final int protocolBuildNumber; private final String clientVersion; private final ClientType clientType; - private final UUID uuid; - private final @Nullable String language; private final @Nullable String identityToken; - private final String username; + private final @Nullable String language; private final byte @Nullable [] referralData; private final @Nullable HostAddress referralSource; @@ -47,9 +44,7 @@ public static Connect deserialize(ByteBuf buf) { String clientVersion = new String(clientVersionBytes, StandardCharsets.US_ASCII); ClientType clientType = ClientType.getById(buf.readByte()); - UUID uuid = ProtocolUtil.readUUID(buf); - int usernameOffset = buf.readIntLE(); int identityTokenOffset = buf.readIntLE(); int languageOffset = buf.readIntLE(); int referralDataOffset = buf.readIntLE(); @@ -58,28 +53,20 @@ public static Connect deserialize(ByteBuf buf) { int readViaOffsets = 0; - int absoluteUsernameOffset = varsOffset + usernameOffset; - Pair varString = ProtocolUtil.readVarString(buf, absoluteUsernameOffset, 16); - String username = varString.left(); - readViaOffsets += varString.right(); - String identityToken = null; if ((nullBits & 0x1) != 0) { int offset = varsOffset + identityTokenOffset; - varString = ProtocolUtil.readVarString(buf, offset, 8192); + Pair varString = ProtocolUtil.readVarString(buf, offset, 8192); identityToken = varString.left(); readViaOffsets += varString.right(); } - String language = null; - int offset = varsOffset + languageOffset; - varString = ProtocolUtil.readVarString(buf, offset, 128); - language = varString.left(); + Pair varString = ProtocolUtil.readVarString(buf, offset, 16); + String language = varString.left(); readViaOffsets += varString.right(); - byte[] referralData = null; if ((nullBits & 0x2) != 0) { @@ -110,7 +97,7 @@ public static Connect deserialize(ByteBuf buf) { buf.readerIndex(varsOffset + readViaOffsets); - return new Connect(protocolCrc, protocolBuildNumber, clientVersion, clientType, uuid, language, identityToken, username, referralData, referralSource); + return new Connect(protocolCrc, protocolBuildNumber, clientVersion, clientType, identityToken, language, referralData, referralSource); } @Override @@ -132,12 +119,13 @@ public void serialize(ByteBuf buf) { buf.writeByte(nullBits); buf.writeIntLE(this.protocolCrc); buf.writeIntLE(this.protocolBuildNumber); - buf.writeBytes(this.clientVersion.getBytes(StandardCharsets.UTF_8)); + + byte[] clientVersionBytes = new byte[20]; + byte[] clientVersionSrc = this.clientVersion.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(clientVersionSrc, 0, clientVersionBytes, 0, Math.min(clientVersionSrc.length, 20)); + buf.writeBytes(clientVersionBytes); buf.writeByte(this.clientType.getId()); - ProtocolUtil.writeUUID(buf, this.uuid); - int usernameOffsetSlot = buf.writerIndex(); - buf.writeIntLE(-1); int identityTokenOffsetSlot = buf.writerIndex(); buf.writeIntLE(-1); int languageOffsetSlot = buf.writerIndex(); @@ -149,16 +137,13 @@ public void serialize(ByteBuf buf) { int varsOffset = buf.writerIndex(); - buf.setIntLE(usernameOffsetSlot, buf.writerIndex() - varsOffset); - ProtocolUtil.writeVarString(buf, this.username); - if (this.identityToken != null) { buf.setIntLE(identityTokenOffsetSlot, buf.writerIndex() - varsOffset); ProtocolUtil.writeVarString(buf, this.identityToken); } buf.setIntLE(languageOffsetSlot, buf.writerIndex() - varsOffset); - ProtocolUtil.writeVarString(buf, this.language); + ProtocolUtil.writeVarString(buf, this.language != null ? this.language : ""); if (this.referralData != null) { buf.setIntLE(referralDataOffsetSlot, buf.writerIndex() - varsOffset); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java new file mode 100644 index 0000000..c777127 --- /dev/null +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java @@ -0,0 +1,54 @@ +package ac.eva.hyproxy.io.packet.impl.auth; + +import io.netty.buffer.ByteBuf; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import org.jspecify.annotations.Nullable; +import ac.eva.hyproxy.io.HytalePacketHandler; +import ac.eva.hyproxy.io.packet.Packet; +import ac.eva.hyproxy.io.proto.PlayerSkin; +import ac.eva.hyproxy.common.util.ProtocolUtil; + +import java.util.UUID; + +@Getter +@RequiredArgsConstructor +@ToString +public class InsecurePlayerOptions implements Packet { + private final UUID uuid; + private final String username; + private final @Nullable PlayerSkin skin; + + public static InsecurePlayerOptions deserialize(ByteBuf buf) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean handle(HytalePacketHandler handler) { + return handler.handle(this); + } + + @Override + public void serialize(ByteBuf buf) { + byte nullBits = (byte) (this.skin != null ? 0x1 : 0x0); + + buf.writeByte(nullBits); + ProtocolUtil.writeUUID(buf, this.uuid); + + int usernameOffsetSlot = buf.writerIndex(); + buf.writeIntLE(-1); + int skinOffsetSlot = buf.writerIndex(); + buf.writeIntLE(-1); + + int varsOffset = buf.writerIndex(); + + buf.setIntLE(usernameOffsetSlot, buf.writerIndex() - varsOffset); + ProtocolUtil.writeVarString(buf, this.username); + + if (this.skin != null) { + buf.setIntLE(skinOffsetSlot, buf.writerIndex() - varsOffset); + this.skin.serialize(buf); + } + } +} diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java new file mode 100644 index 0000000..7b1993d --- /dev/null +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java @@ -0,0 +1,22 @@ +package ac.eva.hyproxy.io.packet.impl.auth; + +import io.netty.buffer.ByteBuf; +import ac.eva.hyproxy.io.HytalePacketHandler; +import ac.eva.hyproxy.io.packet.Packet; + +public class RequestInsecurePlayerOptions implements Packet { + + public static RequestInsecurePlayerOptions deserialize(ByteBuf buf) { + return new RequestInsecurePlayerOptions(); + } + + @Override + public boolean handle(HytalePacketHandler handler) { + return handler.handle(this); + } + + @Override + public void serialize(ByteBuf buf) { + // empty payload + } +} diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/ServerAuthToken.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/ServerAuthToken.java index a95c789..efcc9a8 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/ServerAuthToken.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/ServerAuthToken.java @@ -89,6 +89,7 @@ public void serialize(ByteBuf buf) { if (this.passwordChallenge != null) { buf.setIntLE(passwordChallengeOffsetSlot, buf.writerIndex() - varsOffset); + VarIntUtil.write(buf, this.passwordChallenge.length); buf.writeBytes(this.passwordChallenge); } } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java index 822a717..3f1d345 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java @@ -33,7 +33,7 @@ public static ServerInfo deserialize(ByteBuf buf) { String serverName = null; if ((nullBits & 0x1) != 0) { int offset = varsOffset + serverNameOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 100); + Pair varString = ProtocolUtil.readVarString(buf, offset, 256); serverName = varString.left(); readViaOffsets += varString.right(); } @@ -41,7 +41,7 @@ public static ServerInfo deserialize(ByteBuf buf) { String motd = null; if ((nullBits & 0x2) != 0) { int offset = varsOffset + motdOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 500); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); motd = varString.left(); readViaOffsets += varString.right(); } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/ClientType.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/ClientType.java index a7082e3..fb2281c 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/proto/ClientType.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/ClientType.java @@ -10,6 +10,10 @@ public byte getId() { } public static ClientType getById(byte id) { - return ClientType.values()[id]; + ClientType[] values = ClientType.values(); + if (id < 0 || id >= values.length) { + return null; + } + return values[id]; } } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java new file mode 100644 index 0000000..7bddc34 --- /dev/null +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java @@ -0,0 +1,91 @@ +package ac.eva.hyproxy.io.proto; + +import ac.eva.hyproxy.common.util.ProtocolUtil; +import com.nimbusds.jose.util.JSONObjectUtils; +import io.netty.buffer.ByteBuf; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; + +import java.text.ParseException; +import java.util.Arrays; +import java.util.Map; + +@Slf4j +public class PlayerSkin { + private static final int NULL_BITS_SIZE = 3; + + // order is significant: it defines each part's nullBits bit and offset slot, and must match the engine + private static final String[] PART_KEYS = { + "bodyCharacteristic", "underwear", "face", "eyes", "ears", "mouth", "facialHair", "haircut", + "eyebrows", "pants", "overpants", "undertop", "overtop", "shoes", "headAccessory", "faceAccessory", + "earAccessory", "skinFeature", "gloves", "cape" + }; + + private final @Nullable String[] parts; + + private PlayerSkin(@Nullable String[] parts) { + this.parts = parts; + } + + /** + * Parses the {@code profile.skin} JSON carried in the identity token into a skin. + * @param json the raw skin json, or null/empty when the player has no skin + * @return the parsed skin, or null when absent or unparseable + */ + public static @Nullable PlayerSkin fromJson(@Nullable String json) { + if (json == null || json.isEmpty()) { + return null; + } + + Map skin; + try { + skin = JSONObjectUtils.parse(json); + } catch (ParseException e) { + log.warn("failed to parse skin json from identity token", e); + return null; + } + + String[] parts = new String[PART_KEYS.length]; + for (int i = 0; i < PART_KEYS.length; i++) { + if (skin.get(PART_KEYS[i]) instanceof String part) { + parts[i] = part; + } + } + + return new PlayerSkin(parts); + } + + public void serialize(ByteBuf buf) { + byte[] nullBits = new byte[NULL_BITS_SIZE]; + for (int i = 0; i < this.parts.length; i++) { + if (this.parts[i] != null) { + nullBits[i >> 3] |= (byte) (1 << (i & 7)); + } + } + + buf.writeBytes(nullBits); + + int slotsStart = buf.writerIndex(); + for (int i = 0; i < this.parts.length; i++) { + buf.writeIntLE(0); + } + + int varsOffset = buf.writerIndex(); + for (int i = 0; i < this.parts.length; i++) { + int slot = slotsStart + i * Integer.BYTES; + + if (this.parts[i] == null) { + buf.setIntLE(slot, -1); + continue; + } + + buf.setIntLE(slot, buf.writerIndex() - varsOffset); + ProtocolUtil.writeVarString(buf, this.parts[i]); + } + } + + @Override + public String toString() { + return "PlayerSkin" + Arrays.toString(this.parts); + } +} diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java index 51bb241..55d6076 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java @@ -33,13 +33,20 @@ public class FormattedMessage { private boolean markupEnabled; public static FormattedMessage deserialize(ByteBuf buf) { - byte nullBits = buf.readByte(); - MaybeBool bold = MaybeBool.getById(buf.readByte()); - MaybeBool italic = MaybeBool.getById(buf.readByte()); - MaybeBool monospace = MaybeBool.getById(buf.readByte()); - MaybeBool underlined = MaybeBool.getById(buf.readByte()); + byte nullBits0 = buf.readByte(); + byte nullBits1 = buf.readByte(); + + byte boldByte = buf.readByte(); + byte italicByte = buf.readByte(); + byte monospaceByte = buf.readByte(); + byte underlinedByte = buf.readByte(); boolean markupEnabled = buf.readByte() != 0; + MaybeBool bold = (nullBits0 & 0x1) != 0 ? MaybeBool.fromBool(boldByte != 0) : MaybeBool.NULL; + MaybeBool italic = (nullBits0 & 0x2) != 0 ? MaybeBool.fromBool(italicByte != 0) : MaybeBool.NULL; + MaybeBool monospace = (nullBits0 & 0x4) != 0 ? MaybeBool.fromBool(monospaceByte != 0) : MaybeBool.NULL; + MaybeBool underlined = (nullBits0 & 0x8) != 0 ? MaybeBool.fromBool(underlinedByte != 0) : MaybeBool.NULL; + int rawTextOffset = buf.readIntLE(); int messageIdOffset = buf.readIntLE(); int childrenOffset = buf.readIntLE(); @@ -54,23 +61,23 @@ public static FormattedMessage deserialize(ByteBuf buf) { int readViaOffsets = 0; String rawText = null; - if ((nullBits & 0x1) != 0) { + if ((nullBits0 & 0x10) != 0) { int offset = varsOffset + rawTextOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 128); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); rawText = varString.left(); readViaOffsets += varString.right(); } String messageId = null; - if ((nullBits & 0x2) != 0) { + if ((nullBits0 & 0x20) != 0) { int offset = varsOffset + messageIdOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 128); + Pair varString = ProtocolUtil.readVarString(buf, offset, 256); messageId = varString.left(); readViaOffsets += varString.right(); } FormattedMessage[] children = null; - if ((nullBits & 0x4) != 0) { + if ((nullBits0 & 0x40) != 0) { int oldOffset = buf.readerIndex(); int offset = varsOffset + childrenOffset; @@ -96,7 +103,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { } Map params = null; - if ((nullBits & 0x8) != 0) { + if ((nullBits0 & 0x80) != 0) { int oldOffset = buf.readerIndex(); int offset = varsOffset + paramsOffset; @@ -113,7 +120,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { for (int i = 0; i < length; i++) { int oldParamOffset = buf.readerIndex(); - String key = ProtocolUtil.readVarString(buf, 128); + String key = ProtocolUtil.readVarString(buf, 256); ParamValue value = ParamValue.deserialize(buf); params.put(key, value); @@ -125,7 +132,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { Map messageParams = null; - if ((nullBits & 16) != 0) { + if ((nullBits1 & 0x1) != 0) { int oldOffset = buf.readerIndex(); int offset = varsOffset + messageParamsOffset; @@ -142,7 +149,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { for (int i = 0; i < length; i++) { int oldParamOffset = buf.readerIndex(); - String key = ProtocolUtil.readVarString(buf, 128); + String key = ProtocolUtil.readVarString(buf, 256); FormattedMessage value = FormattedMessage.deserialize(buf); messageParams.put(key, value); @@ -153,23 +160,23 @@ public static FormattedMessage deserialize(ByteBuf buf) { } String color = null; - if ((nullBits & 32) != 0) { + if ((nullBits1 & 0x2) != 0) { int offset = varsOffset + colorOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 32); + Pair varString = ProtocolUtil.readVarString(buf, offset, 256); color = varString.left(); readViaOffsets += varString.right(); } String link = null; - if ((nullBits & 64) != 0) { + if ((nullBits1 & 0x4) != 0) { int offset = varsOffset + linksOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 1024); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); link = varString.left(); readViaOffsets += varString.right(); } FormattedMessageImage image = null; - if ((nullBits & 128) != 0) { + if ((nullBits1 & 0x8) != 0) { int offset = varsOffset + imageOffset; Pair pair = FormattedMessageImage.deserialize(buf, offset); image = pair.left(); @@ -195,44 +202,63 @@ public static FormattedMessage deserialize(ByteBuf buf) { } public void serialize(ByteBuf buf) { - byte nullBits = 0; + byte nullBits0 = 0; + byte nullBits1 = 0; + + if (this.bold != MaybeBool.NULL) { + nullBits0 = (byte) (nullBits0 | 0x1); + } + + if (this.italic != MaybeBool.NULL) { + nullBits0 = (byte) (nullBits0 | 0x2); + } + + if (this.monospace != MaybeBool.NULL) { + nullBits0 = (byte) (nullBits0 | 0x4); + } + + if (this.underlined != MaybeBool.NULL) { + nullBits0 = (byte) (nullBits0 | 0x8); + } + if (this.rawText != null) { - nullBits = (byte) (nullBits | 1); + nullBits0 = (byte) (nullBits0 | 0x10); } if (this.messageId != null) { - nullBits = (byte) (nullBits | 2); + nullBits0 = (byte) (nullBits0 | 0x20); } if (this.children != null) { - nullBits = (byte) (nullBits | 4); + nullBits0 = (byte) (nullBits0 | 0x40); } if (this.params != null) { - nullBits = (byte) (nullBits | 8); + nullBits0 = (byte) (nullBits0 | 0x80); } if (this.messageParams != null) { - nullBits = (byte) (nullBits | 16); + nullBits1 = (byte) (nullBits1 | 0x1); } if (this.color != null) { - nullBits = (byte) (nullBits | 32); + nullBits1 = (byte) (nullBits1 | 0x2); } if (this.link != null) { - nullBits = (byte) (nullBits | 64); + nullBits1 = (byte) (nullBits1 | 0x4); } if (this.image != null) { - nullBits = (byte) (nullBits | 128); + nullBits1 = (byte) (nullBits1 | 0x8); } - buf.writeByte(nullBits); - buf.writeByte(this.bold.getId()); - buf.writeByte(this.italic.getId()); - buf.writeByte(this.monospace.getId()); - buf.writeByte(this.underlined.getId()); + buf.writeByte(nullBits0); + buf.writeByte(nullBits1); + buf.writeByte(this.bold == MaybeBool.TRUE ? 1 : 0); + buf.writeByte(this.italic == MaybeBool.TRUE ? 1 : 0); + buf.writeByte(this.monospace == MaybeBool.TRUE ? 1 : 0); + buf.writeByte(this.underlined == MaybeBool.TRUE ? 1 : 0); buf.writeByte(this.markupEnabled ? 1 : 0); int rawTextOffsetSlot = buf.writerIndex(); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessageImage.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessageImage.java index c4ff1ce..f2ef792 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessageImage.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessageImage.java @@ -13,13 +13,13 @@ public record FormattedMessageImage( public static Pair deserialize(ByteBuf buf, int offset) { int width = buf.getIntLE(offset); int height = buf.getIntLE(offset + 4); - Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); + Pair varString = ProtocolUtil.readVarString(buf, offset + 8, 4096); return Pair.of(new FormattedMessageImage( varString.left(), width, height - ), 4 + 4 + varString.right()); + ), 8 + varString.right()); } public void serialize(ByteBuf buf) { diff --git a/proxy/src/main/java/ac/eva/hyproxy/message/Message.java b/proxy/src/main/java/ac/eva/hyproxy/message/Message.java index d056fbe..78eac7f 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/message/Message.java +++ b/proxy/src/main/java/ac/eva/hyproxy/message/Message.java @@ -166,7 +166,7 @@ public Message italic(boolean italic) { } public Message monospace(boolean monospace) { - this.formatted.setItalic(MaybeBool.fromBool(monospace)); + this.formatted.setMonospace(MaybeBool.fromBool(monospace)); return this; } diff --git a/proxy/src/main/java/ac/eva/hyproxy/player/HyProxyPlayer.java b/proxy/src/main/java/ac/eva/hyproxy/player/HyProxyPlayer.java index 85c9748..45c52aa 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/player/HyProxyPlayer.java +++ b/proxy/src/main/java/ac/eva/hyproxy/player/HyProxyPlayer.java @@ -13,6 +13,7 @@ import ac.eva.hyproxy.io.proto.ClientType; import ac.eva.hyproxy.io.proto.DisconnectType; import ac.eva.hyproxy.io.proto.NetworkChannel; +import ac.eva.hyproxy.io.proto.PlayerSkin; import ac.eva.hyproxy.message.Message; import ac.eva.hyproxy.player.permission.PlayerPermissionProvider; import com.google.common.collect.ImmutableSet; @@ -55,6 +56,8 @@ public class HyProxyPlayer implements CommandSender { private ClientType clientType; @Setter private @Nullable HyProxyBackend referredBackend; + @Setter + private @Nullable PlayerSkin skin; @Setter private boolean authenticated = false; diff --git a/proxy/src/main/resources/default-config.toml b/proxy/src/main/resources/default-config.toml index b8ad967..165b971 100644 --- a/proxy/src/main/resources/default-config.toml +++ b/proxy/src/main/resources/default-config.toml @@ -9,6 +9,23 @@ proxy-secret-file = "proxy.secret" # if we should bind on ipv6 too, heavily recommended ipv6-support = true +# maximum QUIC UDP payload size (bytes) used with clients. this caps the datagram +# size in BOTH directions: it is advertised to the client as our max_udp_payload_size +# (so the client never sends us anything larger) and it bounds our own outgoing +# datagrams. keep it small enough to survive the whole path MTU. +# IMPORTANT (MHPL-615): Cloudflare Spectrum does NOT fragment UDP and silently drops +# any datagram too large to forward, so when the proxy is behind Spectrum this must +# stay under the Spectrum edge->origin forwardable size. 1200 is QUIC's universal +# floor (always deliverable); raise toward 1350 to recover throughput once stability +# is confirmed. must be >= 1200. +max-udp-payload-size = 1200 + +# whether QUIC should probe for a larger path MTU (DPLPMTUD). keep this off behind +# Cloudflare Spectrum: probing grows datagrams past the Spectrum-forwardable size and, +# with ICMP "packet too big" filtered on anycast paths, those packets get blackholed. +# only enable for direct (non-Spectrum) UDP exposure where the path MTU is trustworthy. +discover-pmtu = false + # initial backend or backend set we should in order try to connect the user to. initial-backend = "main"