Compare commits

...
9 Commits
Author SHA1 Message Date
Jones a17b726798 Release 2.0.27 (#277) 2024-04-25 16:57:21 +02:00
Jones 50d3b43f63 Don't fail players when disconnecting (#276) 2024-04-24 15:19:29 +02:00
Jones 0ff0194894 Also check for PaddleBoat packets (#275)
* feat: also check for PaddleBoat packets

* fix: remove debug messages
2024-04-24 15:15:00 +02:00
dependabot[bot] 279fe2e3e9 Bump netty-all from 4.1.108.Final to 4.1.109.Final (#273)
Bumps [io.netty:netty-all](https://github.com/netty/netty) from 4.1.108.Final to 4.1.109.Final.
- [Commits](https://github.com/netty/netty/compare/netty-4.1.108.Final...netty-4.1.109.Final)

---
updated-dependencies:
- dependency-name: io.netty:netty-all
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 14:50:13 +02:00
Jones ffd3a7e5c2 Improve performance through multi-threading (#271)
* fix: optimize JVMProcessInformation#formatMemory

* fix: formatting (spacing) in formatMemory

* fix: don't re-serialize action bar format forever

* fix: optimize chat and title notifications

* feat: change task timers to run less tasks more efficiently

* feat: run all scheduled tasks asynchronously

* fix: small enable message issue

* fix(velocity): don't unnecessarily store the handshake packet

* fix: oh yes I love exclamation marks

* feat: move handshake handling logic to common module

* feat: move login handling logic to common module

* fix(bungee): decoding error caused by sending wrong original packet

* style: make FallbackChannelHandlerAdapter#initialLogin final

* fix: rearrange task priorities and improve performance by removing dead code

* fix: better debug if protocol is invalid

* fix: update default read timeout value to prevent laggy players from being blacklisted

* fix: move the unknown protocol version check to login
2024-04-23 14:49:56 +02:00
Jones 4474f44b0a Release 2.0.26 (#269) 2024-04-20 10:14:13 +02:00
Jones b159947d6d Recode max accounts per IP check (#268) 2024-04-18 18:39:23 +02:00
Jones a29dfa15e4 Implement max accounts per IP check on Bukkit (#266)
* feat(bukkit): add max online per IP check

* fix: remove debug message haha lol
2024-04-18 17:57:00 +02:00
Jones cb9fe75db8 Allow user to manually verify players (#265)
* feat: add /sonar verified add <ip> <uuid/username>

* fix: remove useless and stupid local ip check

* fix: use correct translation for add message
2024-04-18 17:55:09 +02:00
44 changed files with 696 additions and 686 deletions

No files matched your search

+1 -1
View File
@@ -41,7 +41,7 @@ allprojects {
compileOnly("com.j256.ormlite:ormlite-jdbc:6.1") // ORMLite
compileOnly("com.github.ben-manes.caffeine:caffeine:3.1.8") // caching
compileOnly("io.netty:netty-all:4.1.108.Final") // netty
compileOnly("io.netty:netty-all:4.1.109.Final") // netty
// Library/dependency loading
compileOnly("com.alessiodp.libby:libby-core:2.0.0-SNAPSHOT")
+1 -1
View File
@@ -1,3 +1,3 @@
group=xyz.jonesdev.sonar
version=2.0.25
version=2.0.27
description=Effective Anti-bot plugin for Velocity, BungeeCord, and Bukkit (1.7-latest)
@@ -34,6 +34,7 @@ import xyz.jonesdev.sonar.api.verbose.Notification;
import xyz.jonesdev.sonar.api.verbose.Verbose;
import java.io.File;
import java.net.InetAddress;
import java.text.DecimalFormat;
import java.util.UUID;
@@ -52,6 +53,11 @@ public interface Sonar {
*/
@NotNull SonarPlatform getPlatform();
/**
* @return If the amount of players online with the same IP address is greater than a maximum
*/
boolean hasTooManyAccounts(final @NotNull InetAddress inetAddress, final int limit);
/**
* @return A small wrapper for the plugin logger so we can use the logger everywhere
*/
@@ -151,10 +151,4 @@ public final class AttackTracker {
attackThreshold = 0;
}
}
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean isCurrentlyUnderAttack() {
final int minDelay = Sonar.get().getConfig().getAttackCooldownDelay();
return currentAttack != null && currentAttack.timer.delay() < minDelay;
}
}
@@ -53,13 +53,8 @@ public abstract class Subcommand {
final InetAddress inetAddress;
try {
inetAddress = InetAddress.getByName(rawIP);
if (inetAddress.isAnyLocalAddress() || inetAddress.isLoopbackAddress()) {
source.sendMessage(SONAR.getConfig().getCommands().getIllegalIpAddress());
return null;
}
} catch (UnknownHostException exception) {
source.sendMessage(SONAR.getConfig().getCommands().getIncorrectIpAddress());
source.sendMessage(SONAR.getConfig().getCommands().getInvalidIpAddress());
return null;
}
return inetAddress;
@@ -21,6 +21,7 @@ import com.j256.ormlite.db.DatabaseType;
import lombok.*;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.title.Title;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
@@ -256,8 +257,7 @@ public final class SonarConfiguration {
noPermission = formatString(messagesConfig.getString("commands.no-permission"));
commands.incorrectCommandUsage = formatString(messagesConfig.getString("commands.incorrect-usage"));
commands.incorrectIpAddress = formatString(messagesConfig.getString("commands.invalid-ip-address"));
commands.illegalIpAddress = formatString(messagesConfig.getString("commands.illegal-ip-address"));
commands.invalidIpAddress = formatString(messagesConfig.getString("commands.invalid-ip-address"));
commands.playersOnly = formatString(messagesConfig.getString("commands.player-only"));
commands.consoleOnly = formatString(messagesConfig.getString("commands.console-only"));
commands.commandCoolDown = formatString(messagesConfig.getString("commands.cool-down"));
@@ -290,6 +290,8 @@ public final class SonarConfiguration {
commands.verifiedCleared = formatString(messagesConfig.getString("commands.verified.cleared"));
commands.verifiedSize = formatString(messagesConfig.getString("commands.verified.size"));
commands.verifiedRemove = formatString(messagesConfig.getString("commands.verified.removed"));
commands.verifiedAdd = formatString(messagesConfig.getString("commands.verified.added"));
commands.verifiedAlready = formatString(messagesConfig.getString("commands.verified.already"));
commands.verifiedNotFound = formatString(messagesConfig.getString("commands.verified.ip-not-found"));
commands.verifiedBlocked = formatString(messagesConfig.getString("commands.verified.blocked"));
@@ -318,13 +320,16 @@ public final class SonarConfiguration {
verification.verificationSuccess = deserialize(fromList(messagesConfig.getStringList("verification.success")));
verification.verificationFailed = deserialize(fromList(messagesConfig.getStringList("verification.failed")));
verbose.actionBarLayout = formatString(messagesConfig.getString("verbose.layout.normal"));
verbose.actionBarLayoutDuringAttack = formatString(messagesConfig.getString("verbose.layout.attack"));
verbose.actionBarLayout = deserialize(formatString(messagesConfig.getString("verbose.layout.normal")));
verbose.actionBarLayoutDuringAttack = deserialize(formatString(messagesConfig.getString("verbose.layout.attack")));
verbose.animation = Collections.unmodifiableList(messagesConfig.getStringList("verbose.animation"));
notifications.notificationTitle = deserialize(formatString(messagesConfig.getString("notifications.title")));
notifications.notificationSubtitle = deserialize(formatString(messagesConfig.getString("notifications.subtitle")));
notifications.notificationChat = formatString(fromList(messagesConfig.getStringList("notifications.chat")));
notifications.title = Title.title(
Sonar.get().getConfig().getNotifications().getNotificationTitle(),
Sonar.get().getConfig().getNotifications().getNotificationSubtitle());
notifications.notificationChat = deserialize(formatString(fromList(messagesConfig.getStringList("notifications.chat"))));
}
private @NotNull URL getAsset(final String url, final @NotNull Language language) {
@@ -399,17 +404,18 @@ public final class SonarConfiguration {
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Verbose {
private String actionBarLayout;
private String actionBarLayoutDuringAttack;
private Component actionBarLayout;
private Component actionBarLayoutDuringAttack;
private List<String> animation;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Notifications {
private Title title;
private Component notificationTitle;
private Component notificationSubtitle;
private String notificationChat;
private Component notificationChat;
}
@Getter
@@ -525,8 +531,7 @@ public final class SonarConfiguration {
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Commands {
private String incorrectCommandUsage;
private String incorrectIpAddress;
private String illegalIpAddress;
private String invalidIpAddress;
private String subCommandNoPerm;
private String playersOnly;
private String consoleOnly;
@@ -543,6 +548,8 @@ public final class SonarConfiguration {
private String blacklistSize;
private String verifiedRemove;
private String verifiedAdd;
private String verifiedAlready;
private String verifiedNotFound;
private String verifiedCleared;
private String verifiedSize;
@@ -310,13 +310,4 @@ public final class VerifiedPlayerController {
public boolean has(final @NotNull String inetAddress) {
return cache.containsKey(inetAddress);
}
/**
* @param inetAddress IP address
* @return Whether the local cache contains the IP
* @see #has(String)
*/
public boolean has(final @NotNull InetAddress inetAddress) {
return has(inetAddress.toString());
}
}
@@ -83,6 +83,6 @@ public final class Fallback {
private static boolean shouldPerform(final SonarConfiguration.Verification.Timing timing) {
return timing == SonarConfiguration.Verification.Timing.ALWAYS
|| (timing == SonarConfiguration.Verification.Timing.DURING_ATTACK
&& Sonar.get().getAttackTracker().isCurrentlyUnderAttack());
&& Sonar.get().getAttackTracker().getCurrentAttack() != null);
}
}
@@ -26,15 +26,20 @@ import java.net.InetAddress;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ForkJoinPool;
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public final class FallbackQueue {
static final FallbackQueue INSTANCE = new FallbackQueue();
private final Map<InetAddress, Runnable> queuedPlayers = new ConcurrentHashMap<>(64);
// Async executor for all new verifications
public static final ExecutorService QUEUE_EXECUTOR = new ForkJoinPool(
Math.min(0x7fff, Runtime.getRuntime().availableProcessors()),
ForkJoinPool.defaultForkJoinWorkerThreadFactory, null, true);
// Runnable task executed for polling the queue
private final Runnable pollTask = () -> {
public void poll() {
final int maxQueuePolls = Sonar.get().getConfig().getQueue().getMaxQueuePolls();
int index = 0;
@@ -47,30 +52,9 @@ public final class FallbackQueue {
}
// Run the cached runnable
final Map.Entry<InetAddress, Runnable> entry = iterator.next();
entry.getValue().run();
QUEUE_EXECUTOR.execute(entry.getValue());
// Remove runnable from iterator
iterator.remove();
}
// Run the attack check task
Sonar.get().getAttackTracker().checkIfUnderAttack();
// Clean up the cache of rate-limited IPs
Sonar.get().getFallback().getRatelimiter().cleanUpCache();
};
/**
* @param inetAddress IP address of the player
* @param runnable Queued action on the netty thread
* @see #remove
*/
public void queue(final InetAddress inetAddress, final Runnable runnable) {
queuedPlayers.put(inetAddress, runnable);
}
/**
* @param inetAddress IP address of the player
*/
public void remove(final InetAddress inetAddress) {
queuedPlayers.remove(inetAddress);
}
}
@@ -55,11 +55,11 @@ public interface FallbackUser {
* @param encoder Name of the encoder pipeline
* @param decoder Name of the decoder pipeline
* @param timeout Name of the read timeout pipeline
* @param boss Name of the main pipeline
* @param handler Name of the main pipeline
*/
void hijack(final @NotNull String username, final @NotNull UUID uuid,
final @NotNull String encoder, final @NotNull String decoder,
final @NotNull String timeout, final @NotNull String boss);
final @NotNull String timeout, final @NotNull String handler);
/**
* Sends a packet/message to the player
@@ -21,12 +21,14 @@ import com.sun.management.OperatingSystemMXBean;
import lombok.experimental.UtilityClass;
import java.lang.management.ManagementFactory;
import java.text.DecimalFormat;
@UtilityClass
public class JVMProcessInformation {
private final Runtime RUNTIME = Runtime.getRuntime();
private final OperatingSystemMXBean MX = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
private final char[] MEMORY_UNITS = {'K', 'M', 'G', 'T', 'P', 'E'};
private final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#.#");
public String formatMemory(final long size) {
if (size < 1024L) {
@@ -37,7 +39,7 @@ public class JVMProcessInformation {
final double formattedSize = (double) size / (1L << (group * 10));
final char unit = MEMORY_UNITS[group - 1];
// https://en.wikipedia.org/wiki/Byte#Multiple-byte_units
return String.format("%.1f %siB", formattedSize, unit);
return DECIMAL_FORMAT.format(formattedSize) + " " + unit + "iB";
}
/**
@@ -18,13 +18,12 @@
package xyz.jonesdev.sonar.api.timer;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import java.text.SimpleDateFormat;
@Getter
public final class SystemTimer {
private static final SimpleDateFormat FORMATTER = new SimpleDateFormat("mm:ss");
public static final SimpleDateFormat DATE_FORMATTER = new SimpleDateFormat("mm:ss");
private long start = System.currentTimeMillis();
@@ -40,19 +39,12 @@ public final class SystemTimer {
}
/**
* @apiNote Read more: {@link SystemTimer#delay}, {@link SystemTimer#formattedDelay}
* @apiNote Read more: {@link SystemTimer#delay}
*/
public boolean elapsed(final long amount) {
return delay() >= amount;
}
/**
* @apiNote Read more: {@link SystemTimer#delay}
*/
public @NotNull String formattedDelay() {
return FORMATTER.format(delay());
}
@Override
public String toString() {
return String.format("%.3f", delay() / 1000D);
@@ -20,57 +20,33 @@ package xyz.jonesdev.sonar.api.verbose;
import lombok.Getter;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.title.Title;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.statistics.SonarStatistics;
import java.util.Collection;
import java.util.UUID;
import java.util.Vector;
import static xyz.jonesdev.sonar.api.Sonar.DECIMAL_FORMAT;
@Getter
public final class Notification implements Observable {
private final @NotNull Collection<UUID> subscribers = new Vector<>(0);
public void sendAttackNotification() {
// No need to do anything if we have no subscribers
if (subscribers.isEmpty()) return;
final SonarStatistics statistics = Sonar.get().getStatistics();
// Prepare the title
// TODO: Why do custom title times not work?
// Title.Times.times(Ticks.duration(5L), Ticks.duration(60L), Ticks.duration(10L));
final Title title = Title.title(
Sonar.get().getConfig().getNotifications().getNotificationTitle(),
Sonar.get().getConfig().getNotifications().getNotificationSubtitle());
// Prepare the chat message
final Component chat = MiniMessage.miniMessage().deserialize(
Sonar.get().getConfig().getNotifications().getNotificationChat()
.replace("%queued%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getQueue().getQueuedPlayers().size()))
.replace("%verifying%", DECIMAL_FORMAT.format(Sonar.get().getFallback().getConnected().size()))
.replace("%blacklisted%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getBlacklist().estimatedSize()))
.replace("%total-joins%", DECIMAL_FORMAT.format(statistics.getTotalPlayersJoined()))
.replace("%logins-per-second%", DECIMAL_FORMAT.format(statistics.getLoginsPerSecond()))
.replace("%connections-per-second%", DECIMAL_FORMAT.format(statistics.getConnectionsPerSecond()))
.replace("%verify-total%", DECIMAL_FORMAT.format(statistics.getTotalAttemptedVerifications()))
.replace("%verify-success%",
DECIMAL_FORMAT.format(Sonar.get().getVerifiedPlayerController().estimatedSize()))
.replace("%verify-failed%", DECIMAL_FORMAT.format(statistics.getTotalFailedVerifications()))
.replace("%incoming-traffic%", statistics.getCurrentIncomingBandwidthFormatted())
.replace("%outgoing-traffic%", statistics.getCurrentOutgoingBandwidthFormatted())
.replace("%incoming-traffic-ttl%", statistics.getTotalIncomingBandwidthFormatted())
.replace("%outgoing-traffic-ttl%", statistics.getTotalOutgoingBandwidthFormatted()));
// Don't prepare component if there are no subscribers
Component chat = null;
// Send the title and chat messages to all online players
for (final UUID subscriber : Sonar.get().getNotificationHandler().getSubscribers()) {
final Audience audience = Sonar.get().audience(subscriber);
if (audience == null) continue;
audience.showTitle(title);
// Only prepare component if there are subscribers
if (chat == null) {
chat = replaceStatistic(Sonar.get().getConfig().getNotifications().getNotificationChat());
}
// Send the message to all online subscribers
audience.sendMessage(chat);
// Send the title to all online subscribers
audience.showTitle(Sonar.get().getConfig().getNotifications().getTitle());
}
}
}
@@ -17,11 +17,18 @@
package xyz.jonesdev.sonar.api.verbose;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextReplacementConfig;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.statistics.SonarStatistics;
import java.util.Collection;
import java.util.UUID;
import static xyz.jonesdev.sonar.api.Sonar.DECIMAL_FORMAT;
import static xyz.jonesdev.sonar.api.jvm.JVMProcessInformation.*;
public interface Observable {
Collection<UUID> getSubscribers();
@@ -46,4 +53,61 @@ public interface Observable {
default void unsubscribe(final UUID uuid) {
getSubscribers().remove(uuid);
}
// TODO: Make this a bit prettier and better for performance
default @NotNull Component replaceStatistic(final @NotNull Component component) {
final SonarStatistics statistics = Sonar.get().getStatistics();
return component
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%queued%")
.replacement(DECIMAL_FORMAT.format(Sonar.get().getFallback().getQueue().getQueuedPlayers().size()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%verifying%")
.replacement(DECIMAL_FORMAT.format(Sonar.get().getFallback().getConnected().size()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%blacklisted%")
.replacement(DECIMAL_FORMAT.format(Sonar.get().getFallback().getBlacklist().estimatedSize()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%total-joins%")
.replacement(DECIMAL_FORMAT.format(statistics.getTotalPlayersJoined()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%logins-per-second%")
.replacement(DECIMAL_FORMAT.format(statistics.getLoginsPerSecond()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%connections-per-second%")
.replacement(DECIMAL_FORMAT.format(statistics.getConnectionsPerSecond()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%verify-total%")
.replacement(DECIMAL_FORMAT.format(statistics.getTotalAttemptedVerifications()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%verify-success%")
.replacement(DECIMAL_FORMAT.format(statistics.getTotalSuccessfulVerifications()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%verify-failed%")
.replacement(DECIMAL_FORMAT.format(statistics.getTotalFailedVerifications()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%incoming-traffic%")
.replacement(statistics.getCurrentIncomingBandwidthFormatted())
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%outgoing-traffic%")
.replacement(statistics.getCurrentOutgoingBandwidthFormatted())
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%incoming-traffic-ttl%")
.replacement(statistics.getTotalIncomingBandwidthFormatted())
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%outgoing-traffic-ttl%")
.replacement(statistics.getTotalOutgoingBandwidthFormatted())
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%used-memory%")
.replacement(formatMemory(getUsedMemory()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%free-memory%")
.replacement(formatMemory(getFreeMemory()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%total-memory%")
.replacement(formatMemory(getTotalMemory()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%max-memory%")
.replacement(formatMemory(getMaxMemory()))
.build());
}
}
@@ -21,20 +21,17 @@ import lombok.Getter;
import lombok.val;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.jetbrains.annotations.ApiStatus;
import net.kyori.adventure.text.TextReplacementConfig;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.attack.AttackTracker;
import xyz.jonesdev.sonar.api.statistics.SonarStatistics;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.util.Collection;
import java.util.UUID;
import java.util.Vector;
import static xyz.jonesdev.sonar.api.Sonar.DECIMAL_FORMAT;
import static xyz.jonesdev.sonar.api.jvm.JVMProcessInformation.*;
import static xyz.jonesdev.sonar.api.timer.SystemTimer.DATE_FORMATTER;
@Getter
public final class Verbose implements Observable {
@@ -43,53 +40,31 @@ public final class Verbose implements Observable {
// Run action bar verbose
public void update() {
// Make sure to clean up the cached statistics
Sonar.get().getStatistics().cleanUpCache();
// Don't prepare component if there are no subscribers
if (subscribers.isEmpty()) return;
// Prepare the action bar format component
final Component component = prepareActionBarFormat();
// Send the action bar to all online players
Component component = null;
for (final UUID subscriber : subscribers) {
final Audience audience = Sonar.get().audience(subscriber);
if (audience == null) continue;
// Only prepare component if there are subscribers
if (component == null) {
final AttackTracker.AttackStatistics attackStatistics = Sonar.get().getAttackTracker().getCurrentAttack();
final SystemTimer attackDuration = attackStatistics == null ? null : attackStatistics.getDuration();
component = replaceStatistic(attackDuration != null
? Sonar.get().getConfig().getVerbose().getActionBarLayoutDuringAttack()
: Sonar.get().getConfig().getVerbose().getActionBarLayout())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%attack-duration%")
.replacement(attackDuration == null ? "00:00" : DATE_FORMATTER.format(attackDuration.delay()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%animation%")
.replacement(nextAnimation())
.build());
}
// Send the action bar to all online subscribers
audience.sendActionBar(component);
}
}
@ApiStatus.Internal
public @NotNull Component prepareActionBarFormat() {
final SonarStatistics statistics = Sonar.get().getStatistics();
final AttackTracker.AttackStatistics attackStatistics = Sonar.get().getAttackTracker().getCurrentAttack();
final SystemTimer attackDuration = attackStatistics == null ? null : attackStatistics.getDuration();
return MiniMessage.miniMessage().deserialize((attackDuration != null
? Sonar.get().getConfig().getVerbose().getActionBarLayoutDuringAttack()
: Sonar.get().getConfig().getVerbose().getActionBarLayout())
.replace("%queued%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getQueue().getQueuedPlayers().size()))
.replace("%verifying%", DECIMAL_FORMAT.format(Sonar.get().getFallback().getConnected().size()))
.replace("%blacklisted%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getBlacklist().estimatedSize()))
.replace("%blacklisted%", DECIMAL_FORMAT.format(statistics.getCurrentBlacklistSize()))
.replace("%total-joins%", DECIMAL_FORMAT.format(statistics.getTotalPlayersJoined()))
.replace("%logins-per-second%", DECIMAL_FORMAT.format(statistics.getLoginsPerSecond()))
.replace("%connections-per-second%", DECIMAL_FORMAT.format(statistics.getConnectionsPerSecond()))
.replace("%verify-total%", DECIMAL_FORMAT.format(statistics.getTotalAttemptedVerifications()))
.replace("%verify-success%",
DECIMAL_FORMAT.format(Sonar.get().getVerifiedPlayerController().estimatedSize()))
.replace("%verify-failed%", DECIMAL_FORMAT.format(statistics.getTotalFailedVerifications()))
.replace("%attack-duration%", attackDuration == null ? "00:00" : attackDuration.formattedDelay())
.replace("%incoming-traffic%", statistics.getCurrentIncomingBandwidthFormatted())
.replace("%outgoing-traffic%", statistics.getCurrentOutgoingBandwidthFormatted())
.replace("%incoming-traffic-ttl%", statistics.getTotalIncomingBandwidthFormatted())
.replace("%outgoing-traffic-ttl%", statistics.getTotalOutgoingBandwidthFormatted())
.replace("%used-memory%", formatMemory(getUsedMemory()))
.replace("%free-memory%", formatMemory(getFreeMemory()))
.replace("%total-memory%", formatMemory(getTotalMemory()))
.replace("%max-memory%", formatMemory(getMaxMemory()))
.replace("%animation%", nextAnimation()));
}
public String nextAnimation() {
val animations = Sonar.get().getConfig().getVerbose().getAnimation();
final int nextIndex = ++animationIndex % animations.size();
@@ -23,15 +23,15 @@ import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.platform.bukkit.BukkitAudiences;
import org.bstats.bukkit.Metrics;
import org.bstats.charts.SimplePie;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.bukkit.command.BukkitSonarCommand;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import xyz.jonesdev.sonar.common.statistics.CachedBandwidthStatistics;
import java.net.InetAddress;
import java.util.Objects;
import java.util.UUID;
@@ -59,6 +59,20 @@ public final class SonarBukkit extends SonarBootstrap<SonarBukkitPlugin> {
return bukkitAudiences.player(uniqueId);
}
@Override
public boolean hasTooManyAccounts(final @NotNull InetAddress inetAddress, final int limit) {
int count = 1;
for (final Player player : getPlugin().getServer().getOnlinePlayers()) {
// I don't know why, but Bukkit is *special*
if (Objects.isNull(player.getAddress())) continue;
// Check if the IP address of the player is equal to the IP trying to connect
if (!Objects.equals(player.getAddress().getAddress(), inetAddress)) continue;
// Increment count of duplicated accounts
if (++count >= limit) return true;
}
return false;
}
/**
* Create a wrapper for the plugin logger, so we can use it outside
* the velocity module.
@@ -98,18 +112,6 @@ public final class SonarBukkit extends SonarBootstrap<SonarBukkitPlugin> {
// Register Sonar command
Objects.requireNonNull(getPlugin().getCommand("sonar")).setExecutor(new BukkitSonarCommand());
// Register traffic service
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(),
CachedBandwidthStatistics::reset, 20L, 20L);
// Register queue service
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(),
getFallback().getQueue().getPollTask(), 10L, 10L);
// Register verbose service
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(),
Sonar.get().getVerboseHandler()::update, 4L, 4L);
}
@Override
@@ -21,21 +21,20 @@ import com.alessiodp.libby.BungeeLibraryManager;
import lombok.Getter;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.platform.bungeecord.BungeeAudiences;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import org.bstats.bungeecord.Metrics;
import org.bstats.charts.SimplePie;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.bungee.command.BungeeSonarCommand;
import xyz.jonesdev.sonar.bungee.fallback.FallbackInjectionHelper;
import xyz.jonesdev.sonar.bungee.fallback.FallbackLoginListener;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import xyz.jonesdev.sonar.common.statistics.CachedBandwidthStatistics;
import java.net.InetAddress;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Getter
public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
@@ -59,6 +58,19 @@ public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
return bungeeAudiences.player(uniqueId);
}
@Override
@SuppressWarnings("deprecation")
public boolean hasTooManyAccounts(final @NotNull InetAddress inetAddress, final int limit) {
int count = 1;
for (final ProxiedPlayer player : getPlugin().getServer().getPlayers()) {
// Check if the IP address of the player is equal to the IP trying to connect
if (!Objects.equals(player.getAddress().getAddress(), inetAddress)) continue;
// Increment count of duplicated accounts
if (++count >= limit) return true;
}
return false;
}
/**
* Create a wrapper for the plugin logger, so we can use it outside
* the velocity module.
@@ -99,21 +111,6 @@ public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
// Register Sonar command
getPlugin().getServer().getPluginManager().registerCommand(getPlugin(), new BungeeSonarCommand());
// Register Fallback listener
getPlugin().getServer().getPluginManager().registerListener(getPlugin(), new FallbackLoginListener());
// Register traffic service
getPlugin().getServer().getScheduler().schedule(getPlugin(), CachedBandwidthStatistics::reset,
1L, 1L, TimeUnit.SECONDS);
// Register queue service
getPlugin().getServer().getScheduler().schedule(getPlugin(), getFallback().getQueue().getPollTask(),
500L, 500L, TimeUnit.MILLISECONDS);
// Register verbose service
getPlugin().getServer().getScheduler().schedule(getPlugin(), Sonar.get().getVerboseHandler()::update,
200L, 200L, TimeUnit.MILLISECONDS);
// Make sure to inject into the server's connection handler
FallbackInjectionHelper.inject();
}
@@ -19,7 +19,6 @@ package xyz.jonesdev.sonar.bungee.fallback;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.CorruptedFrameException;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.netty.HandlerBoss;
import net.md_5.bungee.protocol.DefinedPacket;
@@ -28,24 +27,12 @@ import net.md_5.bungee.protocol.packet.Handshake;
import net.md_5.bungee.protocol.packet.LoginRequest;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.FallbackBandwidthHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandlerAdapter;
import xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static net.md_5.bungee.netty.PipelineUtils.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_BANDWIDTH;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.customDisconnect;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.deject;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter {
@@ -74,11 +61,18 @@ public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter
if (wrappedPacket != null) {
// Intercept any handshake packet by the client
if (wrappedPacket instanceof Handshake) {
handleHandshake((Handshake) wrappedPacket);
final Handshake handshake = (Handshake) wrappedPacket;
handleHandshake(handshake.getHost(), handshake.getProtocolVersion());
}
// Intercept any server login packet by the client
if (wrappedPacket instanceof LoginRequest) {
handleLogin(ctx, (LoginRequest) wrappedPacket, msg);
// Make sure to use the potentially modified, real IP
final LoginRequest loginRequest = (LoginRequest) wrappedPacket;
final HandlerBoss handlerBoss = channel.pipeline().get(HandlerBoss.class);
final ChannelWrapper channelWrapper = (ChannelWrapper) CHANNEL_WRAPPER_FIELD.get(handlerBoss);
final InetSocketAddress socketAddress = (InetSocketAddress) channelWrapper.getRemoteAddress();
handleLogin(ctx, msg, loginRequest.getData(), socketAddress,
PACKET_ENCODER, PACKET_DECODER, TIMEOUT_HANDLER, BOSS_HANDLER);
return;
}
}
@@ -86,125 +80,4 @@ public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter
// Make sure to let the server handle the rest
ctx.fireChannelRead(msg);
}
private void handleHandshake(final @NotNull Handshake handshake) throws Exception {
// Check if the player has already sent a handshake packet
if (protocolVersion != null) {
throw new CorruptedFrameException("Already sent handshake");
}
// Check if the hostname is invalid
if (handshake.getHost().isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
// Store the protocol version
protocolVersion = ProtocolVersion.fromId(handshake.getProtocolVersion());
// Connections from unknown protocol versions will be discarded
// as this is the safest way of handling unwanted connections
if (protocolVersion.isUnknown()) {
// Sonar does NOT support snapshots or unknown versions;
// I'll try my best to stay up-to-date!
throw new CorruptedFrameException("Unknown protocol");
}
// Hook the traffic listener
// TODO: Can we implement this in channelActive?
channel.pipeline().addFirst(FALLBACK_BANDWIDTH, FallbackBandwidthHandler.INSTANCE);
}
private void handleLogin(final @NotNull ChannelHandlerContext ctx,
final @NotNull LoginRequest loginRequest,
final @NotNull Object wrappedMessage) throws Exception {
// Check if the player has already sent a login packet
if (username != null) {
throw new CorruptedFrameException("Already logged on");
}
// Increase joins per second for the action bar verbose
GlobalSonarStatistics.countLogin();
// Store the username
username = loginRequest.getData();
// Make sure to use the potentially modified, real IP
final HandlerBoss handlerBoss = channel.pipeline().get(HandlerBoss.class);
final ChannelWrapper channelWrapper = (ChannelWrapper) CHANNEL_WRAPPER_FIELD.get(handlerBoss);
final InetSocketAddress socketAddress = (InetSocketAddress) channelWrapper.getRemoteAddress();
inetAddress = socketAddress.getAddress();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklist().asMap().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, blacklisted, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
ctx.fireChannelRead(wrappedMessage);
deject(channel.pipeline());
return;
}
// Completely skip Geyser connections
if (GeyserUtil.isGeyserConnection(channel, socketAddress)) {
ctx.fireChannelRead(wrappedMessage);
deject(channel.pipeline());
return;
}
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyQueued, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(loginRequest.getData())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyVerifying, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols()
.contains(protocolVersion.getProtocol())) {
customDisconnect(channel, protocolVersion, protocolBlacklisted, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Make sure we actually have to verify the player
final String offlineUUIDString = "OfflinePlayer:" + loginRequest.getData();
final UUID offlineUUID = UUID.nameUUIDFromBytes(offlineUUIDString.getBytes(StandardCharsets.UTF_8));
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, offlineUUID)) {
ctx.fireChannelRead(wrappedMessage);
deject(channel.pipeline());
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
customDisconnect(channel, protocolVersion, reconnectedTooFast, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols()
.contains(protocolVersion.getProtocol())) {
ctx.fireChannelRead(wrappedMessage);
deject(channel.pipeline());
return;
}
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// 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(loginRequest.getData()).matches()) {
customDisconnect(channel, protocolVersion, invalidUsername, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Create an instance for the Fallback connection
user = new FallbackUserWrapper(channel, inetAddress, protocolVersion);
// Let the verification handler take over the channel
user.hijack(loginRequest.getData(), offlineUUID, PACKET_ENCODER, PACKET_DECODER, TIMEOUT_HANDLER, BOSS_HANDLER);
}));
}
}
@@ -1,84 +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.bungee.fallback;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.md_5.bungee.api.connection.PendingConnection;
import net.md_5.bungee.api.event.PreLoginEvent;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.chat.ComponentSerializer;
import net.md_5.bungee.connection.InitialHandler;
import net.md_5.bungee.event.EventHandler;
import net.md_5.bungee.event.EventPriority;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.protocol.packet.Kick;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.bungee.SonarBungee;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.util.Objects;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.closeWith;
@RequiredArgsConstructor
public final class FallbackLoginListener implements Listener {
private static final Field CHANNEL_FIELD;
static {
try {
CHANNEL_FIELD = InitialHandler.class.getDeclaredField("ch");
CHANNEL_FIELD.setAccessible(true);
} catch (NoSuchFieldException exception) {
throw new ReflectiveOperationException(exception);
}
}
@SuppressWarnings("deprecation")
@EventHandler(priority = EventPriority.LOWEST)
public void handle(final @NotNull PreLoginEvent event) throws IllegalAccessException {
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
// Don't do anything if the setting is disabled
if (maxOnlinePerIp <= 0) return;
final InetAddress inetAddress = event.getConnection().getAddress().getAddress();
final long onlinePerIp = SonarBungee.INSTANCE.getPlugin().getServer().getPlayers().stream()
.filter(player -> Objects.equals(player.getAddress().getAddress(), inetAddress))
.count()
+ 1 /* add 1 because the player hasn't been added to the list of online players yet */;
// We use '>=' because the player connecting to the server hasn't joined yet
if (onlinePerIp >= maxOnlinePerIp) {
final Component component = Sonar.get().getConfig().getTooManyOnlinePerIp();
final String serialized = GsonComponentSerializer.gson().serialize(component);
final PendingConnection connection = event.getConnection();
closeWith(((ChannelWrapper) CHANNEL_FIELD.get(connection)).getHandle(),
ProtocolVersion.fromId(connection.getVersion()),
new Kick(ComponentSerializer.deserialize(serialized)));
}
}
}
@@ -36,6 +36,7 @@ import xyz.jonesdev.sonar.api.timer.SystemTimer;
import xyz.jonesdev.sonar.api.verbose.Notification;
import xyz.jonesdev.sonar.api.verbose.Verbose;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer;
import xyz.jonesdev.sonar.common.service.SonarServiceThreadManager;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.subcommand.SubcommandRegistryHolder;
import xyz.jonesdev.sonar.common.update.UpdateChecker;
@@ -105,6 +106,10 @@ public abstract class SonarBootstrap<T> implements Sonar {
// Run the per-platform initialization method
enable();
// Start threads
getLogger().info("Starting all managed threads...");
SonarServiceThreadManager.start();
// Done
getLogger().info("Done ({}s)!", launchTimer);
} catch (Throwable throwable) {
@@ -170,6 +175,8 @@ public abstract class SonarBootstrap<T> implements Sonar {
public final void shutdown() {
// Initialize shutdown process
getLogger().info("Starting shutdown process...");
// Interrupt threads
SonarServiceThreadManager.stop();
// Close database connection
verifiedPlayerController.close();
// Run the per-platform disable method
@@ -20,6 +20,7 @@ package xyz.jonesdev.sonar.common.fallback;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.CorruptedFrameException;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -27,9 +28,19 @@ import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_BANDWIDTH;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.customDisconnect;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.deject;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
@RequiredArgsConstructor
public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter {
@@ -59,13 +70,7 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
// The player cannot be in the queue if the IP address is invalid
if (inetAddress != null) {
// Remove the IP address from the queue
FALLBACK.getQueue().remove(inetAddress);
}
// The player cannot be in the verification if the user object doesn't exist,
// or if the user failed/succeeded the bot verification process.
if (user != null && user.getState().canReceivePackets()) {
// Mark the verification attempt as failed
user.fail("disconnected during verification");
FALLBACK.getQueue().getQueuedPlayers().remove(inetAddress);
}
// Make sure to let the server handle the rest
ctx.fireChannelInactive();
@@ -80,4 +85,162 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
// Simply close the channel if we encounter any errors.
ctx.close();
}
/**
* Validates and handles incoming handshake packets
*
* @param hostname Hostname (server address) sent by the client
* @param protocol Protocol version number sent by the client
*/
protected final void handleHandshake(final @NotNull String hostname, final int protocol) throws Exception {
// Check if the player has already sent a handshake packet
if (protocolVersion != null) {
throw new CorruptedFrameException("Already sent handshake");
}
// Check if the hostname is invalid
if (hostname.isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
// Store the protocol version
protocolVersion = ProtocolVersion.fromId(protocol);
// Hook the traffic listener
// TODO: Can we implement this in channelActive?
channel.pipeline().addFirst(FALLBACK_BANDWIDTH, FallbackBandwidthHandler.INSTANCE);
}
/**
* Validates and handles incoming login packets
*
* @param ctx Forwarded channel handler context
* @param loginPacket Login packet sent by the client
* @param username Username sent by the client
* @param socketAddress Socket address of the client
*/
protected final void handleLogin(final @NotNull ChannelHandlerContext ctx,
final @NotNull Object loginPacket,
final @NotNull String username,
final @NotNull InetSocketAddress socketAddress,
final @NotNull String encoder,
final @NotNull String decoder,
final @NotNull String timeout,
final @NotNull String handler) throws Exception {
// Check if the player has already sent a login packet
if (this.username != null) {
throw new CorruptedFrameException("Already sent login");
}
// Connections from unknown protocol versions will be discarded
// as this is the safest way of handling unwanted connections
if (protocolVersion.isUnknown()) {
// Sonar does NOT support snapshots or unknown versions;
// I'll try my best to stay up-to-date!
throw new CorruptedFrameException("Unknown protocol version");
}
// Increase joins per second for the action bar verbose
GlobalSonarStatistics.countLogin();
// Store the username and IP address
this.username = username;
this.inetAddress = socketAddress.getAddress();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklist().asMap().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, blacklisted, encoder, handler);
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
initialLogin(ctx, loginPacket, encoder, handler);
return;
}
// Completely skip Geyser connections
if (GeyserUtil.isGeyserConnection(channel, socketAddress)) {
initialLogin(ctx, loginPacket, encoder, handler);
return;
}
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyQueued, encoder, handler);
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(username)
|| FALLBACK.getConnected().containsValue(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyVerifying, encoder, handler);
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols()
.contains(protocolVersion.getProtocol())) {
customDisconnect(channel, protocolVersion, protocolBlacklisted, encoder, handler);
return;
}
// Make sure we actually have to verify the player
final String offlineUUIDString = "OfflinePlayer:" + username;
final UUID offlineUUID = UUID.nameUUIDFromBytes(offlineUUIDString.getBytes(StandardCharsets.UTF_8));
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, offlineUUID)) {
initialLogin(ctx, loginPacket, encoder, handler);
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
customDisconnect(channel, protocolVersion, reconnectedTooFast, encoder, handler);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols()
.contains(protocolVersion.getProtocol())) {
initialLogin(ctx, loginPacket, encoder, handler);
return;
}
// Queue the connection for further processing
FALLBACK.getQueue().getQueuedPlayers().put(inetAddress, () -> {
// 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(username).matches()) {
customDisconnect(channel, protocolVersion, invalidUsername, encoder, handler);
return;
}
// Create an instance for the Fallback connection
user = new FallbackUserWrapper(channel, inetAddress, protocolVersion);
// Let the verification handler take over the channel
user.hijack(username, offlineUUID, encoder, decoder, timeout, handler);
});
}
/**
* Executes the maximum accounts per IP limit check before letting the player join
*
* @param ctx Forwarded channel handler context
* @param loginPacket Login packet sent by the client
*/
protected final void initialLogin(final @NotNull ChannelHandlerContext ctx,
final @NotNull Object loginPacket,
final @NotNull String encoder,
final @NotNull String handler) throws Exception {
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
// Skip the maximum online per IP check if it's disabled in the configuration
if (maxOnlinePerIp > 0) {
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
if (Sonar.get().hasTooManyAccounts(inetAddress, maxOnlinePerIp)) {
customDisconnect(channel, protocolVersion, FallbackPreparer.tooManyOnlinePerIP, encoder, handler);
return;
}
}
ctx.fireChannelRead(loginPacket);
// Deject the channel since we don't need it anymore
deject(channel.pipeline());
}
}
@@ -46,6 +46,7 @@ import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.*;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_20_2;
@Getter
@ToString(of = {"protocolVersion", "inetAddress"})
@@ -74,26 +75,14 @@ public final class FallbackUserWrapper implements FallbackUser {
@Override
public void hijack(final @NotNull String username, final @NotNull UUID uuid,
final @NotNull String encoder, final @NotNull String decoder,
final @NotNull String timeout, final @NotNull String boss) {
// This rarely happens when the channel hangs, but the player is still connecting
// This also fixes a unique issue with TCPShield and other reverse proxies
if (pipeline.get(encoder) == null || pipeline.get(decoder) == null) {
channel.close();
return;
}
// Remove main pipeline to completely take over the channel
if (pipeline.get(boss) != null) {
pipeline.remove(boss);
}
final @NotNull String timeout, final @NotNull String handler) {
// The player has joined the verification
GlobalSonarStatistics.totalAttemptedVerifications++;
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()
if (Sonar.get().getAttackTracker().getCurrentAttack() == null
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
Sonar.get().getFallback().getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", username)
@@ -117,16 +106,36 @@ public final class FallbackUserWrapper implements FallbackUser {
final FallbackPacketEncoder newEncoder = new FallbackPacketEncoder(protocolVersion);
pipeline.replace(encoder, FALLBACK_PACKET_ENCODER, newEncoder);
// Send LoginSuccess packet to make the client think they are joining the server
write(new LoginSuccessPacket(username, uuid));
channel.eventLoop().execute(() -> {
// Remove main pipeline to completely take over the channel
if (pipeline.get(handler) != null) {
pipeline.remove(handler);
}
// The LoginSuccess packet has been sent, now we can change the registry state
newEncoder.updateRegistry(protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
// Send LoginSuccess packet to make the client think they are joining the server
write(new LoginSuccessPacket(username, uuid));
// Replace normal decoder to allow custom packets
pipeline.replace(decoder, FALLBACK_PACKET_DECODER,
new FallbackPacketDecoder(this, new FallbackVerificationHandler(this, username, uuid)));
// The LoginSuccess packet has been sent, now we can change the registry state
newEncoder.updateRegistry(protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
try {
// Prepare custom packet decoder and verification handler
final FallbackVerificationHandler verification = new FallbackVerificationHandler(this, username, uuid);
final FallbackPacketDecoder fallbackPacketDecoder = new FallbackPacketDecoder(this, verification);
// Replace normal decoder to allow custom packets
pipeline.replace(decoder, FALLBACK_PACKET_DECODER, fallbackPacketDecoder);
if (protocolVersion.compareTo(MINECRAFT_1_20_2) < 0) {
// Start initializing the actual join process for pre-1.20.2 clients
verification.initialJoinProcess();
}
} catch (Throwable throwable) {
// This rarely happens when the channel hangs and the player is still connecting
// I honestly have no idea how else I'm supposed to fix this
channel.close();
}
});
}
@Override
@@ -136,7 +145,7 @@ public final class FallbackUserWrapper implements FallbackUser {
// Only log the failed message if the server isn't currently under attack.
// However, we let the user override this through the configuration.
final boolean shouldLog = !Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
final boolean shouldLog = Sonar.get().getAttackTracker().getCurrentAttack() == null
|| Sonar.get().getConfig().getVerification().isLogDuringAttack();
// Only disconnect the player and log if the channel is active
@@ -223,7 +232,7 @@ public final class FallbackUserWrapper implements FallbackUser {
*
* @param packet Disconnect packet
* @param encoder Encoder to replace
* @param boss Main pipeline to remove
* @param handler Main pipeline to remove
* @param channel Channel of the player
* @param protocolVersion Protocol version of the player
*/
@@ -231,7 +240,19 @@ public final class FallbackUserWrapper implements FallbackUser {
final @NotNull ProtocolVersion protocolVersion,
final @NotNull FallbackPacket packet,
final @NotNull String encoder,
final @NotNull String boss) {
final @NotNull String handler) {
if (channel.eventLoop().inEventLoop()) {
_customDisconnect(channel, protocolVersion, packet, encoder, handler);
} else {
channel.eventLoop().execute(() -> _customDisconnect(channel, protocolVersion, packet, encoder, handler));
}
}
private static void _customDisconnect(final @NotNull Channel channel,
final @NotNull ProtocolVersion protocolVersion,
final @NotNull FallbackPacket packet,
final @NotNull String encoder,
final @NotNull String boss) {
// Remove main pipeline to completely take over the channel
if (channel.pipeline().get(boss) != null) {
channel.pipeline().remove(boss);
@@ -69,6 +69,7 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
private double posX, posY, posZ, lastY, spawnYPosition;
private boolean resolvedClientBrand, resolvedClientSettings;
private boolean listenForMovements;
private boolean receivedSteerBoat, receivedPlayerInput;
// CAPTCHA
private final SystemTimer keepAlive = new SystemTimer();
@@ -89,11 +90,6 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
this.performCollisions = Sonar.get().getConfig().getVerification().getGravity().isCheckCollisions();
this.performCaptcha = FALLBACK.shouldPerformCaptcha();
this.performVehicle = FALLBACK.shouldPerformVehicleCheck();
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) < 0) {
// Start initializing the actual join process
initialJoinProcess();
}
}
private void configure() {
@@ -123,7 +119,7 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
}
}
private void initialJoinProcess() {
public void initialJoinProcess() {
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) < 0) {
// 1.7 players don't have KeepAlive packets in the login process
sendJoinGamePacket();
@@ -307,6 +303,19 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
return;
}
if (packet instanceof PaddleBoatPacket) {
// Check if we are currently expecting a PaddleBoatPacket packet
assertState(VEHICLE);
// Only pass the player if both checks are passed
if (receivedPlayerInput && receivedSteerBoat) {
captchaOrNext();
}
// Mark paddle boat check as passed
receivedSteerBoat = true;
}
if (packet instanceof PlayerInputPacket) {
// Check if we are currently expecting a PlayerInputPacket packet
assertState(VEHICLE);
@@ -318,11 +327,23 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
checkFrame(inputPacket.getSideways() <= 0.98f, "invalid vehicle speed (s)");
// Don't verify if the player tries unmounting or jumping
// We can't fail since the player is able to jump or sneak
if (inputPacket.isJump() || inputPacket.isUnmount()) return;
checkFrame(!inputPacket.isJump(), "tried to jump in vehicle");
checkFrame(!inputPacket.isUnmount(), "tried to escape the vehicle");
// Continue checking or verify the player
captchaOrNext();
// Only pass the player if both checks are passed
if (receivedPlayerInput && receivedSteerBoat) {
captchaOrNext();
}
// pre-1.9 clients do not have a PaddleBoat packet
if (user.getProtocolVersion().compareTo(MINECRAFT_1_9) < 0) {
receivedSteerBoat = true;
} else {
// PaddleBoat → PlayerInput → PaddleBoat
checkFrame(receivedSteerBoat, "invalid vehicle steering order");
}
// Mark player input check as passed
receivedPlayerInput = true;
return;
}
@@ -277,6 +277,20 @@ public enum FallbackPacketRegistry {
clientbound.register(GameEventPacket.class, GameEventPacket::new,
map(0x20, MINECRAFT_1_20_3, true));
serverbound.register(PaddleBoatPacket.class, PaddleBoatPacket::new,
map(0x11, MINECRAFT_1_9, false),
map(0x12, MINECRAFT_1_12, false),
map(0x11, MINECRAFT_1_12_1, false),
map(0x14, MINECRAFT_1_13, false),
map(0x16, MINECRAFT_1_14, false),
map(0x17, MINECRAFT_1_16, false),
map(0x16, MINECRAFT_1_17, false),
map(0x18, MINECRAFT_1_19, false),
map(0x19, MINECRAFT_1_19_1, false),
map(0x18, MINECRAFT_1_19_3, false),
map(0x19, MINECRAFT_1_19_4, false),
map(0x1B, MINECRAFT_1_20_2, false),
map(0x1C, MINECRAFT_1_20_3, false));
serverbound.register(PlayerInputPacket.class, PlayerInputPacket::new,
map(0x0C, MINECRAFT_1_7_2, false),
map(0x15, MINECRAFT_1_9, false),
@@ -64,6 +64,7 @@ public class FallbackPreparer {
public FallbackPacket reconnectedTooFast;
public FallbackPacket protocolBlacklisted;
public FallbackPacket invalidUsername;
public FallbackPacket tooManyOnlinePerIP;
// Vehicle
public FallbackPacket removeEntities;
@@ -137,6 +138,7 @@ public class FallbackPreparer {
protocolBlacklisted = DisconnectPacket.create(Sonar.get().getConfig().getVerification().getProtocolBlacklisted(), true);
reconnectedTooFast = DisconnectPacket.create(Sonar.get().getConfig().getVerification().getTooFastReconnect(), true);
invalidUsername = DisconnectPacket.create(Sonar.get().getConfig().getVerification().getInvalidUsername(), true);
tooManyOnlinePerIP = DisconnectPacket.create(Sonar.get().getConfig().getTooManyOnlinePerIp(), true);
// Precompute captcha answers
if (Sonar.get().getConfig().getVerification().getMap().getTiming() != SonarConfiguration.Verification.Timing.NEVER
@@ -0,0 +1,46 @@
/*
* 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.common.fallback.protocol.packets.play;
import io.netty.buffer.ByteBuf;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public final class PaddleBoatPacket implements FallbackPacket {
private boolean leftPaddle, rightPaddle;
@Override
public void encode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
@Override
public void decode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
leftPaddle = byteBuf.readBoolean();
rightPaddle = byteBuf.readBoolean();
}
}
@@ -0,0 +1,42 @@
/*
* 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.common.service;
public final class SonarServiceThread extends Thread {
private final long delay;
private final Runnable runnable;
public SonarServiceThread(final String name, final long delay, final Runnable runnable) {
setName(name);
this.delay = delay;
this.runnable = runnable;
}
@Override
public void run() {
try {
while (!isInterrupted()) {
runnable.run();
// Sleep thread by specified delay
Thread.sleep(delay);
}
} catch (InterruptedException interruptedException) {
//
}
}
}
@@ -0,0 +1,57 @@
/*
* 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.common.service;
import lombok.experimental.UtilityClass;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.common.statistics.CachedBandwidthStatistics;
@UtilityClass
public final class SonarServiceThreadManager {
private static final SonarServiceThread VERBOSE = new SonarServiceThread("sonar-verbose-thread", 250L,
() -> {
// Make sure to clean up the cached statistics
// since we don't want to display wrong values.
Sonar.get().getStatistics().cleanUpCache();
// Update the attack tracker
Sonar.get().getAttackTracker().checkIfUnderAttack();
// Update the action bar verbose
Sonar.get().getVerboseHandler().update();
});
private static final SonarServiceThread FALLBACK_QUEUE = new SonarServiceThread("sonar-fallback-queue-thread", 1000L,
() -> Sonar.get().getFallback().getQueue().poll());
private static final SonarServiceThread STATISTICS = new SonarServiceThread("sonar-statistics-thread", 1000L,
() -> {
// Clean up bandwidth statistics
CachedBandwidthStatistics.reset();
// Clean up the cache of rate-limited IPs
Sonar.get().getFallback().getRatelimiter().cleanUpCache();
});
public void start() {
VERBOSE.start();
FALLBACK_QUEUE.start();
STATISTICS.start();
}
public void stop() {
VERBOSE.interrupt();
FALLBACK_QUEUE.interrupt();
STATISTICS.interrupt();
}
}
@@ -47,10 +47,9 @@ public enum CachedBandwidthStatistics {
* (per-second) value and resets the current value.
* This method is called every second.
*/
public static synchronized void reset() {
for (final CachedBandwidthStatistics value : values()) {
value.cacheAndReset();
}
public static void reset() {
INCOMING.cacheAndReset();
OUTGOING.cacheAndReset();
}
private void cacheAndReset() {
@@ -63,7 +63,7 @@ public final class GlobalSonarStatistics implements SonarStatistics {
* Called every time the action bar updates to ensure fresh values
*/
@Override
public synchronized void cleanUpCache() {
public void cleanUpCache() {
LOGINS_PER_SECOND.cleanUp();
CONNECTIONS_PER_SECOND.cleanUp();
// Also clean up the blacklist
@@ -60,7 +60,7 @@ public final class BlacklistCommand extends Subcommand {
}
// Display a warning if the IP is verified but being added to the blacklist
if (SONAR.getVerifiedPlayerController().has(inetAddress)) {
if (SONAR.getVerifiedPlayerController().has(inetAddress.toString())) {
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getBlacklistAddWarning()
.replace("%ip%", rawInetAddress));
}
@@ -23,8 +23,10 @@ import xyz.jonesdev.sonar.api.command.CommandInvocation;
import xyz.jonesdev.sonar.api.command.subcommand.Subcommand;
import xyz.jonesdev.sonar.api.command.subcommand.SubcommandInfo;
import xyz.jonesdev.sonar.api.command.subcommand.argument.Argument;
import xyz.jonesdev.sonar.api.model.VerifiedPlayer;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.Queue;
import java.util.UUID;
import java.util.concurrent.LinkedBlockingQueue;
@@ -35,6 +37,7 @@ import java.util.concurrent.LinkedBlockingQueue;
arguments = {
@Argument("history"),
@Argument("remove"),
@Argument("add"),
@Argument("clear"),
@Argument("size")
}
@@ -107,6 +110,51 @@ public final class VerifiedCommand extends Subcommand {
break;
}
case "add": {
if (invocation.getRawArguments().length <= 3) {
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getIncorrectCommandUsage()
.replace("%usage%", "verified add <IP address> <UUID/username>"));
return;
}
final String rawInetAddress = invocation.getRawArguments()[2];
// Make sure we aren't currently locking the IP address to avoid a double I/O operation
if (LOCK.contains(rawInetAddress)) {
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getVerifiedBlocked());
return;
}
final InetAddress inetAddress = getInetAddressIfValid(invocation.getSender(), rawInetAddress);
// Make sure the given IP address is valid
if (inetAddress == null) return;
// Try to parse the UUID (from the username, if needed)
final String rawUUID = invocation.getRawArguments()[3];
final UUID uuid = rawUUID.length() == 36 ? UUID.fromString(rawUUID)
: UUID.nameUUIDFromBytes(("OfflinePlayer:" + rawUUID).getBytes(StandardCharsets.UTF_8));
// Make sure the player is verified already
if (SONAR.getVerifiedPlayerController().has(inetAddress.toString(), uuid)) {
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getVerifiedAlready());
return;
}
// Lock the IP address
// Make sure we don't accidentally run 2 operations at the same time
LOCK.add(rawInetAddress);
// Add verified player to the database
final long timestamp = System.currentTimeMillis();
final VerifiedPlayer verifiedPlayer = new VerifiedPlayer(inetAddress.toString(), uuid, timestamp);
Sonar.get().getVerifiedPlayerController().add(verifiedPlayer);
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getVerifiedAdd()
.replace("%ip%", rawInetAddress));
// Unlock the IP address
LOCK.remove(rawInetAddress);
break;
}
case "clear": {
final int verified = SONAR.getVerifiedPlayerController().estimatedSize();
@@ -56,9 +56,9 @@ database:
maximum-age: 5
# Každý nový login bude přidán do řady kvuli prevenci spam útoků
# Řada je aktulizována každých 500 milisekund (10 ticků)
# Řada je aktulizována každých 1 sekund (20 ticků)
queue:
# Maximum součastných aktualizací řady za 500 milisekund
# Maximum součastných aktualizací řady za 1 sekund
max-polls: 30
# Každý připojený hráč bude přepojen do
@@ -167,7 +167,7 @@ verification:
debug-xyz-positions: false
# Množství času který musí uběhnout aby hráč byl odpojen
# (Hodnota udávaná v milisekundách: 1 sekunda = 1000 milisekund)
read-timeout: 3500
read-timeout: 8000
# How long should a player wait before reconnecting during verification Jak dlouho by měl hráč
# (Hodnota udávaná v milisekundách: 1 sekunda = 1000 milisekund)
rejoin-delay: 8000
@@ -53,9 +53,9 @@ database:
maximum-age: 5
# Jede neue Login-Anfrage wird in eine Warteschlange eingereiht, um Spam-Beitrittsangriffe zu vermeiden
# Die Warteschlange wird alle 500 Millisekunden (10 Ticks) aktualisiert
# Die Warteschlange wird alle 1 Sekunde (20 Ticks) aktualisiert
queue:
# Maximale Anzahl an gleichzeitigen Abfragen der Warteschlange pro 500 Millisekunden
# Maximale Anzahl an gleichzeitigen Abfragen der Warteschlange pro 1 Sekunde
max-polls: 30
# Jeder neue Spieler, der das erste Mal beitritt, wird zu
@@ -164,7 +164,7 @@ verification:
debug-xyz-positions: false
# Zeit, die verstreichen muss, bevor ein Spieler ein Timeout erleidet
# (Dieser Wert stellt die Zeit in Millisekunden dar: 1 Sekunde = 1000 Millisekunden)
read-timeout: 3500
read-timeout: 8000
# Wie lange sollte ein Spieler warten, bevor er während der Überprüfung erneut verbinden kann
# (Dieser Wert stellt die Zeit in Millisekunden dar: 1 Sekunde = 1000 Millisekunden)
rejoin-delay: 8000
@@ -55,9 +55,9 @@ database:
maximum-age: 5
# Every new login request will be queued to avoid spam join attacks
# The queue is updated every 500 milliseconds (10 ticks)
# The queue is updated every 1 second (20 ticks)
queue:
# Maximum number of concurrent queue polls per 500 milliseconds
# Maximum number of concurrent queue polls per 1 second
max-polls: 30
# Every new player that joins for the first time will be sent to
@@ -166,7 +166,7 @@ verification:
debug-xyz-positions: false
# Amount of time that has to pass before a player times out
# (This value represents the time in milliseconds: 1 second = 1000 milliseconds)
read-timeout: 3500
read-timeout: 8000
# How long should a player wait before reconnecting during verification
# (This value represents the time in milliseconds: 1 second = 1000 milliseconds)
rejoin-delay: 8000
@@ -55,9 +55,9 @@ database:
maximum-age: 5
# ყოველი ახალი შესვლის მოთხოვნა იქნება რიგში, რათა თავიდან აიცილოთ სპამის მიერთების შეტევები
# რიგი განახლდება ყოველ 500 მილიწამში (10 ტიკი)
# რიგი განახლდება ყოველ 1000 მილიწამში (20 ტიკი)
queue:
# ახალი მოთამაშეების მაქსიმალური რაოდენობა, რომლებსაც შეუძლიათ შესვლა სერვერზე ყოველ 500 მილიწამში
# ახალი მოთამაშეების მაქსიმალური რაოდენობა, რომლებსაც შეუძლიათ შესვლა სერვერზე ყოველ 1000 მილიწამში
max-polls: 30
# ყოველი ახალი მოთამაშე, რომელიც პირველად შეუერთდება, გაიგზავნება
@@ -166,7 +166,7 @@ verification:
debug-xyz-positions: false
# დროის ოდენობა, რომელიც უნდა გავიდეს, სანამ მოთამაშე ტაიმაუტდება სერვერიდან.
# (ეს არის ნაჩვენები მილიწამებში: 1 წამი = 1000 მილიწამი)
read-timeout: 3500
read-timeout: 8000
# რამდენ ხანს უნდა დაელოდოს მოთამაშე ვერიფიკაციის დროს ხელახლა დაკავშირებამდე?
# (ეს არის ნაჩვენები მილიწამებში: 1 წამი = 1000 მილიწამი)
rejoin-delay: 8000
@@ -55,9 +55,9 @@ database:
maximum-age: 5
# Каждый новый запрос на вход на сервер будет ставиться в очередь, чтобы избежать спам-атак.
# Очередь обновляется каждые 500 миллисекунд (10 тиков)
# Очередь обновляется каждые 1 секунд (20 тиков)
queue:
# Максимальное количество одновременных опросов очереди за 500 миллисекунд
# Максимальное количество одновременных опросов очереди за 1 секунд
max-polls: 30
# Каждый новый игрок, впервые присоединившийся на сервер, будет отправлен на
@@ -166,7 +166,7 @@ verification:
debug-xyz-positions: false
# Количество времени, которое должно пройти, прежде чем игрока кикнет.
# (Это значение представляет время в миллисекундах: 1 секунда = 1000 миллисекунд)
read-timeout: 3500
read-timeout: 8000
# Сколько времени игрок должен ждать перед повторным подключением во время проверки
# (Это значение представляет время в миллисекундах: 1 секунда = 1000 миллисекунд)
rejoin-delay: 8000
@@ -39,8 +39,6 @@ commands:
incorrect-usage: '%prefix%<red>Benutzung: /sonar %usage%'
# Nachricht, die angezeigt wird, wenn jemand eine ungültige IP-Adresse angibt (Ungültiges Format)
invalid-ip-address: '%prefix%Die angegebene IP-Adresse scheint ungültig zu sein.'
# Nachricht, die angezeigt wird, wenn jemand eine illegale IP-Adresse angibt (Lokale IP)
illegal-ip-address: '%prefix%Die angegebene IP-Adresse scheint entweder eine lokale oder eine Schleifenrückführungs-IP zu sein.'
# Nachricht, die angezeigt wird, wenn die Konsole einen Befehl ausführt, der nur für Spieler ist
player-only: '%prefix%<red>Diesen Befehl kannst du nur als Spieler ausführen.'
# Nachricht, die angezeigt wird, wenn ein Spieler einen Befehl ausführt, der nur für die Konsole ist
@@ -123,6 +121,10 @@ commands:
size: '%prefix%Es sind derzeit %amount% einzigartige Spieler verifiziert.'
# Nachricht, die angezeigt wird, wenn jemand eine IP-Adresse unverifiziert macht
removed: '%prefix%Erfolgreich %ip% unverifiziert.'
# Nachricht, die angezeigt wird, wenn jemand eine IP-Adresse verifiziert macht
added: '%prefix%Erfolgreich %ip% verifiziert.'
# Message that is shown when someone tries to verify an IP address/UUID that are already verified
already: '%prefix%The IP address and UUID you provided are already verified.'
# Nachricht, die angezeigt wird, wenn jemand eine IP-Adresse unverifiziert macht, die nicht verifiziert ist
ip-not-found: '%prefix%Die angegebene IP-Adresse ist nicht verifiziert.'
# Nachricht, die angezeigt wird, wenn jemand versucht, die gleiche IP-Adresse zweimal zu unverifizieren (doppelte Operation)
@@ -38,9 +38,6 @@ commands:
incorrect-usage: '%prefix%<red>Usage: /sonar %usage%'
# Message that is shown when someone provides an invalid IP address (Invalid format)
invalid-ip-address: '%prefix%The IP address you provided seems to be invalid.'
# Message that is shown when someone provides an illegal IP address (Local IP)
illegal-ip-address: '%prefix%The IP address you provided seems to be either a local
or loopback IP.'
# Message that is shown when the console runs a command that is player-only
player-only: '%prefix%<red>You can only execute this command as a player.'
# Message that is shown when a player runs a command that is console-only
@@ -127,6 +124,10 @@ commands:
size: '%prefix%There are currently %amount% unique player(s) verified.'
# Message that is shown when someone un-verifies an IP address
removed: '%prefix%Successfully unverified %ip%.'
# Message that is shown when someone verifies an IP address
added: '%prefix%Successfully verified %ip%.'
# Message that is shown when someone tries to verify an IP address/UUID that are already verified
already: '%prefix%The IP address and UUID you provided are already verified.'
# Message that is shown when someone un-verifies an IP address, but it is not verified
ip-not-found: '%prefix%The IP address you provided is not verified.'
# Message that is shown when someone tries un-verifying the same IP address twice (double operation)
@@ -38,8 +38,6 @@ commands:
incorrect-usage: '%prefix%<red>Применение: /sonar %usage%'
# Сообщение, которое отображается, когда кто-то предоставляет неверный формат IP-адреса
invalid-ip-address: '%prefix%Указанный вами IP-адрес недействителен'
# Сообщение, которое отображается, когда кто-то предоставляет локальный IP-адрес
illegal-ip-address: '%prefix%IP-адрес который вы указали является локальным.'
# Сообщение, которое отображается, когда консоль выполняет команду, предназначенную только для игроков
player-only: '%prefix%<red>Эта команда может быть выполнена только игроком.'
# Сообщение, которое отображается, когда игрок выполняет команду, доступную только для консоли
@@ -124,6 +122,10 @@ commands:
size: '%prefix%В настоящее время проверено %amount% уникальных игроков.'
# Сообщение, которое отображается при удалении IP-адреса из списка проверенных игроков
removed: '%prefix%Успешно удален %ip%.'
# Message that is shown when someone verifies an IP address
added: '%prefix%Successfully verified %ip%.'
# Message that is shown when someone tries to verify an IP address/UUID that are already verified
already: '%prefix%The IP address and UUID you provided are already verified.'
# Сообщение, которое показывается когда кто-то удаляет IP из списка но его небыло в списке
ip-not-found: '%prefix%Указанный вами IP-адрес не в списке.'
# Сообщение, которое показывается когда выполняю сразу две операции (двойная операция)
@@ -38,8 +38,6 @@ commands:
incorrect-usage: '%prefix%<red>Kullanım: /sonar %usage%'
# Birisi geçersiz bir IP adresi sağladığında gösterilen mesaj (Geçersiz format)
invalid-ip-address: '%prefix%Sağladığınız IP adresi geçersiz görünüyor.'
# Birisi yasal olmayan bir IP adresi sağladığında gösterilen mesaj (Yerel IP)
illegal-ip-address: '%prefix%Sağladığınız IP adresi yerel veya döngüsel bir IP gibi görünüyor.'
# Konsol, sadece oyuncunun çalıştırabileceği bir komut çalıştırdığında gösterilen mesaj
player-only: '%prefix%<red>Bu komutu sadece bir oyuncu olarak çalıştırabilirsiniz.'
# Bir oyuncu, sadece konsolun çalıştırabileceği bir komutu çalıştırdığında gösterilen mesaj
@@ -115,6 +113,10 @@ commands:
size: '%prefix%Şu anda %amount% benzersiz oyuncu doğrulanmış.'
# Birisi bir IP adresini doğrulanmamış olarak işaretlediğinde gösterilen mesaj
removed: '%prefix%Başarıyla %ip% doğrulanmamış olarak işaretlendi.'
# Message that is shown when someone verifies an IP address
added: '%prefix%Successfully verified %ip%.'
# Message that is shown when someone tries to verify an IP address/UUID that are already verified
already: '%prefix%The IP address and UUID you provided are already verified.'
# Birisi bir IP adresini doğrulanmamış olarak işaretlemeye çalıştığında ama doğrulanmamış olduğunda gösterilen mesaj
ip-not-found: '%prefix%Sağladığınız IP adresi doğrulanmış olarak işaretlenmemiş.'
# Birisi aynı IP adresini iki kez (çift işlem) doğrulanmamış olarak işaretlemeye çalıştığında gösterilen mesaj
@@ -18,23 +18,22 @@
package xyz.jonesdev.sonar.velocity;
import com.alessiodp.libby.VelocityLibraryManager;
import com.velocitypowered.api.proxy.Player;
import lombok.Getter;
import net.kyori.adventure.audience.Audience;
import org.bstats.charts.SimplePie;
import org.bstats.velocity.Metrics;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import xyz.jonesdev.sonar.common.statistics.CachedBandwidthStatistics;
import xyz.jonesdev.sonar.velocity.command.VelocitySonarCommand;
import xyz.jonesdev.sonar.velocity.fallback.FallbackInjectionHelper;
import xyz.jonesdev.sonar.velocity.fallback.FallbackLoginListener;
import java.net.InetAddress;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Getter
public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
@@ -58,6 +57,18 @@ public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
return getPlugin().getServer().getPlayer(uniqueId).orElse(null);
}
@Override
public boolean hasTooManyAccounts(final @NotNull InetAddress inetAddress, final int limit) {
int count = 1;
for (final Player player : getPlugin().getServer().getAllPlayers()) {
// Check if the IP address of the player is equal to the IP trying to connect
if (!Objects.equals(player.getRemoteAddress().getAddress(), inetAddress)) continue;
// Increment count of duplicated accounts
if (++count >= limit) return true;
}
return false;
}
/**
* Create a wrapper for the plugin logger, so we can use it outside
* the velocity module.
@@ -98,24 +109,6 @@ public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
// Register Sonar command
getPlugin().getServer().getCommandManager().register("sonar", new VelocitySonarCommand());
// Register Fallback listener
getPlugin().getServer().getEventManager().register(getPlugin(), new FallbackLoginListener());
// Register traffic service
getPlugin().getServer().getScheduler().buildTask(getPlugin(), CachedBandwidthStatistics::reset)
.repeat(1L, TimeUnit.SECONDS)
.schedule();
// Register queue service
getPlugin().getServer().getScheduler().buildTask(getPlugin(), getFallback().getQueue().getPollTask())
.repeat(500L, TimeUnit.MILLISECONDS)
.schedule();
// Register verbose service
getPlugin().getServer().getScheduler().buildTask(getPlugin(), Sonar.get().getVerboseHandler()::update)
.repeat(200L, TimeUnit.MILLISECONDS)
.schedule();
// Make sure to inject into the server's connection handler
FallbackInjectionHelper.inject(getPlugin().getServer());
}
@@ -22,26 +22,12 @@ import com.velocitypowered.proxy.protocol.packet.HandshakePacket;
import com.velocitypowered.proxy.protocol.packet.ServerLoginPacket;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.CorruptedFrameException;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.FallbackBandwidthHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandlerAdapter;
import xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static com.velocitypowered.proxy.network.Connections.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_BANDWIDTH;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.customDisconnect;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.deject;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter {
@@ -49,141 +35,22 @@ public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter
super(channel);
}
private @ApiStatus.Internal HandshakePacket handshakePacket;
@Override
public void channelRead(final @NotNull ChannelHandlerContext ctx, final Object msg) throws Exception {
// Intercept any handshake packet by the client
if (msg instanceof HandshakePacket handshake) {
handleHandshake(handshake);
handleHandshake(handshake.getServerAddress(), handshake.getProtocolVersion().getProtocol());
}
// Intercept any server login packet by the client
if (msg instanceof ServerLoginPacket serverLogin) {
handleLogin(ctx, serverLogin);
// Make sure to use the potentially modified, original IP
final MinecraftConnection minecraftConnection = (MinecraftConnection) channel.pipeline().get(HANDLER);
final InetSocketAddress socketAddress = (InetSocketAddress) minecraftConnection.getRemoteAddress();
handleLogin(ctx, serverLogin, serverLogin.getUsername(), socketAddress,
MINECRAFT_ENCODER, MINECRAFT_DECODER, READ_TIMEOUT, HANDLER);
return;
}
// Make sure to let the server handle the rest
ctx.fireChannelRead(msg);
}
private void handleHandshake(final @NotNull HandshakePacket handshake) throws Exception {
// Check if the player has already sent a handshake packet
if (handshakePacket != null) {
throw new CorruptedFrameException("Already sent handshake");
}
// Check if the hostname is invalid
if (handshake.getServerAddress().isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
// Store the protocol version
protocolVersion = ProtocolVersion.fromId(handshake.getProtocolVersion().getProtocol());
// Connections from unknown protocol versions will be discarded
// as this is the safest way of handling unwanted connections
if (protocolVersion.isUnknown()) {
// Sonar does NOT support snapshots or unknown versions;
// I'll try my best to stay up-to-date!
throw new CorruptedFrameException("Unknown protocol");
}
// Store the handshake packet
handshakePacket = handshake;
// Hook the traffic listener
// TODO: Can we implement this in channelActive?
channel.pipeline().addFirst(FALLBACK_BANDWIDTH, FallbackBandwidthHandler.INSTANCE);
}
private void handleLogin(final @NotNull ChannelHandlerContext ctx,
final @NotNull ServerLoginPacket serverLogin) throws Exception {
// Check if the player has already sent a login packet
if (username != null) {
throw new CorruptedFrameException("Already logged on");
}
// Increase joins per second for the action bar verbose
GlobalSonarStatistics.countLogin();
// Store the username
username = serverLogin.getUsername();
// Make sure to use the potentially modified, original IP
final MinecraftConnection minecraftConnection = (MinecraftConnection) channel.pipeline().get(HANDLER);
final InetSocketAddress socketAddress = (InetSocketAddress) minecraftConnection.getRemoteAddress();
inetAddress = socketAddress.getAddress();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklist().asMap().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, blacklisted, MINECRAFT_DECODER, HANDLER);
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
ctx.fireChannelRead(serverLogin);
deject(channel.pipeline());
return;
}
// Completely skip Geyser connections
if (GeyserUtil.isGeyserConnection(channel, socketAddress)) {
ctx.fireChannelRead(serverLogin);
deject(channel.pipeline());
return;
}
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyQueued, MINECRAFT_DECODER, HANDLER);
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(serverLogin.getUsername())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyVerifying, MINECRAFT_DECODER, HANDLER);
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols()
.contains(protocolVersion.getProtocol())) {
customDisconnect(channel, protocolVersion, protocolBlacklisted, MINECRAFT_DECODER, HANDLER);
return;
}
// Make sure we actually have to verify the player
final String offlineUUIDString = "OfflinePlayer:" + serverLogin.getUsername();
final UUID offlineUUID = UUID.nameUUIDFromBytes(offlineUUIDString.getBytes(StandardCharsets.UTF_8));
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, offlineUUID)) {
ctx.fireChannelRead(serverLogin);
deject(channel.pipeline());
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
customDisconnect(channel, protocolVersion, reconnectedTooFast, MINECRAFT_DECODER, HANDLER);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols()
.contains(protocolVersion.getProtocol())) {
ctx.fireChannelRead(serverLogin);
deject(channel.pipeline());
return;
}
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// 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(serverLogin.getUsername()).matches()) {
customDisconnect(channel, protocolVersion, invalidUsername, MINECRAFT_DECODER, HANDLER);
return;
}
// Create an instance for the Fallback connection
user = new FallbackUserWrapper(channel, inetAddress, protocolVersion);
// Let the verification handler take over the channel
user.hijack(serverLogin.getUsername(), offlineUUID, MINECRAFT_ENCODER, MINECRAFT_DECODER, READ_TIMEOUT, HANDLER);
}));
}
}
@@ -1,51 +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.event.PostOrder;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.PreLoginEvent;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.velocity.SonarVelocity;
import java.net.InetAddress;
import java.util.Objects;
public final class FallbackLoginListener {
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull PreLoginEvent event) {
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
// Don't do anything if the setting is disabled
if (maxOnlinePerIp <= 0) return;
final InetAddress inetAddress = event.getConnection().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()));
}
}
}