Compare commits

...
11 Commits
Author SHA1 Message Date
Jones 15af0f3823 Release 2.0.32 (#320) 2024-06-12 15:18:11 +02:00
Jones 998a248819 Recode verification system (#319)
* feat: [wip] recode the entire verification

* feat: [wip] add CAPTCHA session handler

* feat: add CAPTCHA xp bar and KeepAlive

* fix(fallback): migrate "connected" map to hashset

* fix(fallback): 1.8 not being able to connect

* fix(fallback): NullPointerException

* fix(fallback): 1.7.2-1.7.10 not being able to verify

* style: fix some doc comments

* style: add doc comment to FallbackVehicleSessionHandler

* style: add doc comment to FallbackCAPTCHASessionHandler

* fix: prevent users from sending multiple login ack packets

* style: rename variable for clarification

* fix: [wip] gravity check not being executed properly

* fix: use absolute value for motion prediction in gravity check

* fix: check for duplicate PluginMessage packets

* fix: oops, I broke the plugin message check

* refactor: restructure verification failing logic

* fix: mark LoginSuccess packet as encode-only

* refactor: store channel pipeline in FallbackUser

* feat: hook the traffic listener in channelActive to allow listening to handshakes

* refactor(common): small maintainability changes

* style(common): code clean up in packet registry

* refactor(all): replace lombok's val with Java's var

* fix: remove unnecessary packet filter in vehicle check

* fix: don't send the disconnect message if the player is transferred

* style: improve/simplify initialization of VerifiedPlayer

* fix: wrong name resulting in compilation error

* chore: fix a broken documentation link in the readme

* fix: don't check Geyser players for plugin messages

* fix: mark 1.7 players successful after 100ms, not 250ms

* fix: skip movements if gravity and collision checks are disabled

* style: don't store Sonar API instance in Fallback

* fix: revert https://github.com/jonesdevelopment/sonar/pull/319/commits/f9c4f42503dfebb6fbba754ff26662e69f7eed00
2024-06-09 12:55:24 +02:00
Jones 3846007613 Bump adventure-platform from 4.3.2 to 4.3.3 (#318) 2024-06-03 00:51:27 +02:00
Jones c5446f9507 Fix several concurrency issues (#317)
* fix: several concurrency issues in Sonar

* fix: recode fallback queue polling and threading

* fix: recode fallback ratelimiter
2024-05-31 15:02:48 +02:00
Jones 5a13b54350 Optimize player data handling (#316)
* fix: improve how fallback stores connected player data

* fix: improve Fallback's custom read timeout handler

* fix: max players per IP check not working as intended
2024-05-31 13:27:24 +02:00
Jones d516da7f9e Recode too many accounts check (#315) 2024-05-31 13:18:06 +02:00
Jones a58b8c8a83 Implement 'verification disabled' warning (#313) 2024-05-29 17:54:54 +02:00
Jones 3be87006aa Add helpful links to issues configuration (#312) 2024-05-28 16:31:43 +02:00
Jones c70c81eb11 Minimize shadowed jar file (#311) 2024-05-28 14:28:21 +02:00
Jones d71334c1b4 Migrate to Gradle version catalog (#310) 2024-05-28 14:10:23 +02:00
dependabot[bot] 59f141d1be Bump netty-all from 4.1.109.Final to 4.1.110.Final (#309)
Bumps [io.netty:netty-all](https://github.com/netty/netty) from 4.1.109.Final to 4.1.110.Final.
- [Commits](https://github.com/netty/netty/compare/netty-4.1.109.Final...netty-4.1.110.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-05-27 14:08:19 +02:00
50 changed files with 1292 additions and 1177 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://docs.jonesdev.xyz/
about: Need help configuring Sonar or have questions regarding the installation and usage?
- name: Discord
url: https://jonesdev.xyz/discord/
about: If you have any further questions, please feel free to join the Discord server and open a ticket.
+1 -1
View File
@@ -63,7 +63,7 @@ Fallback is unlikely to ever falsely prevent a player from joining the server si
## Building
If you want to build your own version of Sonar, please take a look at the [Sonar building documentation](https://docs.jonesdev.xyz/sonar/building).
If you want to build your own version of Sonar, please take a look at the [Sonar building documentation](https://docs.jonesdev.xyz/development/building).
<br>
You can also take a look at the [gradle documentation](https://docs.gradle.org/current/userguide/userguide.html) for a better understanding of Gradle.
+19 -26
View File
@@ -1,17 +1,9 @@
buildscript {
dependencies {
classpath("gradle.plugin.io.toolebox:gradle-git-versioner:1.6.7")
}
}
plugins {
id("java")
id("io.github.goooler.shadow") version "8.1.7"
id("io.toolebox.git-versioner") version "1.6.7"
java
alias(libs.plugins.shadow)
alias(libs.plugins.versioner) apply true
}
apply(plugin = "io.toolebox.git-versioner")
versioner {
pattern {
pattern = "$version-%h-%c-%b"
@@ -20,7 +12,7 @@ versioner {
allprojects {
repositories {
mavenCentral() // Lombok
mavenCentral()
maven(url = "https://repo.jonesdev.xyz/releases/") // Bungee & Velocity proxy module
maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots/") // libby
}
@@ -29,22 +21,18 @@ allprojects {
apply(plugin = "io.github.goooler.shadow")
dependencies {
compileOnly("org.projectlombok:lombok:1.18.32")
annotationProcessor("org.projectlombok:lombok:1.18.32")
compileOnly(rootProject.libs.lombok)
annotationProcessor(rootProject.libs.lombok)
testCompileOnly("org.projectlombok:lombok:1.18.32")
testAnnotationProcessor("org.projectlombok:lombok:1.18.32")
testCompileOnly(rootProject.libs.lombok)
testAnnotationProcessor(rootProject.libs.lombok)
// adventure
compileOnly("net.kyori:adventure-text-minimessage:4.17.0")
compileOnly("net.kyori:adventure-text-serializer-gson:4.17.0")
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.109.Final") // netty
// Library/dependency loading
compileOnly("com.alessiodp.libby:libby-core:2.0.0-SNAPSHOT")
compileOnly(rootProject.libs.adventure.minimessage)
compileOnly(rootProject.libs.adventure.serializer)
compileOnly(rootProject.libs.ormlite)
compileOnly(rootProject.libs.caffeine)
compileOnly(rootProject.libs.netty)
compileOnly(rootProject.libs.libby.core)
}
tasks {
@@ -68,6 +56,11 @@ allprojects {
// Exclude unnecessary metadata information
exclude("META-INF/*/**")
// Minimize shadowed jar file
minimize {
exclude(project(":api"))
}
}
compileJava {
+1 -1
View File
@@ -1,3 +1,3 @@
group=xyz.jonesdev.sonar
version=2.0.31
version=2.0.32
description=Effective Anti-bot plugin for Velocity, BungeeCord, and Bukkit (1.7-latest)
+35
View File
@@ -0,0 +1,35 @@
[versions]
netty = "4.1.110.Final"
adventure = "4.17.0"
adventure-platform = "4.3.3"
bstats = "3.0.2"
libby = "2.0.0-SNAPSHOT"
[plugins]
versioner = "io.toolebox.git-versioner:1.6.7"
shadow = "io.github.goooler.shadow:8.1.7"
[libraries]
netty = { module = "io.netty:netty-all", version.ref = "netty" }
adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" }
adventure-serializer = { module = "net.kyori:adventure-text-serializer-gson", version.ref = "adventure" }
adventure-nbt = { module = "net.kyori:adventure-nbt", version.ref = "adventure" }
adventure-platform-bukkit = { module = "net.kyori:adventure-platform-bukkit", version.ref = "adventure-platform" }
adventure-platform-bungee = { module = "net.kyori:adventure-platform-bungeecord", version.ref = "adventure-platform" }
adventure-platform-facet = { module = "net.kyori:adventure-platform-facet", version.ref = "adventure-platform" }
adventure-platform-api = { module = "net.kyori:adventure-platform-api", version.ref = "adventure-platform" }
libby-core = { module = "com.alessiodp.libby:libby-core", version.ref = "libby" }
libby-bungee = { module = "com.alessiodp.libby:libby-bungee", version.ref = "libby" }
libby-bukkit = { module = "com.alessiodp.libby:libby-bukkit", version.ref = "libby" }
libby-velocity = { module = "com.alessiodp.libby:libby-velocity", version.ref = "libby" }
bstats-bungee = { module = "org.bstats:bstats-bungeecord", version.ref = "bstats" }
bstats-bukkit = { module = "org.bstats:bstats-bukkit", version.ref = "bstats" }
bstats-velocity = { module = "org.bstats:bstats-velocity", version.ref = "bstats" }
caffeine = "com.github.ben-manes.caffeine:caffeine:3.1.8"
ormlite = "com.j256.ormlite:ormlite-jdbc:6.1"
lombok = "org.projectlombok:lombok:1.18.32"
capja = "xyz.jonesdev:capja:1.0.3"
bungeecord = "net.md_5:bungeecord-proxy:master-SNAPSHOT"
velocity = "com.velocitypowered:velocity-proxy:3.3.0-SNAPSHOT"
# We have to use 1.8 for backwards compatibility
spigot = "org.spigotmc:spigot-api:1.8.8-R0.1-SNAPSHOT"
@@ -33,7 +33,6 @@ import xyz.jonesdev.sonar.api.timer.SystemTimer;
import xyz.jonesdev.sonar.api.verbose.Notification;
import xyz.jonesdev.sonar.api.verbose.Verbose;
import java.net.InetAddress;
import java.text.DecimalFormat;
import java.util.UUID;
@@ -52,11 +51,6 @@ 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
*/
@@ -19,6 +19,7 @@ package xyz.jonesdev.sonar.api.command;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Ticker;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
@@ -40,6 +41,7 @@ public interface SonarCommand {
Cache<Audience, Long> COMMAND_DELAY = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMillis(500))
.ticker(Ticker.systemTicker())
.build();
List<Component> CACHED_HELP_MESSAGE = new ArrayList<>();
@@ -52,10 +54,8 @@ public interface SonarCommand {
return;
}
COMMAND_DELAY.cleanUp(); // Clean up the cache
final long timestamp = System.currentTimeMillis();
final long mapTimestamp = COMMAND_DELAY.asMap().getOrDefault(source.getAudience(), -1L);
final long mapTimestamp = COMMAND_DELAY.get(source.getAudience(), audience -> -1L);
// There were some exploits with spamming commands in the past.
// Spamming should be prevented, especially if some heavy operations are done,
@@ -172,6 +172,15 @@ public final class SonarConfiguration {
// Verification
verification.timing = Verification.Timing.valueOf(generalConfig.getString("verification.timing"));
// Warn the user if the verification timing is set to NEVER
if (verification.timing == Verification.Timing.NEVER) {
LOGGER.warn(" ");
LOGGER.warn("You have set the verification timing to 'NEVER'.");
LOGGER.warn("Sonar will NOT perform the bot verification at all, therefore making it useless.");
LOGGER.warn("It is highly suggested to set this option to either 'DURING_ATTACK' or 'ALWAYS'.");
LOGGER.warn("Please only edit this option if you really know what you are doing.");
LOGGER.warn(" ");
}
verification.gravity.enabled = generalConfig.getBoolean("verification.checks.gravity.enabled");
verification.gravity.checkCollisions = generalConfig.getBoolean("verification.checks.gravity.check-collisions");
@@ -238,11 +238,11 @@ public final class VerifiedPlayerController {
*/
private void _add(final @NotNull VerifiedPlayer player) {
cache.computeIfAbsent(player.getInetAddress(), v -> new ArrayList<>())
.add(player.getPlayerUUID());
.add(player.getPlayerUuid());
}
/**
* Returns the amount of verified IP addresses
* Returns the number of verified IP addresses
*
* @return Estimated size of the local cache
*/
@@ -28,46 +28,45 @@ import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import java.net.InetAddress;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class Fallback {
public static final Fallback INSTANCE = new Fallback();
private static final @NotNull Sonar SONAR = Objects.requireNonNull(Sonar.get());
// Map of all connected usernames and their respective IP addresses (used for fast checking)
private final Map<String, InetAddress> connected = new ConcurrentHashMap<>(64, 0.75f);
// Map of all players connected to the server in general
private final ConcurrentMap<InetAddress, Integer> online = new ConcurrentHashMap<>(128);
// Map of all connected IP addresses (used for fast checking)
private final ConcurrentMap<InetAddress, Byte> connected = new ConcurrentHashMap<>(128);
// Cache of all blacklisted IP addresses to ensure each entry can expire after the given time
@Setter
private Cache<InetAddress, Byte> blacklist;
@Setter
private long blacklistTime;
private final @NotNull FallbackQueue queue = FallbackQueue.INSTANCE;
private final @NotNull FallbackRatelimiter ratelimiter = FallbackRatelimiter.INSTANCE;
private final @NotNull FallbackQueue queue = new FallbackQueue();
private final @NotNull FallbackRatelimiter ratelimiter = new FallbackRatelimiter();
private final LoggerWrapper logger = new LoggerWrapper() {
@Override
public void info(final String message, final Object... args) {
SONAR.getLogger().info("[fallback] " + message, args);
Sonar.get().getLogger().info("[fallback] " + message, args);
}
@Override
public void warn(final String message, final Object... args) {
SONAR.getLogger().warn("[fallback] " + message, args);
Sonar.get().getLogger().warn("[fallback] " + message, args);
}
@Override
public void error(final String message, final Object... args) {
SONAR.getLogger().error("[fallback] " + message, args);
Sonar.get().getLogger().error("[fallback] " + message, args);
}
};
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean shouldVerifyNewPlayers() {
return shouldPerform(Sonar.get().getConfig().getVerification().getTiming());
}
@@ -26,34 +26,32 @@ import java.net.InetAddress;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Executors;
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
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);
// Fixed thread pool executor for all new verifications
private static final int THREAD_POOL_SIZE = Math.min(0x7fff, Runtime.getRuntime().availableProcessors() * 2);
private static final ExecutorService QUEUE_EXECUTOR = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
private final ConcurrentMap<InetAddress, Runnable> queuedPlayers = new ConcurrentHashMap<>(
128, 0.75f, Runtime.getRuntime().availableProcessors());
public void poll() {
final int maxQueuePolls = Sonar.get().getConfig().getQueue().getMaxQueuePolls();
int index = 0;
// We need to be cautious here since we don't want any concurrency issues.
// Iterate through the map and process up to maxQueuePolls entries
// We need to be cautious here since we don't want any concurrency issues
final Iterator<Map.Entry<InetAddress, Runnable>> iterator = queuedPlayers.entrySet().iterator();
while (iterator.hasNext()) {
// Break if we reached our maximum entries
if (++index > maxQueuePolls) {
break;
}
while (iterator.hasNext() && index++ < maxQueuePolls) {
// Run the cached runnable
final Map.Entry<InetAddress, Runnable> entry = iterator.next();
QUEUE_EXECUTOR.execute(entry.getValue());
// Remove runnable from iterator
// Remove runnable from the map
iterator.remove();
}
}
@@ -27,40 +27,25 @@ import org.jetbrains.annotations.NotNull;
import java.net.InetAddress;
@Setter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public final class FallbackRatelimiter {
static final FallbackRatelimiter INSTANCE = new FallbackRatelimiter();
private Cache<InetAddress, Byte> attemptCache;
private long timeout;
private Cache<InetAddress, Long> attemptCache;
@Getter
private Cache<InetAddress, Integer> failCountCache;
/**
* We don't want to clean up the cache in the
* {@link #attempt(InetAddress)} method because
* it would take up too many resources.
*/
public void cleanUpCache() {
attemptCache.cleanUp();
failCountCache.cleanUp();
}
/**
* Checks if the player has tried verifying too fast
* Attempts to rate-limit the client
*
* @param inetAddress IP address of the player
* @return Whether the player is allowed to verify
*/
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean attempt(final @NotNull InetAddress inetAddress) {
// Cache the IP address if it's not already cached
if (!attemptCache.asMap().containsKey(inetAddress)) {
// Cache the IP address
attemptCache.put(inetAddress, (byte) 0);
return true;
}
// Deny the connection attempt
return false;
final long expectedTimestamp = System.currentTimeMillis() + timeout;
// Check if the time since the last join has not exceeded the timeout
// The idea for this check was Taken from Velocity
final long last = attemptCache.get(inetAddress, result -> expectedTimestamp);
return expectedTimestamp == last;
}
/**
@@ -18,11 +18,13 @@
package xyz.jonesdev.sonar.api.fallback;
import io.netty.channel.Channel;
import io.netty.channel.ChannelPipeline;
import io.netty.util.ReferenceCountUtil;
import net.kyori.adventure.text.Component;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.net.InetAddress;
import java.util.UUID;
@@ -31,6 +33,9 @@ public interface FallbackUser {
@NotNull
Channel getChannel();
@NotNull
ChannelPipeline getPipeline();
@NotNull
InetAddress getInetAddress();
@@ -38,20 +43,27 @@ public interface FallbackUser {
ProtocolVersion getProtocolVersion();
@NotNull
FallbackUserState getState();
SystemTimer getLoginTimer();
boolean isReceivedClientSettings();
void setReceivedClientSettings(final boolean receivedClientSettings);
boolean isReceivedPluginMessage();
void setReceivedPluginMessage(final boolean receivedPluginMessage);
boolean isGeyser();
void setState(final FallbackUserState state);
/**
* Disconnect the player during/after verification
* using our custom Disconnect packet.
*
* @param reason Disconnect message component
* @param reason Disconnect message component
* @param duringLogin True, if the player has not joined yet
*/
@ApiStatus.Internal
void disconnect(final @NotNull Component reason);
void disconnect(final @NotNull Component reason, final boolean duringLogin);
/**
* Takes over the channel and begins the verification process
@@ -1,41 +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.api.fallback;
public enum FallbackUserState {
// 1.20.2 configuration state
LOGIN_ACK, CONFIGURE,
// pre-JOIN ping check
KEEP_ALIVE,
// post-JOIN checks
CLIENT_SETTINGS, PLUGIN_MESSAGE, TRANSACTION,
// PLAY checks
TELEPORT, POSITION,
// Vehicle check
VEHICLE,
// CAPTCHA
CAPTCHA,
// Done
SUCCESS,
// Placeholder
FAILED;
public boolean canReceivePackets() {
return this != FAILED && this != SUCCESS;
}
}
@@ -23,7 +23,9 @@ import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.jetbrains.annotations.NotNull;
import java.net.InetAddress;
import java.sql.Timestamp;
import java.util.UUID;
@@ -50,7 +52,7 @@ public final class VerifiedPlayer {
uniqueIndexName = "ip_address_player_uuid_idx",
width = 36
)
private UUID playerUUID;
private UUID playerUuid;
@DatabaseField(
columnName = "timestamp",
@@ -58,9 +60,11 @@ public final class VerifiedPlayer {
)
private Timestamp timestamp;
public VerifiedPlayer(final String inetAddress, final UUID playerUUID, final long timestamp) {
this.inetAddress = inetAddress;
this.playerUUID = playerUUID;
public VerifiedPlayer(final @NotNull InetAddress inetAddress,
final @NotNull UUID playerUuid,
final long timestamp) {
this.inetAddress = inetAddress.toString();
this.playerUuid = playerUuid;
this.timestamp = new Timestamp(timestamp);
}
}
@@ -19,6 +19,7 @@ package xyz.jonesdev.sonar.api.statistics;
import org.jetbrains.annotations.ApiStatus;
@SuppressWarnings("unused")
public interface SonarStatistics {
/**
@@ -108,7 +109,7 @@ public interface SonarStatistics {
/**
* @return Total number of cases where an IP was blacklisted
* @apiNote This does not describe the current amount of blacklisted IPs
* @apiNote This does not describe the current number of blacklisted IPs
*/
long getTotalBlacklistSize();
@@ -18,7 +18,6 @@
package xyz.jonesdev.sonar.api.verbose;
import lombok.Getter;
import lombok.val;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextReplacementConfig;
@@ -66,7 +65,7 @@ public final class Verbose implements Observable {
}
public String nextAnimation() {
val animations = Sonar.get().getConfig().getVerbose().getAnimation();
final var animations = Sonar.get().getConfig().getVerbose().getAnimation();
final int nextIndex = ++animationIndex % animations.size();
return animations.toArray(new String[0])[nextIndex];
}
+9 -17
View File
@@ -1,13 +1,12 @@
import net.minecrell.pluginyml.bukkit.BukkitPluginDescription
plugins {
id("net.minecrell.plugin-yml.bukkit") version "0.6.0"
id("net.minecrell.plugin-yml.bukkit") version "0.6.0" apply true
}
apply(plugin = "net.minecrell.plugin-yml.bukkit")
bukkit {
name = rootProject.name
description = rootProject.description
version = rootProject.version.toString().split("-")[0]
main = "xyz.jonesdev.sonar.bukkit.SonarBukkitPlugin"
authors = listOf("Jones Development", "Sonar Contributors")
@@ -33,21 +32,14 @@ dependencies {
implementation(project(":api"))
implementation(project(":common"))
// adventure platform support
implementation("net.kyori:adventure-platform-bukkit:4.3.2")
implementation("net.kyori:adventure-platform-api:4.3.2")
implementation("net.kyori:adventure-platform-facet:4.3.2")
// adventure nbt
implementation("net.kyori:adventure-nbt:4.17.0")
compileOnly(rootProject.libs.spigot)
// We have to use 1.8 for backwards compatibility
compileOnly("org.spigotmc:spigot-api:1.8.8-R0.1-SNAPSHOT")
// Implement bStats.org for metrics
implementation("org.bstats:bstats-bukkit:3.0.2")
// Library/dependency loading
implementation("com.alessiodp.libby:libby-bukkit:2.0.0-SNAPSHOT")
implementation(rootProject.libs.adventure.platform.bukkit)
implementation(rootProject.libs.adventure.platform.api)
implementation(rootProject.libs.adventure.platform.facet)
implementation(rootProject.libs.adventure.nbt)
implementation(rootProject.libs.bstats.bukkit)
implementation(rootProject.libs.libby.bukkit)
}
tasks {
@@ -23,7 +23,6 @@ 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.SonarPlatform;
@@ -31,7 +30,6 @@ import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.bukkit.command.BukkitSonarCommand;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import java.net.InetAddress;
import java.util.Objects;
import java.util.UUID;
@@ -58,20 +56,6 @@ 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.
+10 -17
View File
@@ -1,11 +1,10 @@
plugins {
id("net.minecrell.plugin-yml.bungee") version "0.6.0"
id("net.minecrell.plugin-yml.bungee") version "0.6.0" apply true
}
apply(plugin = "net.minecrell.plugin-yml.bungee")
bungee {
name = rootProject.name
description = rootProject.description
version = rootProject.version.toString().split("-")[0]
main = "xyz.jonesdev.sonar.bungee.SonarBungeePlugin"
author = "Jones Development, Sonar Contributors"
@@ -16,21 +15,15 @@ dependencies {
implementation(project(":api"))
implementation(project(":common"))
compileOnly("net.md_5:bungeecord-proxy:master-SNAPSHOT")
testCompileOnly("net.md_5:bungeecord-proxy:master-SNAPSHOT")
compileOnly(rootProject.libs.bungeecord)
testCompileOnly(rootProject.libs.bungeecord)
// adventure platform support
implementation("net.kyori:adventure-platform-bungeecord:4.3.2")
implementation("net.kyori:adventure-platform-api:4.3.2")
implementation("net.kyori:adventure-platform-facet:4.3.2")
// adventure nbt
implementation("net.kyori:adventure-nbt:4.17.0")
// Implement bStats.org for metrics
implementation("org.bstats:bstats-bungeecord:3.0.2")
// Library/dependency loading
implementation("com.alessiodp.libby:libby-bungee:2.0.0-SNAPSHOT")
implementation(rootProject.libs.adventure.platform.bungee)
implementation(rootProject.libs.adventure.platform.api)
implementation(rootProject.libs.adventure.platform.facet)
implementation(rootProject.libs.adventure.nbt)
implementation(rootProject.libs.bstats.bungee)
implementation(rootProject.libs.libby.bungee)
}
tasks {
@@ -21,7 +21,6 @@ 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;
@@ -32,8 +31,6 @@ import xyz.jonesdev.sonar.bungee.command.BungeeSonarCommand;
import xyz.jonesdev.sonar.bungee.fallback.FallbackInjectionHelper;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import java.net.InetAddress;
import java.util.Objects;
import java.util.UUID;
@Getter
@@ -58,19 +55,6 @@ 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.
@@ -54,7 +54,7 @@ public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter
@Override
public void channelRead(final @NotNull ChannelHandlerContext ctx, final Object msg) throws Exception {
// Intercept any packets processed by BungeeCord
if (msg instanceof PacketWrapper) {
if (listenForPackets && msg instanceof PacketWrapper) {
final PacketWrapper packetWrapper = (PacketWrapper) msg;
final DefinedPacket wrappedPacket = packetWrapper.packet;
// Don't handle any invalid packets
@@ -65,7 +65,7 @@ public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter
handleHandshake(handshake.getHost(), handshake.getProtocolVersion());
}
// Intercept any server login packet by the client
if (wrappedPacket instanceof LoginRequest) {
else if (wrappedPacket instanceof LoginRequest) {
// Make sure to use the potentially modified, real IP
final LoginRequest loginRequest = (LoginRequest) wrappedPacket;
final HandlerBoss handlerBoss = channel.pipeline().get(HandlerBoss.class);
+2 -4
View File
@@ -1,11 +1,9 @@
dependencies {
compileOnly(project(":api"))
// adventure nbt for packets
compileOnly("net.kyori:adventure-nbt:4.17.0")
compileOnly(rootProject.libs.adventure.nbt)
// CAPTCHA image generation
implementation("xyz.jonesdev:capja:1.0.3")
implementation(rootProject.libs.capja)
}
java.sourceCompatibility = JavaVersion.VERSION_11
@@ -19,6 +19,7 @@ package xyz.jonesdev.sonar.common.boot;
import com.alessiodp.libby.LibraryManager;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Ticker;
import com.j256.ormlite.logger.Level;
import com.j256.ormlite.logger.Logger;
import lombok.Getter;
@@ -137,9 +138,11 @@ public abstract class SonarBootstrap<T> implements Sonar {
// Update ratelimiter caches
getFallback().getRatelimiter().setAttemptCache(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMillis(getConfig().getVerification().getReconnectDelay()))
.ticker(Ticker.systemTicker())
.build());
getFallback().getRatelimiter().setFailCountCache(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMillis(getConfig().getVerification().getRememberTime()))
.ticker(Ticker.systemTicker())
.build());
// Update blacklist cache
@@ -148,9 +151,10 @@ public abstract class SonarBootstrap<T> implements Sonar {
// Make sure the blacklist is only set when we need it to prevent data loss
if (!blacklistExists // Make sure we create a new cache if it doesn't exist yet
|| getFallback().getBlacklistTime() != blacklistTime) {
// Create new cache with the configured blacklist time
// Create a new cache with the configured blacklist time
getFallback().setBlacklist(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMillis(getConfig().getVerification().getBlacklistTime()))
.ticker(Ticker.systemTicker())
.build());
// Store the new blacklist time, so we don't have to reset the blacklist every reload
getFallback().setBlacklistTime(blacklistTime);
@@ -39,7 +39,7 @@ public final class FallbackBandwidthHandler extends ChannelDuplexHandler {
final @NotNull Object msg) throws Exception {
// We can only get the size of a message if it's a ByteBuf
if (msg instanceof ByteBuf) {
// Increment the incoming traffic by the amount of readable bytes
// Increment the incoming traffic by the number of readable bytes
INCOMING.increment(((ByteBuf) msg).readableBytes());
}
// Make sure to let the server handle the rest
@@ -52,7 +52,7 @@ public final class FallbackBandwidthHandler extends ChannelDuplexHandler {
final ChannelPromise promise) throws Exception {
// We can only get the size of a message if it's a ByteBuf
if (msg instanceof ByteBuf) {
// Increment the outgoing traffic by the amount of readable bytes
// Increment the outgoing traffic by the number of readable bytes
OUTGOING.increment(((ByteBuf) msg).readableBytes());
}
// Make sure to let the server handle the rest
@@ -38,7 +38,6 @@ 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
@@ -47,29 +46,38 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
protected @Nullable String username;
protected InetAddress inetAddress;
protected ProtocolVersion protocolVersion;
protected @Nullable FallbackUser user;
protected boolean listenForPackets = true;
protected static final Fallback FALLBACK = Sonar.get().getFallback();
@Override
public final void channelActive(final @NotNull ChannelHandlerContext ctx) {
public final void channelActive(final @NotNull ChannelHandlerContext ctx) throws Exception {
// Increase connections per second for the action bar verbose
GlobalSonarStatistics.countConnection();
// Hook the traffic listener
channel.pipeline().addFirst(FALLBACK_BANDWIDTH, FallbackBandwidthHandler.INSTANCE);
// Make sure to let the server handle the rest
ctx.fireChannelActive();
}
@Override
public final void channelInactive(final @NotNull ChannelHandlerContext ctx) {
public final void channelInactive(final @NotNull ChannelHandlerContext ctx) throws Exception {
// The player can disconnect without sending the login packet first
if (username != null) {
// Remove the username from the connected players
FALLBACK.getConnected().remove(username);
}
// The player cannot be in the queue if the IP address is invalid
// Account for this by checking if the inetAddress has been set yet
if (inetAddress != null) {
// Remove the IP address from the connected players
FALLBACK.getConnected().remove(inetAddress);
// Remove the IP address from the queue
FALLBACK.getQueue().getQueuedPlayers().remove(inetAddress);
// Remove this account from the online players
// or decrement the number of accounts with the same IP
FALLBACK.getOnline().compute(inetAddress, (key, value) -> {
if (value == null || value <= 1) {
return null;
} else {
return value - 1;
}
});
}
// Make sure to let the server handle the rest
ctx.fireChannelInactive();
@@ -81,7 +89,7 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
@Override
public final void exceptionCaught(final @NotNull ChannelHandlerContext ctx,
final @NotNull Throwable cause) throws Exception {
// Simply close the channel if we encounter any errors.
// Close the channel if we encounter any errors.
ctx.close();
}
@@ -102,9 +110,6 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
}
// 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);
}
/**
@@ -130,8 +135,7 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
// 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!
// Sonar does not support snapshots or Minecraft versions older than 1.7.2
throw new CorruptedFrameException("Unknown protocol version");
}
// Increase joins per second for the action bar verbose
@@ -165,10 +169,8 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
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)) {
// Check if Fallback is already verifying a player with the same IP address
if (FALLBACK.getConnected().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyVerifying, encoder, handler);
return;
}
@@ -212,7 +214,7 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
}
// Create an instance for the Fallback connection
user = new FallbackUserWrapper(channel, inetAddress, protocolVersion, geyser);
final FallbackUser user = new FallbackUserWrapper(channel, inetAddress, protocolVersion, geyser);
// Let the verification handler take over the channel
user.hijack(username, offlineUUID, encoder, decoder, timeout, handler);
});
@@ -228,19 +230,25 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
final @NotNull Object loginPacket,
final @NotNull String encoder,
final @NotNull String handler) throws Exception {
// Increment the number of accounts with the same IP
FALLBACK.getOnline().compute(inetAddress, (key, value) -> value == null ? 1 : value + 1);
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)) {
final int onlinePerIp = FALLBACK.getOnline().getOrDefault(inetAddress, 0);
if (onlinePerIp > maxOnlinePerIp) {
customDisconnect(channel, protocolVersion, tooManyOnlinePerIP, encoder, handler);
return;
}
}
// Don't listen for further packets
// TODO: deject the pipeline without breaking disconnect logic
listenForPackets = false;
// Let the server know about the login packet
ctx.fireChannelRead(loginPacket);
// Deject the channel since we don't need it anymore
deject(channel.pipeline());
}
}
@@ -26,8 +26,6 @@ import org.jetbrains.annotations.NotNull;
import java.util.concurrent.TimeUnit;
public final class FallbackTimeoutHandler extends IdleStateHandler {
private boolean knownDisconnect;
public FallbackTimeoutHandler(final long timeout, final TimeUnit timeUnit) {
super(timeout, 0L, 0L, timeUnit);
}
@@ -35,21 +33,16 @@ public final class FallbackTimeoutHandler extends IdleStateHandler {
@Override
protected void channelIdle(final ChannelHandlerContext ctx,
final @NotNull IdleStateEvent idleStateEvent) throws Exception {
assert idleStateEvent.state() == IdleState.READER_IDLE;
readTimedOut(ctx);
}
// We are only checking the read timeout state - skip all other states
if (idleStateEvent.state() != IdleState.READER_IDLE) {
return;
}
private void readTimedOut(final ChannelHandlerContext ctx) {
if (!knownDisconnect) {
// The netty (default) ReadTimeoutHandler would normally just throw an Exception
// The default ReadTimeoutHandler does only check for the boolean 'closed' and
// still throws the Exception even if the channel is closed
if (ctx.channel().isActive()) {
ctx.close();
}
knownDisconnect = true;
// The netty (default) ReadTimeoutHandler would normally just throw an Exception
// The default ReadTimeoutHandler does only check for the boolean 'closed' and
// still throws the Exception even if the channel is closed
if (ctx.channel().isActive()) {
ctx.close();
}
}
}
@@ -21,6 +21,7 @@ import io.netty.channel.Channel;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.CorruptedFrameException;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@@ -31,20 +32,22 @@ import xyz.jonesdev.sonar.api.event.impl.UserBlacklistedEvent;
import xyz.jonesdev.sonar.api.event.impl.UserVerifyFailedEvent;
import xyz.jonesdev.sonar.api.event.impl.UserVerifyJoinEvent;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.fallback.FallbackUserState;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketEncoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.DisconnectPacket;
import xyz.jonesdev.sonar.common.fallback.session.FallbackLoginSessionHandler;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import java.net.InetAddress;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_PACKET_DECODER;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_PACKET_ENCODER;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_20_2;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry.CONFIG;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry.GAME;
@@ -56,9 +59,12 @@ public final class FallbackUserWrapper implements FallbackUser {
private final ChannelPipeline pipeline;
private final InetAddress inetAddress;
private final ProtocolVersion protocolVersion;
private final boolean geyser;
@Setter
private FallbackUserState state = FallbackUserState.LOGIN_ACK; // 1.20.2+
private boolean receivedClientSettings;
@Setter
private boolean receivedPluginMessage;
private final boolean geyser;
private final SystemTimer loginTimer = new SystemTimer();
public FallbackUserWrapper(final @NotNull Channel channel,
final @NotNull InetAddress inetAddress,
@@ -72,8 +78,8 @@ public final class FallbackUserWrapper implements FallbackUser {
}
@Override
public void disconnect(final @NotNull Component reason) {
closeWith(channel, protocolVersion, DisconnectPacket.create(reason, false));
public void disconnect(final @NotNull Component reason, final boolean duringLogin) {
closeWith(channel, protocolVersion, DisconnectPacket.create(reason, duringLogin));
}
@Override
@@ -99,7 +105,7 @@ public final class FallbackUserWrapper implements FallbackUser {
Sonar.get().getEventManager().publish(new UserVerifyJoinEvent(username, this));
// Mark the player as connected → verifying players
Sonar.get().getFallback().getConnected().put(username, inetAddress);
Sonar.get().getFallback().getConnected().put(inetAddress, (byte) 0);
// Add better timeout handler to avoid known exploits or issues
// We also want to timeout bots quickly to avoid flooding
@@ -111,7 +117,7 @@ public final class FallbackUserWrapper implements FallbackUser {
pipeline.replace(encoder, FALLBACK_PACKET_ENCODER, newEncoder);
channel.eventLoop().execute(() -> {
// Remove main pipeline to completely take over the channel
// Remove the main pipeline to completely take over the channel
if (pipeline.get(handler) != null) {
pipeline.remove(handler);
}
@@ -123,16 +129,11 @@ public final class FallbackUserWrapper implements FallbackUser {
newEncoder.updateRegistry(protocolVersion.compareTo(MINECRAFT_1_20_2) >= 0 ? CONFIG : 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
final FallbackPacketDecoder fallbackPacketDecoder = new FallbackPacketDecoder(protocolVersion);
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();
}
// Listen for all incoming packets by setting the packet listener
fallbackPacketDecoder.setListener(new FallbackLoginSessionHandler(this, username, uuid));
} 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
@@ -143,27 +144,21 @@ public final class FallbackUserWrapper implements FallbackUser {
@Override
public void fail(final @NotNull String reason) {
// Make sure to set the state to failed to drop all handlers
setState(FallbackUserState.FAILED);
disconnect(Sonar.get().getConfig().getVerification().getVerificationFailed(), false);
// 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().getCurrentAttack() == null
|| Sonar.get().getConfig().getVerification().isLogDuringAttack();
// Only disconnect the player and log if the channel is active
if (channel.isActive()) {
disconnect(Sonar.get().getConfig().getVerification().getVerificationFailed());
if (shouldLog) {
Sonar.get().getFallback().getLogger().info(Sonar.get().getConfig().getVerification().getFailedLog()
.replace("%ip%", Sonar.get().getConfig().formatAddress(getInetAddress()))
.replace("%protocol%", String.valueOf(getProtocolVersion().getProtocol()))
.replace("%reason%", reason));
}
if (shouldLog) {
Sonar.get().getFallback().getLogger().info(Sonar.get().getConfig().getVerification().getFailedLog()
.replace("%ip%", Sonar.get().getConfig().formatAddress(getInetAddress()))
.replace("%protocol%", String.valueOf(getProtocolVersion().getProtocol()))
.replace("%reason%", reason));
}
// Increment amount of total failed verifications
// Increment number of total failed verifications
GlobalSonarStatistics.totalFailedVerifications++;
// Call the VerifyFailedEvent for external API usage
@@ -177,20 +172,18 @@ public final class FallbackUserWrapper implements FallbackUser {
if (blacklistThreshold <= 0) break blacklist;
// Use 1 as the default amount of fails since we haven't cached anything yet
final int failCount = Sonar.get().getFallback().getRatelimiter().getFailCountCache()
.asMap().getOrDefault(inetAddress, 1);
final int fails = Sonar.get().getFallback().getRatelimiter().getFailCountCache().get(inetAddress, ignored -> 1);
// Now we simply need to check if the threshold is reached
if (failCount < blacklistThreshold) {
if (fails < blacklistThreshold) {
// Make sure we increment the number of fails
Sonar.get().getFallback().getRatelimiter().incrementFails(inetAddress, failCount);
Sonar.get().getFallback().getRatelimiter().incrementFails(inetAddress, fails);
break blacklist;
}
// Call the BotBlacklistedEvent for external API usage
Sonar.get().getEventManager().publish(new UserBlacklistedEvent(this));
// Increment amount of total blacklisted players
// Increment number of total blacklisted players
GlobalSonarStatistics.totalBlacklistedPlayers++;
Sonar.get().getFallback().getBlacklist().put(getInetAddress(), (byte) 0);
@@ -204,6 +197,9 @@ public final class FallbackUserWrapper implements FallbackUser {
// Invalidate the cached entry to ensure memory safety
Sonar.get().getFallback().getRatelimiter().getFailCountCache().invalidate(inetAddress);
}
// Throw an exception to avoid further code execution
throw new CorruptedFrameException();
}
/**
@@ -256,12 +252,12 @@ public final class FallbackUserWrapper implements FallbackUser {
final @NotNull FallbackPacket packet,
final @NotNull String encoder,
final @NotNull String boss) {
// Remove main pipeline to completely take over the channel
// Remove the main pipeline to completely take over the channel
if (channel.pipeline().get(boss) != null) {
channel.pipeline().remove(boss);
}
final ChannelHandler currentEncoder = channel.pipeline().get(encoder);
// Simply close the channel if no decoder exists
// Close the channel if no decoder exists
if (currentEncoder != null) {
// We don't need to update the encoder if it's already present
if (!(currentEncoder instanceof FallbackPacketEncoder)) {
@@ -273,17 +269,4 @@ public final class FallbackUserWrapper implements FallbackUser {
channel.close();
}
}
/**
* Removes Sonar's pipelines from a channel to save
* unnecessary RAM and CPU usage
*
* @param pipeline Pipeline object of the channel
*/
public static void deject(final @NotNull ChannelPipeline pipeline) {
// Remove Sonar's pipelines if they exist
if (pipeline.get(FALLBACK_HANDLER) != null) {
pipeline.remove(FALLBACK_HANDLER);
}
}
}
@@ -1,745 +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.common.fallback;
import io.netty.buffer.ByteBuf;
import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.DecoderException;
import lombok.val;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifySuccessEvent;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.fallback.FallbackUserState;
import xyz.jonesdev.sonar.api.model.VerifiedPlayer;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import xyz.jonesdev.sonar.common.fallback.protocol.*;
import xyz.jonesdev.sonar.common.fallback.protocol.captcha.ItemType;
import xyz.jonesdev.sonar.common.fallback.protocol.captcha.MapCaptchaInfo;
import xyz.jonesdev.sonar.common.fallback.protocol.entity.EntityType;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.FinishConfigurationPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginAcknowledgedPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.*;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.util.ProtocolUtil;
import java.util.Objects;
import java.util.Random;
import java.util.UUID;
import java.util.regex.Pattern;
import static xyz.jonesdev.sonar.api.config.SonarConfiguration.Verification.Gravity.Gamemode.CREATIVE;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_PACKET_DECODER;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_PACKET_ENCODER;
import static xyz.jonesdev.sonar.api.fallback.FallbackUserState.*;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
import static xyz.jonesdev.sonar.common.util.ProtocolUtil.BRAND_CHANNEL;
import static xyz.jonesdev.sonar.common.util.ProtocolUtil.BRAND_CHANNEL_LEGACY;
public final class FallbackVerificationHandler implements FallbackPacketListener {
private static final Random RANDOM = new Random();
private static final Fallback FALLBACK = Sonar.get().getFallback();
private final SystemTimer login = new SystemTimer();
private final @NotNull FallbackUser user;
private final String username;
private final UUID playerUuid;
// Checks
private short expectedTransactionId;
private int expectedKeepAliveId, expectedTeleportId = -1;
private int tick, totalReceivedPackets;
private double posX, posY, posZ, lastY, spawnYPosition;
private boolean resolvedClientBrand, resolvedClientSettings;
private boolean listenForMovements;
private boolean receivedSteerBoat, receivedPlayerInput;
// CAPTCHA
private @Nullable MapCaptchaInfo captcha;
private int captchaTriesLeft, lastCountdownIndex;
// Cached options to make sure the original values don't change throughout the verification
private final boolean performVehicle, performCaptcha, performGravity, performCollisions, transfer;
public FallbackVerificationHandler(final @NotNull FallbackUser user,
final @NotNull String username,
final @NotNull UUID playerUuid) {
this.user = user;
this.username = username;
this.playerUuid = playerUuid;
// We don't want to check Geyser players for valid gravity, as this might cause issues because of the protocol
this.performGravity = !user.isGeyser() && Sonar.get().getConfig().getVerification().getGravity().isEnabled();
this.performCollisions = Sonar.get().getConfig().getVerification().getGravity().isCheckCollisions();
this.performCaptcha = FALLBACK.shouldPerformCaptcha();
this.performVehicle = FALLBACK.shouldPerformVehicleCheck();
this.transfer = Sonar.get().getConfig().getVerification().getTransfer().isEnabled()
&& user.getProtocolVersion().compareTo(MINECRAFT_1_20_5) >= 0;
}
private void configure() {
// Set the state to CONFIGURE to avoid false positives
user.setState(CONFIGURE);
// Send the necessary configuration packets to the client
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_5) >= 0) {
for (final FallbackPacket packet : REGISTRY_SYNC_1_20_5) {
user.delayedWrite(packet);
}
} else {
user.delayedWrite(REGISTRY_SYNC_LEGACY);
}
user.delayedWrite(FINISH_CONFIGURATION);
// Send all packets in one flush
user.getChannel().flush();
// Set decoder state to actually catch all packets
updateEncoderDecoderState(FallbackPacketRegistry.CONFIG);
}
private void updateEncoderDecoderState(final @NotNull FallbackPacketRegistry registry) {
val decoder = (FallbackPacketDecoder) user.getChannel().pipeline().get(FALLBACK_PACKET_DECODER);
val encoder = (FallbackPacketEncoder) user.getChannel().pipeline().get(FALLBACK_PACKET_ENCODER);
if (decoder != null && encoder != null) {
// Update the packet registry state to be able to listen for CONFIG packets
decoder.updateRegistry(registry);
encoder.updateRegistry(registry);
} else {
// Something went wrong - the decoder should not be null
FALLBACK.getLogger().warn("Necessary pipelines for {} not found", username);
// Close the channel to prevent bypasses or other potential exploits
user.getChannel().close();
}
}
public void initialJoinProcess() {
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) < 0) {
// 1.7 players don't have KeepAlive packets in the login process
sendJoinGamePacket();
} else {
// Set the state to KEEP_ALIVE to avoid false positives
user.setState(KEEP_ALIVE);
// Generate a random KeepAlive ID for the pre-join check
expectedKeepAliveId = RANDOM.nextInt();
// Send first KeepAlive to check if the connection is somewhat responsive
user.write(new KeepAlivePacket(expectedKeepAliveId));
}
}
private void sendTransaction() {
// Set the state to TRANSACTION to avoid false positives
// and go on with the flow of the verification.
user.setState(TRANSACTION);
// Generate a random transaction ID
expectedTransactionId = (short) RANDOM.nextInt();
// Send a transaction with the given ID
user.write(new TransactionPacket(0, expectedTransactionId, false));
}
private void sendJoinGamePacket() {
final boolean v1_20_2 = user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) >= 0;
if (!v1_20_2) {
// Set the state to CLIENT_SETTINGS to avoid false positives
// (1.20.2 needs the LOGIN_ACK state) and go on with
// the flow of the verification.
user.setState(CLIENT_SETTINGS);
}
// Send the JoinGame packet so we can continue the verification
user.write(joinGame);
if (v1_20_2) {
// Perform the transaction check since the ClientSettings
// and PluginMessage packets have already been validated.
sendTransaction();
}
}
private void sendAbilitiesAndTeleport() {
// Set the state to TELEPORT to avoid false positives
// and go on with the flow of the verification.
user.setState(TELEPORT);
// Make sure the player is unable to fly (e.g. if the player is in creative mode)
if (Sonar.get().getConfig().getVerification().getGravity().getGamemode() == CREATIVE) {
user.delayedWrite(DEFAULT_ABILITIES);
}
// Generate the current teleport ID
expectedTeleportId = RANDOM.nextInt();
// Add a little randomization to the spawn y coordinate
spawnYPosition = dynamicSpawnYPosition + RANDOM.nextDouble(0.4);
// Teleport the player to the spawn position
user.delayedWrite(new PlayerPositionLookPacket(
SPAWN_X_POSITION, spawnYPosition, SPAWN_Z_POSITION,
0f, -90f, expectedTeleportId, false));
// Make sure the player escapes the 1.18.2+ "Loading terrain" screen
if (user.getProtocolVersion().compareTo(MINECRAFT_1_18_2) >= 0) {
user.delayedWrite(dynamicSpawnPosition);
}
}
private void sendChunkData() {
// If we don't have any checks, simply finish verification
// This is *hopefully* a very rare case though...
if (!performGravity && !performCaptcha && !performVehicle) {
// Save some work by finishing before sending even more packets
finish();
return;
}
// Set the state to POSITION to avoid false positives
// and go on with the flow of the verification.
user.setState(POSITION);
// 1.20.3 packets introduced game events
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_3) >= 0) {
// Make sure the client knows that we're sending chunks next
user.delayedWrite(START_WRITING_CHUNKS);
}
// Teleport player into the fake lobby by sending an empty chunk
user.delayedWrite(EMPTY_CHUNK_DATA);
// Checking gravity is disabled, just finish verification
if (!performGravity) {
// Switch to captcha state if needed
vehicleCheckOrNext();
return;
}
// Send an UpdateSectionBlocks packet with a platform of blocks
// to check if the player collides with the solid platform.
user.delayedWrite(updateSectionBlocks);
// Send all packets in one flush
user.getChannel().flush();
}
private static boolean validateClientLocale(final @SuppressWarnings("unused") @NotNull FallbackUser user,
final String locale) {
// Check the client locale by performing a simple regex check on it
final Pattern pattern = Sonar.get().getConfig().getVerification().getValidLocaleRegex();
return pattern.matcher(locale).matches(); // Disallow non-ascii characters (by default)
}
private static boolean validateClientBrand(final @NotNull FallbackUser user, final ByteBuf content) {
// We have to catch every DecoderException, so we can fail and punish
// the player instead of only disconnecting them due to an exception.
try {
// No need to check for empty brands since ProtocolUtil#readBrandMessage
// already performs these checks by default.
final String read = ProtocolUtil.readBrandMessage(content);
// Check if the decoded client brand string is too long
if (read.length() > Sonar.get().getConfig().getVerification().getBrand().getMaxLength()) {
return false;
}
// Regex pattern for validating client brands
final Pattern pattern = Sonar.get().getConfig().getVerification().getBrand().getValidRegex();
return !read.equals("Vanilla") // The normal brand is always lowercase
&& pattern.matcher(read).matches(); // Disallow non-ascii characters (by default)
} catch (DecoderException exception) {
// Fail if the string (client brand) could not be decoded properly
user.fail("could not decode string");
// Throw the exception so we don't continue checking
throw exception;
}
}
private void handlePacketsCAPTCHA(final @NotNull FallbackPacket packet) {
// Check if the player took too long to enter the captcha
final int maxDuration = Sonar.get().getConfig().getVerification().getMap().getMaxDuration();
checkFrame(!login.elapsed(maxDuration), "took too long to enter captcha");
// Handle incoming chat messages
if (packet instanceof UniversalChatPacket) {
final UniversalChatPacket chat = (UniversalChatPacket) packet;
final String answer = Objects.requireNonNull(captcha).getAnswer();
if (chat.getMessage().equals(answer)) {
// Captcha is correct
finish();
return;
}
// Captcha is incorrect
checkFrame(captchaTriesLeft-- > 0, "failed captcha too often");
user.write(incorrectCaptcha);
return;
}
// Only expect idle (position) packets
if (packet instanceof PlayerPositionPacket
|| packet instanceof PlayerPositionLookPacket) {
// A position packet is sent every second
final long difference = maxDuration - login.delay();
final int index = (int) (difference / 1000D);
// Make sure we can actually safely get and send the packet
if (lastCountdownIndex != index && index >= 0 && xpCountdown.length > index) {
// Send the countdown using the experience bar
user.write(xpCountdown[index]);
// Send a KeepAlive packet to prevent timeout
user.write(CAPTCHA_KEEP_ALIVE);
}
lastCountdownIndex = index;
}
}
@Override
public void handle(final @NotNull FallbackPacket packet) {
// The player has already been verified or failed the verification -> drop packet
if (!user.getState().canReceivePackets()) return;
// Calculate maximum amount of packets sent by the client to the server
final int maxPackets = Sonar.get().getConfig().getVerification().getMaxLoginPackets()
+ maxMovementTick // Account for positions/fall check
+ Sonar.get().getConfig().getVerification().getMap().getMaxDuration()
+ Sonar.get().getConfig().getVerification().getMap().getMaxTries();
checkFrame(++totalReceivedPackets < maxPackets, "too many packets");
// We are expecting a code in chat, drop all other packets
if (user.getState() == CAPTCHA) {
handlePacketsCAPTCHA(packet);
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);
final PlayerInputPacket inputPacket = (PlayerInputPacket) packet;
// Make sure the values sent by the client are legitimate
checkFrame(inputPacket.getForward() <= 0.98f, "invalid vehicle speed (f)");
checkFrame(inputPacket.getSideways() <= 0.98f, "invalid vehicle speed (s)");
// Don't verify if the player tries unmounting or jumping
checkFrame(!inputPacket.isJump(), "tried to jump in vehicle");
checkFrame(!inputPacket.isUnmount(), "tried to escape the vehicle");
// 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 if (!user.isGeyser()) {
// PaddleBoat → PlayerInput → PaddleBoat
checkFrame(receivedSteerBoat, "invalid vehicle steering order");
}
// Mark player input check as passed
receivedPlayerInput = true;
return;
}
if (packet instanceof LoginAcknowledgedPacket) {
// Check if we are currently expecting a LoginAcknowledged packet
assertState(LOGIN_ACK);
// Start the configuration process for 1.20.2 clients
configure();
return;
}
if (packet instanceof FinishConfigurationPacket) {
// Check if we are currently expecting a FinishConfiguration packet
assertState(CONFIGURE);
// Check if the client has already sent valid ClientSettings and PluginMessage packets
if (!user.isGeyser()) {
checkFrame(resolvedClientBrand, "did not resolve client brand");
checkFrame(resolvedClientSettings, "did not resolve client settings");
}
// Start initializing the actual join process
updateEncoderDecoderState(FallbackPacketRegistry.GAME);
initialJoinProcess();
return;
}
if (packet instanceof KeepAlivePacket) {
final KeepAlivePacket keepAlive = (KeepAlivePacket) packet;
// 1.7-1.8.9 are sending a KeepAlive packet with the ID 0 every 20 ticks
if (keepAlive.getId() == 0 && user.getProtocolVersion().compareTo(MINECRAFT_1_8) <= 0) return;
// Check if we are currently expecting a KeepAlive packet
assertState(KEEP_ALIVE);
checkFrame(keepAlive.getId() == expectedKeepAliveId, "invalid KeepAlive ID");
// The correct KeepAlive packet has been received
sendJoinGamePacket();
return;
}
if (packet instanceof ClientSettingsPacket) {
final ClientSettingsPacket clientSettings = (ClientSettingsPacket) packet;
// Validate the locale using a regex to filter unwanted characters.
checkFrame(validateClientLocale(user, clientSettings.getLocale()), "invalid locale");
// Clients sometimes mess up the ClientSettings or PluginMessage packet.
// Probably caused by some race condition in Minecraft 1.13+
if (user.getState() == CLIENT_SETTINGS) {
user.setState(PLUGIN_MESSAGE);
}
// Make sure we mark the ClientSettings as valid
resolvedClientSettings = true;
return;
}
if (packet instanceof PluginMessagePacket) {
final PluginMessagePacket pluginMessage = (PluginMessagePacket) packet;
final boolean usingModernChannel = pluginMessage.getChannel().equals(BRAND_CHANNEL);
final boolean usingLegacyChannel = pluginMessage.getChannel().equals(BRAND_CHANNEL_LEGACY);
// Only the brand channel is important, drop the rest
if (usingModernChannel || usingLegacyChannel) {
// Check if the brand packet was sent twice,
// which is not possible when using a vanilla Minecraft client.
checkFrame(!resolvedClientBrand, "duplicate client brand packet");
// Check if the channel is correct - 1.13 uses the new namespace
// system ('minecraft:' + channel) and anything below 1.13 uses
// the legacy namespace system ('MC|' + channel).
final boolean v1_13 = user.getProtocolVersion().compareTo(MINECRAFT_1_13) >= 0;
checkFrame(usingLegacyChannel || v1_13, "invalid channel");
// Validate the client branding using a regex to filter unwanted characters.
if (Sonar.get().getConfig().getVerification().getBrand().isEnabled()) {
checkFrame(validateClientBrand(user, pluginMessage.getSlicedBuffer()), "invalid client brand");
}
// Clients sometimes mess up the ClientSettings or PluginMessage packet.
if (user.getState() == PLUGIN_MESSAGE) {
// Send the transaction packet
sendTransaction();
}
// Make sure we mark the PluginMessage as valid
resolvedClientBrand = true;
}
return;
}
if (packet instanceof TransactionPacket) {
// Check if we are currently expecting a Transaction packet
assertState(TRANSACTION);
final TransactionPacket transaction = (TransactionPacket) packet;
// The transaction should always be accepted
checkFrame(transaction.isAccepted(), "transaction not accepted?!");
// Check if the transaction ID is valid
checkFrame(transaction.getTransactionId() == expectedTransactionId, "invalid transaction id");
// First, send an Abilities packet to the client to make
// sure the player falls even in spectator mode.
// Then, teleport the player to the spawn position.
sendAbilitiesAndTeleport();
// 1.7-1.8 clients do not have a TeleportConfirm packet
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) <= 0) {
// Immediately send the chunk data as 1.7-1.8 can't confirm teleport packets
sendChunkData();
} else {
// Send all previously sent packets in one flush since we didn't flush
// the channel earlier when we teleported the player.
user.getChannel().flush();
}
return;
}
if (packet instanceof TeleportConfirmPacket) {
// Check if we are currently expecting a TeleportConfirm packet
assertState(TELEPORT);
// Check if the teleport ID is correct
final TeleportConfirmPacket teleportConfirm = (TeleportConfirmPacket) packet;
checkFrame(teleportConfirm.getTeleportId() == expectedTeleportId, "invalid teleport ID");
// Reset all values to ensure safety on teleport
tick = 1;
posY = lastY = expectedTeleportId = -1;
// Now we can send the chunk data
sendChunkData();
return;
}
// Only handle position packets if we aren't in certain phases
if (user.getState() != LOGIN_ACK && user.getState() != VEHICLE) {
if (packet instanceof PlayerPositionPacket) {
final PlayerPositionPacket position = (PlayerPositionPacket) packet;
handlePositionUpdate(position.getX(), position.getY(), position.getZ(), position.isOnGround());
return;
}
if (packet instanceof PlayerPositionLookPacket) {
final PlayerPositionLookPacket position = (PlayerPositionLookPacket) packet;
handlePositionUpdate(position.getX(), position.getY(), position.getZ(), position.isOnGround());
return;
}
if (packet instanceof PlayerPositionIdlePacket) {
final PlayerPositionIdlePacket player = (PlayerPositionIdlePacket) packet;
handlePositionUpdate(posX, posY, posZ, player.isOnGround());
}
}
}
private void handlePositionUpdate(final double x, final double y, final double z, final boolean ground) {
try {
// 1.8 does not have a TeleportConfirm packet, so we need to handle the spawning differently
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) <= 0
&& expectedTeleportId != -1 // Check if the teleport ID is currently unset
// Then, check if the position is equal to the spawn position
&& x == SPAWN_X_POSITION && y == spawnYPosition && z == SPAWN_Z_POSITION) {
// Reset all values to ensure safety on teleport
tick = 1;
posY = expectedTeleportId = -1;
// Check for ground state in the first packet
checkFrame(!ground, "invalid ground state");
}
posX = x;
lastY = posY;
posY = y;
posZ = z;
// The player is not allowed to move away from the collision platform.
// This should not happen unless the max movement tick is configured to a high number.
checkFrame(Math.abs(x - BLOCKS_PER_ROW) < BLOCKS_PER_ROW, "moved too far (x)");
checkFrame(Math.abs(z - BLOCKS_PER_ROW) < BLOCKS_PER_ROW, "moved too far (z)");
// Check if the client hasn't moved before sending the first movement packet
if (!listenForMovements) {
if (posX == SPAWN_X_POSITION && posZ == SPAWN_Z_POSITION) {
// Now, once we verified the X and Z position, we can safely check for gravity
listenForMovements = true;
}
lastY = spawnYPosition;
return;
}
final double deltaY = lastY - y;
// The deltaY is 0 whenever the player sends their first position packet.
// We have to account for this or the player will fail the verification.
if (deltaY == 0) return;
// Calculate the difference between the player's Y coordinate and the expected Y coordinate
double collisionOffsetY = (DEFAULT_Y_COLLIDE_POSITION + blockType.getBlockHeight()) - y;
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) < 0) {
collisionOffsetY += 1.62f; // 1.7 is weird and sends the head position instead of the AABB minY
}
// Check if the player is colliding by performing a basic Y offset check.
// The offset cannot be greater than 0 since the blocks will not let the player fall through them.
checkFrame(collisionOffsetY <= 0, "fell through blocks: " + collisionOffsetY);
if (tick > maxMovementTick) {
// Log/debug position if enabled in the configuration
if (Sonar.get().getConfig().getVerification().isDebugXYZPositions()) {
FALLBACK.getLogger().info("{}: {}/{}/{} - deltaY: {} - ground: {} - collision: {}",
username, x, y, z, deltaY, ground, collisionOffsetY);
}
// If the collision check is disabled, finish verification
if (!performCollisions) {
vehicleCheckOrNext();
return;
}
// Perform the collision check
if (ground) {
// Make sure the player is actually colliding with the blocks and not only spoofing ground
checkFrame(collisionOffsetY > -0.03, "illegal collision: " + collisionOffsetY);
// The player is colliding to blocks, finish verification
vehicleCheckOrNext();
return;
} else {
// Make sure the player is colliding with blocks but is sending an invalid ground state
checkFrame(collisionOffsetY != 0, "no ground: " + collisionOffsetY);
}
} else {
// Check if the player is spoofing the ground state
checkFrame(!ground, "spoofed ground state");
}
// Make sure we don't run out of predicted Y motions
checkFrame(tick < preparedCachedYMotions.length, "too many movements");
if (!ground && performGravity) {
final double predictedY = preparedCachedYMotions[tick];
final double offsetY = Math.abs(deltaY - predictedY);
// Log/debug position if enabled in the configuration
if (Sonar.get().getConfig().getVerification().isDebugXYZPositions()) {
FALLBACK.getLogger().info("{}: {}/{}/{} - deltaY: {} - prediction: {} - offset: {}",
username, x, y, z, deltaY, predictedY, offsetY);
}
// Check if the y motion is roughly equal to the predicted value
checkFrame(offsetY < 5e-3, String.format("invalid gravity: %d, %.7f, %.10f, %.10f != %.10f",
tick, y, offsetY, deltaY, predictedY));
}
tick++;
} catch (CorruptedFrameException exception) {
// Do not throw the exception if the user configured to display the CAPTCHA instead
if (Sonar.get().getConfig().getVerification().getGravity().isCaptchaOnFail()) {
handleCAPTCHA();
return;
}
// Interrupt the netty eventLoop to immediately stop further processing
throw new DecoderException(exception);
}
}
// TODO: Maybe recode the flow with a better state system?
private void vehicleCheckOrNext() {
if (performVehicle) {
// Initialize the vehicle check
handleVehicleCheck();
return;
}
captchaOrNext();
}
private void captchaOrNext() {
if (performCaptcha) {
// Initialize the map captcha
handleCAPTCHA();
return;
}
// Finish the verification
finish();
}
private void handleVehicleCheck() {
// Set the state to VEHICLE, so we don't handle any unnecessary packets
user.setState(VEHICLE);
// Send the necessary packets to mount the player on the vehicle
final int vehicleType = EntityType.BOAT.getId(user.getProtocolVersion());
user.delayedWrite(new SpawnEntityPacket(VEHICLE_ENTITY_ID, vehicleType, posX, posY, posZ));
user.delayedWrite(setPassengers);
user.getChannel().flush();
}
private void handleCAPTCHA() {
// Set the state to CAPTCHA, so we don't handle any unnecessary packets
user.setState(CAPTCHA);
if (!MAP_INFO_PREPARER.isCaptchaAvailable()) {
// This should not happen, but we have to return if there is no captcha prepared
user.disconnect(Sonar.get().getConfig().getVerification().getCurrentlyPreparing());
return;
}
if (!performGravity
&& user.getProtocolVersion().compareTo(MINECRAFT_1_18_2) >= 0) {
// Make sure the player escapes the 1.18.2+ "Loading terrain" screen
user.delayedWrite(CAPTCHA_SPAWN_POSITION);
}
if (performVehicle) {
// Make sure the player exits the vehicle before sending the CAPTCHA
user.delayedWrite(removeEntities);
}
// Reset max tries
captchaTriesLeft = Sonar.get().getConfig().getVerification().getMap().getMaxTries();
// Set slot to map
user.delayedWrite(new SetSlotPacket(36, 1,
ItemType.FILLED_MAP.getId(user.getProtocolVersion()), SetSlotPacket.MAP_NBT));
// Send random captcha to the player
captcha = MAP_INFO_PREPARER.getRandomCaptcha();
Objects.requireNonNull(captcha).delayedWrite(user);
// Teleport the player to the position above the platform
user.delayedWrite(CAPTCHA_POSITION);
// Make sure the player cannot move
user.delayedWrite(user.isGeyser() ? CAPTCHA_ABILITIES_BEDROCK : CAPTCHA_ABILITIES);
// Make sure the player knows what to do
user.delayedWrite(enterCodeMessage);
// Send all packets in one flush
user.getChannel().flush();
}
private void finish() {
// Make sure we drop all further packets
user.setState(SUCCESS);
// Increment amount of total successful verifications
GlobalSonarStatistics.totalSuccessfulVerifications++;
// Add verified player to the database
final VerifiedPlayer verifiedPlayer = new VerifiedPlayer(user.getInetAddress().toString(), playerUuid,
login.getStart());
Sonar.get().getVerifiedPlayerController().add(verifiedPlayer);
// Call the VerifySuccessEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifySuccessEvent(username, playerUuid, user, login.delay()));
// If enabled, transfer the player back to the origin server
// This feature was introduced by Mojang in Minecraft version 1.20.5
// Thanks a lot to @Kadeluxe for helping me out with this!
if (transfer) {
user.write(transferToOrigin);
}
// Disconnect player with the verification success message
user.disconnect(Sonar.get().getConfig().getVerification().getVerificationSuccess());
FALLBACK.getLogger().info(Sonar.get().getConfig().getVerification().getSuccessLog()
.replace("%name%", username)
.replace("%time%", login.toString()));
}
/**
* Fails the verification if a certain state is unexpected.
*
* @param expectedState Expected state
*/
private void assertState(final @NotNull FallbackUserState expectedState) {
final FallbackUserState state = user.getState();
checkFrame(state == expectedState, "expected " + expectedState + ", got " + state);
}
/**
* Checks if a certain condition is met, fails the verification if not.
*
* @param condition Condition to fail if it's false
* @param message Messages displayed in the stacktrace
*/
private void checkFrame(final boolean condition, final @NotNull String message) {
if (!condition) {
// Only fail the player if we are either not in the POSITION state,
// or if the user configured Sonar to display a CAPTCHA instead of failing.
if (user.getState() != POSITION
|| !Sonar.get().getConfig().getVerification().getGravity().isCaptchaOnFail()) {
user.fail(message);
}
throw new CorruptedFrameException(message);
}
}
}
@@ -20,10 +20,7 @@ package xyz.jonesdev.sonar.common.fallback.protocol;
import io.netty.buffer.ByteBuf;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import java.util.UUID;
public interface FallbackPacket {
UUID EMPTY_UUID = new UUID(0L, 0L);
/**
* Encodes the packet sent by the server
@@ -21,8 +21,9 @@ import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.CorruptedFrameException;
import lombok.Setter;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_20_2;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry.Direction.SERVERBOUND;
@@ -31,19 +32,18 @@ import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry
import static xyz.jonesdev.sonar.common.util.ProtocolUtil.readVarInt;
public final class FallbackPacketDecoder extends ChannelInboundHandlerAdapter {
private final FallbackUser user;
private final FallbackPacketListener listener;
private final ProtocolVersion protocolVersion;
private FallbackPacketRegistry.ProtocolRegistry registry;
@Setter
private FallbackPacketListener listener;
public FallbackPacketDecoder(final @NotNull FallbackUser user,
final @NotNull FallbackPacketListener listener) {
this.user = user;
this.listener = listener;
updateRegistry(user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) >= 0 ? LOGIN : GAME);
public FallbackPacketDecoder(final @NotNull ProtocolVersion protocolVersion) {
this.protocolVersion = protocolVersion;
updateRegistry(protocolVersion.compareTo(MINECRAFT_1_20_2) >= 0 ? LOGIN : GAME);
}
public void updateRegistry(final @NotNull FallbackPacketRegistry registry) {
this.registry = registry.getProtocolRegistry(SERVERBOUND, user.getProtocolVersion());
this.registry = registry.getProtocolRegistry(SERVERBOUND, protocolVersion);
}
@Override
@@ -73,24 +73,24 @@ public final class FallbackPacketDecoder extends ChannelInboundHandlerAdapter {
try {
// Ensure that the packet isn't too large or too small
doLengthSanityChecks(byteBuf, packet);
checkPacketSize(byteBuf, packet);
try {
// Try to decode the packet for the given protocol version
packet.decode(byteBuf, user.getProtocolVersion());
packet.decode(byteBuf, protocolVersion);
} catch (Throwable throwable) {
user.fail("failed to decode packet (" + byteBuf.readableBytes() + " bytes)");
throw new CorruptedFrameException("Failed to decode packet");
}
// Check if the packet still has bytes left after we decoded it
if (byteBuf.isReadable()) {
user.fail("could not read packet to end (" + byteBuf.readableBytes() + " bytes left)");
throw new CorruptedFrameException("Could not read packet to end");
}
// Let our verification handler process the packet
listener.handle(packet);
if (listener != null) {
listener.handle(packet);
}
// Fire channel read to avoid timeout
ctx.fireChannelRead(packet);
@@ -101,17 +101,15 @@ public final class FallbackPacketDecoder extends ChannelInboundHandlerAdapter {
}
}
private void doLengthSanityChecks(final @NotNull ByteBuf byteBuf,
final @NotNull FallbackPacket packet) throws Exception {
final int expectedMaxLen = packet.expectedMaxLength(byteBuf, user.getProtocolVersion());
private void checkPacketSize(final @NotNull ByteBuf byteBuf,
final @NotNull FallbackPacket packet) throws Exception {
final int expectedMaxLen = packet.expectedMaxLength(byteBuf, protocolVersion);
if (expectedMaxLen != -1 && byteBuf.readableBytes() > expectedMaxLen) {
user.fail("packet too large (" + byteBuf.readableBytes() + " bytes)");
throw new CorruptedFrameException("Packet too large");
}
final int expectedMinLen = packet.expectedMinLength(byteBuf, user.getProtocolVersion());
if (byteBuf.readableBytes() < expectedMinLen) {
user.fail("packet too small (" + byteBuf.readableBytes() + " bytes)");
final int expectedMinLen = packet.expectedMinLength(byteBuf, protocolVersion);
if (expectedMinLen != -1 && byteBuf.readableBytes() < expectedMinLen) {
throw new CorruptedFrameException("Packet too small");
}
}
@@ -19,11 +19,13 @@ package xyz.jonesdev.sonar.common.fallback.protocol;
import io.netty.util.collection.IntObjectHashMap;
import io.netty.util.collection.IntObjectMap;
import lombok.AccessLevel;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.FinishConfigurationPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.RegistryDataPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.configuration.FinishConfigurationPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.configuration.RegistryDataPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginAcknowledgedPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccessPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.*;
@@ -38,14 +40,13 @@ import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
public enum FallbackPacketRegistry {
LOGIN {
{
serverbound.register(LoginAcknowledgedPacket.class, LoginAcknowledgedPacket::new,
map(0x03, MINECRAFT_1_20_2, false));
clientbound.register(LoginSuccessPacket.class, LoginSuccessPacket::new,
map(0x02, MINECRAFT_1_7_2, false));
clientbound.register(DisconnectPacket.class, DisconnectPacket::new,
map(0x00, MINECRAFT_1_7_2, true));
clientbound.register(LoginSuccessPacket.class, LoginSuccessPacket::new,
map(0x02, MINECRAFT_1_7_2, true));
serverbound.register(LoginAcknowledgedPacket.class, LoginAcknowledgedPacket::new,
map(0x03, MINECRAFT_1_20_2, false));
}
},
CONFIG {
@@ -59,7 +60,6 @@ public enum FallbackPacketRegistry {
clientbound.register(KeepAlivePacket.class, KeepAlivePacket::new,
map(0x03, MINECRAFT_1_20_2, false),
map(0x04, MINECRAFT_1_20_5, false));
clientbound.register(RegistryDataPacket.class, RegistryDataPacket::new,
map(0x05, MINECRAFT_1_20_2, true),
map(0x07, MINECRAFT_1_20_5, true));
@@ -208,6 +208,22 @@ public enum FallbackPacketRegistry {
map(0x0E, MINECRAFT_1_7_2, true),
map(0x00, MINECRAFT_1_9, true),
map(0x01, MINECRAFT_1_19_4, true));
clientbound.register(PlayerInfoPacket.class, PlayerInfoPacket::new,
map(0x38, MINECRAFT_1_7_2, true),
map(0x2D, MINECRAFT_1_9, true),
map(0x2E, MINECRAFT_1_12_1, true),
map(0x30, MINECRAFT_1_13, true),
map(0x33, MINECRAFT_1_14, true),
map(0x34, MINECRAFT_1_15, true),
map(0x33, MINECRAFT_1_16, true),
map(0x32, MINECRAFT_1_16_2, true),
map(0x36, MINECRAFT_1_17, true),
map(0x34, MINECRAFT_1_19, true),
map(0x37, MINECRAFT_1_19_1, true),
map(0x36, MINECRAFT_1_19_3, true),
map(0x3A, MINECRAFT_1_19_4, true),
map(0x3C, MINECRAFT_1_20_2, true),
map(0x3E, MINECRAFT_1_20_5, true));
clientbound.register(UpdateSectionBlocksPacket.class, UpdateSectionBlocksPacket::new,
map(0x22, MINECRAFT_1_7_2, true),
map(0x10, MINECRAFT_1_9, true),
@@ -511,7 +527,7 @@ public enum FallbackPacketRegistry {
final PacketMapping next = (i + 1 < mappings.length) ? mappings[i + 1] : current;
final ProtocolVersion from = current.protocolVersion;
final ProtocolVersion to = getProtocolVersion(current, next, from);
final ProtocolVersion to = getProtocolVersion(current, next);
for (final ProtocolVersion protocol : EnumSet.range(from, to)) {
if (protocol == to && next != current) {
@@ -520,19 +536,18 @@ public enum FallbackPacketRegistry {
final ProtocolRegistry registry = versions.get(protocol);
if (registry == null) {
throw new IllegalArgumentException("Unknown protocol version "
+ current.protocolVersion);
throw new IllegalArgumentException("Unknown protocol version " + from);
}
if (registry.packetIdToSupplier.containsKey(current.id)) {
throw new IllegalArgumentException("Can not register class " + clazz.getSimpleName()
+ " with id " + current.id + " for " + registry.version
+ " with id " + current.id + " for " + registry.protocolVersion
+ " because another packet is already registered");
}
if (registry.packetClassToId.containsKey(clazz)) {
throw new IllegalArgumentException(clazz.getSimpleName()
+ " is already registered for version " + registry.version);
+ " is already registered for version " + registry.protocolVersion);
}
if (!current.encodeOnly) {
@@ -545,23 +560,19 @@ public enum FallbackPacketRegistry {
@NotNull
private static ProtocolVersion getProtocolVersion(final @NotNull PacketMapping current,
final @NotNull PacketMapping next,
final @NotNull ProtocolVersion from) {
final @NotNull PacketMapping next) {
return current == next ? LATEST_VERSION : next.protocolVersion;
}
}
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class ProtocolRegistry {
private final ProtocolVersion version;
private final ProtocolVersion protocolVersion;
private final IntObjectMap<Supplier<? extends FallbackPacket>> packetIdToSupplier =
new IntObjectHashMap<>(16, 0.5f);
private final Map<Class<? extends FallbackPacket>, Integer> packetClassToId =
new HashMap<>(16, 0.5f);
ProtocolRegistry(final ProtocolVersion version) {
this.version = version;
}
public FallbackPacket createPacket(final int id) {
final Supplier<? extends FallbackPacket> supplier = packetIdToSupplier.get(id);
@@ -589,16 +600,16 @@ public enum FallbackPacketRegistry {
PacketMapping(final int id,
final ProtocolVersion protocolVersion,
final boolean packetDecoding) {
final boolean encodeOnly) {
this.id = id;
this.protocolVersion = protocolVersion;
this.encodeOnly = packetDecoding;
this.encodeOnly = encodeOnly;
}
}
private static @NotNull PacketMapping map(final int id,
final ProtocolVersion version,
final ProtocolVersion protocolVersion,
final boolean encodeOnly) {
return new PacketMapping(id, version, encodeOnly);
return new PacketMapping(id, protocolVersion, encodeOnly);
}
}
@@ -24,8 +24,8 @@ import xyz.jonesdev.sonar.common.fallback.protocol.block.BlockType;
import xyz.jonesdev.sonar.common.fallback.protocol.block.BlockUpdate;
import xyz.jonesdev.sonar.common.fallback.protocol.captcha.CaptchaPreparer;
import xyz.jonesdev.sonar.common.fallback.protocol.dimension.DimensionRegistry;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.FinishConfigurationPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.config.RegistryDataPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.configuration.FinishConfigurationPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.configuration.RegistryDataPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccessPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.*;
@@ -60,10 +60,16 @@ public class FallbackPreparer {
public FallbackPacket joinGame;
// Update Section Blocks
public FallbackPacket updateSectionBlocks;
// Player Info
public final FallbackPacket PLAYER_INFO = new FallbackPacketSnapshot(new PlayerInfoPacket(
"", new UUID(1L, 1L), 2));
// Default Spawn Position
public FallbackPacket dynamicSpawnPosition;
public FallbackPacket defaultSpawnPosition;
// Spawn Position
public final int TELEPORT_ID = ThreadLocalRandom.current().nextInt();
public FallbackPacket spawnPosition;
// Transfer packet
public FallbackPacket transferToOrigin;
public static FallbackPacket transferToOrigin;
// Disconnect messages
public FallbackPacket blacklisted;
@@ -76,7 +82,7 @@ public class FallbackPreparer {
// Vehicle
public FallbackPacket removeEntities;
public FallbackPacket setPassengers;
public static FallbackPacket setPassengers;
public static final int VEHICLE_ENTITY_ID = PLAYER_ENTITY_ID + 1;
// Collisions
@@ -91,10 +97,8 @@ public class FallbackPreparer {
public final FallbackPacket CAPTCHA_SPAWN_POSITION = new FallbackPacketSnapshot(new DefaultSpawnPositionPacket(
SPAWN_X_POSITION, 1337, SPAWN_Z_POSITION));
// Blocks
private final BlockUpdate[] CHANGED_BLOCKS = new BlockUpdate[BLOCKS_PER_ROW * BLOCKS_PER_ROW];
// Platform
public BlockType blockType = BlockType.BARRIER;
public int maxMovementTick, dynamicSpawnYPosition;
public double[] preparedCachedYMotions;
public double maxFallDistance;
@@ -126,10 +130,15 @@ public class FallbackPreparer {
// Set the dynamic block and collide Y position based on the maximum fall distance
dynamicSpawnYPosition = DEFAULT_Y_COLLIDE_POSITION + (int) Math.ceil(maxFallDistance);
dynamicSpawnPosition = new FallbackPacketSnapshot(new DefaultSpawnPositionPacket(
defaultSpawnPosition = new FallbackPacketSnapshot(new DefaultSpawnPositionPacket(
SPAWN_X_POSITION, dynamicSpawnYPosition, SPAWN_Z_POSITION));
spawnPosition = new FallbackPacketSnapshot(new PlayerPositionLookPacket(
SPAWN_X_POSITION, dynamicSpawnYPosition, SPAWN_Z_POSITION,
0, -90, TELEPORT_ID, false));
// Prepare collision platform positions
final BlockUpdate[] changedBlocks = new BlockUpdate[BLOCKS_PER_ROW * BLOCKS_PER_ROW];
int index = 0;
for (int x = 0; x < BLOCKS_PER_ROW; x++) {
for (int z = 0; z < BLOCKS_PER_ROW; z++) {
@@ -138,12 +147,11 @@ public class FallbackPreparer {
DEFAULT_Y_COLLIDE_POSITION,
z + (BLOCKS_PER_ROW / 2),
0, 0);
CHANGED_BLOCKS[index++] = new BlockUpdate(position, blockType);
changedBlocks[index++] = new BlockUpdate(position, blockType);
}
}
// Prepare UpdateSectionBlocks packet
updateSectionBlocks = new FallbackPacketSnapshot(new UpdateSectionBlocksPacket(0, 0, CHANGED_BLOCKS));
updateSectionBlocks = new FallbackPacketSnapshot(new UpdateSectionBlocksPacket(0, 0, changedBlocks));
// Prepare disconnect packets during login
blacklisted = new FallbackPacketSnapshot(DisconnectPacket.create(Sonar.get().getConfig().getVerification().getBlacklisted(), true));
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.packets.config;
package xyz.jonesdev.sonar.common.fallback.protocol.packets.configuration;
import io.netty.buffer.ByteBuf;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.packets.config;
package xyz.jonesdev.sonar.common.fallback.protocol.packets.configuration;
import io.netty.buffer.ByteBuf;
import lombok.*;
@@ -0,0 +1,98 @@
/*
* 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;
import java.util.EnumSet;
import java.util.UUID;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
import static xyz.jonesdev.sonar.common.util.ProtocolUtil.*;
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public final class PlayerInfoPacket implements FallbackPacket {
private String username;
private UUID uuid;
private int gamemode;
@Override
public void encode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) throws Exception {
if (protocolVersion.compareTo(MINECRAFT_1_8) < 0) {
writeString(byteBuf, username);
byteBuf.writeBoolean(true); // online
byteBuf.writeShort(0);
return;
}
// https://wiki.vg/Protocol#player-info:player-actions
if (protocolVersion.compareTo(MINECRAFT_1_19_3) >= 0) {
final EnumSet<Action> actions = EnumSet.noneOf(Action.class);
actions.add(Action.ADD_PLAYER);
actions.add(Action.UPDATE_LISTED);
actions.add(Action.UPDATE_GAMEMODE);
writeEnumSet(byteBuf, actions, Action.class);
writeVarInt(byteBuf, 1); // size
writeUUID(byteBuf, uuid);
writeString(byteBuf, username);
writeVarInt(byteBuf, 0); // properties
byteBuf.writeBoolean(true); // update listed
writeVarInt(byteBuf, gamemode);
return;
}
writeVarInt(byteBuf, 0); // ADD_PLAYER
writeVarInt(byteBuf, 1);
writeUUID(byteBuf, uuid);
writeString(byteBuf, username);
writeVarInt(byteBuf, 0);
writeVarInt(byteBuf, gamemode);
writeVarInt(byteBuf, 60);
byteBuf.writeBoolean(false);
if (protocolVersion.compareTo(MINECRAFT_1_19) >= 0) {
byteBuf.writeBoolean(false);
}
}
@Override
public void decode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
public enum Action {
ADD_PLAYER,
INITIALIZE_CHAT,
UPDATE_GAMEMODE,
UPDATE_LISTED,
UPDATE_LATENCY,
UPDATE_DISPLAY_NAME
}
}
@@ -27,8 +27,7 @@ import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
import static xyz.jonesdev.sonar.common.util.ProtocolUtil.writeUUID;
import static xyz.jonesdev.sonar.common.util.ProtocolUtil.writeVarInt;
import static xyz.jonesdev.sonar.common.util.ProtocolUtil.*;
@Getter
@ToString
@@ -0,0 +1,110 @@
/*
* 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.session;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.captcha.ItemType;
import xyz.jonesdev.sonar.common.fallback.protocol.captcha.MapCaptchaInfo;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.*;
import java.util.UUID;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
/**
* Flow for this session handler
*
* <li>
* {@link SetSlotPacket} and {@link MapDataPacket} packets are sent to the client,
* therefore, setting the player's item to a map with a code on it (CAPTCHA).
* <br>
* See more: {@link FallbackCAPTCHASessionHandler}, {@link MapCaptchaInfo}
* </li>
* <li>
* Then, we wait for the player to enter the {@link FallbackCAPTCHASessionHandler#answer} in chat.
* </li>
*/
public final class FallbackCAPTCHASessionHandler extends FallbackSessionHandler {
public FallbackCAPTCHASessionHandler(final FallbackUser user, final String username, final UUID uuid) {
super(user, username, uuid);
this.tries = Sonar.get().getConfig().getVerification().getMap().getMaxTries();
// Set slot to map
user.delayedWrite(new SetSlotPacket(36, 1,
ItemType.FILLED_MAP.getId(user.getProtocolVersion()), SetSlotPacket.MAP_NBT));
// Send random captcha to the player
final MapCaptchaInfo captcha = MAP_INFO_PREPARER.getRandomCaptcha();
this.answer = captcha.getAnswer();
captcha.delayedWrite(user);
// Teleport the player to the position above the platform
user.delayedWrite(CAPTCHA_POSITION);
// Make sure the player cannot move
user.delayedWrite(user.isGeyser() ? CAPTCHA_ABILITIES_BEDROCK : CAPTCHA_ABILITIES);
// Make sure the player knows what to do
user.delayedWrite(enterCodeMessage);
// Send all packets in one flush
user.getChannel().flush();
}
private final String answer;
private int tries, lastCountdownIndex, keepAliveStreak;
@Override
public void handle(final @NotNull FallbackPacket packet) {
// Check if the player took too long to enter the captcha
final int maxDuration = Sonar.get().getConfig().getVerification().getMap().getMaxDuration();
checkState(!user.getLoginTimer().elapsed(maxDuration), "took too long to enter captcha");
// Handle incoming chat messages
if (packet instanceof UniversalChatPacket) {
final UniversalChatPacket chat = (UniversalChatPacket) packet;
// Captcha is correct, finish verification
if (chat.getMessage().equals(answer)) {
finishVerification();
return;
}
// Captcha is incorrect, remove one try
checkState(tries-- > 0, "failed captcha too often");
user.write(incorrectCaptcha);
} else if (packet instanceof PlayerPositionPacket
|| packet instanceof PlayerPositionLookPacket) {
// A position packet is sent approximately every second
final long difference = maxDuration - user.getLoginTimer().delay();
final int index = (int) (difference / 1000D);
// Make sure we can actually safely get and send the packet
if (lastCountdownIndex != index && index >= 0 && xpCountdown.length > index) {
// Send the countdown using the experience bar
user.write(xpCountdown[index]);
}
lastCountdownIndex = index;
// Send a KeepAlive packet every few seconds
if (keepAliveStreak++ > 20) {
keepAliveStreak = 0;
// Send a KeepAlive packet to prevent timeout
user.write(CAPTCHA_KEEP_ALIVE);
}
}
}
}
@@ -0,0 +1,276 @@
/*
* 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.session;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.*;
import java.util.UUID;
import static xyz.jonesdev.sonar.api.config.SonarConfiguration.Verification.Gravity.Gamemode.CREATIVE;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
public final class FallbackGravitySessionHandler extends FallbackSessionHandler {
public FallbackGravitySessionHandler(final FallbackUser user, final String username, final UUID uuid) {
super(user, username, uuid);
// We don't want to check Geyser players for valid gravity, as this might cause issues because of the protocol
this.enableGravityCheck = !user.isGeyser() && Sonar.get().getConfig().getVerification().getGravity().isEnabled();
this.enableCollisionsCheck = Sonar.get().getConfig().getVerification().getGravity().isCheckCollisions();
// FIXME: 1.18.2-1.19.3 weird loading world issue
// First, write the JoinGame packet to the buffer
user.delayedWrite(joinGame);
// Then, write the ClientAbilities packet to the buffer
// This is only necessary if the player is in creative mode
if (Sonar.get().getConfig().getVerification().getGravity().getGamemode() == CREATIVE) {
user.delayedWrite(DEFAULT_ABILITIES);
}
// Write the PositionLook packet to the buffer
user.delayedWrite(spawnPosition);
// Write the DefaultSpawnPosition packet to the buffer
if (user.getProtocolVersion().compareTo(MINECRAFT_1_19_3) >= 0) {
user.delayedWrite(defaultSpawnPosition);
}
// Write the PlayerInfo packet to the buffer
if (user.getProtocolVersion().compareTo(MINECRAFT_1_16_4) >= 0) {
user.delayedWrite(PLAYER_INFO);
}
// 1.20.3+ introduced game events
// Make sure the client knows that we're sending chunks next
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_3) >= 0) {
user.delayedWrite(START_WRITING_CHUNKS);
}
// Teleport player into an empty world by sending an empty chunk packet
user.delayedWrite(EMPTY_CHUNK_DATA);
// Spawn the invisible platform below the player
if (this.enableCollisionsCheck) {
user.delayedWrite(updateSectionBlocks);
}
// Send all packets at once
user.getChannel().flush();
// 1.8 and below don't have TeleportConfirm packets, which is why we're skipping that check.
if (user.getProtocolVersion().compareTo(MINECRAFT_1_9) < 0) {
markTeleported();
}
}
private final boolean enableGravityCheck, enableCollisionsCheck;
private boolean teleported, checkMovement;
private short expectedTransactionId = 1;
private double x, y, z, deltaY;
private int movementTick;
private void markTeleported() {
// Activate the movement checks
teleported = true;
}
private void markNextStage() {
// Force-stop the movement checks
teleported = false;
// Check if the player sent all necessary packets
checkState(user.isReceivedClientSettings(), "didn't send client settings");
// Don't check Geyser players for plugin messages
if (!user.isGeyser()) {
checkState(user.isReceivedPluginMessage(), "didn't send plugin message");
}
}
/**
* Uses ping packets to check for an immediate, legitimate response from the client
* <br>
* <a href="https://wiki.vg/Protocol#Ping_.28configuration.29">Wiki.vg - Ping (configuration)</a>
* <a href="https://wiki.vg/Protocol#Ping_.28play.29">Wiki.vg - Ping (play)</a>
*/
private void sendTransaction() {
// Interrupt any ongoing process/check
markNextStage();
// Send a Transaction (Ping) packet with a random ID
expectedTransactionId = (short) -(RANDOM.nextInt(Short.MAX_VALUE));
user.write(new TransactionPacket(0, expectedTransactionId, false));
}
private void forceCAPTCHA() {
// Interrupt any ongoing process/check
markNextStage();
// Forcefully make the player solve a CAPTCHA
final var decoder = (FallbackPacketDecoder) user.getPipeline().get(FallbackPacketDecoder.class);
decoder.setListener(new FallbackCAPTCHASessionHandler(user, username, uuid));
}
private void markSuccess() {
// Either send the player to the vehicle check,
// send the player to the CAPTCHA, or finish the verification.
final var decoder = (FallbackPacketDecoder) user.getPipeline().get(FallbackPacketDecoder.class);
// Pass the player to the next best verification handler
if (Sonar.get().getFallback().shouldPerformVehicleCheck()) {
decoder.setListener(new FallbackVehicleSessionHandler(user, username, uuid, x, y, z));
} else if (Sonar.get().getFallback().shouldPerformCaptcha()) {
decoder.setListener(new FallbackCAPTCHASessionHandler(user, username, uuid));
} else {
// The player has passed all checks
finishVerification();
}
}
@Override
public void handle(final @NotNull FallbackPacket packet) {
if (packet instanceof TransactionPacket) {
final TransactionPacket transaction = (TransactionPacket) packet;
// Make sure random transactions aren't counted
checkState(expectedTransactionId <= 0, "unexpected transaction");
// Make sure the transaction was accepted
// This must - by vanilla protocol - always be accepted
checkState(transaction.isAccepted(), "didn't accept transaction");
// Also check if the transaction ID matches the expected ID
final long transactionId = transaction.getTransactionId();
checkState(transactionId == expectedTransactionId,
"expected T ID " + expectedTransactionId + ", but got " + transactionId);
markSuccess();
} else if (packet instanceof PlayerPositionLookPacket) {
// Make sure the player has teleported before checking for position packets
if (teleported) {
final PlayerPositionLookPacket positionLook = (PlayerPositionLookPacket) packet;
handleMovement(positionLook.getX(), positionLook.getY(), positionLook.getZ(), positionLook.isOnGround());
}
} else if (packet instanceof PlayerPositionPacket) {
// Make sure the player has teleported before checking for position packets
if (teleported) {
final PlayerPositionPacket position = (PlayerPositionPacket) packet;
handleMovement(position.getX(), position.getY(), position.getZ(), position.isOnGround());
}
} else if (packet instanceof TeleportConfirmPacket) {
final TeleportConfirmPacket teleportConfirm = (TeleportConfirmPacket) packet;
// Only expect this packet to be sent once
checkState(!teleported, "duplicate teleport confirm");
// Check if the teleport ID matches the expected ID
final int teleportId = teleportConfirm.getTeleportId();
checkState(teleportId == TELEPORT_ID,
"expected TP ID " + TELEPORT_ID + ", but got " + teleportId);
markTeleported();
}
// Make sure to catch all ClientSettings and PluginMessage packets during the play phase.
else if (packet instanceof ClientSettingsPacket) {
// Let the session handler itself know about this packet
checkClientSettings((ClientSettingsPacket) packet);
} else if (packet instanceof PluginMessagePacket) {
// Let the session handler itself know about this packet
checkPluginMessage((PluginMessagePacket) packet);
}
}
private void handleMovement(final double x, final double y, final double z, final boolean isOnGround) {
// Check if the client hasn't moved before sending the first movement packet
if (!checkMovement) {
// No need to continue checking if the gravity and collision checks are disabled
// Continue with the next stage of the verification
if (!enableGravityCheck && !enableCollisionsCheck) {
sendTransaction();
return;
}
if (x == SPAWN_X_POSITION && z == SPAWN_Z_POSITION) {
// Now, once we verified the X and Z position, we can safely check for gravity
checkMovement = true;
movementTick = 0;
// Synchronize the Y coordinate
this.y = dynamicSpawnYPosition;
}
return;
}
// Calculate/store all necessary positions
final double lastDeltaY = this.deltaY;
final double lastY = this.y;
this.deltaY = y - lastY;
this.x = x;
this.y = y;
this.z = z;
// Log/debug position if enabled in the configuration
if (Sonar.get().getConfig().getVerification().isDebugXYZPositions()) {
Sonar.get().getFallback().getLogger().info("{}: {}/{}/{}, ly={}/dy={}, {}",
username, x, y, z, lastY, deltaY, isOnGround);
}
// The player is not allowed to move away from the collision platform.
// This should not happen unless the max movement tick is configured to a high number.
checkState(Math.abs(x - BLOCKS_PER_ROW) < BLOCKS_PER_ROW, "moved too far (x)");
checkState(Math.abs(z - BLOCKS_PER_ROW) < BLOCKS_PER_ROW, "moved too far (z)");
if (!isOnGround) {
// The deltaY is 0 whenever the player sends their first position packet.
// We have to account for this or the player will fail the verification.
if (deltaY == 0) {
// Reset the movement tick as a safety measure
movementTick = 0;
return;
}
if (enableGravityCheck) {
// Predict the player's current motion based on the last motion
// https://minecraft.wiki/w/Entity#Motion_of_entities
final double predicted = (lastDeltaY - 0.08) * 0.98f;
final double difference = Math.abs(deltaY - predicted);
// Check if the difference between the predicted motion and
// the actual motion is greater than our minimum threshold.
if (difference > 1e-7) {
// Do not throw an exception if the user configured to display the CAPTCHA instead
if (Sonar.get().getConfig().getVerification().getGravity().isCaptchaOnFail()) {
forceCAPTCHA();
return;
}
user.fail("incorrect gravity; predicted: " + predicted + " deltaY: " + deltaY + " y: " + y);
}
// The player is obeying gravity, go on to the next stage if the collision check is disabled.
if (++movementTick == maxMovementTick && !enableCollisionsCheck) {
sendTransaction();
}
}
} else if (enableCollisionsCheck) {
// Calculate the difference between the player's Y coordinate and the expected Y coordinate
double collisionOffsetY = (DEFAULT_Y_COLLIDE_POSITION + blockType.getBlockHeight()) - y;
// 1.7 sends the head position instead of the AABB minY
// This little hack accounts for the offset of approximately 1.62
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) < 0) {
collisionOffsetY += 1.62f;
}
// Make sure the player is actually colliding with the blocks and not only spoofing ground
checkState(collisionOffsetY > -0.03, "illegal collision: " + collisionOffsetY);
// The player has collided with the blocks, go on to the next stage
sendTransaction();
}
}
}
@@ -0,0 +1,170 @@
/*
* 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.session;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketEncoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.configuration.FinishConfigurationPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginAcknowledgedPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.ClientSettingsPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.KeepAlivePacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.PluginMessagePacket;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
/**
* Flow for this session handler
*
* <li>
* A {@link KeepAlivePacket} packet is sent to the client*
* <br>
* See more: {@link FallbackLoginSessionHandler#initialize18()}
* </li>
* <li>
* After the KeepAlive packet is validated, the client enters the configuration phase.**
* </li>
* <li>
* Then, the session handler is set to the {@link FallbackGravitySessionHandler}.
* <br>
* See more: {@link FallbackLoginSessionHandler#markSuccess()}
* </li>
* <br>
* * The KeepAlive check is skipped on 1.7, as KeepAlive packets don't exist during the LOGIN state.
* <br>
* ** The internals of the configuration phase were not mentioned.
* Find out more about the client configuration and registry synchronization:
* {@link #synchronizeClientRegistry()}, {@link #updateEncoderDecoderState(FallbackPacketRegistry)}
*/
public final class FallbackLoginSessionHandler extends FallbackSessionHandler {
public FallbackLoginSessionHandler(final FallbackUser user, final String username, final UUID uuid) {
super(user, username, uuid);
// Start initializing the actual join process for pre-1.20.2 clients
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) < 0) {
// This trick helps in reducing unnecessary outgoing server traffic
// by avoiding sending other packets to clients that are potentially bots.
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) < 0) {
user.getChannel().eventLoop().schedule(this::markSuccess, 100L, TimeUnit.MILLISECONDS);
} else {
initialize18();
}
}
}
private boolean acknowledgedLogin;
private int expectedKeepAliveId;
/**
* The purpose of these KeepAlive packets is to confirm that the connection
* is active and legitimate, thereby preventing bot connections that
* could flood the server with login attempts and other unwanted traffic.
*/
private void initialize18() {
// Send a KeepAlive packet with a random ID
expectedKeepAliveId = RANDOM.nextInt();
user.write(new KeepAlivePacket(expectedKeepAliveId));
}
private void markSuccess() {
// Make sure we can actually switch over to the next check
updateEncoderDecoderState(FallbackPacketRegistry.GAME);
// Pass the player to the next verification handler
final FallbackGravitySessionHandler gravitySessionHandler = new FallbackGravitySessionHandler(user, username, uuid);
final var decoder = (FallbackPacketDecoder) user.getPipeline().get(FallbackPacketDecoder.class);
decoder.setListener(gravitySessionHandler);
}
private void markAcknowledged() {
acknowledgedLogin = true;
synchronizeClientRegistry();
// Write the FinishConfiguration packet to the buffer
user.delayedWrite(FINISH_CONFIGURATION);
// Send all packets in one flush
user.getChannel().flush();
// Set decoder state to actually catch all packets
updateEncoderDecoderState(FallbackPacketRegistry.CONFIG);
}
@Override
public void handle(final @NotNull FallbackPacket packet) {
if (packet instanceof KeepAlivePacket) {
// This is the first packet we expect from the client
final KeepAlivePacket keepAlive = (KeepAlivePacket) packet;
// Check if the KeepAlive ID matches the expected ID
final long keepAliveId = keepAlive.getId();
checkState(keepAliveId == expectedKeepAliveId,
"expected K ID " + expectedKeepAliveId + " but got " + keepAliveId);
// 1.8 clients send KeepAlive packets with the ID 0 every second
// while the player is in the "Downloading terrain" screen.
expectedKeepAliveId = 0;
// Spawn the player in the virtual world if the client does not need
// any configuration (pre-1.20.2).
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) < 0) {
markSuccess();
}
} else if (packet instanceof LoginAcknowledgedPacket) {
// Prevent users from sending multiple LoginAcknowledged packets
checkState(!acknowledgedLogin, "sent duplicate login ack");
markAcknowledged();
} else if (packet instanceof FinishConfigurationPacket) {
markSuccess();
}
// Make sure to catch all ClientSettings and PluginMessage packets during the configuration phase.
else if (packet instanceof ClientSettingsPacket) {
// Let the session handler itself know about this packet
checkClientSettings((ClientSettingsPacket) packet);
} else if (packet instanceof PluginMessagePacket) {
// Let the session handler itself know about this packet
checkPluginMessage((PluginMessagePacket) packet);
}
}
private void updateEncoderDecoderState(final @NotNull FallbackPacketRegistry registry) {
final var decoder = (FallbackPacketDecoder) user.getPipeline().get(FallbackPacketDecoder.class);
final var encoder = (FallbackPacketEncoder) user.getPipeline().get(FallbackPacketEncoder.class);
// Update the packet registry state in the encoder and decoder pipelines
decoder.updateRegistry(registry);
encoder.updateRegistry(registry);
}
private void synchronizeClientRegistry() {
// 1.20.5+ adds new "game bundle features" which overcomplicate all of this...
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_5) >= 0) {
// Write the new RegistrySync packets to the buffer
for (final FallbackPacket syncPacket : REGISTRY_SYNC_1_20_5) {
user.delayedWrite(syncPacket);
}
} else {
// Write the old RegistrySync packet to the buffer
user.delayedWrite(REGISTRY_SYNC_LEGACY);
}
}
}
@@ -0,0 +1,143 @@
/*
* 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.session;
import io.netty.buffer.ByteBuf;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifySuccessEvent;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.model.VerifiedPlayer;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketListener;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.ClientSettingsPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.PluginMessagePacket;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.util.ProtocolUtil;
import java.util.Random;
import java.util.UUID;
import java.util.regex.Pattern;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_13;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_20_5;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.transferToOrigin;
import static xyz.jonesdev.sonar.common.util.ProtocolUtil.BRAND_CHANNEL;
import static xyz.jonesdev.sonar.common.util.ProtocolUtil.BRAND_CHANNEL_LEGACY;
@RequiredArgsConstructor
public abstract class FallbackSessionHandler implements FallbackPacketListener {
protected final FallbackUser user;
protected final String username;
protected final UUID uuid;
protected static final Random RANDOM = new Random();
protected void checkState(final boolean state, final String failReason) {
// Fails the verification if the condition is not met
if (!state) {
// Let the API know that the user has failed the verification
user.fail(failReason);
}
}
protected final void finishVerification() {
// Increment number of total successful verifications
GlobalSonarStatistics.totalSuccessfulVerifications++;
// Add verified player to the database
final long timeTaken = user.getLoginTimer().delay();
Sonar.get().getVerifiedPlayerController().add(new VerifiedPlayer(user.getInetAddress(), uuid, timeTaken));
// Call the VerifySuccessEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifySuccessEvent(username, uuid, user, timeTaken));
// If enabled, transfer the player back to the origin server.
// This feature was introduced by Mojang in Minecraft version 1.20.5.
if (Sonar.get().getConfig().getVerification().getTransfer().isEnabled()
&& user.getProtocolVersion().compareTo(MINECRAFT_1_20_5) >= 0) {
user.write(transferToOrigin);
// Close the channel to ensure that the connection is closed
user.getChannel().close();
} else {
// Disconnect player with the verification success message
user.disconnect(Sonar.get().getConfig().getVerification().getVerificationSuccess(), false);
}
Sonar.get().getFallback().getLogger().info(Sonar.get().getConfig().getVerification().getSuccessLog()
.replace("%name%", username)
.replace("%time%", user.getLoginTimer().toString()));
}
protected final void checkClientSettings(final @NotNull ClientSettingsPacket clientSettings) {
// Ensure that the client locale is correct
validateClientLocale(clientSettings.getLocale());
// Check if the player sent an unused bit flag in the skin section
checkState((clientSettings.getSkinParts() & 0x80) == 0, "sent unused bit flag");
// Mark the ClientSettings packet as received
user.setReceivedClientSettings(true);
}
protected final void checkPluginMessage(final @NotNull PluginMessagePacket pluginMessage) {
final boolean usingModernChannel = pluginMessage.getChannel().equals(BRAND_CHANNEL);
final boolean usingLegacyChannel = pluginMessage.getChannel().equals(BRAND_CHANNEL_LEGACY);
// Skip this payload if it does not contain client brand information
if (!usingModernChannel && !usingLegacyChannel) {
return;
}
// Make sure the player isn't sending the client brand multiple times
checkState(!user.isReceivedPluginMessage(), "sent duplicate plugin message");
// Check if the channel is correct - 1.13 uses the new namespace
// system ('minecraft:' + channel) and anything below 1.13 uses
// the legacy namespace system ('MC|' + channel).
final boolean v1_13 = user.getProtocolVersion().compareTo(MINECRAFT_1_13) >= 0;
checkState(usingLegacyChannel || v1_13, "invalid brand channel");
// Validate the client branding using a regex to filter unwanted characters.
if (Sonar.get().getConfig().getVerification().getBrand().isEnabled()) {
validateClientBrand(pluginMessage.getSlicedBuffer());
}
// Mark the PluginMessage packet as received
user.setReceivedPluginMessage(true);
}
protected final void validateClientBrand(final @NotNull ByteBuf content) {
// No need to check for empty brands since ProtocolUtil#readBrandMessage
// already performs invalid string checks by default.
final String read = ProtocolUtil.readBrandMessage(content);
// Check if the decoded client brand string is too long
checkState(read.length() < Sonar.get().getConfig().getVerification().getBrand().getMaxLength(),
"client brand is too long: " + read.length());
// Check for illegal client brands
checkState(!read.equals("Vanilla"), "illegal client brand: " + read);
// Regex pattern for validating client brands
final Pattern pattern = Sonar.get().getConfig().getVerification().getBrand().getValidRegex();
checkState(pattern.matcher(read).matches(), "client brand does not match pattern");
}
protected final void validateClientLocale(final @NotNull String locale) {
// Check the client locale by performing a simple regex check
// that disallows non-ascii characters by default.
final Pattern pattern = Sonar.get().getConfig().getVerification().getValidLocaleRegex();
checkState(pattern.matcher(locale).matches(), "client locale does not match pattern");
}
}
@@ -0,0 +1,113 @@
/*
* 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.session;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.entity.EntityType;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.PaddleBoatPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.PlayerInputPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.SetPassengersPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.SpawnEntityPacket;
import java.util.UUID;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_9;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
/**
* Flow for this session handler
*
* <li>
* {@link SpawnEntityPacket} and {@link SetPassengersPacket} packets are sent to the client,
* therefore, making the player enter a boat.
* <br>
* See more: {@link FallbackVehicleSessionHandler}
* </li>
* <li>
* Then, all we do is listen for incoming {@link PlayerInputPacket} and {@link PaddleBoatPacket} packets.
* </li>
*/
public final class FallbackVehicleSessionHandler extends FallbackSessionHandler {
public FallbackVehicleSessionHandler(final @NotNull FallbackUser user,
final @NotNull String username,
final @NotNull UUID uuid,
final double x, final double y, final double z) {
super(user, username, uuid);
// Send the necessary packets to mount the player on the vehicle
final int entityTypeId = EntityType.BOAT.getId(user.getProtocolVersion());
user.delayedWrite(new SpawnEntityPacket(VEHICLE_ENTITY_ID, entityTypeId, x, y, z));
user.delayedWrite(setPassengers);
user.getChannel().flush();
}
private boolean receivedPaddle, receivedInput;
private void markSuccess() {
// Pass the player to the next best verification handler
if (Sonar.get().getFallback().shouldPerformCaptcha()) {
// Make sure the player exits the vehicle before sending the CAPTCHA
user.delayedWrite(removeEntities);
// Either send the player to the CAPTCHA, or finish the verification.
final var decoder = (FallbackPacketDecoder) user.getPipeline().get(FallbackPacketDecoder.class);
// Send the player to the CAPTCHA handler
decoder.setListener(new FallbackCAPTCHASessionHandler(user, username, uuid));
} else {
// The player has passed all checks
finishVerification();
}
}
@Override
public void handle(final @NotNull FallbackPacket packet) {
if (packet instanceof PaddleBoatPacket) {
// Mark the PaddleBoat packet as received
receivedPaddle = true;
} else if (packet instanceof PlayerInputPacket) {
final PlayerInputPacket playerInput = (PlayerInputPacket) packet;
// Check if the player is sending invalid vehicle speed values
checkState(Math.abs(playerInput.getForward()) <= 0.98f, "illegal vehicle speed (f)");
checkState(Math.abs(playerInput.getSideways()) <= 0.98f, "illegal vehicle speed (s)");
// Only mark this packet as correct if the player is not moving the vehicle
if (playerInput.isJump() || playerInput.isUnmount()) {
return;
}
// Mark this check as success if the player has sent
// two PlayerInput and a minimum of one PaddleBoat packets.
if (receivedInput && receivedPaddle) {
markSuccess();
}
// 1.8 and below do not have PaddleBoat packets,
// so we simply exempt them from the PaddleBoat check.
if (user.getProtocolVersion().compareTo(MINECRAFT_1_9) < 0) {
receivedPaddle = true;
}
// Mark the PlayerInput packet as received
receivedInput = true;
}
}
}
@@ -23,25 +23,22 @@ 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();
});
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();
Sonar.get().getFallback().getRatelimiter().getFailCountCache().cleanUp();
Sonar.get().getFallback().getBlacklist().cleanUp();
// 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, CachedBandwidthStatistics::reset);
public void start() {
VERBOSE.start();
@@ -66,8 +66,6 @@ public final class GlobalSonarStatistics implements SonarStatistics {
public void cleanUpCache() {
LOGINS_PER_SECOND.cleanUp();
CONNECTIONS_PER_SECOND.cleanUp();
// Also clean up the blacklist
Sonar.get().getFallback().getBlacklist().cleanUp();
}
@Override
@@ -20,7 +20,6 @@ package xyz.jonesdev.sonar.common.subcommand.impl;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import xyz.jonesdev.sonar.api.SonarPlatform;
@@ -56,7 +55,7 @@ public final class DumpCommand extends Subcommand {
@Unmodifiable
private @NotNull Map<String, Object> collectMappedInformation() {
val mappings = new WeakHashMap<String, Object>();
final var mappings = new WeakHashMap<String, Object>();
mappings.put("sonar", new Dump.Sonar(
SONAR.getVersion().getFull(),
SONAR.getPlatform(),
@@ -145,8 +145,7 @@ public final class VerifiedCommand extends Subcommand {
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);
Sonar.get().getVerifiedPlayerController().add(new VerifiedPlayer(inetAddress, uuid, timestamp));
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getVerifiedAdd()
.replace("%ip%", rawInetAddress));
@@ -30,12 +30,17 @@ import org.jetbrains.annotations.NotNull;
import java.io.DataOutput;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.BitSet;
import java.util.EnumSet;
import java.util.UUID;
// Mostly taken from
// https://github.com/PaperMC/Velocity/blob/dev/3.0.0/proxy/src/main/java/com/velocitypowered/proxy/protocol/ProtocolUtils.java
@UtilityClass
public class ProtocolUtil {
public static final UUID EMPTY_UUID = new UUID(0L, 0L);
private static final int FORGE_MAX_ARRAY_LENGTH = Integer.MAX_VALUE & 0x1FFF9A;
public static final String BRAND_CHANNEL_LEGACY = "MC|Brand";
@@ -241,6 +246,28 @@ public class ProtocolUtil {
}
}
// Taken from
// https://github.com/Nan1t/NanoLimbo/blob/main/src/main/java/ua/nanit/limbo/protocol/ByteMessage.java#L276
public <E extends Enum<E>> void writeEnumSet(final ByteBuf byteBuf,
final EnumSet<E> enumset,
final @NotNull Class<E> oclass) {
final E[] enums = oclass.getEnumConstants();
final BitSet bits = new BitSet(enums.length);
for (int i = 0; i < enums.length; ++i) {
bits.set(i, enumset.contains(enums[i]));
}
writeFixedBitSet(byteBuf, bits, enums.length);
}
private static void writeFixedBitSet(final ByteBuf byteBuf, final @NotNull BitSet bits, final int size) {
if (bits.length() > size) {
throw new StackOverflowError("BitSet too large (expected " + size + " got " + bits.size() + ")");
}
byteBuf.writeBytes(Arrays.copyOf(bits.toByteArray(), (size + 8) >> 3));
}
public static void writeCompoundTag(final @NotNull ByteBuf byteBuf, final @NotNull CompoundBinaryTag compoundTag) {
try {
BinaryTagIO.writer().write(compoundTag, (DataOutput) new ByteBufOutputStream(byteBuf));
@@ -250,7 +277,7 @@ public class ProtocolUtil {
}
// Taken from
// https://github.com/Nan1t/NanoLimbo/pull/79/files#diff-4aa8208044741102c6326c7e85086e6fa8fcc7c064f7df6fd0411baf5f2b4504
// https://github.com/Nan1t/NanoLimbo/blob/main/src/main/java/ua/nanit/limbo/protocol/ByteMessage.java#L219
public static void writeNamelessCompoundTag(final @NotNull ByteBuf byteBuf, final @NotNull BinaryTag binaryTag) {
try (final ByteBufOutputStream output = new ByteBufOutputStream(byteBuf)) {
// TODO: Find a way to improve this...
+4 -9
View File
@@ -1,17 +1,12 @@
val velocityVersion = "3.3.0-SNAPSHOT"
dependencies {
implementation(project(":api"))
implementation(project(":common"))
compileOnly("com.velocitypowered:velocity-proxy:$velocityVersion")
testCompileOnly("com.velocitypowered:velocity-proxy:$velocityVersion")
compileOnly(rootProject.libs.velocity)
testCompileOnly(rootProject.libs.velocity)
// Implement bStats.org for metrics
implementation("org.bstats:bstats-velocity:3.0.2")
// Library/dependency loading
implementation("com.alessiodp.libby:libby-velocity:2.0.0-SNAPSHOT")
implementation(rootProject.libs.bstats.velocity)
implementation(rootProject.libs.libby.velocity)
}
tasks {
@@ -18,7 +18,6 @@
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;
@@ -31,8 +30,6 @@ import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import xyz.jonesdev.sonar.velocity.command.VelocitySonarCommand;
import xyz.jonesdev.sonar.velocity.fallback.FallbackInjectionHelper;
import java.net.InetAddress;
import java.util.Objects;
import java.util.UUID;
@Getter
@@ -57,18 +54,6 @@ 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.
@@ -38,17 +38,19 @@ public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter
@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.getServerAddress(), handshake.getProtocolVersion().getProtocol());
}
// Intercept any server login packet by the client
if (msg instanceof ServerLoginPacket 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;
if (listenForPackets) {
if (msg instanceof HandshakePacket handshake) {
handleHandshake(handshake.getServerAddress(), handshake.getProtocolVersion().getProtocol());
}
// Intercept any server login packet by the client
else if (msg instanceof ServerLoginPacket 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);