From c814314532d952953ced18197d653a8fcfd0fe65 Mon Sep 17 00:00:00 2001 From: "minekube-ai-engineer[bot]" Date: Mon, 28 Sep 2026 01:53:47 +0000 Subject: [PATCH] fix(spigot): attribute a tunneled login whose LOGIN_START a packet plugin consumed A packet-level login plugin (a PacketEvents listener, e.g. an auth plugin's premium autologin) sits upstream of connect-spigot's pipeline position and clears the frame buffer when it cancels LOGIN_START. The connector's own data handler then never sees the packet, its fake offline-login cycle - whose only invocation site is the LOGIN_START branch - never runs, and the session is left pending: the client receives nothing and the only line anywhere is the server's cause-blind "Took too long to log in". Add connect_login_stall_watchdog, installed upstream of connect_data_handler. That position is the point of the handler: connect_data_handler consumes LOGIN_START, so a watchdog installed downstream of it would miss every healthy login and report a stall per session. The watchdog arms a bounded 10 s window when the handshake is observed (well inside the server's own ~30 s login timeout, so its line is the first one the operator reads) and, if no LOGIN_START follows, emits exactly one default-verbosity warning that names the player (or ), the session and endpoint correlator, the missing login start, the probable cause and the passthrough caveat: connect-player never fires for passthrough sessions, so a connect-player exemption is not a fix on an offline-mode endpoint. Log-first by design: no packet is consumed, replaced, delayed or fabricated and no channel is closed, so healthy sessions are unaffected; a kick that names the cause changes user-visible behaviour and is deliberately left to a separate decision. Packets are identified by class name (the convention of SpigotChatSessionPacketFilter), not through the server-internals reflection in ClassNames, which keeps the handler testable without a running server. Also expose SessionProposal#getSessionId() - protobuf is an implementation dependency of core, so platform modules cannot resolve the generated types - and extract SpigotInjector.injectsAddons() so the passthrough gate that keeps the watchdog out of offline-mode endpoints is covered by a unit test. RED-first: the new tests fail on the unfixed tree for the right reason (no line at all: "expected: <1> but was: <0>"), then pass; ./gradlew --no-daemon build is green. --- .../connect/watch/SessionProposal.java | 11 + .../connect/addon/data/SpigotDataAddon.java | 25 +- .../connect/addon/data/SpigotDataHandler.java | 3 + .../addon/data/SpigotLoginStallWatchdog.java | 185 ++++++++++++++ .../connect/inject/spigot/SpigotInjector.java | 13 +- .../data/SpigotLoginStallWatchdogTest.java | 227 ++++++++++++++++++ .../inject/spigot/SpigotInjectorTest.java | 26 ++ .../handshake/ClientIntentionPacket.java | 33 +++ .../login/ServerboundHelloPacket.java | 29 +++ 9 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 spigot/src/main/java/com/minekube/connect/addon/data/SpigotLoginStallWatchdog.java create mode 100644 spigot/src/test/java/com/minekube/connect/addon/data/SpigotLoginStallWatchdogTest.java create mode 100644 spigot/src/test/java/net/minecraft/network/protocol/handshake/ClientIntentionPacket.java create mode 100644 spigot/src/test/java/net/minecraft/network/protocol/login/ServerboundHelloPacket.java diff --git a/core/src/main/java/com/minekube/connect/watch/SessionProposal.java b/core/src/main/java/com/minekube/connect/watch/SessionProposal.java index a4eafe5f3..309c43882 100644 --- a/core/src/main/java/com/minekube/connect/watch/SessionProposal.java +++ b/core/src/main/java/com/minekube/connect/watch/SessionProposal.java @@ -105,6 +105,17 @@ public boolean hasBedrockPrincipalV2() { return bedrockPrincipalV2; } + /** + * The id of the proposed session, as a plain string. + * + *

Protobuf is an {@code implementation} dependency of this module, so platform modules + * (Spigot, Velocity, ...) cannot resolve the generated types at compile time. Anything they need + * to log or correlate has to be exposed here rather than through {@link #getSession()}. + */ + public String getSessionId() { + return session == null ? "" : session.getId(); + } + @Override public String toString() { return "SessionProposal[sessionId=" + (session == null ? "" : session.getId()) diff --git a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataAddon.java b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataAddon.java index ac480ae76..97dffe7c3 100644 --- a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataAddon.java +++ b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataAddon.java @@ -52,13 +52,23 @@ public void onInject(Channel channel, boolean toServer) { LocalSession.context(channel, ctx -> { // we have to add the packet blocker in the data handler, otherwise ProtocolSupport breaks channel.pipeline().addBefore( - packetHandlerName, "connect_data_handler", + packetHandlerName, SpigotDataHandler.HANDLER_NAME, new SpigotDataHandler(ctx, packetHandlerName, config, logger, bedrockIdentityEnforcer) ); + // Only a session where Connect owns the login (i.e. not passthrough) is injected here + // at all, which is exactly the case the stall watchdog can attribute. + if (channel.pipeline().get(SpigotLoginStallWatchdog.HANDLER_NAME) == null) { + SpigotLoginStallWatchdog.install(channel.pipeline(), new SpigotLoginStallWatchdog( + logger, + playerName(ctx), + sessionId(ctx), + ctx.getEndpointId() + )); + } if (channel.pipeline().get(SpigotChatSessionPacketFilter.HANDLER_NAME) == null) { channel.pipeline().addBefore( packetHandlerName, @@ -69,6 +79,19 @@ public void onInject(Channel channel, boolean toServer) { }); } + /** + * The identity the connector logs for a session: the signed Bedrock principal is never rendered. + */ + private static String playerName(LocalSession.Context ctx) { + return ctx.getSessionProposal().hasBedrockPrincipalV2() + ? "" + : ctx.getPlayer().getUsername(); + } + + private static String sessionId(LocalSession.Context ctx) { + return ctx.getSessionProposal().getSessionId(); + } + @Override public void onChannelClosed(Channel channel) { LocalSession.context(channel, ctx -> LocalChannelInboundHandler.onChannelClosed(ctx, api, logger)); diff --git a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java index 92dc3f074..c9ef7367f 100644 --- a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java +++ b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotDataHandler.java @@ -48,6 +48,9 @@ import javax.annotation.Nullable; public final class SpigotDataHandler extends CommonDataHandler { + /** Pipeline name the connector injects this handler under. */ + static final String HANDLER_NAME = "connect_data_handler"; + private final Context sessionCtx; private final String packetHandlerName; private final ConnectLogger logger; diff --git a/spigot/src/main/java/com/minekube/connect/addon/data/SpigotLoginStallWatchdog.java b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotLoginStallWatchdog.java new file mode 100644 index 000000000..2729a9347 --- /dev/null +++ b/spigot/src/main/java/com/minekube/connect/addon/data/SpigotLoginStallWatchdog.java @@ -0,0 +1,185 @@ +/* + * 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.addon.data; + +import com.minekube.connect.api.logger.ConnectLogger; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelPipeline; +import java.time.Duration; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Attributes a tunneled (authenticated) login that never completes, on the connector side. + * + *

A packet-level login plugin (PacketEvents-style) sits upstream of this pipeline + * position and clears the frame buffer when it cancels a packet, so {@code LOGIN_START} can vanish + * before the connector's own data handler sees it. That handler's fake offline-login cycle is + * driven from a single site inside its {@code LOGIN_START} branch, so it then never runs: the + * client receives nothing and the only line anywhere is the server's own cause-blind + * {@code Took too long to log in}. This watchdog sees the same packets the connector's login path + * sees and, when a handshake was observed but no {@code LOGIN_START} follows within + * {@link #LOGIN_START_WINDOW_MILLIS}, emits exactly one default-verbosity line naming the session + * and the probable cause. + * + *

Log-first by design: this handler never consumes, replaces, delays or fabricates a packet and + * never closes a channel, so a healthy session is unaffected. A kick that names the cause would + * change user-visible behaviour and is deliberately not part of this change. + * + *

Packets are identified by class name - the convention introduced by + * {@link SpigotChatSessionPacketFilter} - instead of the server-internals reflection in + * {@link com.minekube.connect.util.ClassNames}, which keeps this handler independent of the running + * server version and unit-testable with an {@code EmbeddedChannel}. The names mirror the candidates + * {@code ClassNames} resolves for its {@code HANDSHAKE_PACKET} and {@code LOGIN_START_PACKET}; keep + * both in sync when a Minecraft version moves them again. + * + *

Only non-passthrough sessions reach this handler: for passthrough (offline-mode) endpoints + * Connect deliberately injects no addons at all, and it owns no login decision to attribute there. + */ +final class SpigotLoginStallWatchdog extends ChannelInboundHandlerAdapter { + static final String HANDLER_NAME = "connect_login_stall_watchdog"; + + /** + * Bounded window after the handshake in which {@code LOGIN_START} is expected. Chosen to land + * well before the server's own login timeout (~30 s / 600 ticks) so the cause-naming line is the + * first one the operator reads, and far above the milliseconds a healthy handshake-to-login-start + * gap takes. + */ + static final long LOGIN_START_WINDOW_MILLIS = Duration.ofSeconds(10).toMillis(); + + // Mojang-mapped names on 1.20.5+, obfuscated fallbacks before that (see ClassNames). + private static final String HANDSHAKE_PACKET = + "net.minecraft.network.protocol.handshake.ClientIntentionPacket"; + private static final String LEGACY_HANDSHAKE_PACKET = "PacketHandshakingInSetProtocol"; + private static final String LOGIN_START_PACKET = + "net.minecraft.network.protocol.login.ServerboundHelloPacket"; + private static final String LEGACY_LOGIN_START_PACKET = "PacketLoginInStart"; + + private final ConnectLogger logger; + private final String playerName; + private final String sessionId; + private final String endpointId; + + private ScheduledFuture deadline; + private boolean handshakeSeen; + private boolean loginStartSeen; + private boolean reported; + private int packetsSeen; + + SpigotLoginStallWatchdog( + ConnectLogger logger, + String playerName, + String sessionId, + String endpointId) { + this.logger = logger; + this.playerName = playerName; + this.sessionId = sessionId; + this.endpointId = endpointId; + } + + /** + * Installs the watchdog at its production position: immediately upstream of the + * connector's {@code connect_data_handler}. + * + *

The position is the point of this handler. {@link SpigotDataHandler} consumes + * {@code LOGIN_START} (it does not forward it), so a watchdog installed downstream of it would + * miss every healthy login and report a stall for each session. Anchoring on the data handler's + * name keeps the order correct regardless of the order of the surrounding pipeline edits. + */ + static void install(ChannelPipeline pipeline, SpigotLoginStallWatchdog watchdog) { + pipeline.addBefore(SpigotDataHandler.HANDLER_NAME, HANDLER_NAME, watchdog); + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + packetsSeen++; + if (isPacket(msg, HANDSHAKE_PACKET, LEGACY_HANDSHAKE_PACKET)) { + armDeadline(ctx); + } else if (isPacket(msg, LOGIN_START_PACKET, LEGACY_LOGIN_START_PACKET)) { + loginStartSeen = true; + cancelDeadline(); + } + super.channelRead(ctx, msg); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + cancelDeadline(); + super.channelInactive(ctx); + } + + @Override + public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { + cancelDeadline(); + super.handlerRemoved(ctx); + } + + private void armDeadline(ChannelHandlerContext ctx) { + handshakeSeen = true; + if (deadline != null) { + return; // one window per session + } + deadline = ctx.executor().schedule( + this::reportMissingLoginStart, LOGIN_START_WINDOW_MILLIS, TimeUnit.MILLISECONDS); + } + + private void cancelDeadline() { + if (deadline != null) { + deadline.cancel(false); + deadline = null; + } + } + + /** + * Runs on the session's event loop once the window elapsed without a {@code LOGIN_START}. + */ + private void reportMissingLoginStart() { + if (!handshakeSeen || loginStartSeen || reported) { + return; + } + reported = true; + deadline = null; + logger.warn( + "Connect tunneled login stalled: no LOGIN_START reached the connector within {} ms" + + " player={} session={} endpoint={} packets={} - probable cause: a" + + " packet-level login plugin in the pipeline consumed the login start" + + " before connect_data_handler, so Connect's offline-login cycle never ran" + + " and the client is left without a login response; note: connect-player" + + " never fires for passthrough sessions, so a connect-player exemption is" + + " not a fix on an offline-mode endpoint", + LOGIN_START_WINDOW_MILLIS, playerName, sessionId, endpointId, packetsSeen); + } + + private static boolean isPacket(Object msg, String modernName, String legacyName) { + if (msg == null) { + return false; + } + String className = msg.getClass().getName(); + return className.equals(modernName) || className.equals(legacyName) + || className.endsWith("." + legacyName); + } +} diff --git a/spigot/src/main/java/com/minekube/connect/inject/spigot/SpigotInjector.java b/spigot/src/main/java/com/minekube/connect/inject/spigot/SpigotInjector.java index 79bdaf822..e62772d90 100644 --- a/spigot/src/main/java/com/minekube/connect/inject/spigot/SpigotInjector.java +++ b/spigot/src/main/java/com/minekube/connect/inject/spigot/SpigotInjector.java @@ -169,6 +169,17 @@ public void onAdd(Object object) { return false; } + /** + * Whether Connect injects its addons for - and so owns the login of - the given session. + * + *

Passthrough (offline-mode) endpoints are deliberately left alone: Connect owns no login + * decision there, so nothing it adds (including the login-stall watchdog) can observe such a + * session, and the {@code connect-player} channel attribute is never set for it either. + */ + static boolean injectsAddons(LocalSession.Context context) { + return !context.getPlayer().getAuth().isPassthrough(); + } + public void injectClient(ChannelFuture future) { try { // fix for reloads future.channel().pipeline().remove("connect-init"); @@ -185,7 +196,7 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception @Override public void channelActive(ChannelHandlerContext childCtx) throws Exception { - if (context.getPlayer().getAuth().isPassthrough()) { + if (!injectsAddons(context)) { if (bedrockIdentityEnforcer != null && packetHandlerName != null) { addPassthroughAdmissionHandler(childCtx.channel(), context); trackPassthroughClient(childCtx.channel()); diff --git a/spigot/src/test/java/com/minekube/connect/addon/data/SpigotLoginStallWatchdogTest.java b/spigot/src/test/java/com/minekube/connect/addon/data/SpigotLoginStallWatchdogTest.java new file mode 100644 index 000000000..a56e85227 --- /dev/null +++ b/spigot/src/test/java/com/minekube/connect/addon/data/SpigotLoginStallWatchdogTest.java @@ -0,0 +1,227 @@ +package com.minekube.connect.addon.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.minekube.connect.api.logger.ConnectLogger; +import com.minekube.connect.util.MessageFormatter; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.embedded.EmbeddedChannel; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import net.minecraft.network.protocol.handshake.ClientIntentionPacket; +import net.minecraft.network.protocol.login.ServerboundHelloPacket; +import org.junit.jupiter.api.Test; + +/** + * Guard for the connector-side attribution of a tunneled login that never completes. + * + *

Measured shape of the defect (Paper 26.3 + connect-spigot 0.15.15 + a PacketEvents listener + * that cancels {@code LOGIN_START}): the handshake reaches the connector's pipeline position, + * {@code LOGIN_START} never does, so the connector's fake offline-login cycle never runs and the + * only line anywhere is the server's cause-blind {@code Took too long to log in}. These tests drive + * the real {@link SpigotLoginStallWatchdog} through an {@link EmbeddedChannel} at its production + * position - with a handler at {@code connect_data_handler} that consumes {@code LOGIN_START} + * exactly like {@link SpigotDataHandler} does - so the position, the window and the wording are all + * under test, and a healthy login cannot be reported as a stall. + */ +class SpigotLoginStallWatchdogTest { + private static final String PACKET_HANDLER = "packet_handler"; + private static final String PLAYER = "coconut"; + private static final String SESSION = "8b1f2c34-5d6e-4f70-891a-b2c3d4e5f607"; + private static final String ENDPOINT = "hce2e1535984129"; + + private final RecordingLogger logger = new RecordingLogger(); + + @Test + void watchesEveryLoginStartTheDataHandlerConsumes() { + Fixture fixture = new Fixture(logger); + List names = fixture.channel.pipeline().names(); + assertTrue( + names.indexOf(SpigotLoginStallWatchdog.HANDLER_NAME) + < names.indexOf(SpigotDataHandler.HANDLER_NAME), + "the watchdog must observe packets upstream of the data handler: " + names); + + fixture.writeHandshake(); + fixture.writeLoginStart(); + + assertEquals(2, fixture.reachedDataHandler.size()); + assertEquals(Collections.singletonList(fixture.handshake), fixture.reachedPacketHandler, + "the data handler consumes LOGIN_START, as the real one does"); + assertTrue(logger.warnings.isEmpty(), logger.warnings.toString()); + } + + @Test + void namesTheSessionWhenNoLoginStartReachesTheConnector() { + Fixture fixture = new Fixture(logger); + fixture.writeHandshake(); + + assertTrue(logger.warnings.isEmpty(), "no per-packet noise before the window elapses"); + + fixture.elapseStallWindow(); + + assertEquals(1, logger.warnings.size(), logger.warnings.toString()); + String line = logger.warnings.get(0); + assertTrue(line.contains("no LOGIN_START reached the connector within"), line); + assertTrue(line.contains(SpigotLoginStallWatchdog.LOGIN_START_WINDOW_MILLIS + " ms"), line); + assertTrue(line.contains("player=" + PLAYER), line); + assertTrue(line.contains("session=" + SESSION), line); + assertTrue(line.contains("endpoint=" + ENDPOINT), line); + assertTrue(line.contains("packets=1"), line); + assertTrue(line.contains("login plugin"), line); + assertTrue(line.contains("connect-player"), line); + assertTrue(line.contains("passthrough"), line); + } + + @Test + void namesAStalledSessionOnlyOnce() { + Fixture fixture = new Fixture(logger); + fixture.writeHandshake(); + fixture.elapseStallWindow(); + assertEquals(1, logger.warnings.size(), logger.warnings.toString()); + + // A repeated handshake re-arms the window: the session still must not be named twice. + fixture.writeHandshake(); + fixture.elapseStallWindow(); + + assertEquals(1, logger.warnings.size(), logger.warnings.toString()); + } + + @Test + void staysQuietWhenTheLoginStartArrives() { + Fixture fixture = new Fixture(logger); + fixture.writeHandshake(); + fixture.writeLoginStart(); + + fixture.elapseStallWindow(); + + assertTrue(logger.warnings.isEmpty(), logger.warnings.toString()); + } + + @Test + void staysQuietWithoutAHandshake() { + Fixture fixture = new Fixture(logger); + fixture.writeLoginStart(); + + fixture.elapseStallWindow(); + + assertTrue(logger.warnings.isEmpty(), logger.warnings.toString()); + } + + @Test + void staysQuietWhenTheSessionEndsBeforeTheWindow() { + Fixture fixture = new Fixture(logger); + fixture.writeHandshake(); + fixture.channel.close(); + + fixture.elapseStallWindow(); + + assertTrue(logger.warnings.isEmpty(), logger.warnings.toString()); + } + + /** + * The connector's pipeline tail as production builds it: a {@code packet_handler} sink, a data + * handler at {@code connect_data_handler} that consumes {@code LOGIN_START} (as the real + * {@link SpigotDataHandler} does), and the real watchdog installed through its production + * installation call, which anchors it upstream of that data handler. + */ + private static final class Fixture { + private final EmbeddedChannel channel = new EmbeddedChannel(); + private final Object handshake = new ClientIntentionPacket(777, "play.example.net", 25565); + private final List reachedDataHandler = new ArrayList<>(); + private final List reachedPacketHandler = new ArrayList<>(); + + private Fixture(ConnectLogger logger) { + channel.pipeline().addLast(PACKET_HANDLER, new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reachedPacketHandler.add(msg); + ctx.fireChannelRead(msg); + } + }); + channel.pipeline().addBefore(PACKET_HANDLER, SpigotDataHandler.HANDLER_NAME, + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) + throws Exception { + reachedDataHandler.add(msg); + if (msg instanceof ServerboundHelloPacket) { + return; + } + super.channelRead(ctx, msg); + } + }); + SpigotLoginStallWatchdog.install(channel.pipeline(), new SpigotLoginStallWatchdog( + logger, PLAYER, SESSION, ENDPOINT)); + } + + private void writeHandshake() { + channel.writeInbound(handshake); + } + + private void writeLoginStart() { + channel.writeInbound(new ServerboundHelloPacket(PLAYER, UUID.randomUUID())); + } + + private void elapseStallWindow() { + channel.advanceTimeBy( + SpigotLoginStallWatchdog.LOGIN_START_WINDOW_MILLIS + 1, TimeUnit.MILLISECONDS); + channel.runScheduledPendingTasks(); + channel.runPendingTasks(); + } + } + + /** + * Records the rendered warning lines, using the production formatter so the assertions are about + * what an operator would read. + */ + private static final class RecordingLogger implements ConnectLogger { + private final List warnings = new ArrayList<>(); + + @Override + public void warn(String message, Object... args) { + warnings.add(MessageFormatter.format(message, args)); + } + + @Override + public void error(String message, Object... args) { + } + + @Override + public void error(String message, Throwable throwable, Object... args) { + } + + @Override + public void info(String message, Object... args) { + } + + @Override + public void translatedInfo(String message, Object... args) { + } + + @Override + public void debug(String message, Object... args) { + } + + @Override + public void trace(String message, Object... args) { + } + + @Override + public void enableDebug() { + } + + @Override + public void disableDebug() { + } + + @Override + public boolean isDebug() { + return false; + } + } +} diff --git a/spigot/src/test/java/com/minekube/connect/inject/spigot/SpigotInjectorTest.java b/spigot/src/test/java/com/minekube/connect/inject/spigot/SpigotInjectorTest.java index 6d030f996..4c0c743ba 100644 --- a/spigot/src/test/java/com/minekube/connect/inject/spigot/SpigotInjectorTest.java +++ b/spigot/src/test/java/com/minekube/connect/inject/spigot/SpigotInjectorTest.java @@ -3,8 +3,13 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.minekube.connect.api.logger.ConnectLogger; +import com.minekube.connect.api.player.Auth; +import com.minekube.connect.api.player.ConnectPlayer; +import com.minekube.connect.network.netty.LocalSession; import io.netty.channel.embedded.EmbeddedChannel; import java.lang.reflect.Constructor; import org.junit.jupiter.api.Test; @@ -29,4 +34,25 @@ void passthroughChannelTrackingIsRemovedOnDisconnect() { assertFalse(injector.removeInjectedClient(channel)); } + + /** + * Connect's addons - the login-path data handler and the login-stall watchdog alike - are + * installed only for sessions whose login Connect owns. A passthrough (offline-mode) session + * never gets one, so a stall there cannot be attributed by the connector, and the + * {@code connect-player} channel attribute (never set for passthrough either) cannot be used to + * exempt such an endpoint. + */ + @Test + void passthroughSessionsAreNeverInjected() { + assertFalse(SpigotInjector.injectsAddons(session(true))); + assertTrue(SpigotInjector.injectsAddons(session(false))); + } + + private static LocalSession.Context session(boolean passthrough) { + ConnectPlayer player = mock(ConnectPlayer.class); + when(player.getAuth()).thenReturn(new Auth(passthrough)); + LocalSession.Context context = mock(LocalSession.Context.class); + when(context.getPlayer()).thenReturn(player); + return context; + } } diff --git a/spigot/src/test/java/net/minecraft/network/protocol/handshake/ClientIntentionPacket.java b/spigot/src/test/java/net/minecraft/network/protocol/handshake/ClientIntentionPacket.java new file mode 100644 index 000000000..ea67254c3 --- /dev/null +++ b/spigot/src/test/java/net/minecraft/network/protocol/handshake/ClientIntentionPacket.java @@ -0,0 +1,33 @@ +package net.minecraft.network.protocol.handshake; + +/** + * Test stub of the server-side {@code ClientIntentionPacket} (Mojang mappings; on pre-1.20.5 + * obfuscated servers the same packet is {@code PacketHandshakingInSetProtocol}). + * + *

Connect's login-stall watchdog identifies packets by class name - the convention of + * {@code SpigotChatSessionPacketFilter} - so a test can drive the real handler with this stub + * instead of the reflection-based {@code ClassNames} surface, which needs a running server. + */ +public final class ClientIntentionPacket { + private final int protocolVersion; + private final String hostName; + private final int port; + + public ClientIntentionPacket(int protocolVersion, String hostName, int port) { + this.protocolVersion = protocolVersion; + this.hostName = hostName; + this.port = port; + } + + public int protocolVersion() { + return protocolVersion; + } + + public String hostName() { + return hostName; + } + + public int port() { + return port; + } +} diff --git a/spigot/src/test/java/net/minecraft/network/protocol/login/ServerboundHelloPacket.java b/spigot/src/test/java/net/minecraft/network/protocol/login/ServerboundHelloPacket.java new file mode 100644 index 000000000..9a9634a1a --- /dev/null +++ b/spigot/src/test/java/net/minecraft/network/protocol/login/ServerboundHelloPacket.java @@ -0,0 +1,29 @@ +package net.minecraft.network.protocol.login; + +import java.util.UUID; + +/** + * Test stub of the server-side {@code ServerboundHelloPacket} (Mojang mappings; on pre-1.20.5 + * obfuscated servers the same packet is {@code PacketLoginInStart}). + * + *

Connect's login-stall watchdog identifies packets by class name - the convention of + * {@code SpigotChatSessionPacketFilter} - so a test can drive the real handler with this stub + * instead of the reflection-based {@code ClassNames} surface, which needs a running server. + */ +public final class ServerboundHelloPacket { + private final String name; + private final UUID profileId; + + public ServerboundHelloPacket(String name, UUID profileId) { + this.name = name; + this.profileId = profileId; + } + + public String name() { + return name; + } + + public UUID profileId() { + return profileId; + } +}