Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8e2a394
docs(MHG-1132): start protocol RE notes; confirm fork base
alepaez Jun 16, 2026
6f1c6d8
docs(MHG-1132): record container build recipe; baseline build reprodu…
alepaez Jun 16, 2026
34c25e9
docs(MHG-1132): document current Hytale 0.5.5 handshake/transfer prot…
alepaez Jun 17, 2026
526d3f2
fix(MHG-1132): rewrite Connect to Hytale 0.5.5 layout; source identit…
alepaez Jun 17, 2026
b6f1340
fix(MHG-1132): harden Connect serialize (fixed 20B ASCII version, nul…
alepaez Jun 17, 2026
0283da9
fix(MHG-1132): adapt backend-plugin to Hytale 0.5.5 API (ChannelConne…
alepaez Jun 17, 2026
96b3674
fix(MHG-1132): drop protocolCrc gate (varies per client build; backen…
alepaez Jun 17, 2026
f8f668c
fix(MHG-1132): source uuid from identity token, username from access …
alepaez Jun 17, 2026
7b07003
feat(MHG-1132): implement proxy->backend insecure-options handshake; …
alepaez Jun 17, 2026
3c9f8fb
fix(MHG-1132): backend plugin derives backend name from SERVER_ID env
alepaez Jun 17, 2026
b44e941
Merge branch 'main' into alexandresequeira/mhg-1132-update-protocol
SantioMC Jun 22, 2026
d667675
misc: cleanup & rebase properly
SantioMC Jun 22, 2026
66268f0
misc: remove ai documentation
SantioMC Jun 22, 2026
09e1d25
feat: log when failing to validate
SantioMC Jun 23, 2026
91e0bbc
feat: fix NPE & skins
SantioMC Jun 26, 2026
dd4b4cb
fix: dont forward failed packets & formattedmessage
SantioMC Jun 26, 2026
1989538
misc: more protocol corrections
SantioMC Jul 2, 2026
34ba0a7
fix(MHPL-615): cap QUIC UDP payload size for Cloudflare Spectrum (#3)
alepaez Jul 21, 2026
28e024b
fix: address upstream review feedback on protocol update
alepaez Jul 22, 2026
c0035b5
fix: chunk loading lag (#4)
SantioMC Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ bin/
.vscode/

### Mac OS ###
.DS_Store
.DS_Store
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ 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);
buf.writeBytes(bytes);
}

public String readVarString(ByteBuf buf, int maxLength) {
return readVarString(buf, maxLength, StandardCharsets.US_ASCII);
return readVarString(buf, maxLength, StandardCharsets.UTF_8);
}

public Pair<String, Integer> readVarString(ByteBuf buf, int offset, int maxLength) {
Expand All @@ -43,7 +44,7 @@ public Pair<String, Integer> 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) {
Expand Down
Empty file modified gradlew
100644 → 100755
Empty file.
7 changes: 5 additions & 2 deletions proxy/src/main/java/ac/eva/hyproxy/HyProxy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
13 changes: 11 additions & 2 deletions proxy/src/main/java/ac/eva/hyproxy/auth/JWTVerifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,14 +108,21 @@ public class JWTVerifier {
}

JWTClaimsSet claimsSet = jwt.getJWTClaimsSet();

// todo: this is stupid, and should be cleaned up
Map<String, Object> 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(),
claimsSet.getStringClaim("username"),
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)) {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ public class HyProxyConfiguration {
private Map<String, String> backends;
private Map<String, List<String>> 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);
}
Expand Down Expand Up @@ -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<String, String> backends = backendConfig.valueMap()
.entrySet()
Expand All @@ -208,7 +218,9 @@ public static HyProxyConfiguration load(HyProxy proxy, Path configFilePath) thro
initialBackend,
proxyCommunicationEnabled,
backends,
permissions
permissions,
maxUdpPayloadSize,
discoverPmtu
);
}
}
Expand Down
29 changes: 29 additions & 0 deletions proxy/src/main/java/ac/eva/hyproxy/io/HytaleConnection.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
11 changes: 11 additions & 0 deletions proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -36,6 +38,15 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -146,6 +142,7 @@ private void onAuthenticated(String serverAccessToken) {
return;
}

connection.getProxy().registerPlayer(player);
PlayerAuthSuccessEvent event = connection.getProxy().getEventBus().fire(new PlayerAuthSuccessEvent(
player,
false
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -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()
);

Expand Down Expand Up @@ -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;
Expand Down
Loading