diff --git a/UDP_HOLE_PUNCH_PACKET_FLOW.md b/UDP_HOLE_PUNCH_PACKET_FLOW.md index 4afd74a1..ae51da63 100644 --- a/UDP_HOLE_PUNCH_PACKET_FLOW.md +++ b/UDP_HOLE_PUNCH_PACKET_FLOW.md @@ -16,4 +16,4 @@ Client A refers to the client hosting the world. Client B refers to the connecti 3. Server passes the request to Client A. 4. Client A verifies the request comes from a valid party. 5. If verification is successful, Client A performs Port Lookup and sends an empty packet to Client B's port until Port Lookup finishes. -6. Server notifies Client B of the IP and external port of Client A. +6. Client A asks Server to send Client B its IP and port. diff --git a/src/main/java/io/github/gaming32/worldhost/WorldHost.java b/src/main/java/io/github/gaming32/worldhost/WorldHost.java index 4f2f10f0..dbf8ef3d 100644 --- a/src/main/java/io/github/gaming32/worldhost/WorldHost.java +++ b/src/main/java/io/github/gaming32/worldhost/WorldHost.java @@ -9,6 +9,7 @@ import com.mojang.brigadier.context.CommandContext; import com.mojang.logging.LogUtils; import io.github.gaming32.worldhost.config.WorldHostConfig; +import io.github.gaming32.worldhost.ext.ServerDataExt; import io.github.gaming32.worldhost.gui.OnlineStatusLocation; import io.github.gaming32.worldhost.gui.screen.JoiningWorldHostScreen; import io.github.gaming32.worldhost.gui.screen.OnlineFriendsScreen; @@ -219,6 +220,8 @@ public class WorldHost public static SocketAddress proxySocketAddress; + public static long tickCount; + private static List plugins; //#if FABRIC @@ -433,7 +436,8 @@ public static void friendWentOnline(OnlineFriend friend) { } public static void tickHandler() { - PunchManager.transmitPunches(); + tickCount++; + PunchManager.retransmitAll(); if (protoClient == null || protoClient.isClosed()) { protoClient = null; if (proxyProtocolClient != null) { @@ -810,20 +814,17 @@ public static void connect(Screen parentScreen, long cid, String host, int port) minecraft.getSingleplayerServer().halt(false); } final ServerAddress serverAddress = new ServerAddress(host, port); - ConnectScreen.startConnecting( - parentScreen, minecraft, serverAddress, - //#if MC < 1.20.0 - //$$ null + final var serverData = new ServerData( + WorldHost.connectionIdToString(cid), serverAddress.toString(), + //#if MC < 1.20.2 + //$$ false //#else - new ServerData( - WorldHost.connectionIdToString(cid), serverAddress.toString(), - //#if MC < 1.20.2 - //$$ false - //#else - ServerData.Type.OTHER - //#endif - ), false + ServerData.Type.OTHER //#endif + ); + ((ServerDataExt)serverData).wh$setConnectionId(cid); + ConnectScreen.startConnecting( + parentScreen, minecraft, serverAddress, serverData, false //#if MC >= 1.20.5 , null //#endif diff --git a/src/main/java/io/github/gaming32/worldhost/compat/WorldHostSimpleVoiceChatCompat.java b/src/main/java/io/github/gaming32/worldhost/compat/WorldHostSimpleVoiceChatCompat.java deleted file mode 100644 index c6c59559..00000000 --- a/src/main/java/io/github/gaming32/worldhost/compat/WorldHostSimpleVoiceChatCompat.java +++ /dev/null @@ -1,79 +0,0 @@ -package io.github.gaming32.worldhost.compat; - -import com.google.common.net.HostAndPort; -import de.maxhenkel.voicechat.Voicechat; -import de.maxhenkel.voicechat.api.ForgeVoicechatPlugin; -import de.maxhenkel.voicechat.api.VoicechatPlugin; -import de.maxhenkel.voicechat.api.events.EventRegistration; -import de.maxhenkel.voicechat.api.events.VoiceHostEvent; -import de.maxhenkel.voicechat.voice.server.Server; -import io.github.gaming32.worldhost.WorldHost; -import io.github.gaming32.worldhost.protocol.punch.PunchManager; -import io.github.gaming32.worldhost.protocol.punch.PunchReason; -import io.github.gaming32.worldhost.protocol.punch.PunchTransmitter; -import net.minecraft.client.Minecraft; -import net.minecraft.server.MinecraftServer; - -import java.io.IOException; -import java.util.Optional; - -@ForgeVoicechatPlugin -public class WorldHostSimpleVoiceChatCompat implements VoicechatPlugin { - private static final int RE_PUNCH_RATE = 10 * 20; - - private static HostAndPort externalHost; - private static int prevPort = -1; - - @Override - public String getPluginId() { - return WorldHost.MOD_ID; - } - - @Override - public void registerEvents(EventRegistration registration) { - registration.registerEvent(VoiceHostEvent.class, event -> { - if (externalHost != null) { - event.setVoiceHost(externalHost.toString()); - } - }); - } - - public static void tick(MinecraftServer mcServer) { - if (!mcServer.isPublished()) { - externalHost = null; - prevPort = -1; - return; - } - final var vcServer = Voicechat.SERVER.getServer(); - if (vcServer == null) return; - final int port = vcServer.getPort(); - if (port != prevPort || mcServer.getTickCount() % RE_PUNCH_RATE == 0) { - prevPort = port; - Minecraft.getInstance().execute(() -> PunchManager.punch( - WorldHost.CONNECTION_ID, - PunchReason.SIMPLE_VOICE_CHAT, - result -> { - if (!result.equals(externalHost)) { - WorldHost.LOGGER.info("Found new SVC host {}", result); - externalHost = result; - } - }, - () -> WorldHost.LOGGER.warn("Failed to punch self for Simple Voice Chat compat") - )); - } - } - - public static Optional getTransmitter() { - return Optional.ofNullable(Voicechat.SERVER.getServer()) - .map(Server::getSocket) - .map(socket -> (packet, address) -> { - try { - socket.send(packet, address); - } catch (IOException | RuntimeException e) { - throw e; - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - } -} diff --git a/src/main/java/io/github/gaming32/worldhost/compat/simplevoicechat/WorldHostClientVoicechatSocket.java b/src/main/java/io/github/gaming32/worldhost/compat/simplevoicechat/WorldHostClientVoicechatSocket.java new file mode 100644 index 00000000..d6ee867b --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/compat/simplevoicechat/WorldHostClientVoicechatSocket.java @@ -0,0 +1,62 @@ +package io.github.gaming32.worldhost.compat.simplevoicechat; + +import de.maxhenkel.voicechat.api.ClientVoicechatSocket; +import de.maxhenkel.voicechat.api.RawUdpPacket; +import de.maxhenkel.voicechat.plugins.impl.VoicechatSocketBase; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.SocketAddress; +import java.util.Objects; + +public class WorldHostClientVoicechatSocket extends VoicechatSocketBase implements ClientVoicechatSocket { + private final byte[] buffer = new byte[4096]; + + private DatagramSocket socket; + private SocketAddress targetAddress; + + public DatagramSocket getSocket() { + return socket; + } + + public void setTargetAddress(SocketAddress targetAddress) { + this.targetAddress = targetAddress; + } + + @Override + public void open() throws Exception { + socket = new DatagramSocket(); + } + + @Override + public RawUdpPacket read() throws Exception { + if (socket == null) { + throw new IllegalStateException("Socket not opened yet"); + } + return read(socket); + } + + @Override + public void send(byte[] data, SocketAddress address) throws Exception { + sendDirect(data, Objects.requireNonNullElse(targetAddress, address)); + } + + public void sendDirect(byte[] data, SocketAddress address) throws IOException { + if (socket == null) return; + socket.send(new DatagramPacket(data, data.length, address)); + } + + @Override + public void close() { + if (socket != null) { + socket.close(); + socket = null; + } + } + + @Override + public boolean isClosed() { + return socket == null; + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/compat/simplevoicechat/WorldHostSimpleVoiceChatCompat.java b/src/main/java/io/github/gaming32/worldhost/compat/simplevoicechat/WorldHostSimpleVoiceChatCompat.java new file mode 100644 index 00000000..f261c9f8 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/compat/simplevoicechat/WorldHostSimpleVoiceChatCompat.java @@ -0,0 +1,60 @@ +package io.github.gaming32.worldhost.compat.simplevoicechat; + +import de.maxhenkel.voicechat.Voicechat; +import de.maxhenkel.voicechat.api.ForgeVoicechatPlugin; +import de.maxhenkel.voicechat.api.VoicechatPlugin; +import de.maxhenkel.voicechat.api.events.ClientVoicechatInitializationEvent; +import de.maxhenkel.voicechat.api.events.EventRegistration; +import de.maxhenkel.voicechat.voice.server.Server; +import io.github.gaming32.worldhost.WorldHost; +import io.github.gaming32.worldhost.ext.ServerDataExt; +import io.github.gaming32.worldhost.protocol.punch.PunchManager; +import io.github.gaming32.worldhost.protocol.punch.PunchReason; +import io.github.gaming32.worldhost.protocol.punch.PunchTransmitter; +import net.minecraft.client.Minecraft; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.Optional; + +@ForgeVoicechatPlugin +public class WorldHostSimpleVoiceChatCompat implements VoicechatPlugin { + public static final WorldHostClientVoicechatSocket CLIENT_SOCKET = new WorldHostClientVoicechatSocket(); + + @Override + public String getPluginId() { + return WorldHost.MOD_ID; + } + + @Override + public void registerEvents(EventRegistration registration) { + registration.registerEvent(ClientVoicechatInitializationEvent.class, event -> { + final var connection = Minecraft.getInstance().getConnection(); + if (connection == null) return; + final var serverData = (ServerDataExt)connection.getServerData(); + if (serverData == null) return; + final var connectionId = serverData.wh$getConnectionId(); + if (connectionId == null) return; + PunchManager.punch( + connectionId, PunchReason.SIMPLE_VOICE_CHAT, CLIENT_SOCKET::sendDirect, + hostAndPort -> CLIENT_SOCKET.setTargetAddress(new InetSocketAddress(hostAndPort.getHost(), hostAndPort.getPort())), + () -> WorldHost.LOGGER.info("Failed to punch for Simple Voice Chat. Host probably doesn't have it installed.") + ); + event.setSocketImplementation(CLIENT_SOCKET); + }); + } + + public static Optional getServerTransmitter() { + return Optional.ofNullable(Voicechat.SERVER.getServer()) + .map(Server::getSocket) + .map(socket -> (packet, address) -> { + try { + socket.send(packet, address); + } catch (IOException | RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/ext/ServerDataExt.java b/src/main/java/io/github/gaming32/worldhost/ext/ServerDataExt.java new file mode 100644 index 00000000..50e304b1 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/ext/ServerDataExt.java @@ -0,0 +1,7 @@ +package io.github.gaming32.worldhost.ext; + +public interface ServerDataExt { + Long wh$getConnectionId(); + + void wh$setConnectionId(Long connectionId); +} diff --git a/src/main/java/io/github/gaming32/worldhost/mixin/MixinConnectScreen_1.java b/src/main/java/io/github/gaming32/worldhost/mixin/MixinConnectScreen_1.java index 7b6635c9..332c44ef 100644 --- a/src/main/java/io/github/gaming32/worldhost/mixin/MixinConnectScreen_1.java +++ b/src/main/java/io/github/gaming32/worldhost/mixin/MixinConnectScreen_1.java @@ -54,9 +54,7 @@ private void initRefs( cancellable = true ) private void overrideError(CallbackInfo ci) { - if (WorldHost.protoClient == null || wh$host.endsWith(WorldHost.protoClient.getBaseIp())) { - return; - } + if (WorldHost.protoClient == null || wh$host.endsWith(WorldHost.protoClient.getBaseIp())) return; final Long attemptingToJoin = WorldHost.protoClient.getAttemptingToJoin(); if (attemptingToJoin == null) return; Minecraft.getInstance().execute(() -> WorldHost.connect( diff --git a/src/main/java/io/github/gaming32/worldhost/mixin/MixinServerData.java b/src/main/java/io/github/gaming32/worldhost/mixin/MixinServerData.java new file mode 100644 index 00000000..bf5aa161 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/mixin/MixinServerData.java @@ -0,0 +1,22 @@ +package io.github.gaming32.worldhost.mixin; + +import io.github.gaming32.worldhost.ext.ServerDataExt; +import net.minecraft.client.multiplayer.ServerData; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +@Mixin(ServerData.class) +public class MixinServerData implements ServerDataExt { + @Unique + private Long wh$connectionId = null; + + @Override + public Long wh$getConnectionId() { + return wh$connectionId; + } + + @Override + public void wh$setConnectionId(Long connectionId) { + wh$connectionId = connectionId; + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java b/src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java index f3a931a5..1b3a81b2 100644 --- a/src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java +++ b/src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java @@ -7,7 +7,6 @@ import com.mojang.authlib.exceptions.InvalidCredentialsException; import io.github.gaming32.worldhost.WorldHost; import io.github.gaming32.worldhost.protocol.proxy.ProxyPassthrough; -import io.github.gaming32.worldhost.protocol.punch.PunchCookie; import io.github.gaming32.worldhost.toast.WHToast; import net.minecraft.client.Minecraft; import net.minecraft.client.User; @@ -387,12 +386,20 @@ public void requestDirectJoin(long connectionId) { enqueue(new WorldHostC2SMessage.RequestDirectJoin(connectionId)); } - public void requestPunchOpen(long connectionId, String purpose, PunchCookie cookie) { - enqueue(new WorldHostC2SMessage.RequestPunchOpen(connectionId, purpose, cookie)); + public void requestPunchOpen(long targetConnection, String purpose, UUID punchId, String myHost, int myPort) { + enqueue(new WorldHostC2SMessage.RequestPunchOpen(targetConnection, purpose, punchId, myHost, myPort)); } - public void punchRequestInvalid(PunchCookie cookie) { - enqueue(new WorldHostC2SMessage.PunchRequestInvalid(cookie)); + public void punchFailed(long targetConnection, UUID punchId) { + enqueue(new WorldHostC2SMessage.PunchFailed(targetConnection, punchId)); + } + + public void beginPortLookup(UUID lookupId) { + enqueue(new WorldHostC2SMessage.BeginPortLookup(lookupId)); + } + + public void punchSuccess(long connectionId, UUID punchId, String host, int port) { + enqueue(new WorldHostC2SMessage.PunchSuccess(connectionId, punchId, host, port)); } public Future getConnectingFuture() { diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java index 03139b3f..fcecbcdc 100644 --- a/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java +++ b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java @@ -1,7 +1,6 @@ package io.github.gaming32.worldhost.protocol; import io.github.gaming32.worldhost.WorldHost; -import io.github.gaming32.worldhost.protocol.punch.PunchCookie; import net.minecraft.network.protocol.status.ServerStatus; import java.io.DataOutputStream; @@ -177,7 +176,9 @@ public void encode(DataOutputStream dos) throws IOException { } } - record RequestPunchOpen(long targetConnection, String purpose, PunchCookie cookie) implements WorldHostC2SMessage { + record RequestPunchOpen( + long targetConnection, String purpose, UUID punchId, String myHost, int myPort + ) implements WorldHostC2SMessage { @Override public byte typeId() { return 12; @@ -187,7 +188,9 @@ public byte typeId() { public void encode(DataOutputStream dos) throws IOException { dos.writeLong(targetConnection); writeString(dos, purpose); - cookie.writeTo(dos); + writeUuid(dos, punchId); + writeString(dos, myHost); + dos.writeShort(myPort); } @Override @@ -196,7 +199,7 @@ public boolean isEncrypted() { } } - record PunchRequestInvalid(PunchCookie cookie) implements WorldHostC2SMessage { + record PunchFailed(long targetConnection, UUID punchId) implements WorldHostC2SMessage { @Override public byte typeId() { return 13; @@ -204,7 +207,44 @@ public byte typeId() { @Override public void encode(DataOutputStream dos) throws IOException { - cookie.writeTo(dos); + writeUuid(dos, punchId); + } + + @Override + public boolean isEncrypted() { + return true; + } + } + + record BeginPortLookup(UUID lookupId) implements WorldHostC2SMessage { + @Override + public byte typeId() { + return 14; + } + + @Override + public void encode(DataOutputStream dos) throws IOException { + writeUuid(dos, lookupId); + } + + @Override + public boolean isEncrypted() { + return true; + } + } + + record PunchSuccess(long connectionId, UUID punchId, String host, int port) implements WorldHostC2SMessage { + @Override + public byte typeId() { + return 15; + } + + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeLong(connectionId); + writeUuid(dos, punchId); + writeString(dos, host); + dos.writeShort(port); } @Override @@ -217,6 +257,7 @@ public boolean isEncrypted() { void encode(DataOutputStream dos) throws IOException; + // TODO: Rewrite encryption to encrypt whole stream default boolean isEncrypted() { return false; } diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java index 6ab2a22e..2b1e6baf 100644 --- a/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java +++ b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java @@ -13,7 +13,6 @@ import io.github.gaming32.worldhost.plugin.vanilla.WorldHostFriendListFriend; import io.github.gaming32.worldhost.plugin.vanilla.WorldHostOnlineFriend; import io.github.gaming32.worldhost.protocol.proxy.ProxyProtocolClient; -import io.github.gaming32.worldhost.protocol.punch.PunchCookie; import io.github.gaming32.worldhost.protocol.punch.PunchManager; import io.github.gaming32.worldhost.protocol.punch.PunchReason; import io.github.gaming32.worldhost.toast.WHToast; @@ -452,14 +451,17 @@ public void handle(ProtocolClient client) { } record PunchOpenRequest( - PunchCookie cookie, String purpose, UUID user, SecurityLevel security + UUID punchId, String purpose, String fromHost, int fromPort, long connectionId, UUID user, SecurityLevel security ) implements WorldHostS2CMessage, SecurityCheckable { public static final int ID = 18; public static PunchOpenRequest decode(DataInputStream dis) throws IOException { return new PunchOpenRequest( - PunchCookie.readFrom(dis), + readUuid(dis), + readString(dis), readString(dis), + dis.readUnsignedShort(), + dis.readLong(), readUuid(dis), SecurityLevel.byId(dis.readUnsignedByte()) ); @@ -470,68 +472,85 @@ public void handle(ProtocolClient client) { if (!checkAndLogSecurity()) return; final PunchReason reason = PunchReason.byId(purpose); if (reason == null) { - WorldHost.LOGGER.warn("Punch {} from {} has unknown purpose {}", cookie, user, purpose); - client.punchRequestInvalid(cookie); + WorldHost.LOGGER.warn("Punch {} from {} has unknown purpose {}", punchId, user, purpose); + client.punchFailed(connectionId, punchId); return; } if (!reason.verificationType().verify(user)) { WorldHost.LOGGER.warn( "Punch {} from {} failed verification (verification type {})", - cookie, user, reason.verificationType() + punchId, user, PunchReason.VerificationType.getName(reason.verificationType()) ); - client.punchRequestInvalid(cookie); + client.punchFailed(connectionId, punchId); return; } - final var transmitter = reason.transmitterFinder().findTransmitter(); + final var transmitter = reason.transmitterFinder().findServerTransmitter(); if (transmitter == null) { WorldHost.LOGGER.warn( "Punch {} from {} couldn't find transmitter (transmitter finder {})", - cookie, user, reason.transmitterFinder() + punchId, user, reason.transmitterFinder() ); - client.punchRequestInvalid(cookie); + client.punchFailed(connectionId, punchId); return; } - Minecraft.getInstance().execute(() -> PunchManager.openPunchRequest(cookie, transmitter)); + Minecraft.getInstance().execute( + () -> PunchManager.openPunchRequest(punchId, transmitter, fromHost, fromPort, connectionId) + ); } } - record StopPunchRetransmit(PunchCookie cookie) implements WorldHostS2CMessage { + record CancelPortLookup(UUID lookupId) implements WorldHostS2CMessage { public static final int ID = 19; - public static StopPunchRetransmit decode(DataInputStream dis) throws IOException { - return new StopPunchRetransmit(PunchCookie.readFrom(dis)); + public static CancelPortLookup decode(DataInputStream dis) throws IOException { + return new CancelPortLookup(readUuid(dis)); } @Override public void handle(ProtocolClient client) { - Minecraft.getInstance().execute(() -> PunchManager.stopTransmit(cookie)); + Minecraft.getInstance().execute(() -> PunchManager.cancelPortLookup(lookupId)); } } - record PunchRequestSuccess(PunchCookie cookie, String host, int port) implements WorldHostS2CMessage { + record PortLookupSuccess(UUID lookupId, String host, int port) implements WorldHostS2CMessage { public static final int ID = 20; - public static PunchRequestSuccess decode(DataInputStream dis) throws IOException { - return new PunchRequestSuccess(PunchCookie.readFrom(dis), readString(dis), dis.readUnsignedShort()); + public static PortLookupSuccess decode(DataInputStream dis) throws IOException { + var lookupId = readUuid(dis); + return new PortLookupSuccess(lookupId, readString(dis), dis.readUnsignedShort()); } @Override public void handle(ProtocolClient client) { final HostAndPort hostAndPort = HostAndPort.fromParts(host, port); - Minecraft.getInstance().execute(() -> PunchManager.punchSuccess(cookie, hostAndPort)); + Minecraft.getInstance().execute(() -> PunchManager.portLookupSuccess(lookupId, hostAndPort)); } } - record PunchRequestCancelled(PunchCookie cookie) implements WorldHostS2CMessage { + record PunchRequestCancelled(UUID punchId) implements WorldHostS2CMessage { public static final int ID = 21; public static PunchRequestCancelled decode(DataInputStream dis) throws IOException { - return new PunchRequestCancelled(PunchCookie.readFrom(dis)); + return new PunchRequestCancelled(readUuid(dis)); + } + + @Override + public void handle(ProtocolClient client) { + Minecraft.getInstance().execute(() -> PunchManager.cancelPunch(punchId)); + } + } + + record PunchSuccess(UUID punchId, String host, int port) implements WorldHostS2CMessage { + public static final int ID = 22; + + public static PunchSuccess decode(DataInputStream dis) throws IOException { + return new PunchSuccess(readUuid(dis), readString(dis), dis.readUnsignedShort()); } @Override public void handle(ProtocolClient client) { - Minecraft.getInstance().execute(() -> PunchManager.punchCancelled(cookie)); + final HostAndPort hostAndPort = HostAndPort.fromParts(host, port); + Minecraft.getInstance().execute(() -> PunchManager.punchSuccess(punchId, hostAndPort)); } } @@ -548,9 +567,10 @@ public void handle(ProtocolClient client) { static boolean isEncrypted(int typeId) { return switch (typeId) { case PunchOpenRequest.ID, - StopPunchRetransmit.ID, - PunchRequestSuccess.ID, - PunchRequestCancelled.ID -> true; + CancelPortLookup.ID, + PortLookupSuccess.ID, + PunchRequestCancelled.ID, + PunchSuccess.ID -> true; default -> false; }; } @@ -576,9 +596,10 @@ static WorldHostS2CMessage decode(int typeId, DataInputStream dis) throws IOExce case NewQueryResponse.ID -> NewQueryResponse.decode(dis); case Warning.ID -> Warning.decode(dis); case PunchOpenRequest.ID -> PunchOpenRequest.decode(dis); - case StopPunchRetransmit.ID -> StopPunchRetransmit.decode(dis); - case PunchRequestSuccess.ID -> PunchRequestSuccess.decode(dis); + case CancelPortLookup.ID -> CancelPortLookup.decode(dis); + case PortLookupSuccess.ID -> PortLookupSuccess.decode(dis); case PunchRequestCancelled.ID -> PunchRequestCancelled.decode(dis); + case PunchSuccess.ID -> PunchSuccess.decode(dis); default -> new Error("Received packet with unknown typeId from server (outdated client?): " + typeId); }; } diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchCookie.java b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchCookie.java deleted file mode 100644 index bad43b65..00000000 --- a/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchCookie.java +++ /dev/null @@ -1,63 +0,0 @@ -package io.github.gaming32.worldhost.protocol.punch; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.security.SecureRandom; -import java.util.Arrays; -import java.util.HexFormat; - -public final class PunchCookie { - public static final int BITS = 128; - public static final int BYTES = BITS / 8; - - private final byte[] cookie; - private int hashCode; - - public PunchCookie(byte[] cookie) { - if (cookie.length != BYTES) { - throw new IllegalArgumentException("PunchCookie data length must be " + BITS + " bits"); - } - this.cookie = cookie; - } - - public static PunchCookie random() { - final byte[] cookie = new byte[BYTES]; - new SecureRandom().nextBytes(cookie); - return new PunchCookie(cookie); - } - - public static PunchCookie readFrom(InputStream is) throws IOException { - return new PunchCookie(is.readNBytes(BYTES)); - } - - public byte[] toBytes() { - return cookie; - } - - public void writeTo(OutputStream os) throws IOException { - os.write(cookie); - } - - @Override - public String toString() { - return HexFormat.of().formatHex(cookie); - } - - @Override - public boolean equals(Object obj) { - return obj instanceof PunchCookie other && Arrays.equals(cookie, other.cookie); - } - - @Override - public int hashCode() { - if (hashCode != 0) { - return hashCode; - } - int h = Arrays.hashCode(cookie); - if (h == 0) { - h = 31; - } - return hashCode = h; - } -} diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchManager.java b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchManager.java index 51601db6..6dab257f 100644 --- a/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchManager.java +++ b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchManager.java @@ -2,88 +2,150 @@ import com.google.common.net.HostAndPort; import io.github.gaming32.worldhost.WorldHost; -import io.github.gaming32.worldhost.protocol.ProtocolClient; +import io.github.gaming32.worldhost.protocol.WorldHostC2SMessage; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; import java.io.IOException; import java.net.InetSocketAddress; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; public final class PunchManager { - private static final Map PENDING_CLIENT_PUNCHES = new HashMap<>(); - private static final Map PENDING_SERVER_PUNCHES = new HashMap<>(); + private static final int PORT_LOOKUP_RETRANSMIT_PERIOD = 3; + private static final int SERVER_PUNCH_EXPIRY = 10 * 20; + + private static final Map PENDING_CLIENT_PUNCHES = new HashMap<>(); + private static final Map PENDING_SERVER_PUNCHES = new ConcurrentHashMap<>(); + private static final Map PENDING_PORT_LOOKUPS = new ConcurrentHashMap<>(); private PunchManager() { } + public static void lookupPort( + PunchTransmitter transmitter, Consumer successAction, Runnable cancelledAction + ) { + final var lookupId = UUID.randomUUID(); + final var lookup = new PendingPortLookup(lookupId, transmitter, successAction, cancelledAction); + PENDING_PORT_LOOKUPS.put(lookupId, lookup); + if (WorldHost.protoClient != null) { + WorldHost.protoClient.beginPortLookup(lookupId); + final var signalling = WorldHost.protoClient.getHostAndPort(); + Thread.ofVirtual() + .name("PunchManager-TransmitLookup-" + lookup) + .start(() -> lookup.transmit(signalling)); + } + } + public static void punch( long connectionId, PunchReason reason, + PunchTransmitter transmitter, Consumer successAction, Runnable cancelledAction ) { - final PunchCookie cookie = PunchCookie.random(); - PENDING_CLIENT_PUNCHES.put(cookie, new PendingClientPunch(successAction, cancelledAction)); - if (WorldHost.protoClient != null) { - WorldHost.protoClient.requestPunchOpen(connectionId, reason.id(), cookie); - } + lookupPort(transmitter, myHostAndPort -> { + final var punchId = UUID.randomUUID(); + PENDING_CLIENT_PUNCHES.put(punchId, new PendingClientPunch(successAction, cancelledAction)); + if (WorldHost.protoClient != null) { + WorldHost.protoClient.requestPunchOpen( + connectionId, reason.id(), punchId, myHostAndPort.getHost(), myHostAndPort.getPort() + ); + } + }, cancelledAction); } - public static void transmitPunches() { - final HostAndPort signalling = getSignallingServer(); - if (signalling == null) return; - final List punches = new ArrayList<>(PENDING_SERVER_PUNCHES.values()); - Thread.ofVirtual().name("PunchManager-Retransmit").start(() -> { - for (final PendingServerPunch punch : punches) { - punch.transmit(signalling); + public static void retransmitAll() { + final long tickCount = WorldHost.tickCount; + + if (tickCount % PORT_LOOKUP_RETRANSMIT_PERIOD == 0 && WorldHost.protoClient != null) { + final var signalling = WorldHost.protoClient.getHostAndPort(); + Thread.ofVirtual().name("PunchManager-RetransmitLookups").start(() -> { + for (final PendingPortLookup lookup : PENDING_PORT_LOOKUPS.values()) { + lookup.transmit(signalling); + } + }); + } + + Thread.ofVirtual().name("PunchManager-RetransmitPunches").start(() -> { + final var iter = PENDING_SERVER_PUNCHES.values().iterator(); + while (iter.hasNext()) { + final var punch = iter.next(); + punch.transmit(); + if (tickCount > punch.expiryTick) { + iter.remove(); + } } }); } - public static void openPunchRequest(PunchCookie cookie, PunchTransmitter transmitter) { - final PendingServerPunch punch = new PendingServerPunch(cookie, transmitter); - final PendingServerPunch old = PENDING_SERVER_PUNCHES.put(cookie, punch); + public static void openPunchRequest( + UUID punchId, PunchTransmitter transmitter, String host, int port, long connectionId + ) { + final var punch = new PendingServerPunch( + punchId, connectionId, host, port, transmitter, + WorldHost.tickCount + SERVER_PUNCH_EXPIRY + ); + final var old = PENDING_SERVER_PUNCHES.put(punchId, punch); if (old != null) { - WorldHost.LOGGER.warn("New punch request {} replaced old request {}", punch, old); + WorldHost.LOGGER.warn("New punch request {} replaced old request {} (ID {})", punch, old, punchId); } - final HostAndPort signalling = getSignallingServer(); - if (signalling == null) return; Thread.ofVirtual() - .name("PunchManager-Transmit-" + cookie) - .start(() -> punch.transmit(signalling)); + .name("PunchManager-TransmitPunch-" + punchId) + .start(punch::transmit); + + lookupPort( + transmitter, + myAddr -> { + PENDING_SERVER_PUNCHES.remove(punchId); + if (WorldHost.protoClient != null) { + WorldHost.protoClient.punchSuccess(connectionId, punchId, myAddr.getHost(), myAddr.getPort()); + } + }, + () -> { + PENDING_SERVER_PUNCHES.remove(punchId); + if (WorldHost.protoClient != null) { + WorldHost.protoClient.punchFailed(connectionId, punchId); + } + } + ); } - private static HostAndPort getSignallingServer() { - final ProtocolClient client = WorldHost.protoClient; - if (client == null) { - return null; + public static void portLookupSuccess(UUID lookupId, HostAndPort hostAndPort) { + final var lookup = PENDING_PORT_LOOKUPS.remove(lookupId); + if (lookup == null) { + WorldHost.LOGGER.warn("Success received for unknown port lookup {}", lookupId); + return; } - return client.getHostAndPort(); + lookup.successAction.accept(hostAndPort); } - public static void stopTransmit(PunchCookie cookie) { - if (PENDING_SERVER_PUNCHES.remove(cookie) == null) { - WorldHost.LOGGER.warn("Requested to stop transmitting unknown punch {}", cookie); + public static void cancelPortLookup(UUID lookupId) { + final var lookup = PENDING_PORT_LOOKUPS.remove(lookupId); + if (lookup == null) { + WorldHost.LOGGER.warn("Cancellation received for unknown port lookup {}", lookupId); + return; } + lookup.cancelledAction.run(); } - public static void punchSuccess(PunchCookie cookie, HostAndPort hostAndPort) { - final PendingClientPunch punch = PENDING_CLIENT_PUNCHES.remove(cookie); + public static void punchSuccess(UUID punchId, HostAndPort hostAndPort) { + final PendingClientPunch punch = PENDING_CLIENT_PUNCHES.remove(punchId); if (punch == null) { - WorldHost.LOGGER.warn("Success received for unknown punch {}", cookie); + WorldHost.LOGGER.warn("Success received for unknown punch {}", punchId); return; } punch.successAction.accept(hostAndPort); } - public static void punchCancelled(PunchCookie cookie) { - final PendingClientPunch punch = PENDING_CLIENT_PUNCHES.remove(cookie); + public static void cancelPunch(UUID punchId) { + final PendingClientPunch punch = PENDING_CLIENT_PUNCHES.remove(punchId); if (punch == null) { - WorldHost.LOGGER.warn("Cancellation received for unknown punch {}", cookie); + WorldHost.LOGGER.warn("Cancellation received for unknown punch {}", punchId); return; } punch.cancelledAction.run(); @@ -92,15 +154,27 @@ public static void punchCancelled(PunchCookie cookie) { private record PendingClientPunch(Consumer successAction, Runnable cancelledAction) { } - private record PendingServerPunch(PunchCookie cookie, PunchTransmitter transmitter) { + private record PendingServerPunch( + UUID punchId, long connectionId, String host, int port, PunchTransmitter transmitter, long expiryTick + ) { + void transmit() { + try { + transmitter.transmit(new byte[0], new InetSocketAddress(host, port)); + } catch (IOException e) { + WorldHost.LOGGER.error("Failed to transmit {}", this, e); + } + } + } + + private record PendingPortLookup( + UUID lookupId, PunchTransmitter transmitter, + Consumer successAction, Runnable cancelledAction + ) { void transmit(HostAndPort target) { -// try (DatagramSocket socket = new DatagramSocket(localPort)) { -// socket.send(new DatagramPacket( -// cookie.toBytes(), PunchCookie.BYTES, -// new InetSocketAddress(target.getHost(), target.getPort()) -// )); try { - transmitter.transmit(cookie.toBytes(), new InetSocketAddress(target.getHost(), target.getPort())); + final var packet = new ByteArrayOutputStream(); + WorldHostC2SMessage.writeUuid(new DataOutputStream(packet), lookupId); + transmitter.transmit(packet.toByteArray(), new InetSocketAddress(target.getHost(), target.getPort())); } catch (IOException e) { WorldHost.LOGGER.error("Failed to transmit {}", this, e); } diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchReason.java b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchReason.java index 403858a0..855d98cc 100644 --- a/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchReason.java +++ b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchReason.java @@ -1,18 +1,24 @@ package io.github.gaming32.worldhost.protocol.punch; import io.github.gaming32.worldhost.WorldHost; +import io.github.gaming32.worldhost.compat.simplevoicechat.WorldHostSimpleVoiceChatCompat; import net.minecraft.client.Minecraft; import org.jetbrains.annotations.Nullable; +import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; -public record PunchReason(String id, VerificationType verificationType, TransmitterFinder transmitterFinder) { +public record PunchReason( + String id, + VerificationType verificationType, + TransmitterFinder transmitterFinder +) { public static final String SIMPLE_VOICE_CHAT_ID = "simple_voice_chat"; public static final PunchReason SIMPLE_VOICE_CHAT = new PunchReason( SIMPLE_VOICE_CHAT_ID, - VerificationType.SELF, + VerificationType.IN_WORLD, TransmitterFinder.SIMPLE_VOICE_CHAT ); @@ -24,49 +30,60 @@ public static PunchReason byId(String id) { }; } - public enum VerificationType { - SELF { - @Override - public boolean verify(UUID uuid) { - return uuid.equals(WorldHost.getUserId()); - } - }, - IS_FRIEND { - @Override - public boolean verify(UUID uuid) { - return WorldHost.isFriend(uuid); - } - }, - IN_WORLD { - @Override - public boolean verify(UUID uuid) { - final Minecraft minecraft = Minecraft.getInstance(); - return minecraft.submit(minecraft::getSingleplayerServer) - .thenCompose(server -> { - if (server == null) { - return CompletableFuture.completedFuture(false); - } - return server.submit(() -> server.getPlayerList().getPlayer(uuid) != null); - }) - .join(); - } + @Override + public String toString() { + return "PunchReason[" + id + "]"; + } + + @FunctionalInterface + public interface VerificationType { + VerificationType SELF = (uuid) -> uuid.equals(WorldHost.getUserId()); + VerificationType IS_FRIEND = WorldHost::isFriend; + VerificationType IN_WORLD = (uuid) -> { + final Minecraft minecraft = Minecraft.getInstance(); + return minecraft.submit(minecraft::getSingleplayerServer) + .thenCompose(server -> { + if (server == null) { + return CompletableFuture.completedFuture(false); + } + return server.submit(() -> server.getPlayerList().getPlayer(uuid) != null); + }) + .join(); }; - public abstract boolean verify(UUID uuid); + Map TYPE_NAMES = Map.of( + SELF, "SELF", + IS_FRIEND, "IS_FRIEND", + IN_WORLD, "IN_WORLD" + ); + + boolean verify(UUID uuid); + + static String getName(VerificationType type) { + final var result = TYPE_NAMES.get(type); + return result != null ? result : type.toString(); + } } - public enum TransmitterFinder { - SIMPLE_VOICE_CHAT { - @Override - public PunchTransmitter findTransmitter() { -// if (!WorldHost.isModLoaded("voicechat")) { - return null; -// } -// return WorldHostSimpleVoiceChatCompat.getTransmitter().orElse(null); + @FunctionalInterface + public interface TransmitterFinder { + TransmitterFinder SIMPLE_VOICE_CHAT = () -> { + if (!WorldHost.isModLoaded("voicechat")) { + return null; } + return WorldHostSimpleVoiceChatCompat.getServerTransmitter().orElse(null); }; + Map TYPE_NAMES = Map.of( + SIMPLE_VOICE_CHAT, "SIMPLE_VOICE_CHAT" + ); + @Nullable - public abstract PunchTransmitter findTransmitter(); + PunchTransmitter findServerTransmitter(); + + static String getName(TransmitterFinder finder) { + final var result = TYPE_NAMES.get(finder); + return result != null ? result : finder.toString(); + } } } diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index a822f7f2..8e47c1a6 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -26,7 +26,7 @@ "io.github.gaming32.worldhost.compat.WorldHostModMenuCompat" ], "voicechat": [ - "io.github.gaming32.worldhost.compat.WorldHostSimpleVoiceChatCompat" + "io.github.gaming32.worldhost.compat.simplevoicechat.WorldHostSimpleVoiceChatCompat" ] }, "mixins": [ diff --git a/src/main/resources/world-host.mixins.json b/src/main/resources/world-host.mixins.json index f258efa2..3c7ab286 100644 --- a/src/main/resources/world-host.mixins.json +++ b/src/main/resources/world-host.mixins.json @@ -27,6 +27,7 @@ "MixinOptions", "MixinPauseScreen", "MixinSelectWorldScreen", + "MixinServerData", "MixinShareToLanScreen", "MixinTitleScreen", "MixinWorldSelectionList_WorldListEntry",