From 8080f70063c1b1cac748c318c0a697647f4fa19c Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 28 Jul 2026 03:44:46 +0200 Subject: [PATCH] feat(api): publish stable connect-player channel attribute Connect authenticates players at its edge and hands the proxy an already-verified, offline-mode connection. Login/auth plugins that run later and force ONLINE mode on that connection hang the login, because no second Mojang session exists. Publish a stable marker any login plugin can read to detect a Connect-tunneled player and skip its own flow, mirroring Floodgate's 'floodgate-player' convention: - new ConnectAttributes.CONNECT_PLAYER, wire name exactly 'connect-player', valued with the ConnectPlayer so integrators get identity and skin properties while a plain presence check needs no dependency on Connect - set in LocalServerChannelWrapper#newLocalChannel, the earliest point at which both the proxy-facing channel and the Connect player exist, so it is readable before any Velocity/Bungee/Spigot login event fires; only for non-passthrough sessions, since passthrough is not authenticated by Connect and must still go through the server's login flow - ConnectPlayerAttributeBoundaryTest pins both the name and the set-site - docs/login-plugin-integration.md publishes the contract, the ConnectApi UUID lookups, and the permanent-stability commitment Adds a readable marker only; no change to Connect's PreLogin or GameProfile behavior. --- AGENTS.md | 11 ++ README.md | 10 ++ .../connect/api/ConnectAttributes.java | 87 ++++++++++ .../netty/LocalServerChannelWrapper.java | 27 +++- .../ConnectPlayerAttributeBoundaryTest.java | 132 +++++++++++++++ docs/login-plugin-integration.md | 152 ++++++++++++++++++ 6 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 api/src/main/java/com/minekube/connect/api/ConnectAttributes.java create mode 100644 core/src/test/java/com/minekube/connect/network/netty/ConnectPlayerAttributeBoundaryTest.java create mode 100644 docs/login-plugin-integration.md diff --git a/AGENTS.md b/AGENTS.md index 1c1273fe7..2d18afb95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,17 @@ curl -I -L --fail https://github.com/minekube/connect-java/releases/download/Stable public contract. The attribute names in this class are a permanent part + * of Connect's public integration surface. External plugins (login/auth plugins in particular) + * identify Connect-tunneled connections by these names, usually without compiling against Connect + * at all. They will therefore never be removed or renamed. Treat them exactly like a published + * method signature: additive changes only. + */ +public final class ConnectAttributes { + /** + * Marks a proxy-facing connection as already authenticated by Connect at the edge. + * + *

The wire-visible attribute name is exactly {@code "connect-player"}, deliberately + * mirroring Floodgate's {@code "floodgate-player"} convention so plugin authors who already + * exempt Floodgate players can apply the identical pattern. + * + *

When it is set. Connect sets this attribute when it creates the proxy-facing + * channel, before any platform login event fires, so it is readable from Velocity's + * {@code PreLoginEvent}/{@code GameProfileRequestEvent}, BungeeCord's {@code PreLoginEvent}, + * and Spigot's login handling alike. It is set only for sessions Connect itself + * authenticated, i.e. when {@link com.minekube.connect.api.player.Auth#isPassthrough()} is + * {@code false}. Passthrough sessions are not authenticated by Connect and must still go + * through the server's own login flow, so the attribute is absent for them. + * + *

How to integrate. A plugin that forces online mode, rewrites the game profile, or + * gates the player behind its own login flow should skip all of that when this attribute is + * present: + * + *

{@code
+     * private static final AttributeKey CONNECT_PLAYER =
+     *         AttributeKey.valueOf("connect-player");
+     *
+     * if (channel.hasAttr(CONNECT_PLAYER) && channel.attr(CONNECT_PLAYER).get() != null) {
+     *     return; // externally authenticated by Connect - do not force online mode
+     * }
+     * }
+     *
+     * 

Netty keys are interned by name, so the snippet above works without any compile-time + * dependency on Connect: presence alone is the signal. Plugins that do depend on Connect's API + * jar can use this constant directly and read the {@link ConnectPlayer} value for the player's + * real Mojang UUID, username and game profile. That is why the value is a {@code ConnectPlayer} + * rather than a bare {@code UUID} - it carries everything an integrator has been observed to + * need (identity, skin properties, {@link com.minekube.connect.api.player.Auth}), while callers + * who only need the boolean "is this Connect?" answer pay nothing for it. + * + *

Once the player is online, {@link ConnectApi#isConnectPlayer(java.util.UUID)} and + * {@link ConnectApi#getPlayer(java.util.UUID)} provide the same information keyed by UUID. + * This attribute exists because those lookups are only available after login completes, which + * is too late to avoid conflicting with Connect during login. + */ + public static final AttributeKey CONNECT_PLAYER = + AttributeKey.valueOf("connect-player"); + + private ConnectAttributes() { + } +} diff --git a/core/src/main/java/com/minekube/connect/network/netty/LocalServerChannelWrapper.java b/core/src/main/java/com/minekube/connect/network/netty/LocalServerChannelWrapper.java index 0d2a63918..928ed589f 100644 --- a/core/src/main/java/com/minekube/connect/network/netty/LocalServerChannelWrapper.java +++ b/core/src/main/java/com/minekube/connect/network/netty/LocalServerChannelWrapper.java @@ -25,6 +25,9 @@ package com.minekube.connect.network.netty; +import com.minekube.connect.api.ConnectAttributes; +import com.minekube.connect.api.player.ConnectPlayer; +import com.minekube.connect.network.netty.LocalSession.Context; import io.netty.channel.local.LocalChannel; import io.netty.channel.local.LocalServerChannel; @@ -40,9 +43,31 @@ protected LocalChannel newLocalChannel(LocalChannel peer) { // and access related session data from the channel if (peer instanceof LocalChannelWithSessionContext) { LocalChannelWrapper channel = new LocalChannelWrapper(this, peer); - channel.wrapper().setContext(((LocalChannelWithSessionContext) peer).getContext()); + Context context = ((LocalChannelWithSessionContext) peer).getContext(); + channel.wrapper().setContext(context); + markExternallyAuthenticated(channel, context); return channel; } return super.newLocalChannel(peer); } + + /** + * Publishes the {@link ConnectAttributes#CONNECT_PLAYER} marker on the proxy-facing channel. + * + *

This is the earliest point at which both the channel and the Connect player identity + * exist, so the marker is readable by every platform's login handling (Velocity, BungeeCord, + * Spigot) before any of their events fire. Passthrough sessions are not authenticated by + * Connect and are deliberately left unmarked so they still go through the server's own login + * flow. See {@link ConnectAttributes#CONNECT_PLAYER} for the public contract. + */ + private static void markExternallyAuthenticated(LocalChannelWrapper channel, Context context) { + if (context == null) { + return; + } + ConnectPlayer player = context.getPlayer(); + if (player == null || player.getAuth() == null || player.getAuth().isPassthrough()) { + return; + } + channel.attr(ConnectAttributes.CONNECT_PLAYER).set(player); + } } diff --git a/core/src/test/java/com/minekube/connect/network/netty/ConnectPlayerAttributeBoundaryTest.java b/core/src/test/java/com/minekube/connect/network/netty/ConnectPlayerAttributeBoundaryTest.java new file mode 100644 index 000000000..17c7e926b --- /dev/null +++ b/core/src/test/java/com/minekube/connect/network/netty/ConnectPlayerAttributeBoundaryTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2021-2022 Minekube. https://minekube.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author Minekube + * @link https://github.com/minekube/connect-java + */ + +package com.minekube.connect.network.netty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.minekube.connect.api.ConnectAttributes; +import com.minekube.connect.api.player.Auth; +import com.minekube.connect.api.player.ConnectPlayer; +import com.minekube.connect.network.netty.LocalSession.Context; +import com.minekube.connect.watch.SessionProposal; +import io.netty.channel.local.LocalChannel; +import io.netty.util.AttributeKey; +import java.lang.reflect.Constructor; +import java.net.InetSocketAddress; +import minekube.connect.v1alpha1.WatchServiceOuterClass.SessionProtocol; +import org.junit.jupiter.api.Test; + +/** + * Boundary test for the public {@code connect-player} channel attribute. + * + *

The attribute name and the place it is set are a permanent public contract that third-party + * login plugins depend on (see {@link ConnectAttributes#CONNECT_PLAYER}). Renaming the attribute, + * or moving the marker off the proxy-facing channel creation path, silently breaks every external + * integration - so both are pinned here. + */ +class ConnectPlayerAttributeBoundaryTest { + + /** + * The literal a third-party plugin writes. Deliberately duplicated instead of referencing the + * constant: a rename of the constant's value must fail this test. + */ + private static final String WIRE_NAME = "connect-player"; + + @Test + void attributeNameIsExactlyConnectPlayer() { + assertEquals(WIRE_NAME, ConnectAttributes.CONNECT_PLAYER.name(), + "connect-player is a permanent public contract and must not be renamed"); + } + + @Test + void markerIsSetOnProxyFacingChannelForAuthenticatedSession() { + ConnectPlayer player = connectPlayer(false); + LocalChannel channel = newProxyFacingChannel(player); + + // Read it back exactly the way an external plugin would: by name only, with no + // compile-time dependency on Connect. + AttributeKey externalKey = AttributeKey.valueOf(WIRE_NAME); + assertNotNull(channel.attr(externalKey).get(), + "externally authenticated Connect sessions must be marked at channel creation"); + assertSame(player, channel.attr(ConnectAttributes.CONNECT_PLAYER).get()); + } + + @Test + void markerIsAbsentForPassthroughSession() { + LocalChannel channel = newProxyFacingChannel(connectPlayer(true)); + + assertNull(channel.attr(AttributeKey.valueOf(WIRE_NAME)).get(), + "passthrough sessions are not authenticated by Connect and must still go " + + "through the server's own login flow"); + } + + /** + * Drives the real production path: {@link LocalServerChannelWrapper#newLocalChannel} is what + * creates the channel the proxy's login handling sees. + */ + private static LocalChannel newProxyFacingChannel(ConnectPlayer player) { + LocalChannelWithSessionContext peer = new LocalChannelWithSessionContext(); + peer.setContext(context(player)); + return new LocalServerChannelWrapper().newLocalChannel(peer); + } + + private static ConnectPlayer connectPlayer(boolean passthrough) { + ConnectPlayer player = mock(ConnectPlayer.class); + when(player.getAuth()).thenReturn(new Auth(passthrough)); + return player; + } + + private static Context context(ConnectPlayer player) { + try { + Constructor constructor = Context.class.getDeclaredConstructor( + ConnectPlayer.class, + InetSocketAddress.class, + SessionProposal.class, + String.class, + String.class, + SessionProtocol.class, + com.minekube.connect.bedrock.BedrockAdmissionCoordinator.AdmissionToken.class); + constructor.setAccessible(true); + return constructor.newInstance( + player, + new InetSocketAddress("127.0.0.1", 0), + mock(SessionProposal.class), + "endpoint", + "org", + SessionProtocol.SESSION_PROTOCOL_JAVA, + null); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "LocalSession.Context shape changed; update the connect-player boundary test", + e); + } + } +} diff --git a/docs/login-plugin-integration.md b/docs/login-plugin-integration.md new file mode 100644 index 000000000..373aabf95 --- /dev/null +++ b/docs/login-plugin-integration.md @@ -0,0 +1,152 @@ +# Login / auth plugin integration + +**Audience:** authors of login, auth, or premium-detection plugins (LibreLogin, AuthMe, +nLogin, and anything similar) that run on a proxy or server which also runs Minekube +Connect. + +**Summary:** Connect authenticates players at its edge and hands your proxy an +already-verified, offline-mode connection. Connect publishes a stable marker on that +connection — the `connect-player` Netty channel attribute — so your plugin can detect a +Connect-tunneled player and skip its own login flow. This is exactly the pattern many +plugins already implement for Floodgate's `floodgate-player` attribute. + +## Why the marker exists + +Connect terminates the real client connection at the Minekube edge, performs the Mojang +online-mode handshake there, and then relays the player's traffic into a local channel +inside your proxy. On that inner connection, Connect marks the player **offline mode** +and supplies the real Mojang UUID, username, and skin properties itself. + +There is no second Mojang session for the proxy to verify. So any plugin that later forces +**online mode** on a Connect-tunneled connection makes the proxy send an +`EncryptionRequest` that can never be answered — the player's login hangs and never +completes. The same applies to rewriting the game profile after Connect has set it: the +player ends up with the wrong UUID and no skin. + +The `connect-player` attribute lets you detect that situation before you act. + +## The contract + +### `connect-player` — channel attribute + +| | | +|---|---| +| Attribute name | `connect-player` (exact, permanent) | +| Netty key | `io.netty.util.AttributeKey.valueOf("connect-player")` | +| Constant | `com.minekube.connect.api.ConnectAttributes.CONNECT_PLAYER` | +| Value type | `com.minekube.connect.api.player.ConnectPlayer` (never `null` when the attribute is set) | +| Set on | The proxy-facing channel, at channel creation, before any platform login event fires | +| Platforms | Velocity, BungeeCord, Spigot — one set-site covers all three | +| Set when | The session is authenticated by Connect, i.e. `Auth#isPassthrough()` is `false` | +| Absent when | The connection is not Connect-tunneled, **or** it is a passthrough session | + +Passthrough sessions are deliberately left unmarked: Connect did not authenticate them, so +they should still go through your plugin's normal login flow. + +Because the marker is set when the channel is created, it is already present in Velocity's +`PreLoginEvent` and `GameProfileRequestEvent`, in BungeeCord's `PreLoginEvent`, and in +Spigot's login handling — including handlers registered at the very earliest priority. + +### `ConnectApi` — UUID lookups for online players + +Once a player has finished logging in, `com.minekube.connect.api.ConnectApi` answers the +same question by UUID: + +```java +ConnectApi api = ConnectApi.getInstance(); +boolean tunneled = api.isConnectPlayer(uuid); // is this online player tunneled by Connect? +ConnectPlayer player = api.getPlayer(uuid); // null if not +``` + +These are the right tool for post-login checks (session tracking, limbo routing, command +gating). They are **not** usable during login, because the player is not registered yet — +that is what the channel attribute is for. + +## How to integrate + +If your plugin already exempts Floodgate players, the Connect exemption is the identical +shape. Add it at every point where you would otherwise override the connection's +authentication decision or identity. + +**Presence check, with no dependency on Connect** — Netty interns attribute keys by name, +so this compiles and works whether or not Connect is installed: + +```java +private static final AttributeKey CONNECT_PLAYER = + AttributeKey.valueOf("connect-player"); + +private static boolean isConnectPlayer(Channel channel) { + return channel.hasAttr(CONNECT_PLAYER) && channel.attr(CONNECT_PLAYER).get() != null; +} +``` + +**Velocity — pre-login.** Reach the channel the same way you already do for Floodgate +(`LoginInboundConnection#delegate` → `InitialInboundConnection` → `MinecraftConnection` → +`Channel`), then bail out: + +```java +@Subscribe(order = PostOrder.LAST) +public void onPreLogin(PreLoginEvent event) { + Channel channel = channelOf(event.getConnection()); + if (channel != null && isConnectPlayer(channel)) { + return; // externally authenticated by Connect - never force online mode + } + ... +} +``` + +**Velocity — game profile.** Do not rebuild the profile from the original one; Connect has +already put the player's real UUID and skin properties in it. + +**BungeeCord — pre-login.** Same check before any `setOnlineMode(true)`. + +**Post-login / session tracking.** Use `ConnectApi.isConnectPlayer(player.getUniqueId())` +to skip your own authorization tracking, limbo routing, and command blocking, the same way +you skip them for Floodgate players. + +**Optional: read the player.** If you add Connect's `api` artifact as a dependency you can +use `ConnectAttributes.CONNECT_PLAYER` directly and read the `ConnectPlayer` value for the +player's UUID, username, game profile, and `Auth`. That is why the attribute value is a +`ConnectPlayer` rather than a bare UUID — presence alone costs you nothing, and the full +identity is there when you want it. + +## Stability commitment + +**`connect-player` is a permanent public contract.** + +Minekube commits to this explicitly: once external plugins depend on the name +`connect-player`, it will **never be removed or renamed**. The attribute name, the fact +that it is set before any login event, and the rule that it is absent for passthrough +sessions are all part of Connect's published API surface — treat them exactly like a +published method signature. Changes may only be additive. + +This is a deliberate commitment, not an accident of the implementation. A future +contributor must not treat `connect-player` as an internal detail that is free to be +refactored, renamed, or moved to a later point in the connection lifecycle. Doing so +silently breaks every third-party login plugin that integrates with Connect, with no +compile error anywhere to warn about it. + +The commitment is enforced in code by +`core/src/test/java/com/minekube/connect/network/netty/ConnectPlayerAttributeBoundaryTest.java`, +which pins both the exact name and the set-site; a rename or a moved marker fails the +build. + +## Checking a login plugin for compatibility + +- **Conflicts with Connect** if it can force online mode at pre-login + (`forceOnlineMode()` / `setOnlineMode(true)`), or if it rewrites the game profile after + Connect has set it. +- **Compatible by design** if it only ever acts on offline-mode connections and never + forces online mode. +- **If it has a Floodgate exemption**, ask its author to extend that exemption to + Connect's `connect-player` attribute — the code is the same three lines. + +## Related source + +- `api/src/main/java/com/minekube/connect/api/ConnectAttributes.java` — the attribute key + and its Javadoc contract +- `api/src/main/java/com/minekube/connect/api/ConnectApi.java` — `isConnectPlayer(UUID)` / + `getPlayer(UUID)` +- `api/src/main/java/com/minekube/connect/api/player/Auth.java` — `isPassthrough()` +- `core/src/main/java/com/minekube/connect/network/netty/LocalServerChannelWrapper.java` — + the single set-site