Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions velocity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
25 changes: 25 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 —
Expand Down
83 changes: 83 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/edition/FloodgateLookup.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<GameProfile.Property>): List<GameProfile.Property>? =
if (existing.any { it.name == PROPERTY }) null
else existing + GameProfile.Property(PROPERTY, BEDROCK, "")
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}