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)) + } +}