diff --git a/.github/workflows/docker-gradle-build-push.yml b/.github/workflows/docker-gradle-build-push.yml new file mode 100644 index 0000000..09fb424 --- /dev/null +++ b/.github/workflows/docker-gradle-build-push.yml @@ -0,0 +1,22 @@ +name: Docker Build + +# Publishes ghcr.io/groundsgg/plugin-player (edge on main, semver on tag). +# The image carries the shaded JAR at /jar/plugin.jar so the platform bundle +# can pull the plugin into the velocity proxy via the plugin-velocity-jar chart. + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + +permissions: + packages: write + contents: write + actions: write + +jobs: + reusable: + uses: groundsgg/.github/.github/workflows/docker-gradle-build-push.yml@main diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6b5b7d4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# syntax=docker/dockerfile:1 +# +# Builds the plugin-player Velocity-plugin JAR and packages it for the +# platform-test environment. The output image carries the JAR at +# /jar/plugin.jar — the shape the `plugin-velocity-jar` Helm chart expects +# (oci://ghcr.io/groundsgg/charts/plugin-velocity-jar). Velocity proxy pods +# in a per-engineer vCluster fetch this image's JAR via the chart's +# init-container + httpd indirection. +# +# Pushed as `ghcr.io/groundsgg/plugin-player:edge` (main) / `:` (tag) +# by .github/workflows/docker-gradle-build-push.yml. + +FROM eclipse-temurin:25-jdk AS build +WORKDIR /src + +# GitHub Packages credentials for the gg.grounds.velocity convention plugin. +# The token comes from the `github_token` build secret (never a build-arg — +# that would leak it into the layer history). +ARG GITHUB_USER + +# Copy gradle wrapper + root config first so dependency caches stay warm +# across source-only changes. +COPY gradle/ gradle/ +COPY gradlew settings.gradle.kts build.gradle.kts ./ + +COPY common/ common/ +COPY velocity/ velocity/ + +# `:velocity:build` produces the shaded plugin JAR. plugin-proxy-api stays +# compileOnly (never shaded): the ProxyServiceRegistry this plugin writes its +# session lookup into must be the class plugin-proxy loaded at runtime. +RUN --mount=type=secret,id=github_token,required=true \ + /bin/sh -euc '\ + : "${GITHUB_USER:?GITHUB_USER build arg is required}"; \ + token="$(cat /run/secrets/github_token)"; \ + ./gradlew --no-daemon :velocity:build \ + -Pgithub.user="${GITHUB_USER}" \ + -Pgithub.token="${token}" \ + ' + +RUN mkdir -p /out && \ + cp "$(ls -S /src/velocity/build/libs/*.jar | head -n1)" /out/plugin.jar + +FROM alpine:3 +RUN mkdir -p /jar +COPY --from=build /out/plugin.jar /jar/plugin.jar +# No ENTRYPOINT — the plugin-velocity-jar chart's init-container `cp`s +# /jar/plugin.jar out, then the chart's main container (busybox httpd) +# serves the JAR. This image only carries data. diff --git a/common/build.gradle.kts b/common/build.gradle.kts index f783fec..818d935 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -10,4 +10,10 @@ repositories { } } -dependencies { protobuf("gg.grounds:library-grpc-contracts-player:0.2.0") } +dependencies { + protobuf("gg.grounds:library-grpc-contracts-player:0.3.0") + + 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/common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt b/common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt new file mode 100644 index 0000000..26fb8a5 --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/player/presence/GroundsTokenInterceptor.kt @@ -0,0 +1,57 @@ +package gg.grounds.player.presence + +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.ClientInterceptor +import io.grpc.ForwardingClientCall +import io.grpc.Metadata +import io.grpc.MethodDescriptor +import java.nio.file.Files +import java.nio.file.Path + +/** + * Attaches the projected ServiceAccount JWT as `Authorization: Bearer ...` to every outgoing call. + * + * service-player runs with `grounds.auth.enabled=true` and rejects tokenless calls with + * UNAUTHENTICATED. The Grounds charts project a short-lived token (audience `grounds-services`) + * into the proxy pod and point [TOKEN_FILE_ENV] at it; kubelet rotates the file, so it is re-read + * per call rather than cached. + * + * With no token file present (local dev against a service running `grounds.auth.enabled=false`) the + * call goes out without the header. + */ +class GroundsTokenInterceptor(private val tokenLoader: () -> String? = ::loadTokenFromFile) : + ClientInterceptor { + + override fun interceptCall( + method: MethodDescriptor, + callOptions: CallOptions, + next: Channel, + ): ClientCall { + val delegate = next.newCall(method, callOptions) + return object : ForwardingClientCall.SimpleForwardingClientCall(delegate) { + override fun start(responseListener: Listener, headers: Metadata) { + tokenLoader()?.let { headers.put(AUTHORIZATION, "Bearer $it") } + super.start(responseListener, headers) + } + } + } + + companion object { + const val TOKEN_FILE_ENV = "GROUNDS_TOKEN_FILE" + const val DEFAULT_TOKEN_PATH = "/var/run/secrets/grounds/token" + + private val AUTHORIZATION: Metadata.Key = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER) + + private fun loadTokenFromFile(): String? { + val path = Path.of(System.getenv(TOKEN_FILE_ENV) ?: DEFAULT_TOKEN_PATH) + return try { + if (Files.exists(path)) Files.readString(path).trim().ifEmpty { null } else null + } catch (_: Exception) { + null + } + } + } +} diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt index 680006f..0a47337 100644 --- a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt +++ b/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt @@ -1,11 +1,16 @@ package gg.grounds.player.presence +import gg.grounds.grpc.player.GetPlayerSessionRequest import gg.grounds.grpc.player.PlayerHeartbeatBatchReply import gg.grounds.grpc.player.PlayerHeartbeatBatchRequest import gg.grounds.grpc.player.PlayerLoginRequest import gg.grounds.grpc.player.PlayerLogoutReply import gg.grounds.grpc.player.PlayerLogoutRequest import gg.grounds.grpc.player.PlayerPresenceServiceGrpc +import gg.grounds.grpc.player.PlayerSessionInfo +import gg.grounds.grpc.player.ResolvePlayerNameRequest +import gg.grounds.grpc.player.SuggestPlayerNamesRequest +import gg.grounds.grpc.player.UpdatePlayerServerRequest import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder import io.grpc.Status @@ -18,13 +23,17 @@ private constructor( private val channel: ManagedChannel, private val stub: PlayerPresenceServiceGrpc.PlayerPresenceServiceBlockingStub, ) : AutoCloseable { - fun tryLogin(playerId: UUID): PlayerLoginResult { + fun tryLogin(playerId: UUID, playerName: String = "", proxyId: String = ""): PlayerLoginResult { return try { val reply = stub .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) .tryPlayerLogin( - PlayerLoginRequest.newBuilder().setPlayerId(playerId.toString()).build() + PlayerLoginRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setPlayerName(playerName) + .setProxyId(proxyId) + .build() ) PlayerLoginResult.Success(reply) } catch (e: StatusRuntimeException) { @@ -67,6 +76,70 @@ private constructor( } } + /** + * The lookups below back cross-proxy features, and they run on the command path (`/msg`, + * tab-complete). A failure means "I don't know", never an exception into Velocity's event loop + * — the caller then falls back to what it can see locally. + */ + fun getSession(playerId: UUID): PlayerSessionInfo? { + return try { + val reply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .getPlayerSession( + GetPlayerSessionRequest.newBuilder() + .setPlayerId(playerId.toString()) + .build() + ) + if (reply.found) reply.session else null + } catch (e: RuntimeException) { + null + } + } + + fun resolveName(playerName: String): PlayerSessionInfo? { + return try { + val reply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .resolvePlayerName( + ResolvePlayerNameRequest.newBuilder().setPlayerName(playerName).build() + ) + if (reply.found) reply.session else null + } catch (e: RuntimeException) { + null + } + } + + fun suggestNames(prefix: String, limit: Int): List { + return try { + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .suggestPlayerNames( + SuggestPlayerNamesRequest.newBuilder().setPrefix(prefix).setLimit(limit).build() + ) + .playerNamesList + } catch (e: RuntimeException) { + emptyList() + } + } + + fun updateServer(playerId: UUID, serverName: String): Boolean { + return try { + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .updatePlayerServer( + UpdatePlayerServerRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setServerName(serverName) + .build() + ) + .updated + } catch (e: RuntimeException) { + false + } + } + override fun close() { channel.shutdown() try { @@ -84,6 +157,10 @@ private constructor( fun create(target: String): GrpcPlayerPresenceClient { val channelBuilder = ManagedChannelBuilder.forTarget(target) channelBuilder.usePlaintext() + // service-player rejects tokenless calls with UNAUTHENTICATED, and every failure here + // is + // swallowed into "unknown" — so without this the whole presence chain fails silently. + channelBuilder.intercept(GroundsTokenInterceptor()) val channel = channelBuilder.build() val stub = PlayerPresenceServiceGrpc.newBlockingStub(channel) return GrpcPlayerPresenceClient(channel, stub) diff --git a/common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt b/common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt new file mode 100644 index 0000000..855bfe2 --- /dev/null +++ b/common/src/test/kotlin/gg/grounds/player/presence/GroundsTokenInterceptorTest.kt @@ -0,0 +1,80 @@ +package gg.grounds.player.presence + +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.Metadata +import io.grpc.MethodDescriptor +import java.io.InputStream +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test + +class GroundsTokenInterceptorTest { + + @Test + fun attachesBearerTokenWhenTokenIsAvailable() { + val channel = CapturingChannel() + + GroundsTokenInterceptor(tokenLoader = { "jwt-abc" }) + .interceptCall(METHOD, CallOptions.DEFAULT, channel) + .start(NoopListener(), Metadata()) + + assertEquals("Bearer jwt-abc", channel.capturedHeaders?.get(AUTHORIZATION)) + } + + @Test + fun sendsNoAuthorizationHeaderWhenTokenIsMissing() { + val channel = CapturingChannel() + + GroundsTokenInterceptor(tokenLoader = { null }) + .interceptCall(METHOD, CallOptions.DEFAULT, channel) + .start(NoopListener(), Metadata()) + + assertFalse(channel.capturedHeaders?.containsKey(AUTHORIZATION) ?: true) + } + + private class CapturingChannel : Channel() { + var capturedHeaders: Metadata? = null + + override fun authority(): String = "test" + + override fun newCall( + methodDescriptor: MethodDescriptor, + callOptions: CallOptions, + ): ClientCall = + object : ClientCall() { + override fun start(responseListener: Listener, headers: Metadata) { + capturedHeaders = headers + } + + override fun request(numMessages: Int) = Unit + + override fun cancel(message: String?, cause: Throwable?) = Unit + + override fun halfClose() = Unit + + override fun sendMessage(message: ReqT) = Unit + } + } + + private class NoopListener : ClientCall.Listener() + + private companion object { + val AUTHORIZATION: Metadata.Key = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER) + + val MARSHALLER = + object : MethodDescriptor.Marshaller { + override fun stream(value: String): InputStream = value.byteInputStream() + + override fun parse(stream: InputStream): String = stream.reader().readText() + } + + val METHOD: MethodDescriptor = + MethodDescriptor.newBuilder(MARSHALLER, MARSHALLER) + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("gg.grounds.player.Test/Call") + .build() + } +} diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 403cf89..9a284cd 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -1,7 +1,20 @@ plugins { id("gg.grounds.velocity-conventions") } +repositories { + maven { + url = uri("https://maven.pkg.github.com/groundsgg/*") + credentials { + username = providers.gradleProperty("github.user").get() + password = providers.gradleProperty("github.token").get() + } + } +} + dependencies { implementation(project(":common")) + // plugin-proxy owns the ProxyServiceRegistry at runtime — compileOnly, never shaded, or the + // registry this plugin writes into would be a different class from the one chat/social read. + compileOnly("gg.grounds:plugin-proxy-api:0.1.0") implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.4") implementation("tools.jackson.module:jackson-module-kotlin:3.0.4") implementation("io.grpc:grpc-netty-shaded:1.78.0") diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index c56e123..b277c8c 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -4,6 +4,7 @@ import com.google.inject.Inject import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.proxy.ProxyInitializeEvent import com.velocitypowered.api.event.proxy.ProxyShutdownEvent +import com.velocitypowered.api.plugin.Dependency import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer @@ -11,6 +12,9 @@ import gg.grounds.config.MessagesConfigLoader import gg.grounds.listener.PlayerConnectionListener import gg.grounds.presence.PlayerHeartbeatScheduler import gg.grounds.presence.PlayerPresenceService +import gg.grounds.presence.PlayerSessionQueryImpl +import gg.grounds.proxy.api.PlayerSessionQuery +import gg.grounds.proxy.api.ProxyServiceRegistry import io.grpc.LoadBalancerRegistry import io.grpc.NameResolverRegistry import io.grpc.internal.DnsNameResolverProvider @@ -25,6 +29,8 @@ import org.slf4j.Logger description = "A plugin which manages player related actions and data transactions", authors = ["Grounds Development Team and contributors"], url = "https://github.com/groundsgg/plugin-player", + // plugin-proxy owns the ProxyServiceRegistry this plugin publishes its session lookup into. + dependencies = [Dependency(id = "plugin-proxy")], ) class GroundsPluginPlayer @Inject @@ -58,12 +64,21 @@ constructor( ), ) + // Publish the network-wide player lookup. plugin-proxy's ProxyService falls back to this + // for + // anyone who is not on this proxy — it is what lets chat and social reach across proxies. + ProxyServiceRegistry.register( + PlayerSessionQuery::class.java, + PlayerSessionQueryImpl(playerPresenceService), + ) + heartbeatScheduler.start() logger.info("Configured player presence gRPC client (target={})", target) } @Subscribe fun onShutdown(event: ProxyShutdownEvent) { + ProxyServiceRegistry.unregister(PlayerSessionQuery::class.java) heartbeatScheduler.stop() playerPresenceService.close() } diff --git a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt index be56f94..ae7ef70 100644 --- a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt @@ -4,6 +4,7 @@ import com.velocitypowered.api.event.EventTask import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.connection.DisconnectEvent import com.velocitypowered.api.event.connection.PreLoginEvent +import com.velocitypowered.api.event.player.ServerConnectedEvent import com.velocitypowered.api.util.UuidUtils import gg.grounds.config.MessagesConfig import gg.grounds.grpc.player.LoginStatus @@ -25,7 +26,9 @@ class PlayerConnectionListener( val playerId = event.uniqueId ?: UuidUtils.generateOfflinePlayerUuid(name) return EventTask.async { - when (val result = playerPresenceService.tryLogin(playerId)) { + // The name is what makes the session findable from another proxy — without it a player + // exists in presence but nobody can /msg them. PROXY_ID says which proxy holds them. + when (val result = playerPresenceService.tryLogin(playerId, name, proxyId())) { is PlayerLoginResult.Success -> { if (handleSuccess(event, name, playerId, result.reply)) { return@async @@ -117,4 +120,17 @@ class PlayerConnectionListener( private fun deny(event: PreLoginEvent, message: String) { event.result = PreLoginEvent.PreLoginComponentResult.denied(Component.text(message)) } + + /** + * Keep the session pointing at the backend the player is actually on — a party warp wants to + * send someone to the leader's server, and that only works if we know where people are. + */ + @Subscribe + fun onServerConnected(event: ServerConnectedEvent): EventTask { + val playerId = event.player.uniqueId + val serverName = event.server.serverInfo.name + return EventTask.async { playerPresenceService.updateServer(playerId, serverName) } + } + + private fun proxyId(): String = System.getenv("PROXY_ID") ?: "" } diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index 2ccd3c1..543f96d 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -1,6 +1,7 @@ package gg.grounds.presence import gg.grounds.grpc.player.PlayerLogoutReply +import gg.grounds.grpc.player.PlayerSessionInfo import gg.grounds.player.presence.GrpcPlayerPresenceClient import gg.grounds.player.presence.PlayerLoginResult import java.util.UUID @@ -20,14 +21,50 @@ class PlayerPresenceService : AutoCloseable { client = GrpcPlayerPresenceClient.create(target) } - fun tryLogin(playerId: UUID): PlayerLoginResult { + fun tryLogin(playerId: UUID, playerName: String, proxyId: String): PlayerLoginResult { return try { - client.tryLogin(playerId) + client.tryLogin(playerId, playerName, proxyId) } catch (e: RuntimeException) { PlayerLoginResult.Error(e.message ?: e::class.java.name) } } + /** + * Cross-proxy lookups. Never throw: a failure means "unknown", and the caller falls back to + * local. + */ + fun getSession(playerId: UUID): PlayerSessionInfo? { + return try { + client.getSession(playerId) + } catch (e: RuntimeException) { + null + } + } + + fun resolveName(playerName: String): PlayerSessionInfo? { + return try { + client.resolveName(playerName) + } catch (e: RuntimeException) { + null + } + } + + fun suggestNames(prefix: String, limit: Int): List { + return try { + client.suggestNames(prefix, limit) + } catch (e: RuntimeException) { + emptyList() + } + } + + fun updateServer(playerId: UUID, serverName: String): Boolean { + return try { + client.updateServer(playerId, serverName) + } catch (e: RuntimeException) { + false + } + } + fun logout(playerId: UUID): PlayerLogoutReply? { return try { client.logout(playerId) diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt new file mode 100644 index 0000000..9dd3483 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerSessionQueryImpl.kt @@ -0,0 +1,41 @@ +package gg.grounds.presence + +import gg.grounds.proxy.api.PlayerSessionInfo +import gg.grounds.proxy.api.PlayerSessionQuery +import java.util.UUID + +/** + * Answers "who is this player, and are they online" for the whole network, from service-player. + * + * plugin-proxy's ProxyService resolves locally first and falls back to whatever is registered here; + * without this, a proxy could only ever see its own players, which is why /msg and party invites + * did not cross proxies. + */ +class PlayerSessionQueryImpl(private val presenceService: PlayerPresenceService) : + PlayerSessionQuery { + + override fun getSession(playerId: UUID): PlayerSessionInfo? = + presenceService.getSession(playerId)?.let(::toInfo) + + override fun resolveByName(name: String): PlayerSessionInfo? = + presenceService.resolveName(name)?.let(::toInfo) + + override fun suggestNames(prefix: String, limit: Int): List = + presenceService.suggestNames(prefix, limit) + + /** + * A session with no usable id or name tells the caller nothing — drop it rather than + * half-answer. + */ + private fun toInfo(session: gg.grounds.grpc.player.PlayerSessionInfo): PlayerSessionInfo? { + val playerId = runCatching { UUID.fromString(session.playerId) }.getOrNull() ?: return null + val name = session.playerName.takeIf { it.isNotEmpty() } ?: return null + return PlayerSessionInfo( + playerId = playerId, + name = name, + proxyId = session.proxyId.takeIf { it.isNotEmpty() }, + server = session.serverName.takeIf { it.isNotEmpty() }, + connectedAt = session.connectedAtMillis, + ) + } +}