diff --git a/changelog/release-1.3.4.1.md b/changelog/release-1.3.4.1.md new file mode 100644 index 0000000..df0b44e --- /dev/null +++ b/changelog/release-1.3.4.1.md @@ -0,0 +1,8 @@ +Title: Bug Patch +Summary: Minor bug fixes + +## Bug Fixes +- Fixed World Hosting failing to connect if your computer is set to certain languages +- Fixed timeout / bad connection issue some people were experiencing with World Hosting +- Multiple minor improvements to World Hosting connection quality +- Fixed skin being automatically selected when uploaded diff --git a/gradle.properties b/gradle.properties index 26478a0..e21c833 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,4 +7,4 @@ org.gradle.configureondemand=true org.gradle.parallel.threads=128 org.gradle.jvmargs=-Xmx16G minecraftVersion=11202 -version=1.3.4 +version=1.3.4.1 diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/EssentialModal.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/EssentialModal.kt index 2e326a7..7fb950a 100644 --- a/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/EssentialModal.kt +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/EssentialModal.kt @@ -93,7 +93,7 @@ open class EssentialModal( } protected val keyListener: UIComponent.(Char, Int) -> Unit = keyListener@{_, keyCode -> - if (isAnimating) { return@keyListener } + if (modalManager.isCurrentlyFadingIn) { return@keyListener } when (keyCode) { // Activate selected button on enter or primary button if no button is selected diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/EssentialModal2.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/EssentialModal2.kt index 3c92e1b..c4f8201 100644 --- a/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/EssentialModal2.kt +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/EssentialModal2.kt @@ -270,7 +270,7 @@ abstract class EssentialModal2( /** See [styledButton], just an overload without any state to make it easier to call. */ fun LayoutScope.styledButton( modifier: Modifier = Modifier, - style: StyledButton.Style = StyledButton.Style.GRAY, + style: StyledButton.Style, enableRetexturing: Boolean = false, action: suspend () -> Unit, content: LayoutScope.(style: State) -> Unit, diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/Modal.kt b/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/Modal.kt index 588fa5b..d3dbe50 100644 --- a/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/Modal.kt +++ b/gui/essential/src/main/kotlin/gg/essential/gui/common/modal/Modal.kt @@ -33,8 +33,6 @@ abstract class Modal(val modalManager: ModalManager) : UIContainer() { */ protected lateinit var coroutineScope: CoroutineScope - var isAnimating = false - private var windowListListener: (UIComponent.(Char, Int) -> Unit)? = null private val escapeListener: UIComponent.(Char, Int) -> Unit = { _, keyCode -> if (keyCode == UKeyboard.KEY_ESCAPE) { @@ -55,7 +53,7 @@ abstract class Modal(val modalManager: ModalManager) : UIContainer() { } onLeftClick { event -> - if (!isAnimating && event.target == this) { + if (!modalManager.isCurrentlyFadingIn && event.target == this) { handleEscapeKeyPress() } } diff --git a/gui/essential/src/main/kotlin/gg/essential/gui/overlay/ModalManager.kt b/gui/essential/src/main/kotlin/gg/essential/gui/overlay/ModalManager.kt index 98f8dce..2c14786 100644 --- a/gui/essential/src/main/kotlin/gg/essential/gui/overlay/ModalManager.kt +++ b/gui/essential/src/main/kotlin/gg/essential/gui/overlay/ModalManager.kt @@ -12,11 +12,26 @@ package gg.essential.gui.overlay import gg.essential.gui.common.modal.Modal +import kotlinx.coroutines.CoroutineScope /** * Queues [Modal]s to be displayed on a [Layer]. */ interface ModalManager { + /** + * Coroutine scope which is cancelled once the last modal in the queue is closed and no followup modals are queued. + * + * Beware that if no modal is ever opened, this scope will never be cancelled. + */ + val coroutineScope: CoroutineScope + + /** + * True for the short amount of time where the modal is already visible but still fading in. + * Modals should ideally ignore user input during this period, so they're not inadvertently dismissed before + * even being properly visible. + */ + val isCurrentlyFadingIn: Boolean + /** * Queues the given modal to be displayed after existing modals (or immediately if there are no active modals). */ diff --git a/src/main/kotlin/gg/essential/gui/overlay/UIContainerModalManagerImpl.kt b/gui/essential/src/main/kotlin/gg/essential/gui/overlay/UIContainerModalManagerImpl.kt similarity index 81% rename from src/main/kotlin/gg/essential/gui/overlay/UIContainerModalManagerImpl.kt rename to gui/essential/src/main/kotlin/gg/essential/gui/overlay/UIContainerModalManagerImpl.kt index db0f28c..c405615 100644 --- a/src/main/kotlin/gg/essential/gui/overlay/UIContainerModalManagerImpl.kt +++ b/gui/essential/src/main/kotlin/gg/essential/gui/overlay/UIContainerModalManagerImpl.kt @@ -22,6 +22,11 @@ import gg.essential.elementa.utils.withAlpha import gg.essential.gui.common.modal.Modal import gg.essential.gui.common.modal.defaultEssentialModalFadeTime import gg.essential.gui.elementa.transitions.FadeInTransition +import gg.essential.util.Client +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import java.awt.Color /** @@ -30,7 +35,13 @@ import java.awt.Color class UIContainerModalManagerImpl( backgroundColor: Color = Color.BLACK.withAlpha(150) ) : UIContainer(), ModalManager { + override val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Client) + private val modalQueue = mutableListOf() + override var isCurrentlyFadingIn: Boolean = false + private set + + private var didFade: Boolean = false private val background by UIBlock(backgroundColor).constrain { x = 0.pixels @@ -49,13 +60,12 @@ class UIContainerModalManagerImpl( } override fun queueModal(modal: Modal) { - // We can't immediately show a modal if this component doesn't have a parent. + modalQueue.add(modal) + if (hasParent && background.children.isEmpty()) { pushModalFromQueue() return } - - modalQueue.add(modal) } override fun modalClosed() { @@ -63,6 +73,7 @@ class UIContainerModalManagerImpl( if (pushedModal == null) { // If there are no modals left, we can remove the manager from its parent. parent.removeChild(this) + coroutineScope.cancel() } } @@ -72,12 +83,7 @@ class UIContainerModalManagerImpl( // If a modal was queued before we had a parent, we need to make it a child now for it to be // displayed as expected. if (background.children.isEmpty()) { - val modal = pushModalFromQueue() ?: return - modal.isAnimating = true - - FadeInTransition(defaultEssentialModalFadeTime).transition(this) { - modal.isAnimating = false - } + pushModalFromQueue() } } @@ -86,6 +92,15 @@ class UIContainerModalManagerImpl( modal childOf background modal.onOpen() + if (!didFade) { + didFade = true + + isCurrentlyFadingIn = true + FadeInTransition(defaultEssentialModalFadeTime).transition(this) { + isCurrentlyFadingIn = false + } + } + return modal } } \ No newline at end of file diff --git a/gui/essential/src/main/kotlin/gg/essential/network/connectionmanager/ice/IceManagerImpl.kt b/gui/essential/src/main/kotlin/gg/essential/network/connectionmanager/ice/IceManagerImpl.kt index 615f180..a1964e5 100644 --- a/gui/essential/src/main/kotlin/gg/essential/network/connectionmanager/ice/IceManagerImpl.kt +++ b/gui/essential/src/main/kotlin/gg/essential/network/connectionmanager/ice/IceManagerImpl.kt @@ -439,11 +439,19 @@ abstract class IceManagerImpl( } } - private val inboundDataChannel = Channel(10, BufferOverflow.DROP_OLDEST) - private val outboundDataChannel = Channel(10, BufferOverflow.DROP_OLDEST) + private val inboundDataChannel = Channel(1000, BufferOverflow.DROP_OLDEST) { packet -> + logger.warn("IceConnection.inboundDataChannel overflow, dropping packet of {} bytes", packet.size) + } + private val outboundDataChannel = Channel(1000, BufferOverflow.DROP_OLDEST) { packet -> + logger.warn("IceConnection.outboundDataChannel overflow, dropping packet of {} bytes", packet.size) + } - private val inboundVoiceChannel = Channel(10, BufferOverflow.DROP_OLDEST) - private val outboundVoiceChannel = Channel(10, BufferOverflow.DROP_OLDEST) + private val inboundVoiceChannel = Channel(1000, BufferOverflow.DROP_OLDEST) { packet -> + logger.warn("IceConnection.inboundVoiceChannel overflow, dropping packet of {} bytes", packet.size) + } + private val outboundVoiceChannel = Channel(1000, BufferOverflow.DROP_OLDEST) { packet -> + logger.warn("IceConnection.outboundVoiceChannel overflow, dropping packet of {} bytes", packet.size) + } val inboundPacketSortingJob = coroutineScope.launch(Dispatchers.Unconfined) { for ((candidate, data) in agent.inboundDataChannel) { diff --git a/src/main/java/gg/essential/network/connectionmanager/telemetry/TelemetryManager.java b/src/main/java/gg/essential/network/connectionmanager/telemetry/TelemetryManager.java index e67c614..216018b 100644 --- a/src/main/java/gg/essential/network/connectionmanager/telemetry/TelemetryManager.java +++ b/src/main/java/gg/essential/network/connectionmanager/telemetry/TelemetryManager.java @@ -18,10 +18,15 @@ import gg.essential.event.essential.TosAcceptedEvent; import gg.essential.event.network.server.ServerJoinEvent; import gg.essential.gui.elementa.state.v2.ReferenceHolderImpl; +import gg.essential.lib.gson.Gson; +import gg.essential.lib.gson.JsonElement; +import gg.essential.lib.gson.JsonObject; +import gg.essential.lib.gson.JsonPrimitive; import gg.essential.network.connectionmanager.ConnectionManager; import gg.essential.network.connectionmanager.NetworkedManager; import gg.essential.network.connectionmanager.queue.SequentialPacketQueue; import gg.essential.universal.UMinecraft; +import gg.essential.util.Multithreading; import me.kbrewster.eventbus.Subscribe; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; @@ -34,11 +39,17 @@ //$$ import oshi.hardware.CentralProcessor; //#endif +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; import static gg.essential.network.connectionmanager.telemetry.TelemetryManagerKt.*; @@ -103,6 +114,7 @@ private void init(InitializationEvent event) { enqueue(new ClientTelemetryPacket("LANGUAGE", new HashMap(){{ put("lang", UMinecraft.getMinecraft().gameSettings.language); }})); + queueInstallerTelemetryPacket(); } /** @@ -185,4 +197,50 @@ public void sendHardwareAndOSTelemetry(@NotNull final TosAcceptedEvent event) { enqueue(new ClientTelemetryPacket("HARDWARE_V2", hardwareMap)); } + private void queueInstallerTelemetryPacket() { + // We go async, since we are reading a file + Multithreading.runAsync(() -> { + try { + Path installerMetadataPath = Essential.getInstance().getBaseDir().toPath().resolve("installer-metadata.json").toRealPath(); + + if (Files.notExists(installerMetadataPath)) + return; + + // Calculate the sha-1 checksum of the current game directory in the same way the installer does. + byte[] pathBytes = installerMetadataPath.toString().getBytes(StandardCharsets.UTF_8); + byte[] pathChecksumBytes = MessageDigest.getInstance("SHA-1").digest(pathBytes); + StringBuilder pathChecksumBuilder = new StringBuilder(); + for (byte checksumByte : pathChecksumBytes) { + pathChecksumBuilder.append(String.format(Locale.ROOT, "%02x", checksumByte)); + } + String pathChecksum = pathChecksumBuilder.toString(); + + // Grab the raw JSON object from the telemetry file + // This is to allow installer to add telemetry fields without having to update the mod + String rawFile = new String(Files.readAllBytes(installerMetadataPath), StandardCharsets.UTF_8); + JsonObject telemetryObject = new Gson().fromJson(rawFile, JsonObject.class); + // Convert to map + HashMap telemetryMap = new HashMap<>(); + for (Map.Entry entry : telemetryObject.entrySet()) { + telemetryMap.put(entry.getKey(), entry.getValue()); + } + // Check if the game folder has been moved + boolean hasBeenMoved = false; + Object installPathChecksum = telemetryMap.get("installPathChecksum"); + if (installPathChecksum instanceof JsonPrimitive) { + String installerPathChecksum = ((JsonPrimitive) installPathChecksum).getAsString(); + hasBeenMoved = !installerPathChecksum.equals(pathChecksum); + } + + telemetryMap.put("installPathChecksum", pathChecksum); + telemetryMap.put("hasBeenMoved", hasBeenMoved); + // Then queue the packet on the main thread again + Multithreading.scheduleOnMainThread(() -> enqueue(new ClientTelemetryPacket("INSTALLER", telemetryMap)), 0, TimeUnit.SECONDS); + + } catch (Exception e) { + Essential.logger.warn("Error when trying to parse installer telemetry!", e); + } + }); + } + } diff --git a/src/main/kotlin/gg/essential/gui/overlay/ModalManagerImpl.kt b/src/main/kotlin/gg/essential/gui/overlay/ModalManagerImpl.kt index 15b8f37..8aa708c 100644 --- a/src/main/kotlin/gg/essential/gui/overlay/ModalManagerImpl.kt +++ b/src/main/kotlin/gg/essential/gui/overlay/ModalManagerImpl.kt @@ -20,6 +20,11 @@ import gg.essential.elementa.utils.withAlpha import gg.essential.gui.common.modal.Modal import gg.essential.gui.common.modal.defaultEssentialModalFadeTime import gg.essential.gui.elementa.transitions.FadeInTransition +import gg.essential.util.Client +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import java.awt.Color /** @@ -29,6 +34,8 @@ class ModalManagerImpl( private val overlayManager: OverlayManager, private val backgroundColor: Color = Color.BLACK.withAlpha(150), ) : ModalManager { + override val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Client) + private val modalQueue = mutableListOf() /** @@ -36,6 +43,9 @@ class ModalManagerImpl( */ private var layer: Layer? = null + override var isCurrentlyFadingIn: Boolean = false + private set + override fun modalClosed() { val nextModal = modalQueue.removeFirstOrNull() if (nextModal != null) { @@ -44,6 +54,8 @@ class ModalManagerImpl( // If we have no modals left to push, we should remove the layer. layer?.let { overlayManager.removeLayer(it) } layer = null + isCurrentlyFadingIn = false + coroutineScope.cancel() } } @@ -53,7 +65,8 @@ class ModalManagerImpl( // If the layer is null, this means that this is our first modal, or, that the previous layer // was cleaned up. We should create a new one for this modal to be pushed on to. if (currentLayer == null) { - createAndSetupLayer(modal) + val layer = createAndSetupLayer() + layer.pushModal(modal) return } @@ -62,21 +75,18 @@ class ModalManagerImpl( modalQueue.add(modal) } - private fun createAndSetupLayer(modal: Modal): Layer { + private fun createAndSetupLayer(): Layer { return overlayManager.createPersistentLayer(LayerPriority.Modal).apply { - val background = UIBlock(backgroundColor).constrain { + UIBlock(backgroundColor).constrain { x = 0.pixels y = 0.pixels width = 100.percentOfWindow() height = 100.percentOfWindow() } childOf window - modal childOf background - modal.onOpen() - - modal.isAnimating = true + isCurrentlyFadingIn = true FadeInTransition(defaultEssentialModalFadeTime).transition(window) { - modal.isAnimating = false + isCurrentlyFadingIn = false } this@ModalManagerImpl.layer = this @@ -87,6 +97,9 @@ class ModalManagerImpl( val background = window.children.first() modal childOf background + + // Beware: This call may recursively call [queueModal] and/or [modalClosed]. As such, it should ideally be the + // last thing which effectively happens in these methods. modal.onOpen() } } \ No newline at end of file diff --git a/src/main/kotlin/gg/essential/gui/wardrobe/modals/SkinModal.kt b/src/main/kotlin/gg/essential/gui/wardrobe/modals/SkinModal.kt index d4e9acc..60d8a8c 100644 --- a/src/main/kotlin/gg/essential/gui/wardrobe/modals/SkinModal.kt +++ b/src/main/kotlin/gg/essential/gui/wardrobe/modals/SkinModal.kt @@ -152,7 +152,7 @@ class SkinModal private constructor( companion object { - fun add(modalManager: ModalManager, skin: Skin, selectSkin: Boolean = true, initialName: String) = SkinModal(modalManager, skin, "Add Skin", initialName) { name, model -> + fun add(modalManager: ModalManager, skin: Skin, selectSkin: Boolean = false, initialName: String) = SkinModal(modalManager, skin, "Add Skin", initialName) { name, model -> val updatedSkin = skin.copy(model = model) Essential.getInstance().connectionManager.skinsManager.addSkin(name, updatedSkin, selectSkin).whenComplete { skin, throwable -> if (skin != null) { @@ -193,6 +193,10 @@ class SkinModal private constructor( primaryButtonText = "Steal" } + /** + * Uploads skin file and adds new skin to the Wardrobe. + * Mojang skin uploads are reverted immediately to keep the skin unselected. + */ fun addFile(modalManager: ModalManager, path: Path, skinOverride: ResourceLocation, initialName: String, defaultModel: Model) = SkinModal(modalManager, skinOverride, "Add Skin", initialName, defaultModel) { name, model -> val mojangSkinManager = Essential.getInstance().skinManager val oldSkin = mojangSkinManager.activeSkin @@ -202,17 +206,17 @@ class SkinModal private constructor( Notifications.push("Skin Upload", "Skin upload failed!") return@SkinModal } - Essential.getInstance().connectionManager.skinsManager.addSkin(name, skin).whenComplete { skinItem, throwable -> + + val oldActiveSkin = oldSkin.join() + mojangSkinManager.changeSkin(getMinecraft().session.token, oldActiveSkin.model, oldActiveSkin.url) + mojangSkinManager.flushChanges(false) + + Essential.getInstance().connectionManager.skinsManager.addSkin(name, skin, false).whenComplete { skinItem, throwable -> if (skinItem != null) { sendCheckmarkNotification("Skin has been added.") } else { Essential.logger.warn("Error adding skin!", throwable) Notifications.push("Error adding skin", "An unexpected error has occurred. Try again.") - // Since we uploaded a skin, we have to revert that if we cannot add it - oldSkin.whenComplete { oldActiveSkin, _ -> - mojangSkinManager.changeSkin(getMinecraft().session.token, oldActiveSkin.model, oldActiveSkin.url) - mojangSkinManager.flushChanges(true) - } } } } diff --git a/src/main/resources/assets/essential/commit.txt b/src/main/resources/assets/essential/commit.txt index 96f6154..79b050c 100644 --- a/src/main/resources/assets/essential/commit.txt +++ b/src/main/resources/assets/essential/commit.txt @@ -1 +1 @@ -ffe87e84d7 \ No newline at end of file +311f9c466c \ No newline at end of file diff --git a/subprojects/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/GitRepoCosmeticsDatabase.kt b/subprojects/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/GitRepoCosmeticsDatabase.kt index 8e8ab2b..f6b9639 100644 --- a/subprojects/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/GitRepoCosmeticsDatabase.kt +++ b/subprojects/cosmetics/src/commonMain/kotlin/gg/essential/mod/cosmetics/database/GitRepoCosmeticsDatabase.kt @@ -579,7 +579,7 @@ class GitRepoCosmeticsDatabase( cosmetic != null -> { val root = if (cosmetic.type.slot == CosmeticSlot.EMOTE) "emotes" else "cosmetics" val type = cosmetic.type.id.lowercase().removeSuffix("_emote") - .let { if (it == "emote") "basic" else "" } + .let { if (it == "emote") "basic" else it } Path.of("$root/$type/${id.lowercase()}/${id.lowercase()}.cosmetic-metadata.json") } else -> return emptyMap() diff --git a/subprojects/ice/src/main/kotlin/gg/essential/ice/IceAgent.kt b/subprojects/ice/src/main/kotlin/gg/essential/ice/IceAgent.kt index 58575ce..02c4bc5 100644 --- a/subprojects/ice/src/main/kotlin/gg/essential/ice/IceAgent.kt +++ b/subprojects/ice/src/main/kotlin/gg/essential/ice/IceAgent.kt @@ -39,6 +39,7 @@ import org.slf4j.Logger import org.slf4j.spi.LoggingEventBuilder import java.io.IOException import java.net.InetSocketAddress +import java.security.MessageDigest import kotlin.math.max import kotlin.math.min import kotlin.time.Duration @@ -85,8 +86,12 @@ class IceAgent( val remoteCandidateChannel = Channel(Channel.UNLIMITED) /** Completes once we are ready to send data. Should be used `withTimeout` as it may never complete if ICE fails. */ val readyForData = CompletableDeferred(parent = job) - val inboundDataChannel = Channel>(10, BufferOverflow.DROP_OLDEST) - val outboundDataChannel = Channel(10, BufferOverflow.DROP_OLDEST) + val inboundDataChannel = Channel>(1000, BufferOverflow.DROP_OLDEST) { pair -> + logger.warn("IceAgent.inboundDataChannel overflow, dropping packet of {} bytes", pair.second.size) + } + val outboundDataChannel = Channel(1000, BufferOverflow.DROP_OLDEST) { packet -> + logger.warn("IceAgent.outboundDataChannel overflow, dropping packet of {} bytes", packet.size) + } // The old Ice4J ICE implementation will only try to establish a connection once a candidate has nominated, so when // talking to one, we need to hurry up with nomination, and we can't start sending data until we have one. @@ -204,6 +209,9 @@ class IceAgent( for (other in remoteCandidates) { if (other.address == candidate.address) { logger.debug("Ignoring new remote candidate {} because we already have {}", candidate, other) + if (other.type == CandidateType.PeerReflexive && candidate.isRelay) { + other.type = CandidateType.Relayed + } return } } @@ -288,7 +296,11 @@ class IceAgent( // Prefer triggered checks because they have a huge chance of success val pair = pollTriggeredCheck() ?: checklist.find { it.state == CandidatePair.State.Waiting } - ?: continue // maybe next round + + if (pair == null) { + performRTTChecks() + continue + } pair.state = CandidatePair.State.InProgress pair.check?.cancel() @@ -298,6 +310,40 @@ class IceAgent( } } + private suspend fun performRTTChecks() { + val pair = validList.minByOrNull { it.extraRttChecks } ?: return + + // If we have a valid pair, we must have already received a successful response and should therefore be aware + // of which software the remote uses. + if (remoteIsIce4J.await()) { + return // Ice4J might react badly to repeated Binding requests, let's just not risk it + } + + pair.extraRttChecks++ + checksScope.launch { + val tId = TransactionId.create() + val logger = logger.withKeyValue("tId", tId) + logger.trace("Starting rtt check: {}", pair) + + val (request, response) = sendIceBindingRequest(tId, pair) + if (response == null) { + logger.warn("RTT check of previously valid pair failed, no response: {}", pair) + return@launch + } + + if (response.message.cls == StunClass.ResponseError) { + // We never send error responses, so any we receive are unexpected + logger.warn("Failed, got unexpected error response: {}", response.message) + return@launch + } + + // Success! + val rtt = request.getRoundTripTime(response) + logger.trace("Measured RTT of {} to be {}ms", pair, rtt.inWholeMilliseconds) + pair.rtt = min(pair.rtt ?: INFINITE, rtt) + } + } + private suspend fun checkPair(pair: CandidatePair) { val tId = TransactionId.create() val logger = logger.withKeyValue("tId", tId) @@ -333,7 +379,8 @@ class IceAgent( } // Success! - logger.debug("Connectivity check succeeded: {}", pair) + val rtt = request.getRoundTripTime(response) + logger.debug("Connectivity check succeeded: {} ({}ms)", pair, rtt.inWholeMilliseconds) remoteIsIce4J.complete(response.message.attribute()?.value == "ice4j.org") @@ -358,7 +405,6 @@ class IceAgent( } validList.add(validPair) - val rtt = request.getRoundTripTime(response) pair.rtt = min(pair.rtt ?: INFINITE, rtt) validPair.rtt = min(validPair.rtt ?: INFINITE, rtt) @@ -510,23 +556,25 @@ class IceAgent( val pair = selectedPair ?: (if (controlling) null else lastReceivedDataPair) ?: getBestValidPair() ?: return if (highVolumeLogging) { + val checksum = sha256.digest(bytes).toBase64String() logger.atTrace() .addKeyValues(pair.local) .addKeyValue("remoteAddress", pair.remote.address) - .log("Sending {} bytes of data", bytes.size) + .log("Sending {} bytes of data with checksum {}", bytes.size, checksum) } pair.local.sendUnchecked(DatagramPacket(bytes, pair.remote.address)) } private suspend fun processPacket(packet: ReceivedPacket) { if (highVolumeLogging) { + val checksum = sha256.digest(packet.data).toBase64String() logger.atTrace() .addKeyValues(packet.candidate) .addKeyValue("remoteAddress", packet.source) - .log("Received {} bytes of data", packet.data.size) + .log("Received {} bytes of data with checksum {}", packet.data.size, checksum) } if (!controlling && selectedPair == null) { - lastReceivedDataPair = validList.find { it.local == packet.candidate && it.remote.address == packet.source } + lastReceivedDataPair = validList.find { it.local.base == packet.candidate.base && it.remote.address == packet.source } } inboundDataChannel.send(Pair(packet.candidate, packet.data)) } @@ -565,10 +613,12 @@ class IceAgent( } // We received a request, this is very promising, schedule a triggered check for this pair asap - val pair = checklist.find { it.local == packet.candidate && it.remote == remoteCandidate } + val pair = validList.find { it.local.base == packet.candidate.base && it.remote.address == packet.source } + ?: checklist.find { it.local == packet.candidate && it.remote == remoteCandidate } ?: tryPair(packet.candidate, remoteCandidate) ?: return - if (pair !in triggeredCheckQueue) { + if (!pair.hadTriggeredCheck && pair !in triggeredCheckQueue) { + pair.hadTriggeredCheck = true when (pair.state) { CandidatePair.State.Succeeded -> {} CandidatePair.State.Waiting, CandidatePair.State.InProgress -> triggeredCheckQueue.add(pair) @@ -609,6 +659,8 @@ class IceAgent( var state: State = State.Waiting var check: Job? = null var rtt: Duration? = null + var extraRttChecks = 0 + var hadTriggeredCheck = false /** * Set on the controlled side when this pair is nominated by the controlling agent but we don't yet know whether @@ -641,6 +693,7 @@ class IceAgent( companion object { private const val MAX_CHECKLIST_SIZE = 100 private val RELAY_PENALTY = Integer.getInteger("essential.sps.relay_latency_threshold", 100) + private val sha256 = MessageDigest.getInstance("SHA-256") private fun LoggingEventBuilder.addKeyValues(candidate: LocalCandidate): LoggingEventBuilder { if (candidate.type == CandidateType.Relayed) { diff --git a/subprojects/ice/src/main/kotlin/gg/essential/ice/candidates.kt b/subprojects/ice/src/main/kotlin/gg/essential/ice/candidates.kt index fee2226..f960fdf 100644 --- a/subprojects/ice/src/main/kotlin/gg/essential/ice/candidates.kt +++ b/subprojects/ice/src/main/kotlin/gg/essential/ice/candidates.kt @@ -64,7 +64,9 @@ sealed interface LocalCandidate : Candidate { } } -interface RemoteCandidate : Candidate +interface RemoteCandidate : Candidate { + override var type: CandidateType // mutable only for peer-reflexive candidates if we later learn they are relays +} open class LocalCandidateImpl( final override val type: CandidateType, @@ -116,7 +118,7 @@ class LocalPeerReflexiveCandidate( ) : LocalCandidateImpl(CandidateType.PeerReflexive, baseCandidate.socket, baseCandidate.relay, address, baseCandidate.preference, {}) class RemoteCandidateImpl( - override val type: CandidateType, + override var type: CandidateType, override val address: InetSocketAddress, override val priority: Int, ) : RemoteCandidate { diff --git a/subprojects/ice/src/main/kotlin/gg/essential/ice/stun/StunSocket.kt b/subprojects/ice/src/main/kotlin/gg/essential/ice/stun/StunSocket.kt index d06b308..c6e0642 100644 --- a/subprojects/ice/src/main/kotlin/gg/essential/ice/stun/StunSocket.kt +++ b/subprojects/ice/src/main/kotlin/gg/essential/ice/stun/StunSocket.kt @@ -30,10 +30,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.yield import org.slf4j.Logger +import java.net.BindException import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress import java.net.InetSocketAddress +import java.net.NoRouteToHostException import java.net.SocketException import kotlin.time.ComparableTimeMark import kotlin.time.Duration @@ -75,8 +77,11 @@ class StunSocket( } private val hostSendChannel: Channel?>> = - // On overflow, we resolve the deferred as successful because overflow is not unrecoverable (just re-try) - Channel(100, BufferOverflow.DROP_OLDEST) { it.second?.complete(true) } + Channel(1000, BufferOverflow.DROP_OLDEST) { (packet, deferred) -> + logger.warn("Failed to send packet of {} bytes to {}: hostSendChannel overflow", packet.length, packet.address) + // On overflow, we resolve the deferred as successful because overflow is not unrecoverable (just re-try) + deferred?.complete(true) + } private val endpoints = mutableMapOf() private val stunBindings = mutableMapOf() @@ -97,7 +102,9 @@ class StunSocket( try { socket.send(packet) } catch (e: Exception) { - if (e is SocketException && e.message?.startsWith("Network is unreachable:") == true) { + if (e is SocketException && e.message?.startsWith("Network is unreachable:") == true + || e is BindException && e.message == "Cannot assign requested address: no further information" + || e is NoRouteToHostException) { logger.trace("Failed to send to {}: {}", packet.socketAddress, e.message) knownUnreachable.add(packet.address) deferred?.complete(false) diff --git a/subprojects/quic-connector/src/main/java/gg/essential/quic/backend/QuicBackendImpl.java b/subprojects/quic-connector/src/main/java/gg/essential/quic/backend/QuicBackendImpl.java index 5b1dccf..d83c084 100644 --- a/subprojects/quic-connector/src/main/java/gg/essential/quic/backend/QuicBackendImpl.java +++ b/subprojects/quic-connector/src/main/java/gg/essential/quic/backend/QuicBackendImpl.java @@ -38,7 +38,6 @@ import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; -import io.netty.handler.ssl.util.SelfSignedCertificate; import io.netty.incubator.codec.quic.InsecureQuicTokenHandler; import io.netty.incubator.codec.quic.QuicChannel; import io.netty.incubator.codec.quic.QuicClientCodecBuilder; @@ -56,7 +55,6 @@ import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; -import java.security.cert.CertificateException; import java.util.concurrent.TimeUnit; import static gg.essential.quic.QuicUtil.LOCALHOST; @@ -80,11 +78,11 @@ public class QuicBackendImpl implements QuicBackend { .initialMaxStreamDataBidirectionalLocal(10_000_000) ; - private static final SelfSignedCertificate certificate; + private static final SelfSignedCert certificate; static { try { - certificate = new SelfSignedCertificate(); - } catch (CertificateException e) { + certificate = new SelfSignedCert(); + } catch (Exception e) { throw new RuntimeException(e); } } diff --git a/subprojects/quic-connector/src/main/java/gg/essential/quic/backend/SelfSignedCert.java b/subprojects/quic-connector/src/main/java/gg/essential/quic/backend/SelfSignedCert.java new file mode 100644 index 0000000..5fa3dd4 --- /dev/null +++ b/subprojects/quic-connector/src/main/java/gg/essential/quic/backend/SelfSignedCert.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2024 ModCore Inc. All rights reserved. + * + * This code is part of ModCore Inc.'s Essential Mod repository and is protected + * under copyright registration # TX0009138511. For the full license, see: + * https://github.com/EssentialGG/Essential/blob/main/LICENSE + * + * You may not use, copy, reproduce, modify, sell, license, distribute, + * commercialize, or otherwise exploit, or create derivative works based + * upon, this file or any other in this repository, all of which is reserved by Essential. + */ +package gg.essential.quic.backend; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.Locale; + +/** + * A simplified version of Netty's {@code SelfSignedCertificate} that does not suffer from any + * locale specific issues. + */ +public class SelfSignedCert { + + /** Current time minus 1 year, just in case software clock goes back due to time synchronization */ + private static final Date DEFAULT_NOT_BEFORE = new Date(System.currentTimeMillis() - 86400000L * 365); + + /** The maximum possible value in X.509 specification: 9999-12-31 23:59:59 */ + private static final Date DEFAULT_NOT_AFTER = new Date(253402300799000L); + + private final X509Certificate certificate; + private final PublicKey publicKey; + private final PrivateKey privateKey; + + public SelfSignedCert() throws Exception { + SecureRandom random = new SecureRandom(); + + KeyPair keypair; + try { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048, random); + keypair = keyGen.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + // Should not reach here because every Java implementation must have RSA and EC key pair generator. + throw new Error(e); + } + + X500Name owner = new X500Name("CN=localhost"); + + BigInteger serial = new BigInteger(64, random); + + X509v3CertificateBuilder builder = new X509v3CertificateBuilder( + owner, + serial, + DEFAULT_NOT_BEFORE, DEFAULT_NOT_AFTER, Locale.ROOT, + owner, + SubjectPublicKeyInfo.getInstance(keypair.getPublic().getEncoded()) + ); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSAEncryption").build(keypair.getPrivate()); + X509CertificateHolder certHolder = builder.build(signer); + X509Certificate certificate = new JcaX509CertificateConverter().setProvider(new BouncyCastleProvider()).getCertificate(certHolder); + certificate.verify(keypair.getPublic()); + + this.certificate = certificate; + this.publicKey = keypair.getPublic(); + this.privateKey = keypair.getPrivate(); + } + + public X509Certificate certificate() { + return certificate; + } + + public PublicKey publicKey() { + return publicKey; + } + + public PrivateKey privateKey() { + return privateKey; + } +}