Skip to content

Commit e030f4a

Browse files
Close the gap for duplicate login casing (#892)
* Close the gap for duplicate login casing * Undo unrelated formatting change * Refactor kick before registering into separate method * Rewrite registerConnection & friends * Extract PlayerRegistry with per-identity lock across login and disconnect phase * Apply various fixes after PR review --------- Co-authored-by: Wouter Gritter <wouter@gritter.nl>
1 parent 7ebcaa4 commit e030f4a

5 files changed

Lines changed: 646 additions & 167 deletions

File tree

proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java

Lines changed: 22 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
import com.velocitypowered.proxy.config.ProxyAddress;
7676
import com.velocitypowered.proxy.config.VelocityConfiguration;
7777
import com.velocitypowered.proxy.connection.client.ConnectedPlayer;
78+
import com.velocitypowered.proxy.connection.client.PlayerRegistry;
7879
import com.velocitypowered.proxy.connection.player.resourcepack.VelocityResourcePackInfo;
7980
import com.velocitypowered.proxy.connection.util.ServerListPingHandler;
8081
import com.velocitypowered.proxy.console.VelocityConsole;
@@ -112,14 +113,12 @@
112113
import java.util.Collections;
113114
import java.util.HashSet;
114115
import java.util.List;
115-
import java.util.Locale;
116116
import java.util.Map;
117117
import java.util.Objects;
118118
import java.util.Optional;
119119
import java.util.Set;
120120
import java.util.UUID;
121121
import java.util.concurrent.CompletableFuture;
122-
import java.util.concurrent.ConcurrentHashMap;
123122
import java.util.concurrent.CountDownLatch;
124123
import java.util.concurrent.ExecutionException;
125124
import java.util.concurrent.TimeUnit;
@@ -204,9 +203,7 @@ public class VelocityServer implements ProxyServer, ForwardingAudience {
204203

205204
private final VelocityPluginManager pluginManager;
206205

207-
private final Map<UUID, ConnectedPlayer> connectionsByUuid = new ConcurrentHashMap<>();
208-
209-
private final Map<String, ConnectedPlayer> connectionsByName = new ConcurrentHashMap<>();
206+
private final PlayerRegistry playerRegistry = new PlayerRegistry(this);
210207

211208
/**
212209
* Holds a set of all registered BuiltinCommand instances. Used for unregistering these commands later.
@@ -882,8 +879,10 @@ public void shutdown(boolean explicitExit, Component reason) {
882879
LOGGER.warn("Interrupted while waiting for ProxyPreShutdownEvent; continuing shutdown.");
883880
}
884881

885-
ImmutableList<@NotNull ConnectedPlayer> players = ImmutableList.copyOf(connectionsByUuid.values());
886-
ImmutableList<@NotNull UUID> playerUuids = ImmutableList.copyOf(connectionsByUuid.keySet());
882+
ImmutableList<@NotNull ConnectedPlayer> players = ImmutableList.copyOf(playerRegistry.getPlayers());
883+
ImmutableList<@NotNull UUID> playerUuids = players.stream()
884+
.map(ConnectedPlayer::getUniqueId)
885+
.collect(ImmutableList.toImmutableList());
887886

888887
if (!getConfiguration().isAcceptTransfers()) {
889888
for (ConnectedPlayer player : players) {
@@ -954,6 +953,8 @@ public void shutdown(boolean explicitExit, Component reason) {
954953
this.redis.shutdown();
955954
}
956955

956+
this.playerRegistry.shutdown();
957+
957958
// Since we manually removed the shutdown hook, we need to handle the shutdown ourselves.
958959
LogManager.shutdown();
959960

@@ -1057,112 +1058,32 @@ public ConnectionManager getConnectionManager() {
10571058
return tabCompleteRateLimiter;
10581059
}
10591060

1060-
/**
1061-
* Checks if a connection identified by the given profile can be registered with the proxy.
1062-
* Called before the {@link ConnectedPlayer} instance is constructed so that rejected
1063-
* connections never create a phantom player object.
1064-
*
1065-
* @param profile the resolved game profile of the incoming connection
1066-
* @return {@code true} if we can register the connection, {@code false} if not
1067-
*/
1068-
public boolean canRegisterConnection(GameProfile profile) {
1069-
// When kick-existing-players is enabled, skip duplicate checks here.
1070-
// registerConnection() handles kicking the existing player and enforcing IP rules.
1071-
if (configuration.isKickExistingPlayers()) {
1072-
return true;
1073-
}
1074-
1075-
String lowerName = profile.getName().toLowerCase(Locale.US);
1076-
1077-
if (connectionsByName.containsKey(lowerName)) {
1078-
return false;
1079-
}
1080-
1081-
return !connectionsByUuid.containsKey(profile.getId());
1061+
public PlayerRegistry getPlayerRegistry() {
1062+
return playerRegistry;
10821063
}
10831064

10841065
/**
1085-
* Attempts to register the {@code connection} with the proxy.
1066+
* Attempts to register the {@code connection} with the proxy, kicking any existing
1067+
* player under the same name or UUID if present and if
1068+
* {@link VelocityConfiguration#isKickExistingPlayers()} is {@code true}.
10861069
*
10871070
* @param connection the connection to register
1088-
* @return {@code true} if we registered the connection, {@code false} if not
1071+
* @return a future resolving to {@code true} if we registered the connection, {@code false} if not
10891072
*/
1090-
public boolean registerConnection(ConnectedPlayer connection) {
1091-
String lowerName = connection.getUsername().toLowerCase(Locale.US);
1092-
1093-
if (!this.configuration.isKickExistingPlayers()) {
1094-
// Standard behavior: block duplicate connections
1095-
if (connectionsByName.containsKey(lowerName)) {
1096-
return false;
1097-
}
1098-
1099-
connectionsByName.put(lowerName, connection);
1100-
1101-
if (connectionsByUuid.putIfAbsent(connection.getUniqueId(), connection) != null) {
1102-
connectionsByName.remove(lowerName, connection);
1103-
return false;
1104-
}
1105-
1106-
return true;
1107-
}
1108-
1109-
// kick-existing-players is enabled. Kick the existing session so the new one can take over.
1110-
// When kick-existing-players-check-ip is also enabled, only kick if the new connection comes
1111-
// from the same IP address.
1112-
ConnectedPlayer existingByUuid = connectionsByUuid.get(connection.getUniqueId());
1113-
if (existingByUuid != null) {
1114-
if (this.configuration.isKickExistingPlayersCheckIp()) {
1115-
InetAddress newIp = connection.getRemoteAddress().getAddress();
1116-
InetAddress existingIp = existingByUuid.getRemoteAddress().getAddress();
1117-
if (!newIp.equals(existingIp)) {
1118-
// Different IP with same UUID: protect the existing player, deny the new connection.
1119-
return false;
1120-
}
1121-
}
1122-
existingByUuid.disconnect(Component.translatable("multiplayer.disconnect.duplicate_login"));
1123-
}
1124-
1125-
// Also check for a username conflict whose UUID differs from the one above.
1126-
ConnectedPlayer existingByName = connectionsByName.get(lowerName);
1127-
if (existingByName != null && existingByName != existingByUuid) {
1128-
if (this.configuration.isKickExistingPlayersCheckIp()) {
1129-
InetAddress newIp = connection.getRemoteAddress().getAddress();
1130-
InetAddress existingIp = existingByName.getRemoteAddress().getAddress();
1131-
if (!newIp.equals(existingIp)) {
1132-
return false;
1133-
}
1134-
}
1135-
existingByName.disconnect(Component.translatable("multiplayer.disconnect.duplicate_login"));
1136-
}
1137-
1138-
connectionsByName.put(lowerName, connection);
1139-
connectionsByUuid.put(connection.getUniqueId(), connection);
1140-
1141-
return true;
1142-
}
1143-
1144-
/**
1145-
* Unregisters the given player from the proxy.
1146-
*
1147-
* @param connection the connection to unregister
1148-
*/
1149-
public void unregisterConnection(ConnectedPlayer connection) {
1150-
connectionsByName.remove(connection.getUsername().toLowerCase(Locale.US), connection);
1151-
connectionsByUuid.remove(connection.getUniqueId(), connection);
1152-
connection.disconnected();
1073+
public CompletableFuture<Boolean> registerConnection(ConnectedPlayer connection) {
1074+
return playerRegistry.registerConnection(connection);
11531075
}
11541076

11551077
@Override
11561078
public Optional<ConnectedPlayer> getPlayer(String username) {
11571079
Preconditions.checkNotNull(username, "username");
1158-
1159-
return Optional.ofNullable(connectionsByName.get(username.toLowerCase(Locale.US)));
1080+
return playerRegistry.getPlayer(username);
11601081
}
11611082

11621083
@Override
11631084
public Optional<ConnectedPlayer> getPlayer(UUID uuid) {
11641085
Preconditions.checkNotNull(uuid, "uuid");
1165-
return Optional.ofNullable(connectionsByUuid.get(uuid));
1086+
return playerRegistry.getPlayer(uuid);
11661087
}
11671088

11681089
@Override
@@ -1185,12 +1106,12 @@ public Collection<VelocityRegisteredServer> matchServer(String partialName) {
11851106

11861107
@Override
11871108
public Collection<ConnectedPlayer> getAllPlayers() {
1188-
return ImmutableList.copyOf(connectionsByUuid.values());
1109+
return ImmutableList.copyOf(playerRegistry.getPlayers());
11891110
}
11901111

11911112
@Override
11921113
public @UnmodifiableView Collection<ConnectedPlayer> getOnlinePlayers() {
1193-
return Collections.unmodifiableCollection(connectionsByUuid.values());
1114+
return playerRegistry.getPlayers();
11941115
}
11951116

11961117
/**
@@ -1202,7 +1123,7 @@ public Collection<ConnectedPlayer> getAllPlayers() {
12021123
*/
12031124
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
12041125
public boolean isPlayerOnline(ConnectedPlayer player) {
1205-
return connectionsByUuid.get(player.getUniqueId()) == player;
1126+
return playerRegistry.isPlayerOnline(player);
12061127
}
12071128

12081129
@Override
@@ -1218,7 +1139,7 @@ public int getPlayerCount() {
12181139
* @return the number of locally connected players
12191140
*/
12201141
public int getLocalPlayerCount() {
1221-
return connectionsByUuid.size();
1142+
return playerRegistry.getLocalPlayerCount();
12221143
}
12231144

12241145
@Override

proxy/src/main/java/com/velocitypowered/proxy/connection/client/AuthSessionHandler.java

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_1_8;
2121

2222
import com.google.common.base.Preconditions;
23-
import com.velocitypowered.api.event.connection.DisconnectEvent;
2423
import com.velocitypowered.api.event.connection.LoginEvent;
2524
import com.velocitypowered.api.event.connection.PostLoginEvent;
2625
import com.velocitypowered.api.event.permission.PermissionsSetupEvent;
@@ -142,46 +141,46 @@ public void activated() {
142141

143142
GameProfile resolvedProfile = profileEvent.getGameProfile();
144143

145-
if (!server.canRegisterConnection(resolvedProfile)) {
146-
inbound.disconnect(
147-
Component.translatable("velocity.error.already-connected-proxy", NamedTextColor.RED));
148-
return CompletableFuture.completedFuture(null);
149-
}
150-
151-
// Initiate a regular connection and move over to it.
144+
// Initiate a regular connection and move over to it. Duplicate-detection and IP-check
145+
// rejection happen atomically inside server.registerConnection() under the identity
146+
// lock; if rejected, the !registered branch below disconnects the player. Because
147+
// LoginEvent never fires for a rejected player, the natural teardown path does not
148+
// fire a spurious DisconnectEvent (see PlayerRegistry#fireDisconnectAndCleanup).
152149
ConnectedPlayer player = new ConnectedPlayer(server, resolvedProfile,
153150
mcConnection, inbound.getVirtualHost().orElse(null), inbound.getRawVirtualHost().orElse(null), onlineMode,
154151
inbound.getHandshakeIntent(), inbound.getIdentifiedKey());
155152
this.connectedPlayer = player;
156153

157-
if (!server.registerConnection(player)) {
158-
player.disconnect0(
159-
Component.translatable("velocity.error.already-connected-proxy", NamedTextColor.RED),
160-
true);
161-
return CompletableFuture.completedFuture(null);
162-
}
154+
return server.registerConnection(player).thenComposeAsync(registered -> {
155+
if (!registered) {
156+
player.disconnect0(
157+
Component.translatable("velocity.error.already-connected-proxy", NamedTextColor.RED),
158+
true);
159+
return CompletableFuture.completedFuture(null);
160+
}
163161

164-
if (server.getConfiguration().isLogPlayerConnections()) {
165-
LOGGER.info("{} has connected", player);
166-
}
162+
if (server.getConfiguration().isLogPlayerConnections()) {
163+
LOGGER.info("{} has connected", player);
164+
}
167165

168-
return server.getEventManager()
169-
.fire(new PermissionsSetupEvent(player, ConnectedPlayer.DEFAULT_PERMISSIONS))
170-
.thenAcceptAsync(event -> {
171-
if (!mcConnection.isClosed()) {
172-
// wait for permissions to load, then set the players permission function
173-
PermissionFunction function = event.createFunction(player);
174-
if (function == null) {
175-
LOGGER.error("A plugin permission provider {} provided an invalid permission "
176-
+ "function for player {}. This is a bug in the plugin, not in "
177-
+ "Velocity. Falling back to the default permission function.",
178-
event.getProvider().getClass().getName(), player.getUsername());
179-
} else {
180-
player.setPermissionFunction(function);
166+
return server.getEventManager()
167+
.fire(new PermissionsSetupEvent(player, ConnectedPlayer.DEFAULT_PERMISSIONS))
168+
.thenAcceptAsync(event -> {
169+
if (!mcConnection.isClosed()) {
170+
// wait for permissions to load, then set the players permission function
171+
PermissionFunction function = event.createFunction(player);
172+
if (function == null) {
173+
LOGGER.error("A plugin permission provider {} provided an invalid permission "
174+
+ "function for player {}. This is a bug in the plugin, not in "
175+
+ "Velocity. Falling back to the default permission function.",
176+
event.getProvider().getClass().getName(), player.getUsername());
177+
} else {
178+
player.setPermissionFunction(function);
179+
}
180+
startLoginCompletion(player);
181181
}
182-
startLoginCompletion(player);
183-
}
184-
}, mcConnection.eventLoop());
182+
}, mcConnection.eventLoop());
183+
}, mcConnection.eventLoop());
185184
}, mcConnection.eventLoop()).exceptionally((ex) -> {
186185
LOGGER.error("Exception during connection of {}", finalProfile, ex);
187186
return null;
@@ -256,9 +255,12 @@ public boolean handle(LoginAcknowledgedPacket packet) {
256255
loginState = State.ACKNOWLEDGED;
257256
mcConnection.setActiveSessionHandler(StateRegistry.CONFIG, new ClientConfigSessionHandler(server, connectedPlayer));
258257

259-
server.getEventManager().fire(new PostLoginEvent(connectedPlayer)).thenCompose(ignored -> connectToInitialServer(connectedPlayer))
258+
ConnectedPlayer player = connectedPlayer;
259+
server.getEventManager().fire(new PostLoginEvent(player))
260+
.whenComplete((ignored, throwable) -> server.getPlayerRegistry().finalizeLogin(player))
261+
.thenCompose(ignored -> connectToInitialServer(player))
260262
.exceptionally((ex) -> {
261-
LOGGER.error("Exception while connecting {} to initial server", connectedPlayer, ex);
263+
LOGGER.error("Exception while connecting {} to initial server", player, ex);
262264
return null;
263265
});
264266
}
@@ -288,11 +290,11 @@ public boolean handle(ServerboundCookieResponsePacket packet) {
288290
private void completeLoginProtocolPhaseAndInitialize(ConnectedPlayer player) {
289291
mcConnection.setAssociation(player);
290292

293+
player.markLoginEventFired();
291294
server.getEventManager().fire(new LoginEvent(player, serverIdHash)).thenAcceptAsync(event -> {
292295
if (mcConnection.isClosed()) {
293-
// The player was disconnected
294-
server.getEventManager().fireAndForget(new DisconnectEvent(player,
295-
DisconnectEvent.LoginStatus.CANCELLED_BY_USER_BEFORE_COMPLETE));
296+
// The player was disconnected during LoginEvent processing. The natural teardown path
297+
// is in flight (or will be) and will fire DisconnectEvent + release the identity lock.
296298
return;
297299
}
298300

@@ -316,8 +318,10 @@ private void completeLoginProtocolPhaseAndInitialize(ConnectedPlayer player) {
316318
if (inbound.getProtocolVersion().lessThan(ProtocolVersion.MINECRAFT_1_20_2)) {
317319
loginState = State.ACKNOWLEDGED;
318320
mcConnection.setActiveSessionHandler(StateRegistry.PLAY, new InitialConnectSessionHandler(player, server));
319-
server.getEventManager().fire(new PostLoginEvent(player)).thenCompose((ignored) ->
320-
connectToInitialServer(player)).exceptionally((ex) -> {
321+
server.getEventManager().fire(new PostLoginEvent(player))
322+
.whenComplete((ignored, throwable) -> server.getPlayerRegistry().finalizeLogin(player))
323+
.thenCompose((ignored) -> connectToInitialServer(player))
324+
.exceptionally((ex) -> {
321325
LOGGER.error("Exception while connecting {} to initial server", player, ex);
322326
return null;
323327
});

0 commit comments

Comments
 (0)