Compare commits

...
3 Commits
Author SHA1 Message Date
Jones bea1d292ee Release 2.0.15 (#154) 2024-01-11 18:30:20 +01:00
Jones 468a11e0be Fix Geyser profiles by reverting parts of the new injection (#153)
* fix: check for Geyser by using the port instead of channel attribute

* feat: revert some of https://github.com/jonesdevelopment/sonar/commit/a66f60f1773ab517ec786ba264313f0d0ad7ed76 to fix https://github.com/jonesdevelopment/sonar/issues/152
2024-01-11 18:21:38 +01:00
Jones cf2fce9c29 Run DB cleaning and caching in the same thread (#151) 2024-01-07 17:22:35 +01:00
11 changed files with 567 additions and 494 deletions

No files matched your search

+1 -1
View File
@@ -41,8 +41,8 @@
- [Fallback](https://github.com/jonesdevelopment/sonar#fallback) is Sonar's main anti-bot component
- Sonar queues new player logins to prevent spam login attacks
- Sonar checks the handshake packets for legitimacy
- Protective components only on BungeeCord:
- Sonar checks the handshake packets for legitimacy
- Sonar validates the login/server ping packets
- Sonar handles all server pings asynchronously
- The Varint decoder is updated to Velocity's
+1 -1
View File
@@ -1,3 +1,3 @@
group=xyz.jonesdev.sonar
version=2.0.14
version=2.0.15
description=Effective Anti-bot plugin for Velocity, BungeeCord, and Bukkit (1.7-latest)
@@ -76,16 +76,49 @@ public final class VerifiedPlayerController {
dao = DaoManager.createDao(connectionSource, VerifiedPlayer.class);
queryBuilder = dao.queryBuilder();
// Make sure to clear all outdated entries first
clearOld(Sonar.get().getConfig().getDatabase().getMaximumAge());
// Add all entries from the database to the cache
dao.queryForAll().forEach(this::_add);
// Make sure to run the clean task and the caching task in the same thread
// https://github.com/jonesdevelopment/sonar/issues/150
DB_UPDATE_SERVICE.execute(() -> {
// Make sure to clear all outdated entries first
clearOld(Sonar.get().getConfig().getDatabase().getMaximumAge());
// Add all entries from the database to the cache
try {
dao.queryForAll().forEach(this::_add);
} catch (SQLException exception) {
Sonar.get().getLogger().error("Could not cache entries: {}", exception);
}
});
} catch (Throwable throwable) {
Sonar.get().getLogger().error("Error setting up database connection: {}", throwable);
Sonar.get().getLogger().error("Error setting up database: {}", throwable);
throwable.printStackTrace(System.err);
}
}
/**
* Clear all old entries using the given timestamp.
*/
private void clearOld(final @Range(from = 1, to = 365) int maximumAge) {
try {
final long timestamp = Instant.now()
.minus(maximumAge, ChronoUnit.DAYS)
.getEpochSecond() * 1000L; // convert to ms
final List<VerifiedPlayer> oldEntries = queryBuilder.where()
.lt("timestamp", new Timestamp(timestamp))
.query();
if (oldEntries != null) {
for (final VerifiedPlayer player : oldEntries) {
dao.delete(player);
}
Sonar.get().getLogger().info("Removed {} database entries older than {} days.",
oldEntries.size(), maximumAge);
}
} catch (SQLException exception) {
Sonar.get().getLogger().error("Error trying to clear old entries: {}", exception);
}
}
/**
* First, remove the player from the local cache and then,
* secondly, asynchronously add the player to the database.
@@ -204,44 +237,6 @@ public final class VerifiedPlayerController {
}
}
/**
* Clear all old entries using the given timestamp.
*/
public void clearOld(final @Range(from = 1, to = 365) int maximumAge) {
// Don't try to update the column if the database type is NONE
if (cachedDatabaseType == SonarConfiguration.Database.Type.NONE) {
return;
}
DB_UPDATE_SERVICE.execute(() -> {
// We cannot throw a NullPointerException within the executor service
// because we want to handle the error instead of simply throwing an exception
if (connectionSource == null) {
return;
}
try {
final long timestamp = Instant.now()
.minus(maximumAge, ChronoUnit.DAYS)
.getEpochSecond() * 1000L; // convert to ms
final List<VerifiedPlayer> oldEntries = queryBuilder.where()
.lt("timestamp", new Timestamp(timestamp))
.query();
if (oldEntries != null) {
for (final VerifiedPlayer player : oldEntries) {
dao.delete(player);
}
Sonar.get().getLogger().info("Removed {} database entries older than {} days.",
oldEntries.size(), maximumAge);
}
} catch (SQLException exception) {
Sonar.get().getLogger().error("Error trying to clear old entries: {}", exception);
}
});
}
/**
* @param inetAddress IP address
* @param uuid UUID associated to the IP
@@ -18,23 +18,17 @@
package xyz.jonesdev.sonar.common.utility.geyser;
import io.netty.channel.Channel;
import io.netty.util.AttributeKey;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import java.net.InetSocketAddress;
/**
* Simple utility to determine if someone joins using GeyserMC
*/
@UtilityClass
public class GeyserUtil {
/*
* Geyser attribute key for every floodgate player
*/
private AttributeKey<Object> playerAttribute() {
return AttributeKey.valueOf("floodgate-player");
}
/**
* @param channel Channel of the player
* @return Whether the player is on GeyserMC or not
@@ -42,6 +36,9 @@ public class GeyserUtil {
public boolean isGeyserConnection(final @NotNull Channel channel) {
// https://discord.com/channels/613163671870242838/613170125696270357/1168599123889504266
// Every floodgate player has a channel attribute called "floodgate-player"
return channel.attr(playerAttribute()).get() != null;
// However, this is not safe to use as even Java players sometimes have this attribute.
// Instead, we should check if the port is 0, since TCP didn't resolve a port (UDP)
// Thanks to @dbruni on Discord who reported it in ticket-0072
return ((InetSocketAddress) channel.remoteAddress()).getPort() == 0;
}
}
@@ -17,7 +17,6 @@
package xyz.jonesdev.sonar.velocity;
import com.velocitypowered.proxy.VelocityServer;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.SonarPlatform;
@@ -26,8 +25,6 @@ import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import xyz.jonesdev.sonar.velocity.audience.AudienceListener;
import xyz.jonesdev.sonar.velocity.command.VelocitySonarCommand;
import xyz.jonesdev.sonar.velocity.fallback.FallbackListener;
import xyz.jonesdev.sonar.velocity.fallback.injection.BaseChannelInitializer;
import xyz.jonesdev.sonar.velocity.fallback.injection.BaseInjectionHelper;
@Getter
public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
@@ -76,9 +73,5 @@ public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
// Register audience register listener
getPlugin().getServer().getEventManager().register(getPlugin(), new AudienceListener());
// Inject new channel initializer
final VelocityServer server = (VelocityServer) getPlugin().getServer();
BaseInjectionHelper.inject(server, new BaseChannelInitializer(server));
}
}
@@ -0,0 +1,248 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.fallback;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.*;
import io.netty.util.Attribute;
import io.netty.util.AttributeKey;
import org.jetbrains.annotations.NotNull;
import java.net.SocketAddress;
public final class FallbackClosedConnection extends MinecraftConnection {
public FallbackClosedConnection() {
super(new Channel() {
@Override
public ChannelId id() {
return null;
}
@Override
public EventLoop eventLoop() {
return null;
}
@Override
public Channel parent() {
return null;
}
@Override
public ChannelConfig config() {
return null;
}
@Override
public boolean isOpen() {
return false;
}
@Override
public boolean isRegistered() {
return false;
}
@Override
public boolean isActive() {
return false;
}
@Override
public ChannelMetadata metadata() {
return null;
}
@Override
public SocketAddress localAddress() {
return null;
}
@Override
public SocketAddress remoteAddress() {
return null;
}
@Override
public ChannelFuture closeFuture() {
return null;
}
@Override
public boolean isWritable() {
return false;
}
@Override
public long bytesBeforeUnwritable() {
return 0;
}
@Override
public long bytesBeforeWritable() {
return 0;
}
@Override
public Unsafe unsafe() {
return null;
}
@Override
public ChannelPipeline pipeline() {
return null;
}
@Override
public ByteBufAllocator alloc() {
return null;
}
@Override
public Channel read() {
return null;
}
@Override
public Channel flush() {
return null;
}
@Override
public ChannelFuture bind(SocketAddress socketAddress) {
return null;
}
@Override
public ChannelFuture connect(SocketAddress socketAddress) {
return null;
}
@Override
public ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1) {
return null;
}
@Override
public ChannelFuture disconnect() {
return null;
}
@Override
public ChannelFuture close() {
return null;
}
@Override
public ChannelFuture deregister() {
return null;
}
@Override
public ChannelFuture bind(SocketAddress socketAddress, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture connect(SocketAddress socketAddress, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture disconnect(ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture close(ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture deregister(ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture write(Object o) {
return null;
}
@Override
public ChannelFuture write(Object o, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture writeAndFlush(Object o, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture writeAndFlush(Object o) {
return null;
}
@Override
public ChannelPromise newPromise() {
return null;
}
@Override
public ChannelProgressivePromise newProgressivePromise() {
return null;
}
@Override
public ChannelFuture newSucceededFuture() {
return null;
}
@Override
public ChannelFuture newFailedFuture(Throwable throwable) {
return null;
}
@Override
public ChannelPromise voidPromise() {
return null;
}
@Override
public <T> Attribute<T> attr(AttributeKey<T> attributeKey) {
return null;
}
@Override
public <T> boolean hasAttr(AttributeKey<T> attributeKey) {
return false;
}
@Override
public int compareTo(@NotNull Channel o) {
return 0;
}
}, null);
}
}
@@ -19,32 +19,289 @@ package xyz.jonesdev.sonar.velocity.fallback;
import com.velocitypowered.api.event.PostOrder;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.ConnectionHandshakeEvent;
import com.velocitypowered.api.event.connection.LoginEvent;
import com.velocitypowered.api.event.connection.PreLoginEvent;
import com.velocitypowered.api.util.GameProfile;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import com.velocitypowered.proxy.connection.MinecraftSessionHandler;
import com.velocitypowered.proxy.connection.client.ConnectedPlayer;
import com.velocitypowered.proxy.connection.client.InitialInboundConnection;
import com.velocitypowered.proxy.connection.client.InitialLoginSessionHandler;
import com.velocitypowered.proxy.connection.client.LoginInboundConnection;
import com.velocitypowered.proxy.protocol.packet.Disconnect;
import io.netty.channel.Channel;
import io.netty.channel.ChannelPipeline;
import lombok.val;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifyJoinEvent;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.statistics.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackVerificationHandler;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketEncoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccess;
import xyz.jonesdev.sonar.common.fallback.traffic.TrafficChannelHooker;
import xyz.jonesdev.sonar.velocity.SonarVelocity;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import static com.velocitypowered.proxy.network.Connections.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.*;
import static xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil.isGeyserConnection;
public final class FallbackListener {
private static final Fallback FALLBACK = Sonar.get().getFallback();
private static final FallbackClosedConnection CLOSED_CONNECTION;
private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
private static final MethodHandle INITIAL_CONNECTION;
private static final Field CONNECTION_FIELD;
static {
CLOSED_CONNECTION = new FallbackClosedConnection();
try {
CONNECTION_FIELD = InitialLoginSessionHandler.class.getDeclaredField("mcConnection");
CONNECTION_FIELD.setAccessible(true);
INITIAL_CONNECTION = MethodHandles.privateLookupIn(LoginInboundConnection.class, LOOKUP)
.findGetter(LoginInboundConnection.class,
"delegate",
InitialInboundConnection.class);
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
}
private static void markConnectionAsDead(final MinecraftSessionHandler sessionHandler) throws Throwable {
// The AuthSessionHandler isn't supposed to continue the connection process,
// which is why we override the field value for the MinecraftConnection with
// a fake (closed) connection.
CONNECTION_FIELD.set(sessionHandler, CLOSED_CONNECTION);
}
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull PreLoginEvent event) {
final InetAddress inetAddress = event.getConnection().getRemoteAddress().getAddress();
public void handle(final @NotNull ConnectionHandshakeEvent event) throws Throwable {
// Increase connections per second for the action bar verbose
Counters.CONNECTIONS_PER_SECOND.put(System.nanoTime());
}
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull PreLoginEvent event) throws Throwable {
// Increase joins per second for the action bar verbose
Counters.LOGINS_PER_SECOND.put(System.nanoTime());
final LoginInboundConnection inboundConnection = (LoginInboundConnection) event.getConnection();
val initialConnection = (InitialInboundConnection) INITIAL_CONNECTION.invokeExact(inboundConnection);
final MinecraftConnection mcConnection = initialConnection.getConnection();
final Channel channel = mcConnection.getChannel();
// Run in the channel's event loop
channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (mcConnection.isClosed() || mcConnection.isKnownDisconnect()) return;
// Hook the custom traffic pipeline, so we can count the incoming and outgoing traffic
final ChannelPipeline pipeline = channel.pipeline();
TrafficChannelHooker.hook(pipeline, MINECRAFT_DECODER, MINECRAFT_ENCODER);
final InetAddress inetAddress = event.getConnection().getRemoteAddress().getAddress();
// Increase total traffic statistic
Statistics.TOTAL_TRAFFIC.increment();
try {
val activeSessionHandler = mcConnection.getActiveSessionHandler();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklisted().has(inetAddress)) {
// Mark the connection as dead to avoid unnecessary console logs
markConnectionAsDead(activeSessionHandler);
mcConnection.closeWith(Disconnect.create(
Sonar.get().getConfig().getVerification().getBlacklisted(),
mcConnection.getProtocolVersion(), false));
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) return;
// Completely skip Geyser connections
if (isGeyserConnection(channel)) {
FALLBACK.getLogger().info("Skipping Geyser player: {}{}",
event.getUsername(), Sonar.get().getConfig().formatAddress(inetAddress));
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
final int protocolId = mcConnection.getProtocolVersion().getProtocol();
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols().contains(protocolId)) {
// Mark the connection as dead to avoid unnecessary console logs
markConnectionAsDead(activeSessionHandler);
mcConnection.closeWith(Disconnect.create(
Sonar.get().getConfig().getVerification().getProtocolBlacklisted(),
mcConnection.getProtocolVersion(), false));
return;
}
// Check if the player is already verified.
// No one wants to be verified over and over again.
final GameProfile gameProfile = GameProfile.forOfflinePlayer(event.getUsername());
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, gameProfile.getId())) return;
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols().contains(protocolId)) return;
// We now mark the connection as dead by using our fake connection
markConnectionAsDead(activeSessionHandler);
// Don't allow exceptions or disconnect messages
mcConnection.setAssociation(null);
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
mcConnection.closeWith(Disconnect.create(
Sonar.get().getConfig().getVerification().getAlreadyQueued(),
mcConnection.getProtocolVersion(), false));
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(event.getUsername())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
mcConnection.closeWith(Disconnect.create(
Sonar.get().getConfig().getVerification().getAlreadyVerifying(),
mcConnection.getProtocolVersion(), false));
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
mcConnection.closeWith(Disconnect.create(
Sonar.get().getConfig().getVerification().getTooFastReconnect(),
mcConnection.getProtocolVersion(), false));
return;
}
// We have to add this pipeline to monitor whenever the client disconnects
// to remove them from the list of connected and queued players
pipeline.addFirst(FALLBACK_HANDLER, new FallbackChannelHandler(event.getUsername(), inetAddress));
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (mcConnection.isClosed() || mcConnection.isKnownDisconnect()) return;
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex().matcher(event.getUsername()).matches()) {
mcConnection.closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getInvalidUsername(),
mcConnection.getProtocolVersion(), false));
return;
}
// Add better timeout handler to avoid known exploits or issues
// We also want to timeout bots quickly to avoid flooding
final int readTimeout = Sonar.get().getConfig().getVerification().getReadTimeout();
pipeline.replace(READ_TIMEOUT, READ_TIMEOUT, new FallbackTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS));
// Create an instance for the Fallback connection
final FallbackUserWrapper user = new FallbackUserWrapper(
FALLBACK, mcConnection, mcConnection.getChannel(),
mcConnection.getChannel().pipeline(), inetAddress,
ProtocolVersion.fromId(protocolId));
// Disconnect if the protocol version could not be resolved
if (user.getProtocolVersion().isUnknown()) {
user.disconnect(Sonar.get().getConfig().getVerification().getInvalidProtocol());
return;
}
// The player joined the verification
Statistics.REAL_TRAFFIC.increment();
if (Sonar.get().getConfig().getVerification().isLogConnections()) {
// Only log the processing message if the server isn't under attack.
// We let the user override this through the configuration.
if (!Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
FALLBACK.getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", gameProfile.getName())
.replace("%ip%", Sonar.get().getConfig().formatAddress(user.getInetAddress()))
.replace("%protocol%", String.valueOf(user.getProtocolVersion().getProtocol())));
}
}
// Call the VerifyJoinEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyJoinEvent(gameProfile.getName(), user));
// Mark the player as connected → verifying players
FALLBACK.getConnected().put(gameProfile.getName(), inetAddress);
// This sometimes happens when the channel hangs, but the player is still connecting
// This also fixes a unique issue with TCPShield and other reverse proxies
if (user.getPipeline().get(MINECRAFT_ENCODER) == null
|| user.getPipeline().get(MINECRAFT_DECODER) == null) {
mcConnection.close(true);
return;
}
// Replace normal encoder to allow custom packets
final FallbackPacketEncoder encoder = new FallbackPacketEncoder(user.getProtocolVersion());
user.getPipeline().replace(MINECRAFT_ENCODER, FALLBACK_PACKET_ENCODER, encoder);
// Send LoginSuccess packet to make the client think they are joining the server
user.write(new LoginSuccess(gameProfile.getName(), gameProfile.getId()));
// The LoginSuccess packet has been sent, now we can change the registry state
encoder.updateRegistry(user.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
// Replace normal decoder to allow custom packets
user.getPipeline().replace(MINECRAFT_DECODER, FALLBACK_PACKET_DECODER, new FallbackPacketDecoder(user,
new FallbackVerificationHandler(user, gameProfile.getName(), gameProfile.getId())));
}));
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
});
}
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull LoginEvent event) {
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
if (maxOnlinePerIp > 0) {
final ConnectedPlayer connectedPlayer = (ConnectedPlayer) event.getPlayer();
final InetAddress inetAddress = event.getPlayer().getRemoteAddress().getAddress();
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
final long onlinePerIp = SonarVelocity.INSTANCE.getPlugin().getServer().getAllPlayers().stream()
.filter(player -> Objects.equals(player.getRemoteAddress().getAddress(), inetAddress))
.count();
// We use '>=' because the player connecting to the server hasn't joined yet
if (onlinePerIp >= maxOnlinePerIp) {
event.setResult(PreLoginEvent.PreLoginComponentResult.denied(Sonar.get().getConfig().getTooManyOnlinePerIp()));
connectedPlayer.getConnection().closeWith(Disconnect.create(
Sonar.get().getConfig().getTooManyOnlinePerIp(), connectedPlayer.getProtocolVersion(), false));
}
}
}
@@ -1,315 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.fallback;
import com.velocitypowered.api.proxy.crypto.IdentifiedKey;
import com.velocitypowered.api.util.GameProfile;
import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import com.velocitypowered.proxy.crypto.IdentifiedKeyImpl;
import com.velocitypowered.proxy.protocol.packet.Disconnect;
import com.velocitypowered.proxy.protocol.packet.Handshake;
import com.velocitypowered.proxy.protocol.packet.ServerLogin;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.CorruptedFrameException;
import net.kyori.adventure.text.Component;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifyJoinEvent;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.statistics.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackVerificationHandler;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketEncoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccess;
import xyz.jonesdev.sonar.common.fallback.traffic.TrafficChannelHooker;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_1_19;
import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_1_19_3;
import static com.velocitypowered.proxy.network.Connections.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.*;
import static xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil.isGeyserConnection;
public final class FallbackMinecraftConnection extends MinecraftConnection {
public FallbackMinecraftConnection(final Channel channel, final VelocityServer server) {
super(channel, server);
this.channel = channel;
}
private final Channel channel;
private FallbackUserWrapper user;
private boolean receivedLoginPacket;
private static final @NotNull Fallback FALLBACK = Objects.requireNonNull(Sonar.get().getFallback());
private static final boolean FORCE_SECURE_PROFILES;
static {
FORCE_SECURE_PROFILES = Boolean.getBoolean("auth.forceSecureProfiles");
}
@Override
public void channelActive(final ChannelHandlerContext ctx) throws Exception {
super.channelActive(ctx);
// Increase connections per second for the action bar verbose
Counters.CONNECTIONS_PER_SECOND.put(System.nanoTime());
}
@Override
public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
if (msg instanceof Handshake handshake) {
// Check if the host is invalid
if (handshake.getServerAddress().isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
}
if (msg instanceof ServerLogin login) {
// Increase joins per second for the action bar verbose
Counters.LOGINS_PER_SECOND.put(System.nanoTime());
// Fix login packet spam exploit
if (receivedLoginPacket || user != null) {
throw new CorruptedFrameException("Duplicate login packet");
}
receivedLoginPacket = true;
// Run in the channel's event loop
channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (isClosed() || isKnownDisconnect()) return;
try {
// Hook the custom traffic pipeline, so we can count the incoming and outgoing traffic
final ChannelPipeline pipeline = channel.pipeline();
TrafficChannelHooker.hook(pipeline, MINECRAFT_DECODER, MINECRAFT_ENCODER);
final InetAddress inetAddress = ((InetSocketAddress) getRemoteAddress()).getAddress();
// Increase total traffic statistic
Statistics.TOTAL_TRAFFIC.increment();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklisted().has(inetAddress)) {
// Mark the connection as dead to avoid unnecessary console logs
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getBlacklisted(),
getProtocolVersion(), false));
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
super.channelRead(ctx, msg);
return;
}
// Completely skip Geyser connections
if (isGeyserConnection(channel)) {
FALLBACK.getLogger().info("Skipping Geyser player: {}{}",
login.getUsername(), Sonar.get().getConfig().formatAddress(inetAddress));
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
final int protocolId = getProtocolVersion().getProtocol();
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols().contains(protocolId)) {
// Mark the connection as dead to avoid unnecessary console logs
//markConnectionAsDead(activeSessionHandler);
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getProtocolBlacklisted(),
getProtocolVersion(), false));
return;
}
// Check if the player is already verified.
// No one wants to be verified over and over again.
final GameProfile gameProfile = GameProfile.forOfflinePlayer(login.getUsername());
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, gameProfile.getId())) {
super.channelRead(ctx, msg);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols().contains(protocolId)) {
super.channelRead(ctx, msg);
return;
}
// Don't allow exceptions or disconnect messages
setAssociation(null);
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getAlreadyQueued(),
getProtocolVersion(), false));
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(login.getUsername())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getAlreadyVerifying(),
getProtocolVersion(), false));
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getTooFastReconnect(),
getProtocolVersion(), false));
return;
}
// We have to add this pipeline to monitor whenever the client disconnects
// to remove them from the list of connected and queued players
pipeline.addFirst(FALLBACK_HANDLER, new FallbackChannelHandler(login.getUsername(), inetAddress));
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (isClosed() || isKnownDisconnect()) return;
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex().matcher(login.getUsername()).matches()) {
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getInvalidUsername(),
getProtocolVersion(), false));
return;
}
// Add better timeout handler to avoid known exploits or issues
// We also want to timeout bots quickly to avoid flooding
final int readTimeout = Sonar.get().getConfig().getVerification().getReadTimeout();
pipeline.replace(READ_TIMEOUT, READ_TIMEOUT, new FallbackTimeoutHandler(readTimeout,
TimeUnit.MILLISECONDS));
// Create an instance for the Fallback connection
user = new FallbackUserWrapper(FALLBACK, this, channel, channel.pipeline(),
inetAddress, ProtocolVersion.fromId(protocolId));
// Perform default Velocity checks
final IdentifiedKey playerKey = login.getPlayerKey();
if (playerKey != null) {
if (playerKey.hasExpired()) {
closeWith(Disconnect.create(
Component.translatable("multiplayer.disconnect.invalid_public_key_signature"),
getProtocolVersion(), false));
return;
}
boolean isKeyValid;
if (playerKey.getKeyRevision() == IdentifiedKey.Revision.LINKED_V2 && playerKey instanceof IdentifiedKeyImpl keyImpl) {
isKeyValid = keyImpl.internalAddHolder(login.getHolderUuid());
} else {
isKeyValid = playerKey.isSignatureValid();
}
if (!isKeyValid) {
closeWith(Disconnect.create(
Component.translatable("multiplayer.disconnect.invalid_public_key"),
getProtocolVersion(), false));
return;
}
} else if ((FORCE_SECURE_PROFILES || server.getConfiguration().isForceKeyAuthentication())
&& getProtocolVersion().compareTo(MINECRAFT_1_19) >= 0
&& getProtocolVersion().compareTo(MINECRAFT_1_19_3) < 0) {
closeWith(Disconnect.create(
Component.translatable("multiplayer.disconnect.missing_public_key"),
getProtocolVersion(), false));
return;
}
// Disconnect if the protocol version could not be resolved
if (user.getProtocolVersion().isUnknown()) {
user.disconnect(Sonar.get().getConfig().getVerification().getInvalidProtocol());
return;
}
// Check if the player is already connected to the proxy but still tries to verify
if (server.getPlayer(gameProfile.getId()).isPresent()
|| server.getPlayer(gameProfile.getName()).isPresent()) {
user.disconnect(Sonar.get().getConfig().getVerification().getAlreadyConnected());
return;
}
// The player joined the verification
Statistics.REAL_TRAFFIC.increment();
if (Sonar.get().getConfig().getVerification().isLogConnections()) {
// Only log the processing message if the server isn't under attack.
// We let the user override this through the configuration.
if (!Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
FALLBACK.getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", login.getUsername())
.replace("%ip%", Sonar.get().getConfig().formatAddress(user.getInetAddress()))
.replace("%protocol%", String.valueOf(user.getProtocolVersion().getProtocol())));
}
}
// Call the VerifyJoinEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyJoinEvent(gameProfile.getName(), user));
// Mark the player as connected → verifying players
FALLBACK.getConnected().put(gameProfile.getName(), inetAddress);
// This sometimes happens when the channel hangs, but the player is still connecting
// This also fixes a unique issue with TCPShield and other reverse proxies
if (user.getPipeline().get(MINECRAFT_ENCODER) == null
|| user.getPipeline().get(MINECRAFT_DECODER) == null) {
close(true);
return;
}
// Replace normal encoder to allow custom packets
final FallbackPacketEncoder encoder = new FallbackPacketEncoder(user.getProtocolVersion());
user.getPipeline().replace(MINECRAFT_ENCODER, FALLBACK_PACKET_ENCODER, encoder);
// Send LoginSuccess packet to make the client think they are joining the server
user.write(new LoginSuccess(gameProfile.getName(), gameProfile.getId()));
// The LoginSuccess packet has been sent, now we can change the registry state
encoder.updateRegistry(user.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
// Replace normal decoder to allow custom packets
user.getPipeline().replace(MINECRAFT_DECODER, FALLBACK_PACKET_DECODER, new FallbackPacketDecoder(user,
new FallbackVerificationHandler(user, gameProfile.getName(), gameProfile.getId())));
}));
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
});
return;
}
super.channelRead(ctx, msg);
}
}
@@ -20,6 +20,7 @@ package xyz.jonesdev.sonar.velocity.fallback;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import io.netty.channel.Channel;
import io.netty.channel.ChannelPipeline;
import io.netty.util.ReferenceCountUtil;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
@@ -50,11 +51,19 @@ public final class FallbackUserWrapper implements FallbackUser<MinecraftConnecti
@Override
public void write(final @NotNull Object packet) {
connection.write(packet);
if (channel.isActive()) {
channel.writeAndFlush(packet, channel.voidPromise());
} else {
ReferenceCountUtil.release(packet);
}
}
@Override
public void delayedWrite(final @NotNull Object packet) {
connection.delayedWrite(packet);
if (channel.isActive()) {
channel.write(packet, channel.voidPromise());
} else {
ReferenceCountUtil.release(packet);
}
}
}
@@ -1,66 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.fallback.injection;
import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import com.velocitypowered.proxy.connection.client.HandshakeSessionHandler;
import com.velocitypowered.proxy.network.Connections;
import com.velocitypowered.proxy.protocol.ProtocolUtils;
import com.velocitypowered.proxy.protocol.StateRegistry;
import com.velocitypowered.proxy.protocol.netty.*;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.handler.codec.haproxy.HAProxyMessageDecoder;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
import xyz.jonesdev.sonar.velocity.fallback.FallbackMinecraftConnection;
import java.util.concurrent.TimeUnit;
import static com.velocitypowered.proxy.network.Connections.*;
@RequiredArgsConstructor
public final class BaseChannelInitializer extends ChannelInitializer<Channel> {
private final VelocityServer server;
// Taken from
// https://github.com/PaperMC/Velocity/blob/dev/3.0.0/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java
@Override
protected void initChannel(final @NotNull Channel channel) {
channel.pipeline()
.addLast(LEGACY_PING_DECODER, new LegacyPingDecoder())
.addLast(FRAME_DECODER, new MinecraftVarintFrameDecoder())
.addLast(READ_TIMEOUT,
new FallbackTimeoutHandler(server.getConfiguration().getReadTimeout(), TimeUnit.MILLISECONDS))
.addLast(LEGACY_PING_ENCODER, LegacyPingEncoder.INSTANCE)
.addLast(FRAME_ENCODER, MinecraftVarintLengthEncoder.INSTANCE)
.addLast(MINECRAFT_DECODER, new MinecraftDecoder(ProtocolUtils.Direction.SERVERBOUND))
.addLast(MINECRAFT_ENCODER, new MinecraftEncoder(ProtocolUtils.Direction.CLIENTBOUND));
final MinecraftConnection connection = new FallbackMinecraftConnection(channel, server);
connection.setActiveSessionHandler(StateRegistry.HANDSHAKE,
new HandshakeSessionHandler(connection, server));
channel.pipeline().addLast(Connections.HANDLER, connection);
if (server.getConfiguration().isProxyProtocol()) {
channel.pipeline().addFirst(new HAProxyMessageDecoder());
}
}
}
@@ -1,45 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.fallback.injection;
import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.network.ConnectionManager;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import lombok.experimental.UtilityClass;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import java.lang.reflect.Field;
@UtilityClass
public class BaseInjectionHelper {
@SuppressWarnings("deprecation")
public void inject(final VelocityServer server, final ChannelInitializer<Channel> serverInitializer) {
try {
final Field field = VelocityServer.class.getDeclaredField("cm");
field.setAccessible(true);
final ConnectionManager cm = (ConnectionManager) field.get(server);
cm.serverChannelInitializer.set(serverInitializer);
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
}
}