From 9963403d5a30668dc1e381993bec35dc84f55367 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 23 Aug 2026 18:37:12 +0200 Subject: [PATCH] feat(edition): tell backends which players came from Bedrock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing downstream of the proxy knows today. Geyser terminates the Bedrock session and connects as an ordinary Java client, and Velocity's modern forwarding carries a UUID, a username, skin properties and an address — no edition. Backends make do with the shape of the Floodgate UUID (mostSignificantBits == 0), which is right for unlinked players and silently wrong for linked ones: they arrive under their Mojang UUID. That is not a corner case. The Floodgate build the network ships defaults to enable-global-linking: true, and a global link is one the player may have made on any Geyser server. Stamps grounds:edition=bedrock onto the game profile at PostLoginEvent, which fires before the player reaches a backend, so it is in the payload Velocity HMACs with the forwarding secret — and in every later one, since a server switch rebuilds it from the same profile. Being inside the signature is the point rather than a detail: the flag turns anti-cheat off downstream, so a marker the client could set would be a self-exemption for any modified Java client. It is why the client brand is not used, despite Geyser announcing itself in it. Floodgate is reached by reflection over one method. Only the Bedrock proxy carries it, Floodgate publishes its API as a moving -SNAPSHOT and nothing else, and library-gui already avoids the dependency for the same reasons. Where Floodgate is absent the listener is not registered at all. The stamp appends, and Floodgate's own skin applier removes only textures, so neither can drop what the other wrote. --- velocity/build.gradle.kts | 3 + .../kotlin/gg/grounds/GroundsPluginPlayer.kt | 25 ++++++ .../gg/grounds/edition/FloodgateLookup.kt | 83 +++++++++++++++++++ .../edition/listener/EditionStampListener.kt | 75 +++++++++++++++++ .../edition/EditionStampListenerTest.kt | 65 +++++++++++++++ 5 files changed, 251 insertions(+) create mode 100644 velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/edition/listener/EditionStampListener.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/edition/EditionStampListenerTest.kt diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 79b35f5..e43564c 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -21,6 +21,9 @@ dependencies { // compileOnly above is not visible to tests; PlayerSessionQueryImplTest needs the interface's // types. testImplementation("gg.grounds:plugin-proxy-api:0.5.0") + // Same reason: the conventions plugin puts velocity-api on compileOnly, and + // EditionStampListenerTest builds a GameProfile.Property. + testImplementation("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT") testImplementation("org.junit.jupiter:junit-jupiter-api:5.13.4") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.13.4") testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.13.4") diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index 8de73ae..f10faad 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -10,6 +10,8 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer import gg.grounds.config.MessagesConfig import gg.grounds.config.MessagesConfigLoader +import gg.grounds.edition.FloodgateLookup +import gg.grounds.edition.listener.EditionStampListener import gg.grounds.link.ForgeLinkClient import gg.grounds.link.LinkCommand import gg.grounds.listener.PlayerConnectionListener @@ -92,11 +94,34 @@ constructor( ) registerLinkCommands(messages) + registerEditionStamp() heartbeatScheduler.start() logger.info("Configured player presence client (serviceUrl={})", serviceUrl) } + /** + * Tells backends which of their players came from Bedrock, by stamping a property onto the + * signed part of the forwarding payload. + * + * Only the Bedrock proxy carries Floodgate, so on the Java proxies there is nothing to ask and + * the listener is not registered at all — rather than registered and answering "Java" for + * everyone, which is the same outcome for more moving parts. + */ + private fun registerEditionStamp() { + val floodgate = FloodgateLookup.create(logger) + if (floodgate == null) { + logger.debug("Floodgate not installed; backends see no edition marker from this proxy") + return + } + proxy.eventManager.register(this, EditionStampListener(floodgate, logger)) + logger.info( + "Marking Bedrock players for backends ({}={})", + EditionStampListener.PROPERTY, + EditionStampListener.BEDROCK, + ) + } + /** * /link + /unlink talk to forge over HTTP, which needs the platform context forge injects into * pushed workloads. Outside that context (a bare local proxy, say) the env vars are absent — diff --git a/velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt b/velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt new file mode 100644 index 0000000..13a88f4 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt @@ -0,0 +1,83 @@ +package gg.grounds.edition + +import java.lang.reflect.Method +import java.util.UUID +import org.slf4j.Logger + +/** + * Asks Floodgate whether a player came from Bedrock, without depending on Floodgate. + * + * Only one of the three proxies carries Floodgate — the Bedrock one — so a compile-time dependency + * would put a class on the other two that can never resolve. Floodgate also publishes its API as a + * moving `-SNAPSHOT` and nothing else, which is not a version this repo can pin. Reflection over + * one method costs less than either, and `library-gui` already avoids the same dependency for the + * same reason. + * + * [create] returns null when Floodgate is not installed, which is the ordinary state of the Java + * proxies rather than an error worth logging loudly. + * + * **Why not just look at the UUID.** Floodgate builds an unlinked Bedrock player's UUID as `new + * UUID(0, xuid)`, so the shape alone answers for them — which is what backends do today. It does + * not answer for a player who has **linked** a Java account: they arrive under the linked account's + * Mojang UUID and are shaped like anyone else. `isFloodgatePlayer` covers both, because it resolves + * through `getPlayer(uuid)`, which falls back to scanning for a player whose `getCorrectUniqueId()` + * matches. Confirmed in the bytecode of the build the network ships (`containers/plugin-floodgate`, + * Floodgate 2.2.5 build 140), where that build also defaults to `enable-global-linking: true` — and + * a global link is one the player may have made on any Geyser server, so linked players are not a + * rare case. + */ +class FloodgateLookup +private constructor( + private val logger: Logger, + private val getInstance: Method, + private val isFloodgatePlayer: Method, +) { + + @Volatile private var failed = false + + /** + * Whether Floodgate knows this player as a Bedrock player. + * + * Answers false on any failure, and the direction is deliberate: false means "treat as Java", + * which downstream means "keep checking them". The opposite default would let a Floodgate + * hiccup quietly exempt players from anti-cheat. + */ + fun isBedrock(playerId: UUID): Boolean { + if (failed) return false + return try { + // Resolved per call rather than cached: Floodgate's singleton is not guaranteed to + // exist when this proxy wires its listeners, only by the time a player logs in. + val api = getInstance.invoke(null) ?: return false + isFloodgatePlayer.invoke(api, playerId) as Boolean + } catch (e: ReflectiveOperationException) { + // Once, not per login: a broken API would otherwise write a line for every player who + // ever joins. + failed = true + logger.warn("Floodgate lookup failed; treating every player as Java from here on", e) + false + } + } + + companion object { + private const val API = "org.geysermc.floodgate.api.FloodgateApi" + + /** Null when Floodgate is not installed on this proxy. */ + fun create(logger: Logger): FloodgateLookup? = + try { + val api = Class.forName(API) + FloodgateLookup( + logger, + api.getMethod("getInstance"), + api.getMethod("isFloodgatePlayer", UUID::class.java), + ) + } catch (e: ClassNotFoundException) { + null + } catch (e: NoSuchMethodException) { + // Floodgate is here but is not the API this expects. Say so: a silent null would + // hide a version skew until someone wondered why Bedrock players were being + // flagged by anti-cheat. + logger.warn("Floodgate is installed but {} is not the expected shape", API, e) + null + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/edition/listener/EditionStampListener.kt b/velocity/src/main/kotlin/gg/grounds/edition/listener/EditionStampListener.kt new file mode 100644 index 0000000..9ba2390 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/edition/listener/EditionStampListener.kt @@ -0,0 +1,75 @@ +package gg.grounds.edition.listener + +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.PostLoginEvent +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.util.GameProfile +import gg.grounds.edition.FloodgateLookup +import org.slf4j.Logger + +/** + * Marks a Bedrock player's game profile so the backend can tell. + * + * Nothing downstream of this proxy otherwise knows. Geyser terminates the Bedrock session and + * connects here as an ordinary Java client, and Velocity's modern forwarding carries a UUID, a + * username, skin properties and an address — no edition. Backends make do with the shape of the + * Floodgate UUID (`mostSignificantBits == 0`), which is right for unlinked players and silently + * wrong for linked ones, who arrive under their Mojang UUID. + * + * A game profile property is the carrier because the forwarding payload signs it. Velocity HMACs + * `(version, address, uuid, name, properties, key)` with the forwarding secret, and the properties + * list is the one extensible part of it — so a backend that trusts the payload at all can trust + * this. That matters more than convenience here: the flag turns anti-cheat *off*, so a marker the + * client could set would be a self-exemption for any modified Java client. It is why the client + * brand is not used, despite Geyser announcing itself as `Geyser` in it. + * + * **Timing.** `PostLoginEvent` fires after login and before the player is sent to a backend, so the + * property is in the profile by the time Velocity builds any forwarding payload — including on + * every later server switch, which rebuilds it from the same profile. + * + * **Ordering.** Floodgate's own skin applier also rewrites the property list, but it copies the + * list and removes only `textures`, so it cannot drop this one whichever way round the two run. + * + * Registered only when Floodgate is installed, which in practice means only on the Bedrock proxy. + */ +class EditionStampListener(private val floodgate: FloodgateLookup, private val logger: Logger) { + + @Subscribe + fun onPostLogin(event: PostLoginEvent) { + stamp(event.player) + } + + private fun stamp(player: Player) { + if (!floodgate.isBedrock(player.uniqueId)) return + + val updated = withEditionProperty(player.gameProfileProperties) ?: return + player.gameProfileProperties = updated + logger.debug("Marked {} as a Bedrock player ({}={})", player.username, PROPERTY, BEDROCK) + } + + companion object { + /** + * Namespaced because it rides in a list Mojang also writes to — `textures` is theirs. + * + * Backends match on the name and treat any other value, or its absence, as Java. + */ + const val PROPERTY = "grounds:edition" + + /** The only value written today. A Java player carries no property rather than a value. */ + const val BEDROCK = "bedrock" + + /** + * The property list to write, or null when [existing] already carries the marker. + * + * A profile is per connection, so the marker should never already be there — but stamping + * twice would put two of them on the wire, and a backend that read the first would be right + * only by luck. + * + * Appends rather than replaces: the list is where Mojang's `textures` lives, and dropping + * that would take the player's skin with it. + */ + fun withEditionProperty(existing: List): List? = + if (existing.any { it.name == PROPERTY }) null + else existing + GameProfile.Property(PROPERTY, BEDROCK, "") + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/edition/EditionStampListenerTest.kt b/velocity/src/test/kotlin/gg/grounds/edition/EditionStampListenerTest.kt new file mode 100644 index 0000000..7b06dc5 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/edition/EditionStampListenerTest.kt @@ -0,0 +1,65 @@ +package gg.grounds.edition + +import com.velocitypowered.api.util.GameProfile +import gg.grounds.edition.listener.EditionStampListener +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.slf4j.helpers.NOPLogger + +class EditionStampListenerTest { + + private fun textures() = GameProfile.Property("textures", "skin-payload", "skin-signature") + + @Test + fun marksAProfileThatCarriesNothingYet() { + val stamped = EditionStampListener.withEditionProperty(emptyList()) + + assertNotNull(stamped) + assertEquals(1, stamped!!.size) + assertEquals(EditionStampListener.PROPERTY, stamped[0].name) + assertEquals(EditionStampListener.BEDROCK, stamped[0].value) + } + + /** + * The skin is the reason this appends rather than replaces. Floodgate uploads a Bedrock + * player's skin and puts it here, so a stamp that rebuilt the list would leave them skinless. + */ + @Test + fun keepsTheSkinTheProfileAlreadyHad() { + val stamped = EditionStampListener.withEditionProperty(listOf(textures())) + + assertNotNull(stamped) + assertEquals(listOf("textures", EditionStampListener.PROPERTY), stamped!!.map { it.name }) + assertEquals("skin-payload", stamped[0].value) + assertEquals("skin-signature", stamped[0].signature) + } + + @Test + fun doesNothingWhenTheMarkerIsAlreadyThere() { + val already = + listOf( + textures(), + GameProfile.Property( + EditionStampListener.PROPERTY, + EditionStampListener.BEDROCK, + "", + ), + ) + + assertNull( + EditionStampListener.withEditionProperty(already), + "a second stamp would put two markers on the wire", + ) + } + + /** + * Two of the three proxies have no Floodgate, and that is the ordinary case rather than a + * misconfiguration — the listener is simply not registered there. + */ + @Test + fun theLookupIsAbsentWithoutFloodgate() { + assertNull(FloodgateLookup.create(NOPLogger.NOP_LOGGER)) + } +}