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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,25 @@ allowed can still _show_ the MOTD; `/motd set` then reports the refusal instead

## Configuration

The tab list uses compact rank labels (`ADMIN`, `DEV`, `MOD`, `USER`, `SUP`, `BUILD`)
and the resource pack's `grounds:tab_labels` font. Its five-pixel letters leave one
transparent pixel above and below the ink. Bedrock players get a bedrock-block
icon immediately before their name (`U+E004` in `grounds:tab`). Deploy the matching
resource pack before the proxy update.

Floodgate identifies local Bedrock sessions, including linked Java accounts. Every
five seconds, proxies exchange edition snapshots on `proxy.platform.<GROUNDS_ENVIRONMENT>`;
remote snapshots expire after 30 seconds. All proxies in an environment must share
`GROUNDS_ENVIRONMENT`, have distinct `PROXY_ID` values, and be allowed to publish and
subscribe to that subject when NATS subject permissions are configured. Without a
snapshot, unlinked Floodgate UUIDs remain recognizable; linked accounts require
the originating proxy's snapshot. This cache only controls the visual indicator.

| env | meaning |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `NATS_URL` | broker for `proxy.system.*` / `proxy.transfer.*` (default `nats://nats.infra:4222`) |
| `PROXY_ID` | this proxy's identity, recorded in a player's session — must differ per proxy (`velocity`, `velocity-2`) |
| `GROUNDS_ENVIRONMENT` | edition snapshot scope shared by all proxies in the same environment, e.g. `stage` |
| `GROUNDS_TOKEN_FILE` | projected SA-token, presented as the NATS bearer and as the service-config gRPC bearer (default `/var/run/secrets/grounds/token`) |
| `CONFIG_SERVICE_URL` | service-config contract target, e.g. `service-config:9000`. **Unset disables `/motd` entirely** and Velocity's own MOTD is served |
| `CONFIG_GRPC_TARGET` | Legacy fallback for deployments that have not migrated to `CONFIG_SERVICE_URL` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import gg.grounds.proxy.velocity.metrics.ProxySnapshot
import gg.grounds.proxy.velocity.motd.MotdConfigStore
import gg.grounds.proxy.velocity.motd.MotdGgClient
import gg.grounds.proxy.velocity.motd.MotdManager
import gg.grounds.proxy.velocity.tab.BedrockPlayers
import gg.grounds.proxy.velocity.tab.BedrockRoster
import gg.grounds.proxy.velocity.tab.TabList
import io.nats.client.Subscription
import java.net.InetSocketAddress
Expand Down Expand Up @@ -114,6 +116,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
private var countSubscription: Subscription? = null
private var metrics: ProxyMetrics? = null
private var tabList: TabList? = null
private var bedrockRoster: BedrockRoster? = null

/**
* The network-wide MOTD, or null when this proxy has no service-config to read it from. Null
Expand Down Expand Up @@ -170,6 +173,25 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {

subscribeForProxy()

val bedrockPlayers = BedrockPlayers.of(logger)
val platformSubject = BedrockRoster.subject(System.getenv("GROUNDS_ENVIRONMENT"))
val roster =
BedrockRoster(
System.getenv("PROXY_ID").orEmpty(),
{ proxy.allPlayers.map { it.uniqueId } },
bedrockPlayers::isBedrock,
{ payload -> platformSubject?.let { natsHandler.publish(it, payload) } },
)
bedrockRoster = roster
if (platformSubject != null) {
crossProxySubscriptions += natsHandler.subscribe(platformSubject, roster::receive)
} else {
logger.warn(
"GROUNDS_ENVIRONMENT is unset or invalid; linked Bedrock indicators are local-only"
)
}
roster.refresh()

val messages =
Translations.forBundle(
"gg.grounds.proxy.messages",
Expand All @@ -190,13 +212,20 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {
roleQuery = { ProxyServiceRegistry.get(PlayerRoleQuery::class.java) },
localeQuery = { ProxyServiceRegistry.get(PlayerLocaleQuery::class.java) },
serverQuery = { ProxyServiceRegistry.get(ServerDisplayQuery::class.java) },
isBedrock = roster::isBedrock,
)
tabList = tab

// On a timer as well as on join: the ping and the roster both change with no event to hang
// off, and a footer that shows the ping from the moment you logged in is worse than none.
proxy.scheduler
.buildTask(this, Runnable { tab.refreshAll() })
.buildTask(
this,
Runnable {
roster.refresh()
tab.refreshAll()
},
)
.delay(Duration.ofSeconds(TAB_REFRESH_SECONDS))
.repeat(Duration.ofSeconds(TAB_REFRESH_SECONDS))
.schedule()
Expand Down Expand Up @@ -309,6 +338,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {

@Subscribe
fun onLogin(event: PostLoginEvent) {
bedrockRoster?.refresh()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid publishing the full roster on every login

When many players reconnect after a proxy restart, every PostLoginEvent calls refresh(), which rescans all connected players, serializes the entire roster, and publishes it to every proxy. A burst of n logins therefore sends O(n²) entries—at the supported 10,000-player limit, roughly 50 million UUID entries before NATS fan-out—and also performs the work on each login path. Update local state without publishing here, or debounce broadcasts and rely on the existing five-second refresh.

Useful? React with 👍 / 👎.

tabList?.refresh(event.player)
}

Expand All @@ -323,6 +353,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) {

@Subscribe
fun onShutdown(event: ProxyShutdownEvent) {
bedrockRoster?.close()
ProxyServiceRegistry.unregister(ProxyService::class.java)
countSubscription?.let { natsHandler.unsubscribe(it) }
crossProxySubscriptions.forEach { natsHandler.unsubscribe(it) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package gg.grounds.proxy.velocity.tab

import java.util.UUID
import org.slf4j.Logger

/** Optional Floodgate lookup; this plugin also runs on proxies without Floodgate. */
internal class BedrockPlayers(private val logger: Logger, private val lookup: () -> Class<*>?) {
@Volatile private var api: Class<*>? = null

fun isBedrock(id: UUID): Boolean {
val type = api ?: lookup()?.also { api = it }
if (type != null) {
try {
val instance = type.getMethod("getInstance").invoke(null)
if (instance != null) {
return type
.getMethod("isFloodgatePlayer", UUID::class.java)
.invoke(instance, id) == true
}
} catch (failure: ReflectiveOperationException) {
logger.debug("Could not read a player's edition from Floodgate", failure)
}
}
return isUnlinkedUuid(id)
}

companion object {
// Floodgate represents an unlinked XUID in the UUID's lower 64 bits.
fun isUnlinkedUuid(id: UUID): Boolean =
id.mostSignificantBits == 0L && id.leastSignificantBits != 0L

fun of(logger: Logger): BedrockPlayers =
BedrockPlayers(logger) {
try {
Class.forName(
"org.geysermc.floodgate.api.FloodgateApi",
false,
BedrockPlayers::class.java.classLoader,
)
} catch (_: ClassNotFoundException) {
null
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package gg.grounds.proxy.velocity.tab

import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap

/** Short-lived edition snapshots let Java proxies label linked Bedrock accounts too. */
internal class BedrockRoster(
private val proxyId: String,
private val connectedPlayers: () -> Collection<UUID>,
private val detect: (UUID) -> Boolean,
private val publish: (String) -> Unit,
private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 },
) {
private data class Snapshot(val players: Map<UUID, Boolean>, val receivedAt: Long)

private val remote = ConcurrentHashMap<String, Snapshot>()
@Volatile private var local: Map<UUID, Boolean> = emptyMap()

fun refresh() {
local = connectedPlayers().associateWith(detect)
expire()
send(local)
}

fun close() {
local = emptyMap()
send(emptyMap())
remote.clear()
}

fun isBedrock(id: UUID): Boolean {
local[id]?.let {
return it
}
val now = nowMillis()
val current =
remote.values
.filter { now - it.receivedAt < EXPIRY_MILLIS && id in it.players }
.maxByOrNull { it.receivedAt }
return current?.players?.get(id) ?: BedrockPlayers.isUnlinkedUuid(id)
}

fun receive(raw: String) {
if (raw.length > MAX_PAYLOAD) return
val parsed =
try {
val root = JsonParser.parseString(raw)
if (!root.isJsonObject) return
val obj = root.asJsonObject
if (obj.get("schemaVersion")?.asInt != 1) return
val owner =
obj.get("proxy")
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
?.asString ?: return
if (owner == proxyId || !owner.matches(PROXY_ID)) return
val entries = obj.get("players")?.takeIf { it.isJsonObject }?.asJsonObject ?: return
if (entries.size() > MAX_PLAYERS) return
val players =
entries.entrySet().associate { (key, value) ->
val id = UUID.fromString(key)
if (
id.toString() != key ||
!value.isJsonPrimitive ||
!value.asJsonPrimitive.isBoolean
)
return
id to value.asBoolean
}
owner to Snapshot(players, nowMillis())
} catch (_: RuntimeException) {
return
}
expire()
if (!remote.containsKey(parsed.first) && remote.size >= MAX_PROXIES) return
remote[parsed.first] = parsed.second
}

private fun send(players: Map<UUID, Boolean>) {
if (!proxyId.matches(PROXY_ID) || players.size > MAX_PLAYERS) return
val entries = JsonObject()
players.forEach { (id, bedrock) -> entries.addProperty(id.toString(), bedrock) }
val root = JsonObject()
root.addProperty("schemaVersion", 1)
root.addProperty("proxy", proxyId)
root.add("players", entries)
publish(root.toString())
}

private fun expire() {
val now = nowMillis()
remote.entries.removeIf { now - it.value.receivedAt >= EXPIRY_MILLIS }
}

companion object {
const val EXPIRY_MILLIS = 30_000L
private const val MAX_PAYLOAD = 512 * 1024
private const val MAX_PLAYERS = 10_000
private const val MAX_PROXIES = 256
private val PROXY_ID = Regex("[A-Za-z0-9_-]{1,128}")

fun subject(environment: String?): String? =
environment?.trim()?.takeIf { it.matches(PROXY_ID) }?.let { "proxy.platform.$it" }
}
}
14 changes: 11 additions & 3 deletions velocity/src/main/kotlin/gg/grounds/proxy/velocity/tab/TabBadge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package gg.grounds.proxy.velocity.tab
import kotlin.math.max
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import net.kyori.adventure.text.format.ShadowColor
import net.kyori.adventure.text.format.TextColor
import net.kyori.adventure.text.format.TextDecoration

object TabBadge {
fun width(label: String): Int {
val textWidth = VanillaAdvances.width(label)
val textWidth = TabLabelAdvances.width(label)
val pad = 4
val inner =
max(textWidth + pad, TabGlyphs.LEFT_PX + TabGlyphs.RIGHT_PX + TabGlyphs.MIDDLE_PX)
Expand All @@ -16,7 +18,7 @@ object TabBadge {
}

fun chip(label: String, fill: TextColor): Component {
val textWidth = VanillaAdvances.width(label)
val textWidth = TabLabelAdvances.width(label)
val badgeWidth = width(label)
val middles = badgeWidth - TabGlyphs.LEFT_PX - TabGlyphs.RIGHT_PX
val padLeft = (badgeWidth - textWidth) / 2
Expand All @@ -36,7 +38,13 @@ object TabBadge {
.append(Component.text(slices, fill).font(TabGlyphs.FONT))
.append(Component.text(TabSpaces.of(-badgeWidth)).font(TabGlyphs.FONT))
.append(Component.text(TabSpaces.of(padLeft)).font(TabGlyphs.FONT))
.append(Component.text(label, NamedTextColor.WHITE))
.append(
Component.text(label, NamedTextColor.WHITE)
.font(TabGlyphs.LABEL_FONT)
.decoration(TextDecoration.BOLD, false)
.decoration(TextDecoration.ITALIC, false)
.shadowColor(ShadowColor.none())
)
.append(Component.text(TabSpaces.of(padRight)).font(TabGlyphs.FONT))
.build()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import net.kyori.adventure.key.Key

object TabGlyphs {
val FONT: Key = Key.key("grounds", "tab")
val LABEL_FONT: Key = Key.key("grounds", "tab_labels")
const val LOGO = '\uE000'
const val BADGE_LEFT = '\uE001'
const val BADGE_MIDDLE = '\uE002'
const val BADGE_RIGHT = '\uE003'
const val BEDROCK_ICON = '\uE004'
const val BEDROCK_ADVANCE = 9
const val LEFT_PX = 3
const val MIDDLE_PX = 1
const val RIGHT_PX = 3
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package gg.grounds.proxy.velocity.tab

object TabLabelAdvances {
fun width(text: String): Int = text.sumOf(::advance)

private fun advance(ch: Char): Int =
when {
ch in 'A'..'Z' && ch != 'I' -> 6
ch in '0'..'9' -> 6
ch == 'I' -> 4
ch == ' ' || ch == '-' -> 4
ch == '!' || ch == '.' || ch == ':' -> 2
ch == '+' || ch == '_' || ch == '?' || ch == '/' -> 6
else -> VanillaAdvances.width(ch.toString())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class TabList(
private val roleQuery: () -> PlayerRoleQuery?,
private val localeQuery: () -> PlayerLocaleQuery?,
private val serverQuery: () -> ServerDisplayQuery?,
private val isBedrock: (java.util.UUID) -> Boolean = { false },
) {

/** Redraws everything [viewer] sees. */
Expand Down Expand Up @@ -109,7 +110,9 @@ class TabList(
val locale =
localeQ?.localeOf(entry.profile.id)
?: proxy.getPlayer(entry.profile.id).map { it.effectiveLocale }.orElse(null)
entry.setDisplayName(TabName.format(entry.profile.name, locale, role))
entry.setDisplayName(
TabName.format(entry.profile.name, locale, role, isBedrock(entry.profile.id))
)
}
}

Expand Down
Loading