Skip to content
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ deployments do not need to set anything.
| `GROUNDS_AGONES_POLL_INTERVAL` | `2s` | Accepts `Ns`, `Nm`, `Nh` |
| `GROUNDS_AGONES_ADDRESS_TYPE` | `PodIP` | Which entry of `status.addresses` to dial (`PodIP`, `ExternalIP`, …) |
| `GROUNDS_AGONES_PORT` | `25565` | TCP port on the GameServer |
| `GROUNDS_STATIC_SERVERS` | _(none)_ | Comma-separated `name=host:port` static Velocity backends |

`GROUNDS_STATIC_SERVERS` is validated strictly: names and hosts must be non-empty, ports must be
between `1` and `65535`, and names must be unique. For example, Stage proxies can use:

```text
GROUNDS_STATIC_SERVERS=buildserver=buildserver:25565
```

This only registers the backend with Velocity; configuring the backend itself for Velocity
forwarding remains a separate deployment concern.

Typical Helm chart wiring uses a `ConfigMap` consumed via `envFrom`, plus
`POD_NAMESPACE` from the Downward API for clusters where the proxy should
Expand Down
16 changes: 14 additions & 2 deletions velocity/src/main/kotlin/gg/grounds/GroundsPluginAgones.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import gg.grounds.drain.DrainConfig
import gg.grounds.drain.DrainHttpServer
import gg.grounds.drain.DrainListener
import gg.grounds.drain.DrainManager
import gg.grounds.drain.DrainTransferCookie
import gg.grounds.gameserver.GameServerStateManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -53,23 +54,34 @@ constructor(private val proxyServer: ProxyServer, private val logger: Logger) {

stateManager =
GameServerStateManager(this, proxyServer, logger, coroutineScope).also { it.start() }
val drainCookie = DrainTransferCookie(System.getenv(DrainTransferCookie.SECRET_ENV))
lateinit var drainManager: DrainManager
discoveryService =
DiscoveryService(this, proxyServer, logger, discoveryConfig).also { it.start() }
DiscoveryService(
this,
proxyServer,
logger,
discoveryConfig,
drainTransferCookie = drainCookie,
sourceCookiePending = { playerId -> drainManager.isCookiePending(playerId) },
)
.also { it.start() }

proxyServer.commandManager.register(
proxyServer.commandManager.metaBuilder("agones").build(),
AgonesCommand(proxyServer, { serverName -> discoveryService.getServerRole(serverName) }),
)

val drainConfig = DrainConfig.fromEnv()
val drainManager =
drainManager =
DrainManager(
this,
proxyServer,
logger,
drainConfig,
{ serverName -> discoveryService.getServerRole(serverName) },
discoveryConfig.lobbyValue,
drainCookie,
)
proxyServer.eventManager.register(this, DrainListener(drainManager))
drainHttpServer =
Expand Down
38 changes: 38 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package gg.grounds.discovery

import java.time.Duration
import java.util.Locale

internal fun canonicalServerName(name: String): String = name.lowercase(Locale.ROOT)

/**
* Discovery configuration sourced from environment variables. All keys are optional; the defaults
Expand All @@ -19,7 +22,11 @@ import java.time.Duration
* - `GROUNDS_AGONES_ADDRESS_TYPE` — Which `status.addresses` entry to use (`PodIP`, `ExternalIP`,
* `InternalIP`, `Hostname`).
* - `GROUNDS_AGONES_PORT` — TCP port to dial on the discovered GameServer.
* - `GROUNDS_STATIC_SERVERS` — Comma-separated `name=host:port` Velocity backends that are
* registered without Agones discovery.
*/
data class StaticServer(val name: String, val host: String, val port: Int)

data class DiscoveryConfig(
val namespace: String,
val labelSelector: String,
Expand All @@ -29,6 +36,7 @@ data class DiscoveryConfig(
val pollInterval: Duration,
val addressType: String,
val port: Int,
val staticServers: List<StaticServer>,
) {
companion object {
const val DEFAULT_NAMESPACE = "games"
Expand Down Expand Up @@ -59,6 +67,7 @@ data class DiscoveryConfig(
?: DEFAULT_POLL_INTERVAL,
addressType = env["GROUNDS_AGONES_ADDRESS_TYPE"] ?: DEFAULT_ADDRESS_TYPE,
port = env["GROUNDS_AGONES_PORT"]?.toIntOrNull() ?: DEFAULT_PORT,
staticServers = parseStaticServers(env["GROUNDS_STATIC_SERVERS"]),
)

private val DURATION_PATTERN = Regex("""^(\d+)\s*(s|m|h)$""")
Expand All @@ -77,5 +86,34 @@ data class DiscoveryConfig(
else -> error("unreachable")
}
}

private fun parseStaticServers(raw: String?): List<StaticServer> {
if (raw.isNullOrBlank()) return emptyList()

val names = mutableSetOf<String>()
return raw.split(",").map { entry ->
val trimmedEntry = entry.trim()
val separator = trimmedEntry.indexOf('=')
require(separator >= 0) { "Invalid GROUNDS_STATIC_SERVERS entry '$trimmedEntry'" }

val name = trimmedEntry.substring(0, separator).trim()
val address = trimmedEntry.substring(separator + 1).trim()
val portSeparator = address.lastIndexOf(':')
require(name.isNotEmpty() && portSeparator >= 0) {
"Invalid GROUNDS_STATIC_SERVERS entry '$trimmedEntry'"
}

val host = address.substring(0, portSeparator).trim()
val port = address.substring(portSeparator + 1).trim().toIntOrNull()
require(host.isNotEmpty() && port != null && port in 1..65535) {
"Invalid GROUNDS_STATIC_SERVERS entry '$trimmedEntry'"
}
require(names.add(canonicalServerName(name))) {
"Invalid GROUNDS_STATIC_SERVERS entry '$trimmedEntry': duplicate name '$name'"
}

StaticServer(name, host, port)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,77 @@
package gg.grounds.discovery

import com.velocitypowered.api.event.Continuation
import com.velocitypowered.api.event.EventTask
import com.velocitypowered.api.event.ResultedEvent
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.connection.LoginEvent
import com.velocitypowered.api.event.player.CookieReceiveEvent
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent
import com.velocitypowered.api.network.ProtocolVersion
import com.velocitypowered.api.proxy.ProxyServer
import com.velocitypowered.api.proxy.server.RegisteredServer
import com.velocitypowered.api.scheduler.ScheduledTask
import gg.grounds.drain.DrainTransferCookie
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import net.kyori.adventure.text.Component

internal fun selectDrainStaticServer(
serverName: String,
servers: Collection<RegisteredServer>,
serverRole: (String) -> String?,
): RegisteredServer? =
servers.firstOrNull { server ->
canonicalServerName(server.serverInfo.name) == canonicalServerName(serverName) &&
serverRole(server.serverInfo.name) == STATIC_SERVER_ROLE
}

private const val STATIC_SERVER_ROLE = "static"

internal fun shouldDenyInitialLogin(
hasLobby: Boolean,
hasStatic: Boolean,
protocolVersion: ProtocolVersion,
): Boolean = !hasLobby && !(hasStatic && protocolVersion >= ProtocolVersion.MINECRAFT_1_20_5)

internal fun consumeDrainTransferCookie(clearCookie: () -> Unit) {
try {
clearCookie()
} catch (_: Exception) {
// The destination choice and its continuation must not depend on client cookie storage.
}
}

class DiscoveryPlayerListener(
private val plugin: Any,
private val proxyServer: ProxyServer,
private val lobbyServers: Set<String>,
private val serverRole: (String) -> String?,
/**
* Network-wide players per backend server, or null when the network cannot be asked. Null falls
* back to this proxy's own view — on a single proxy that is the same number, and with several
* it still spreads, just per proxy rather than per network.
*/
private val networkCounts: () -> Map<String, Int>?,
private val drainTransferCookie: DrainTransferCookie = DrainTransferCookie(),
private val sourceCookiePending: (String) -> Boolean = { false },
) {
private val pendingCookies = ConcurrentHashMap<UUID, PendingCookieRequest>()

@Subscribe
fun onLogin(event: LoginEvent) {
if (findLobbyServer() != null) return
if (
!shouldDenyInitialLogin(
findLobbyServer() != null,
proxyServer.allServers.any { server ->
serverRole(server.serverInfo.name) == STATIC_SERVER_ROLE
},
event.player.protocolVersion,
)
) {
return
}

event.result =
ResultedEvent.ComponentResult.denied(
Expand All @@ -32,13 +82,64 @@ class DiscoveryPlayerListener(
}

@Subscribe
fun onPlayerChooseInitialServer(event: PlayerChooseInitialServerEvent) {
if (event.initialServer.isPresent) return
fun onPlayerChooseInitialServer(event: PlayerChooseInitialServerEvent): EventTask =
EventTask.withContinuation { continuation ->
if (event.initialServer.isPresent) {
continuation.resume()
return@withContinuation
}
val player = event.player
if (player.protocolVersion < ProtocolVersion.MINECRAFT_1_20_5) {
chooseServer(event, null)
continuation.resume()
return@withContinuation
}

val lobby = findLobbyServer()
if (lobby != null) {
event.setInitialServer(lobby)
val pending = PendingCookieRequest(event, continuation)
pendingCookies.put(player.uniqueId, pending)?.complete(null)
try {
player.requestCookie(DrainTransferCookie.KEY)
pending.timeoutTask =
proxyServer.scheduler
.buildTask(
plugin,
Runnable {
if (pendingCookies.remove(player.uniqueId, pending))
pending.complete(null)
},
)
.delay(COOKIE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
.schedule()
} catch (error: IllegalArgumentException) {
if (pendingCookies.remove(player.uniqueId, pending)) pending.complete(null)
}
}

// This must run before DrainListener: source-proxy cookie echoes belong to its stager, not to
// initial-server selection. Velocity invokes higher priorities first.
@Subscribe(priority = DRAIN_COOKIE_SOURCE_SUPPRESSION_PRIORITY)
fun onCookieReceive(event: CookieReceiveEvent) {
if (event.originalKey != DrainTransferCookie.KEY) return
if (sourceCookiePending(event.player.uniqueId.toString())) return
event.result = CookieReceiveEvent.ForwardResult.handled()
val payload = event.originalData
consumeDrainTransferCookie {
event.player.storeCookie(DrainTransferCookie.KEY, byteArrayOf())
}
pendingCookies.remove(event.player.uniqueId)?.complete(payload)
}

private fun chooseServer(event: PlayerChooseInitialServerEvent, payload: ByteArray?) {
if (event.initialServer.isPresent) return
val preferred =
drainTransferCookie.decode(payload)?.let { serverName ->
selectDrainStaticServer(serverName, proxyServer.allServers, serverRole)
}
if (preferred != null) {
event.setInitialServer(preferred)
return
}
findLobbyServer()?.let(event::setInitialServer)
}

private fun findLobbyServer(): RegisteredServer? {
Expand All @@ -57,4 +158,25 @@ class DiscoveryPlayerListener(
val chosen = LobbySelection.pick(candidates) ?: return null
return lobbies.firstOrNull { it.serverInfo.name == chosen }
}

private inner class PendingCookieRequest(
private val event: PlayerChooseInitialServerEvent,
private val continuation: Continuation,
) {
var timeoutTask: ScheduledTask? = null

fun complete(payload: ByteArray?) {
timeoutTask?.cancel()
try {
chooseServer(event, payload)
} finally {
continuation.resume()
}
}
}

private companion object {
private const val COOKIE_TIMEOUT_MILLIS = 1_000L
private const val DRAIN_COOKIE_SOURCE_SUPPRESSION_PRIORITY: Short = 100
}
}
Loading