diff --git a/UDP_HOLE_PUNCH_PACKET_FLOW.md b/UDP_HOLE_PUNCH_PACKET_FLOW.md new file mode 100644 index 0000000..5093d41 --- /dev/null +++ b/UDP_HOLE_PUNCH_PACKET_FLOW.md @@ -0,0 +1,10 @@ +Client A refers to the client hosting the world. Client B refers to the connecting client. Server refers to the World Host server. In some situations, Client A and Client B may be the same client. + +1. Client B asks Server over main channel for Client A to open a port for a specified purpose. A unique "cookie" is included. +2. Server passes the request to Client A. +3. Client A verifies the request comes from a valid party. +4. If verification is successful, Client A sends a message over UDP signaling channel with the same cookie. +5. Client A retransmits this message every tick. +6. If Server doesn't receive the cookie over the signalling channel within 10 seconds, it assumes the request was invalid and drops the request. +7. When Server receives the cookie, it notifies Client A that the message was received. Client A stops retransmitting. +8. Server notifies Client B of the IP and external port of Client A. diff --git a/src/main/java/io/github/gaming32/worldhost/WorldHost.java b/src/main/java/io/github/gaming32/worldhost/WorldHost.java index e7aba1b..b8f9982 100644 --- a/src/main/java/io/github/gaming32/worldhost/WorldHost.java +++ b/src/main/java/io/github/gaming32/worldhost/WorldHost.java @@ -21,6 +21,7 @@ import io.github.gaming32.worldhost.protocol.ProtocolClient; import io.github.gaming32.worldhost.protocol.proxy.ProxyPassthrough; import io.github.gaming32.worldhost.protocol.proxy.ProxyProtocolClient; +import io.github.gaming32.worldhost.protocol.punch.PunchManager; import io.github.gaming32.worldhost.proxy.ProxyClient; import io.github.gaming32.worldhost.toast.WHToast; import io.github.gaming32.worldhost.upnp.Gateway; @@ -434,6 +435,7 @@ public static void friendWentOnline(OnlineFriend friend) { } public static void tickHandler() { + PunchManager.transmitPunches(); if (protoClient == null || protoClient.isClosed()) { protoClient = null; if (proxyProtocolClient != null) { @@ -987,4 +989,13 @@ private static Path getGameDir() { //$$ return FMLPaths.GAMEDIR.get(); //#endif } + + public static UUID getUserId() { + final var user = Minecraft.getInstance().getUser(); + //#if MC >= 1.20.4 + return user.getProfileId(); + //#else + //$$ return user.getGameProfile().getId(); + //#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 new file mode 100644 index 0000000..c6c5955 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/compat/WorldHostSimpleVoiceChatCompat.java @@ -0,0 +1,79 @@ +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/mixin/MixinMinecraftServer.java b/src/main/java/io/github/gaming32/worldhost/mixin/MixinMinecraftServer.java new file mode 100644 index 0000000..d1f5297 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/mixin/MixinMinecraftServer.java @@ -0,0 +1,21 @@ +package io.github.gaming32.worldhost.mixin; + +import io.github.gaming32.worldhost.WorldHost; +import io.github.gaming32.worldhost.compat.WorldHostSimpleVoiceChatCompat; +import net.minecraft.server.MinecraftServer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.function.BooleanSupplier; + +@Mixin(MinecraftServer.class) +public class MixinMinecraftServer { + @Inject(method = "tickServer", at = @At("RETURN")) + private void tickVoiceChat(BooleanSupplier hasTimeLeft, CallbackInfo ci) { + if (WorldHost.isModLoaded("voicechat")) { + WorldHostSimpleVoiceChatCompat.tick((MinecraftServer)(Object)this); + } + } +} 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 92d3298..8cf15e3 100644 --- a/src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java +++ b/src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java @@ -2,11 +2,18 @@ import com.google.common.net.HostAndPort; import com.mojang.authlib.exceptions.AuthenticationException; +import com.mojang.authlib.exceptions.AuthenticationUnavailableException; +import com.mojang.authlib.exceptions.ForcedUsernameChangeException; +import com.mojang.authlib.exceptions.InsufficientPrivilegesException; +import com.mojang.authlib.exceptions.InvalidCredentialsException; +import com.mojang.authlib.exceptions.UserBannedException; 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; +import net.minecraft.client.resources.language.I18n; import net.minecraft.network.chat.Component; import net.minecraft.util.Crypt; import net.minecraft.util.CryptException; @@ -43,10 +50,12 @@ public final class ProtocolClient implements AutoCloseable, ProxyPassthrough { private static final Thread.Builder SEND_THREAD_BUILDER = Thread.ofVirtual().name("WH-SendThread-", 1); private static final Thread.Builder RECV_THREAD_BUILDER = Thread.ofVirtual().name("WH-RecvThread-", 1); - public static final int PROTOCOL_VERSION = 6; + public static final int PROTOCOL_VERSION = 7; private static final int KEY_PREFIX = 0xFAFA0000; private final String originalHost; + private HostAndPort hostAndPort; + final CompletableFuture connectingFuture = new CompletableFuture<>(); private final BlockingQueue> sendQueue = new LinkedBlockingQueue<>(); @@ -68,13 +77,12 @@ public final class ProtocolClient implements AutoCloseable, ProxyPassthrough { public ProtocolClient(String host, boolean successToast, boolean failureToast) { this.originalHost = host; CONNECTION_THREAD_BUILDER.start(() -> { - HostAndPort target = null; Socket socket = null; Cipher decryptCipher = null; Cipher encryptCipher = null; try { - target = HostAndPort.fromString(host).withDefaultPort(9646); - socket = new Socket(target.getHost(), target.getPort()); + hostAndPort = HostAndPort.fromString(host).withDefaultPort(9646); + socket = new Socket(hostAndPort.getHost(), hostAndPort.getPort()); final User user = authUser.join(); authUser = null; @@ -83,7 +91,7 @@ public ProtocolClient(String host, boolean successToast, boolean failureToast) { decryptCipher = Crypt.getCipher(Cipher.DECRYPT_MODE, secretKey); encryptCipher = Crypt.getCipher(Cipher.ENCRYPT_MODE, secretKey); } catch (Exception e) { - WorldHost.LOGGER.error("Failed to connect to {} ({}).", originalHost, target, e); + WorldHost.LOGGER.error("Failed to connect to {} ({}).", originalHost, hostAndPort, e); if (failureToast) { WHToast.builder("world-host.wh_connect.connect_failed") .description(Component.nullToEmpty(e.getLocalizedMessage())) @@ -151,9 +159,9 @@ public ProtocolClient(String host, boolean successToast, boolean failureToast) { WorldHost.LOGGER.warn("Received invalid empty packet from WH server"); continue; } - final int packetId = dis.readUnsignedByte(); + final int typeId = dis.readUnsignedByte(); final CountingInputStream cis; - if (WorldHostS2CMessage.isEncrypted(packetId)) { + if (WorldHostS2CMessage.isEncrypted(typeId)) { final byte[] data = dis.readNBytes(length); final byte[] decrypted = fDecryptCipher.update(data); cis = new CountingInputStream(new ByteArrayInputStream(decrypted)); @@ -164,9 +172,9 @@ public ProtocolClient(String host, boolean successToast, boolean failureToast) { } WorldHostS2CMessage message = null; try { - message = WorldHostS2CMessage.decode(packetId, new DataInputStream(cis)); + message = WorldHostS2CMessage.decode(typeId, new DataInputStream(cis)); } catch (EOFException e) { - WorldHost.LOGGER.error("Message decoder read past end (length {})!", length); + WorldHost.LOGGER.error("Message decoder for message {} read past end (length {})!", typeId, length); } catch (Exception e) { WorldHost.LOGGER.error("Error decoding WH message", e); } @@ -214,7 +222,7 @@ public ProtocolClient(String host, boolean successToast, boolean failureToast) { private static SecretKey performHandshake( Socket socket, User user, long connectionId - ) throws IOException, CryptException, AuthenticationException { + ) throws IOException, CryptException { final DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); dos.writeInt(PROTOCOL_VERSION); dos.flush(); @@ -251,16 +259,17 @@ private static SecretKey performHandshake( //#endif if (profileId.version() == 4) { - Minecraft.getInstance() - .getMinecraftSessionService() - .joinServer( - //#if MC >= 1.20.4 - profileId, - //#else - //$$ profile, - //#endif - user.getAccessToken(), authKey - ); + final String failure = authenticateServer( + //#if MC >= 1.20.4 + profileId, + //#else + //$$ profile, + //#endif + user.getAccessToken(), authKey + ); + if (failure != null) { + throw new IllegalStateException(failure); + } } WorldHostC2SMessage.writeUuid(dos, profileId); @@ -271,10 +280,38 @@ private static SecretKey performHandshake( return secretKey; } + private static String authenticateServer( + //#if MC >= 1.20.4 + UUID profile, + //#else + //$$ GameProfile profile, + //#endif + String authenticationToken, String serverId + ) { + try { + Minecraft.getInstance().getMinecraftSessionService().joinServer(profile, authenticationToken, serverId); + return null; + } catch (AuthenticationUnavailableException e) { + return null; + } catch (InvalidCredentialsException e) { + return I18n.get("disconnect.loginFailedInfo.invalidSession"); + } catch (InsufficientPrivilegesException e) { + return I18n.get("disconnect.loginFailedInfo.insufficientPrivileges"); + } catch (ForcedUsernameChangeException | UserBannedException e) { + return I18n.get("disconnect.loginFailedInfo.userBanned"); + } catch (AuthenticationException e) { + return e.getMessage(); + } + } + public String getOriginalHost() { return originalHost; } + public HostAndPort getHostAndPort() { + return hostAndPort; + } + public void authenticate(User user) { authenticated = true; if (authUser != null) { @@ -336,6 +373,14 @@ 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 punchRequestInvalid(PunchCookie cookie) { + enqueue(new WorldHostC2SMessage.PunchRequestInvalid(cookie)); + } + public Future getConnectingFuture() { return connectingFuture; } 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 34abd78..03139b3 100644 --- a/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java +++ b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java @@ -1,6 +1,7 @@ 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; @@ -176,6 +177,42 @@ public void encode(DataOutputStream dos) throws IOException { } } + record RequestPunchOpen(long targetConnection, String purpose, PunchCookie cookie) implements WorldHostC2SMessage { + @Override + public byte typeId() { + return 12; + } + + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeLong(targetConnection); + writeString(dos, purpose); + cookie.writeTo(dos); + } + + @Override + public boolean isEncrypted() { + return true; + } + } + + record PunchRequestInvalid(PunchCookie cookie) implements WorldHostC2SMessage { + @Override + public byte typeId() { + return 13; + } + + @Override + public void encode(DataOutputStream dos) throws IOException { + cookie.writeTo(dos); + } + + @Override + public boolean isEncrypted() { + return true; + } + } + byte typeId(); void encode(DataOutputStream dos) throws IOException; 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 34330cc..6ab2a22 100644 --- a/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java +++ b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java @@ -1,5 +1,6 @@ package io.github.gaming32.worldhost.protocol; +import com.google.common.net.HostAndPort; import com.mojang.authlib.GameProfile; import io.github.gaming32.worldhost.FriendsListUpdate; import io.github.gaming32.worldhost.SecurityLevel; @@ -12,6 +13,9 @@ 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; import io.github.gaming32.worldhost.versions.Components; import net.minecraft.Util; @@ -447,6 +451,90 @@ public void handle(ProtocolClient client) { } } + record PunchOpenRequest( + PunchCookie cookie, String purpose, 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), + readString(dis), + readUuid(dis), + SecurityLevel.byId(dis.readUnsignedByte()) + ); + } + + @Override + 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); + return; + } + if (!reason.verificationType().verify(user)) { + WorldHost.LOGGER.warn( + "Punch {} from {} failed verification (verification type {})", + cookie, user, reason.verificationType() + ); + client.punchRequestInvalid(cookie); + return; + } + final var transmitter = reason.transmitterFinder().findTransmitter(); + if (transmitter == null) { + WorldHost.LOGGER.warn( + "Punch {} from {} couldn't find transmitter (transmitter finder {})", + cookie, user, reason.transmitterFinder() + ); + client.punchRequestInvalid(cookie); + return; + } + Minecraft.getInstance().execute(() -> PunchManager.openPunchRequest(cookie, transmitter)); + } + } + + record StopPunchRetransmit(PunchCookie cookie) implements WorldHostS2CMessage { + public static final int ID = 19; + + public static StopPunchRetransmit decode(DataInputStream dis) throws IOException { + return new StopPunchRetransmit(PunchCookie.readFrom(dis)); + } + + @Override + public void handle(ProtocolClient client) { + Minecraft.getInstance().execute(() -> PunchManager.stopTransmit(cookie)); + } + } + + record PunchRequestSuccess(PunchCookie cookie, 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()); + } + + @Override + public void handle(ProtocolClient client) { + final HostAndPort hostAndPort = HostAndPort.fromParts(host, port); + Minecraft.getInstance().execute(() -> PunchManager.punchSuccess(cookie, hostAndPort)); + } + } + + record PunchRequestCancelled(PunchCookie cookie) implements WorldHostS2CMessage { + public static final int ID = 21; + + public static PunchRequestCancelled decode(DataInputStream dis) throws IOException { + return new PunchRequestCancelled(PunchCookie.readFrom(dis)); + } + + @Override + public void handle(ProtocolClient client) { + Minecraft.getInstance().execute(() -> PunchManager.punchCancelled(cookie)); + } + } + /** * NOTE: This method is called from the RecvThread, so it should be careful to not do anything that could *
    @@ -458,7 +546,13 @@ public void handle(ProtocolClient client) { void handle(ProtocolClient client); static boolean isEncrypted(int typeId) { - return false; + return switch (typeId) { + case PunchOpenRequest.ID, + StopPunchRetransmit.ID, + PunchRequestSuccess.ID, + PunchRequestCancelled.ID -> true; + default -> false; + }; } static WorldHostS2CMessage decode(int typeId, DataInputStream dis) throws IOException { @@ -481,6 +575,10 @@ static WorldHostS2CMessage decode(int typeId, DataInputStream dis) throws IOExce case ConnectionNotFound.ID -> ConnectionNotFound.decode(dis); 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 PunchRequestCancelled.ID -> PunchRequestCancelled.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 new file mode 100644 index 0000000..bad43b6 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchCookie.java @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..51601db --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchManager.java @@ -0,0 +1,109 @@ +package io.github.gaming32.worldhost.protocol.punch; + +import com.google.common.net.HostAndPort; +import io.github.gaming32.worldhost.WorldHost; +import io.github.gaming32.worldhost.protocol.ProtocolClient; + +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.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 PunchManager() { + } + + public static void punch( + long connectionId, + PunchReason reason, + 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); + } + } + + 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 openPunchRequest(PunchCookie cookie, PunchTransmitter transmitter) { + final PendingServerPunch punch = new PendingServerPunch(cookie, transmitter); + final PendingServerPunch old = PENDING_SERVER_PUNCHES.put(cookie, punch); + if (old != null) { + WorldHost.LOGGER.warn("New punch request {} replaced old request {}", punch, old); + } + + final HostAndPort signalling = getSignallingServer(); + if (signalling == null) return; + Thread.ofVirtual() + .name("PunchManager-Transmit-" + cookie) + .start(() -> punch.transmit(signalling)); + } + + private static HostAndPort getSignallingServer() { + final ProtocolClient client = WorldHost.protoClient; + if (client == null) { + return null; + } + return client.getHostAndPort(); + } + + 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 punchSuccess(PunchCookie cookie, HostAndPort hostAndPort) { + final PendingClientPunch punch = PENDING_CLIENT_PUNCHES.remove(cookie); + if (punch == null) { + WorldHost.LOGGER.warn("Success received for unknown punch {}", cookie); + return; + } + punch.successAction.accept(hostAndPort); + } + + public static void punchCancelled(PunchCookie cookie) { + final PendingClientPunch punch = PENDING_CLIENT_PUNCHES.remove(cookie); + if (punch == null) { + WorldHost.LOGGER.warn("Cancellation received for unknown punch {}", cookie); + return; + } + punch.cancelledAction.run(); + } + + private record PendingClientPunch(Consumer successAction, Runnable cancelledAction) { + } + + private record PendingServerPunch(PunchCookie cookie, PunchTransmitter transmitter) { + 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())); + } 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 new file mode 100644 index 0000000..8e87c45 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchReason.java @@ -0,0 +1,73 @@ +package io.github.gaming32.worldhost.protocol.punch; + +import io.github.gaming32.worldhost.WorldHost; +import io.github.gaming32.worldhost.compat.WorldHostSimpleVoiceChatCompat; +import net.minecraft.client.Minecraft; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +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, + TransmitterFinder.SIMPLE_VOICE_CHAT + ); + + @Nullable + public static PunchReason byId(String id) { + return switch (id) { + case SIMPLE_VOICE_CHAT_ID -> SIMPLE_VOICE_CHAT; + default -> null; + }; + } + + 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(); + } + }; + + public abstract boolean verify(UUID uuid); + } + + public enum TransmitterFinder { + SIMPLE_VOICE_CHAT { + @Override + public PunchTransmitter findTransmitter() { + if (!WorldHost.isModLoaded("voicechat")) { + return null; + } + return WorldHostSimpleVoiceChatCompat.getTransmitter().orElse(null); + } + }; + + @Nullable + public abstract PunchTransmitter findTransmitter(); + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchTransmitter.java b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchTransmitter.java new file mode 100644 index 0000000..fbbcd71 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/protocol/punch/PunchTransmitter.java @@ -0,0 +1,9 @@ +package io.github.gaming32.worldhost.protocol.punch; + +import java.io.IOException; +import java.net.InetSocketAddress; + +@FunctionalInterface +public interface PunchTransmitter { + void transmit(byte[] packet, InetSocketAddress address) throws IOException; +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 8ff6ef6..5ebd3e8 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -24,6 +24,9 @@ ], "modmenu": [ "io.github.gaming32.worldhost.compat.WorldHostModMenuCompat" + ], + "voicechat": [ + "io.github.gaming32.worldhost.compat.WorldHostSimpleVoiceChatCompat" ] }, "mixins": [ diff --git a/src/main/resources/world-host.mixins.json b/src/main/resources/world-host.mixins.json index a48beae..f258efa 100644 --- a/src/main/resources/world-host.mixins.json +++ b/src/main/resources/world-host.mixins.json @@ -8,6 +8,7 @@ "MixinConnection", "MixinInputConstants", "MixinLevelSummary", + "MixinMinecraftServer", "MixinPlayerList", "MixinPublishCommand", "MixinServerConnectionListener_1", diff --git a/version.gradle.kts b/version.gradle.kts index 77a2edb..36c163c 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -223,6 +223,12 @@ repositories { maven("https://pkgs.dev.azure.com/djtheredstoner/DevAuth/_packaging/public/maven/v1") maven("https://repo.viaversion.com") maven("https://maven.wagyourtail.xyz/snapshots") + maven("https://maven.maxhenkel.de/repository/public") + maven("https://api.modrinth.com/maven") { + content { + includeGroup("maven.modrinth") + } + } maven("https://jitpack.io") } @@ -315,6 +321,20 @@ dependencies { } } + compileOnly("de.maxhenkel.voicechat:voicechat-api:2.5.0") + when (mcVersion) { + 1_21_00 -> "2.5.19" + 1_20_06 -> "2.5.19" + 1_20_04 -> "2.5.19" + 1_20_01 -> "2.5.19" + 1_19_04 -> "2.5.16" + 1_19_02 -> "2.5.19" + 1_18_02 -> "2.5.19" + else -> null + }?.let { + modCompileOnly("maven.modrinth:simple-voice-chat:$loaderName-$mcVersionString-$it") + } + compileOnly("com.demonwav.mcdev:annotations:2.1.0") // Resolves javac warnings about Guava