Compare commits

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

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

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

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

* fix: formatting (spacing) in formatMemory

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

* fix: optimize chat and title notifications

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

* feat: run all scheduled tasks asynchronously

* fix: small enable message issue

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

* fix: oh yes I love exclamation marks

* feat: move handshake handling logic to common module

* feat: move login handling logic to common module

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

* style: make FallbackChannelHandlerAdapter#initialLogin final

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

* fix: better debug if protocol is invalid

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

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

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

* fix: remove useless and stupid local ip check

* fix: use correct translation for add message
2024-04-18 17:55:09 +02:00
Jones 23815351f0 Release 2.0.25 (#261) 2024-04-07 14:44:06 +02:00
Jones 5a68ea8af6 Restructure dependency management (#260)
* feat: recode command invocation source system

* feat: remove obsolete audience listener on Velocity

* fix: invalid console action bar sending when uuid is null

* feat: microoptimize InvocationSource#isPlayer

* build: recode and restructure dependency management

* build(bungee,bukkit): downgrade to adventure-platform 4.3.2

* build(all): minimize JAR (excluding api)

* build: fix minimizing breaking kyori adventure

* style: mark some api functions as internal as they would require relocating when used

* build(bukkit): mark as folia supported

* build: recode `build-sonar` task

* fix: remove unused/outdated files

* chore: I think I was dead when writing this

* build(deps): load minimessage and serializer-gson using libby

* refactor: remove some duplicate/unnecessary code
2024-04-07 14:38:40 +02:00
Jones 9b54b484fb Update Russian translations (#259) 2024-04-05 15:02:45 +02:00
Jones 4fa56f2f3c Hide blacklist logs during attack (#257) 2024-04-03 13:11:36 +02:00
Jones 51751584dc Improve code readability and maintainability (#255)
* style: add comments explaining certain functions/methods

* style: add link to stackoverflow comment explaining memory formatting

* style: add some more documentation and explanations to the API

* style: improve Discord webhook API readability
2024-04-03 13:04:48 +02:00
Jones 3955f9bc8a Improve verification limits and state system (#254)
* fix: fail for disconnecting during verification

* chore: update bot check failed message

* feat: improve state, failing and blacklist system
2024-04-02 21:20:20 +02:00
Jones e333162032 Implement vehicle mounting check (#251)
* fix: recode captcha-on-fail mechanic

* feat: remove unnecessary max-ping option

* feat: [wip] vehicle check and other improvements

* feat: add RemoveEntitiesPacket

* fix: RemoveEntitiesPacket for 1.7

* fix: PlayerInputPacket for 1.7

* feat: validate jump and unmount state (but don't fail)

* fix: RemoveEntitiesPacket not working on 1.17

* fix: boat entity type being broken on 1.13

* feat: [wip] support all versions (1.13-1.13.2 are broken)

* fix: boat entity type for 1.13-1.13.2

* feat: add missing vehicle configurations

* fix: wait 10s between each action bar on 1.7

* fix: remove unnecessary flush in KeepAlive

* fix: only expect PlayerInput packets when needed
2024-03-30 19:16:27 +01:00
dependabot[bot] b3a7f4de09 Bump netty-all from 4.1.107.Final to 4.1.108.Final (#248)
Bumps [io.netty:netty-all](https://github.com/netty/netty) from 4.1.107.Final to 4.1.108.Final.
- [Commits](https://github.com/netty/netty/compare/netty-4.1.107.Final...netty-4.1.108.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-03-25 13:45:29 +01:00
dependabot[bot] 5752f1e208 Bump lombok from 1.18.30 to 1.18.32 (#249)
Bumps [org.projectlombok:lombok](https://github.com/projectlombok/lombok) from 1.18.30 to 1.18.32.
- [Changelog](https://github.com/projectlombok/lombok/blob/master/doc/changelog.markdown)
- [Commits](https://github.com/projectlombok/lombok/compare/v1.18.30...v1.18.32)

---
updated-dependencies:
- dependency-name: org.projectlombok:lombok
  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-03-25 13:45:16 +01:00
MISHA 1c6ba7050d Add a missed line to Russian translations (#247) 2024-03-24 20:55:35 +01:00
MISHA 340d4a225d Add the missing Russian language in messages (#245)
* Update ru.yml

* Update ru.yml
2024-03-24 20:39:10 +01:00
Jones 475bf28ecf Release 2.0.24 (#244) 2024-03-24 20:25:30 +01:00
Jones 9c29753084 Recode map CAPTCHA (#243)
* feat: implement capja for CAPTCHA generation

* feat: implement map color palette for 1.7.2-1.20.4
2024-03-24 20:17:18 +01:00
Jones f0f5196fbe Don't drop messages that aren't ByteBufs (#242) 2024-03-22 14:01:32 +01:00
Jones d98ce28a59 Implement missing packet limits (#241) 2024-03-22 13:56:55 +01:00
MISHA e0d5c67cdf Fix translation error in the Russian language (#240) 2024-03-16 11:25:43 +01:00
Jones 65e059368c Release 2.0.23 (#239) 2024-03-13 16:15:38 +01:00
dependabot[bot] b7a031da12 Bump io.github.goooler.shadow from 8.1.5 to 8.1.7 (#238)
Bumps [io.github.goooler.shadow](https://github.com/Goooler/shadow) from 8.1.5 to 8.1.7.
- [Release notes](https://github.com/Goooler/shadow/releases)
- [Commits](https://github.com/Goooler/shadow/compare/v8.1.5...v8.1.7)

---
updated-dependencies:
- dependency-name: io.github.goooler.shadow
  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-03-11 19:11:17 +01:00
Jones e15206a382 Implement CAPTCHA-on-fail option (#237)
* feat: add option to display a CAPTCHA on fail

* fix: remove debug message
2024-03-10 13:11:09 +01:00
Jones 5e4af3053e Fix local database cache duplicating after reload (#236) 2024-03-07 20:19:42 +01:00
Jones 7bb231ae0d Release 2.0.22 (#234) 2024-03-05 18:07:55 +01:00
Jones a57dfc4e98 Correctly close database connection (#233) 2024-03-04 17:29:39 +01:00
Jones 618db4804b Recode bStats metrics implementation (#232)
* feat: add verification timing and map captcha timing to bstats charts

* fix: use correct char ids for bStats

* fix: implement shutdown method to correctly stop certain services

* style: small codestyle improvements
2024-03-03 18:31:27 +01:00
Jones 613f793dbf Relocate database drivers to avoid compatibility issues (#231) 2024-03-03 10:03:48 +01:00
Jones 40964d431c Release 2.0.21 (#230) 2024-03-01 22:58:39 +01:00
Jones b9deff9c0b Remove unused protocol ids to avoid exploits (#229) 2024-03-01 22:55:33 +01:00
Jones e3f27171cf Fix NullPointerException (#227) 2024-03-01 22:50:46 +01:00
Jones d3601e10fe Remove pipelines if they are not needed anymore (#226)
* fix: optimize counting of connections and logins

* fix: remove Sonar's pipelines if they aren't needed anymore
2024-03-01 19:25:13 +01:00
Jones 258e425ba1 Use correct packet limits for PluginMessagePacket (#225) 2024-03-01 17:04:24 +01:00
91 changed files with 2499 additions and 1825 deletions

No files matched your search

-1
View File
@@ -8,7 +8,6 @@ joining the [Discord](https://jonesdev.xyz/discord).
2. Follow the coding guidelines for your code.
3. Test your code before committing and pushing it.
4. Use [semantic](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) commit messages for your commits.
5. Create a feature branch or push to the `dev` branch.
## Coding Guidelines:
+46 -57
View File
@@ -6,7 +6,7 @@ buildscript {
plugins {
id("java")
id("io.github.goooler.shadow") version "8.1.5"
id("io.github.goooler.shadow") version "8.1.7"
id("io.toolebox.git-versioner") version "1.6.7"
}
@@ -29,78 +29,67 @@ allprojects {
apply(plugin = "io.github.goooler.shadow")
dependencies {
compileOnly("org.projectlombok:lombok:1.18.30")
annotationProcessor("org.projectlombok:lombok:1.18.30")
compileOnly("org.projectlombok:lombok:1.18.32")
annotationProcessor("org.projectlombok:lombok:1.18.32")
testCompileOnly("org.projectlombok:lombok:1.18.30")
testAnnotationProcessor("org.projectlombok:lombok:1.18.30")
testCompileOnly("org.projectlombok:lombok:1.18.32")
testAnnotationProcessor("org.projectlombok:lombok:1.18.32")
// adventure
implementation("net.kyori:adventure-text-minimessage:4.16.0")
implementation("net.kyori:adventure-text-serializer-gson:4.16.0")
implementation("net.kyori:adventure-nbt:4.16.0")
compileOnly("net.kyori:adventure-text-minimessage:4.16.0")
compileOnly("net.kyori:adventure-text-serializer-gson:4.16.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.107.Final") // netty
compileOnly("io.netty:netty-all:4.1.109.Final") // netty
// Library/dependency loading
compileOnly("com.alessiodp.libby:libby-core:2.0.0-SNAPSHOT")
}
}
dependencies {
sequenceOf("api", "bukkit", "bungee", "common", "velocity").forEach {
// We want the jar to actually contain our modules
implementation(project(":$it"))
tasks {
shadowJar {
// Set the file name of the shadowed jar
archiveFileName.set("${rootProject.name}-${project.name.replaceFirstChar { it.uppercase() }}.jar")
// Remove file timestamps
isPreserveFileTimestamps = false
// Relocate libraries
relocate("org.bstats", "xyz.jonesdev.sonar.libs.bstats")
relocate("com.alessiodp.libby", "xyz.jonesdev.sonar.libs.libby")
relocate("com.simpleyaml", "xyz.jonesdev.sonar.libs.yaml")
relocate("com.google.gson", "xyz.jonesdev.sonar.libs.gson")
relocate("com.j256.ormlite", "xyz.jonesdev.sonar.libs.ormlite")
relocate("com.github.benmanes.caffeine", "xyz.jonesdev.sonar.libs.caffeine")
relocate("com.mysql", "xyz.jonesdev.sonar.libs.mysql")
relocate("org.mariadb", "xyz.jonesdev.sonar.libs.mariadb")
relocate("xyz.jonesdev.capja", "xyz.jonesdev.sonar.libs.capja")
// Exclude unnecessary metadata information
exclude("META-INF/*/**")
}
compileJava {
options.encoding = "UTF-8"
}
jar {
manifest {
// Set the implementation version, so we can create exact version
// information in-game and make it accessible to the user.
attributes["Implementation-Version"] = rootProject.version
attributes["Implementation-Vendor"] = "Jones Development, Sonar Contributors"
}
}
}
}
tasks {
jar {
manifest {
// Set the implementation version, so we can create exact version
// information in-game and make it accessible to the user.
attributes["Implementation-Version"] = version
attributes["Implementation-Vendor"] = "Jones Development, Sonar Contributors"
}
}
shadowJar {
// Set the file name of the shadowed jar
archiveFileName.set("${rootProject.name}.jar")
// Remove file timestamps
isPreserveFileTimestamps = false
// Relocate libraries
relocate("org.bstats", "xyz.jonesdev.sonar.libs.bstats")
relocate("com.alessiodp.libby", "xyz.jonesdev.sonar.libs.libby")
relocate("com.simpleyaml", "xyz.jonesdev.sonar.libs.yaml")
relocate("com.google.gson", "xyz.jonesdev.sonar.libs.gson")
relocate("com.j256.ormlite", "xyz.jonesdev.sonar.libs.ormlite")
relocate("com.github.benmanes.caffeine", "xyz.jonesdev.sonar.libs.caffeine")
// TODO: actually fix this :/
// We have to be careful here, so we don't accidentally break adventure
relocate("net.kyori", "xyz.jonesdev.sonar.libs.kyori") {
exclude("net.kyori.adventure.text.**")
exclude("net.kyori.adventure.audience.Audience")
exclude("net.kyori.adventure.title.*")
}
// Exclude unnecessary metadata information
exclude("META-INF/*/**")
// We want to load our own Gson dynamically
exclude("com/google/gson/**")
}
compileJava {
options.encoding = "UTF-8"
}
// This is a small wrapper tasks to simplify the building process
register("build-sonar") {
dependsOn(clean, shadowJar)
val subprojects = listOf("api", "common", "bukkit", "bungee", "velocity")
val buildTasks = subprojects.flatMap { listOf("$it:clean", "$it:shadowJar") }
dependsOn(buildTasks)
}
}
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
# Fix permissions so we are actually able to run our gradle script
chmod +x gradle*
# You can also use clean shadowJar if something goes wrong here
# e.g. "./gradlew clean shadowJar --stacktrace"
./gradlew build-sonar --stacktrace
+1 -1
View File
@@ -1,3 +1,3 @@
group=xyz.jonesdev.sonar
version=2.0.20
version=2.0.27
description=Effective Anti-bot plugin for Velocity, BungeeCord, and Bukkit (1.7-latest)
@@ -18,6 +18,7 @@
package xyz.jonesdev.sonar.api;
import net.kyori.adventure.audience.Audience;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.attack.AttackTracker;
@@ -33,6 +34,7 @@ import xyz.jonesdev.sonar.api.verbose.Notification;
import xyz.jonesdev.sonar.api.verbose.Verbose;
import java.io.File;
import java.net.InetAddress;
import java.text.DecimalFormat;
import java.util.UUID;
@@ -43,6 +45,7 @@ public interface Sonar {
* Since we want to use Adventure on every server platform,
* we have to use their platform module to support BungeeCord and Bukkit
*/
@ApiStatus.Internal
@Nullable Audience audience(final @Nullable UUID uniqueId);
/**
@@ -50,6 +53,11 @@ public interface Sonar {
*/
@NotNull SonarPlatform getPlatform();
/**
* @return If the amount of players online with the same IP address is greater than a maximum
*/
boolean hasTooManyAccounts(final @NotNull InetAddress inetAddress, final int limit);
/**
* @return A small wrapper for the plugin logger so we can use the logger everywhere
*/
@@ -151,10 +151,4 @@ public final class AttackTracker {
attackThreshold = 0;
}
}
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean isCurrentlyUnderAttack() {
final int minDelay = Sonar.get().getConfig().getAttackCooldownDelay();
return currentAttack != null && currentAttack.timer.delay() < minDelay;
}
}
@@ -28,30 +28,33 @@ import java.util.function.Predicate;
@Getter
@RequiredArgsConstructor
public abstract class InvocationSource {
public final class InvocationSource {
private final UUID uuid;
private final Audience audience;
private final boolean player;
private final Predicate<String> permissionFunction;
/**
* Sends an empty chat message to the command executor
* @return True, if {@link InvocationSource#uuid} is not null
* @apiNote This indicates a player as a player will always have a UUID
*/
public final void sendMessage() {
sendMessage(Component.empty());
public boolean isPlayer() {
return uuid != null;
}
/**
* Sends a message to the command executor
*
* @apiNote We should probably use cached components...
* (See {@link InvocationSource#sendMessage(Component)})
*/
public final void sendMessage(final String legacy) {
public void sendMessage(final String legacy) {
sendMessage(MiniMessage.miniMessage().deserialize(legacy));
}
/**
* Sends a message to the command executor
*/
public final void sendMessage(final Component component) {
public void sendMessage(final Component component) {
audience.sendMessage(component);
}
}
@@ -53,13 +53,8 @@ public abstract class Subcommand {
final InetAddress inetAddress;
try {
inetAddress = InetAddress.getByName(rawIP);
if (inetAddress.isAnyLocalAddress() || inetAddress.isLoopbackAddress()) {
source.sendMessage(SONAR.getConfig().getCommands().getIllegalIpAddress());
return null;
}
} catch (UnknownHostException exception) {
source.sendMessage(SONAR.getConfig().getCommands().getIncorrectIpAddress());
source.sendMessage(SONAR.getConfig().getCommands().getInvalidIpAddress());
return null;
}
return inetAddress;
@@ -18,16 +18,17 @@
package xyz.jonesdev.sonar.api.config;
import com.j256.ormlite.db.DatabaseType;
import com.j256.ormlite.jdbc.db.MariaDbDatabaseType;
import com.j256.ormlite.jdbc.db.MysqlDatabaseType;
import lombok.*;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.title.Title;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.command.SonarCommand;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.api.ormlite.MariaDbDatabaseTypeAdapter;
import xyz.jonesdev.sonar.api.ormlite.MysqlDatabaseTypeAdapter;
import xyz.jonesdev.sonar.api.webhook.DiscordWebhook;
import java.io.File;
@@ -171,26 +172,30 @@ public final class SonarConfiguration {
// Verification
verification.timing = Verification.Timing.valueOf(generalConfig.getString("verification.timing"));
verification.gravity.enabled = generalConfig.getBoolean("verification.checks.gravity.enabled");
verification.gravity.checkCollisions = generalConfig.getBoolean("verification.checks.gravity.check-collisions");
verification.gravity.captchaOnFail = generalConfig.getBoolean("verification.checks.gravity.captcha-on-fail");
verification.gravity.maxMovementTicks = clamp(generalConfig.getInt("verification.checks.gravity.max-movement-ticks"), 2, 100);
verification.gravity.maxIgnoredTicks = clamp(generalConfig.getInt("verification.checks.gravity.max-ignored-ticks"), 1, 128);
verification.gravity.gamemode = Verification.Gravity.Gamemode.valueOf(generalConfig.getString("verification.checks.gravity.gamemode"));
verification.vehicle.timing = Verification.Timing.valueOf(generalConfig.getString("verification.checks.vehicle.timing"));
verification.map.timing = Verification.Timing.valueOf(generalConfig.getString("verification.checks.map-captcha.timing"));
verification.map.randomizePositions = generalConfig.getBoolean("verification.checks.map-captcha.random-position");
verification.map.distortionsFactorX = clamp(generalConfig.getInt(
"verification.checks.map-captcha.distortions-factor-x"), 0, 100) / 100D;
verification.map.distortionsFactorY = clamp(generalConfig.getInt(
"verification.checks.map-captcha.distortions-factor-y"), 0, 100) / 100D;
verification.map.randomLinesAmount = generalConfig.getInt("verification.checks.map-captcha.random-lines");
verification.map.randomOvalsAmount = generalConfig.getInt("verification.checks.map-captcha.random-ovals");
verification.map.randomizeFontSize = generalConfig.getBoolean("verification.checks.map-captcha.random-font-size");
verification.map.flare = generalConfig.getBoolean("verification.checks.map-captcha.effects.flare");
verification.map.scratches = generalConfig.getBoolean("verification.checks.map-captcha.effects.scratches");
verification.map.ripple = generalConfig.getBoolean("verification.checks.map-captcha.effects.ripple");
verification.map.smear = generalConfig.getBoolean("verification.checks.map-captcha.effects.smear");
verification.map.pinch = generalConfig.getBoolean("verification.checks.map-captcha.effects.pinch");
verification.map.saturation = (float) generalConfig.getYaml().getDouble(
"verification.checks.map-captcha.effects.saturation", 0.3);
verification.map.distortion = (float) generalConfig.getYaml().getDouble(
"verification.checks.map-captcha.effects.distortion", 2);
verification.map.precomputeAmount = generalConfig.getInt("verification.checks.map-captcha.precompute");
verification.map.maxDuration = generalConfig.getInt("verification.checks.map-captcha.max-duration");
verification.map.maxTries = generalConfig.getInt("verification.checks.map-captcha.max-tries");
verification.map.dictionary = generalConfig.getString("verification.checks.map-captcha.dictionary");
verification.map.fonts = generalConfig.getStringList("verification.checks.map-captcha.fonts");
verification.map.fonts = generalConfig.getStringList("verification.checks.map-captcha.font-names");
verification.brand.enabled = generalConfig.getBoolean("verification.checks.client-brand.enabled");
verification.brand.validRegex = Pattern.compile(generalConfig.getString("verification.checks.client-brand.valid-regex"));
@@ -198,7 +203,6 @@ public final class SonarConfiguration {
verification.validNameRegex = Pattern.compile(generalConfig.getString("verification.checks.valid-name-regex"));
verification.validLocaleRegex = Pattern.compile(generalConfig.getString("verification.checks.valid-locale-regex"));
verification.maxPing = clamp(generalConfig.getInt("verification.checks.max-ping"), 500, 30000);
verification.maxLoginPackets = clamp(generalConfig.getInt("verification.checks.max-login-packets"), 128, 8192);
verification.logConnections = generalConfig.getBoolean("verification.log-connections");
@@ -206,6 +210,7 @@ public final class SonarConfiguration {
verification.debugXYZPositions = generalConfig.getBoolean("verification.debug-xyz-positions");
verification.readTimeout = clamp(generalConfig.getInt("verification.read-timeout"), 500, 30000);
verification.reconnectDelay = clamp(generalConfig.getInt("verification.rejoin-delay"), 0, 100000);
verification.rememberTime = clamp(generalConfig.getInt("verification.remember-time"), 0, 86400000);
verification.blacklistTime = clamp(generalConfig.getInt("verification.blacklist-time"), 0, 86400000);
verification.blacklistThreshold = clamp(generalConfig.getInt("verification.blacklist-threshold"), 0, 100);
verification.whitelistedProtocols.clear();
@@ -252,8 +257,7 @@ public final class SonarConfiguration {
noPermission = formatString(messagesConfig.getString("commands.no-permission"));
commands.incorrectCommandUsage = formatString(messagesConfig.getString("commands.incorrect-usage"));
commands.incorrectIpAddress = formatString(messagesConfig.getString("commands.invalid-ip-address"));
commands.illegalIpAddress = formatString(messagesConfig.getString("commands.illegal-ip-address"));
commands.invalidIpAddress = formatString(messagesConfig.getString("commands.invalid-ip-address"));
commands.playersOnly = formatString(messagesConfig.getString("commands.player-only"));
commands.consoleOnly = formatString(messagesConfig.getString("commands.console-only"));
commands.commandCoolDown = formatString(messagesConfig.getString("commands.cool-down"));
@@ -286,6 +290,8 @@ public final class SonarConfiguration {
commands.verifiedCleared = formatString(messagesConfig.getString("commands.verified.cleared"));
commands.verifiedSize = formatString(messagesConfig.getString("commands.verified.size"));
commands.verifiedRemove = formatString(messagesConfig.getString("commands.verified.removed"));
commands.verifiedAdd = formatString(messagesConfig.getString("commands.verified.added"));
commands.verifiedAlready = formatString(messagesConfig.getString("commands.verified.already"));
commands.verifiedNotFound = formatString(messagesConfig.getString("commands.verified.ip-not-found"));
commands.verifiedBlocked = formatString(messagesConfig.getString("commands.verified.blocked"));
@@ -314,13 +320,16 @@ public final class SonarConfiguration {
verification.verificationSuccess = deserialize(fromList(messagesConfig.getStringList("verification.success")));
verification.verificationFailed = deserialize(fromList(messagesConfig.getStringList("verification.failed")));
verbose.actionBarLayout = formatString(messagesConfig.getString("verbose.layout.normal"));
verbose.actionBarLayoutDuringAttack = formatString(messagesConfig.getString("verbose.layout.attack"));
verbose.actionBarLayout = deserialize(formatString(messagesConfig.getString("verbose.layout.normal")));
verbose.actionBarLayoutDuringAttack = deserialize(formatString(messagesConfig.getString("verbose.layout.attack")));
verbose.animation = Collections.unmodifiableList(messagesConfig.getStringList("verbose.animation"));
notifications.notificationTitle = deserialize(formatString(messagesConfig.getString("notifications.title")));
notifications.notificationSubtitle = deserialize(formatString(messagesConfig.getString("notifications.subtitle")));
notifications.notificationChat = formatString(fromList(messagesConfig.getStringList("notifications.chat")));
notifications.title = Title.title(
Sonar.get().getConfig().getNotifications().getNotificationTitle(),
Sonar.get().getConfig().getNotifications().getNotificationSubtitle());
notifications.notificationChat = deserialize(formatString(fromList(messagesConfig.getStringList("notifications.chat"))));
}
private @NotNull URL getAsset(final String url, final @NotNull Language language) {
@@ -395,17 +404,18 @@ public final class SonarConfiguration {
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Verbose {
private String actionBarLayout;
private String actionBarLayoutDuringAttack;
private Component actionBarLayout;
private Component actionBarLayoutDuringAttack;
private List<String> animation;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Notifications {
private Title title;
private Component notificationTitle;
private Component notificationSubtitle;
private String notificationChat;
private Component notificationChat;
}
@Getter
@@ -420,23 +430,30 @@ public final class SonarConfiguration {
private Timing timing;
@Getter
@RequiredArgsConstructor
public enum Timing {
ALWAYS, DURING_ATTACK, NEVER
ALWAYS("Always"),
DURING_ATTACK("During Attack"),
NEVER("Never");
private final String displayName;
}
private final Map map = new Map();
private final Gravity gravity = new Gravity();
private final Vehicle vehicle = new Vehicle();
private final Brand brand = new Brand();
@Getter
public static final class Map {
private Timing timing;
private boolean randomizePositions;
private boolean randomizeFontSize;
private double distortionsFactorX;
private double distortionsFactorY;
private int randomLinesAmount;
private int randomOvalsAmount;
private boolean flare;
private boolean scratches;
private boolean ripple;
private boolean smear;
private boolean pinch;
private float saturation;
private float distortion;
private int precomputeAmount;
private int maxDuration;
private int maxTries;
@@ -451,9 +468,9 @@ public final class SonarConfiguration {
public static final class Gravity {
private boolean enabled;
private boolean checkCollisions;
private boolean captchaOnFail;
private Gamemode gamemode;
private int maxMovementTicks;
private int maxIgnoredTicks;
@Getter
@RequiredArgsConstructor
@@ -468,6 +485,11 @@ public final class SonarConfiguration {
}
}
@Getter
public static final class Vehicle {
private Timing timing;
}
@Getter
public static final class Brand {
private boolean enabled;
@@ -486,9 +508,9 @@ public final class SonarConfiguration {
private String blacklistLog;
private int maxLoginPackets;
private int maxPing;
private int readTimeout;
private int reconnectDelay;
private int rememberTime;
private int blacklistTime;
private int blacklistThreshold;
private final Collection<Integer> whitelistedProtocols = new HashSet<>(0);
@@ -509,8 +531,7 @@ public final class SonarConfiguration {
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Commands {
private String incorrectCommandUsage;
private String incorrectIpAddress;
private String illegalIpAddress;
private String invalidIpAddress;
private String subCommandNoPerm;
private String playersOnly;
private String consoleOnly;
@@ -527,6 +548,8 @@ public final class SonarConfiguration {
private String blacklistSize;
private String verifiedRemove;
private String verifiedAdd;
private String verifiedAlready;
private String verifiedNotFound;
private String verifiedCleared;
private String verifiedSize;
@@ -560,13 +583,10 @@ public final class SonarConfiguration {
@Getter
@RequiredArgsConstructor
public enum Type {
MYSQL("com.mysql.cj.jdbc.NonRegisteringDriver",
"jdbc:mysql://%s:%d/%s", new MysqlDatabaseType()),
MARIADB("org.mariadb.jdbc.Driver",
"jdbc:mariadb://%s:%d/%s", new MariaDbDatabaseType()),
NONE(null, null, null);
MYSQL("jdbc:mysql://%s:%d/%s", new MysqlDatabaseTypeAdapter()),
MARIADB("jdbc:mariadb://%s:%d/%s", new MariaDbDatabaseTypeAdapter()),
NONE(null, null);
private final String driverClassName;
private final String connectionString;
private final DatabaseType databaseType;
}
@@ -27,6 +27,7 @@ import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Range;
import org.jetbrains.annotations.Unmodifiable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
@@ -43,7 +44,6 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class VerifiedPlayerController {
private static final Map<String, Collection<UUID>> MAP = new ConcurrentHashMap<>();
private static final ExecutorService DB_UPDATE_SERVICE = Executors.newSingleThreadExecutor();
private static final LoggerWrapper LOGGER = new LoggerWrapper() {
@@ -64,6 +64,7 @@ public final class VerifiedPlayerController {
}
};
private final Map<String, Collection<UUID>> cache = new ConcurrentHashMap<>(128);
private @Nullable ConnectionSource connectionSource;
private Dao<VerifiedPlayer, Integer> dao;
private QueryBuilder<VerifiedPlayer, Integer> queryBuilder;
@@ -81,6 +82,9 @@ public final class VerifiedPlayerController {
return;
}
// Initialize MySQL/MariaDB driver
database.getType().getDatabaseType().loadDriver();
try {
connectionSource = new JdbcPooledConnectionSource(
String.format(database.getType().getConnectionString(),
@@ -116,6 +120,21 @@ public final class VerifiedPlayerController {
}
}
/**
* Closes the connection if instantiated
*/
public void close() {
// The connection source will always be null if the database type is NONE.
if (connectionSource != null) {
try {
// Properly close the connection
connectionSource.close();
} catch (Exception exception) {
LOGGER.error("Error closing database: {}", exception);
}
}
}
/**
* Clear all old entries using the given timestamp.
*/
@@ -180,7 +199,7 @@ public final class VerifiedPlayerController {
* @param inetAddress IP address of the player
*/
private void _remove(final @NotNull String inetAddress) {
MAP.remove(inetAddress);
cache.remove(inetAddress);
}
/**
@@ -218,24 +237,30 @@ public final class VerifiedPlayerController {
* @param player VerifiedPlayer model
*/
private void _add(final @NotNull VerifiedPlayer player) {
MAP.computeIfAbsent(player.getInetAddress(), v -> new Vector<>())
cache.computeIfAbsent(player.getInetAddress(), v -> new ArrayList<>())
.add(player.getPlayerUUID());
}
/**
* Returns the amount of verified IP addresses
*
* @return Estimated size of the local cache
*/
public synchronized int estimatedSize() {
return MAP.values().stream()
.mapToInt(Collection::size)
.sum();
/*
* TODO: Work on this
* return MAP.values().stream()
* .mapToInt(Collection::size)
* .sum();
*/
return cache.size();
}
/**
* @return List of UUIDs associated with one IP address
*/
public Collection<UUID> getUUIDs(final @NotNull String inetAddress) {
return MAP.getOrDefault(inetAddress, Collections.emptyList());
public @Unmodifiable Collection<UUID> getUUIDs(final @NotNull String inetAddress) {
return cache.getOrDefault(inetAddress, Collections.emptyList());
}
/**
@@ -244,7 +269,7 @@ public final class VerifiedPlayerController {
*/
public void clearAll() {
try {
MAP.clear();
cache.clear();
// Only update the column if the database type is not NONE
if (cachedDatabaseType != SonarConfiguration.Database.Type.NONE) {
@@ -261,7 +286,7 @@ public final class VerifiedPlayerController {
* @return Whether the local cache contains the IP and UUID
*/
public boolean has(final @NotNull String inetAddress, final @NotNull UUID uuid) {
final Collection<UUID> got = MAP.get(inetAddress);
final Collection<UUID> got = cache.get(inetAddress);
if (got != null) {
return got.contains(uuid);
}
@@ -283,15 +308,6 @@ public final class VerifiedPlayerController {
* @return Whether the local cache contains the IP
*/
public boolean has(final @NotNull String inetAddress) {
return MAP.containsKey(inetAddress);
}
/**
* @param inetAddress IP address
* @return Whether the local cache contains the IP
* @see #has(String)
*/
public boolean has(final @NotNull InetAddress inetAddress) {
return has(inetAddress.toString());
return cache.containsKey(inetAddress);
}
}
@@ -19,23 +19,17 @@ package xyz.jonesdev.sonar.api.event.impl;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.event.SonarEvent;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import java.util.Optional;
@Getter
@ToString
@EqualsAndHashCode
@RequiredArgsConstructor
public final class UserVerifyFailedEvent implements SonarEvent {
private final FallbackUser user;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
private final Optional<String> reason;
public UserVerifyFailedEvent(final FallbackUser user, final @Nullable String reason) {
this.user = user;
this.reason = Optional.ofNullable(reason);
}
private final @NotNull FallbackUser user;
private final @NotNull String reason;
}
@@ -69,16 +69,20 @@ public final class Fallback {
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean shouldVerifyNewPlayers() {
return performTimingCheck(Sonar.get().getConfig().getVerification().getTiming());
return shouldPerform(Sonar.get().getConfig().getVerification().getTiming());
}
public boolean shouldDoMapCaptcha() {
return performTimingCheck(Sonar.get().getConfig().getVerification().getMap().getTiming());
public boolean shouldPerformCaptcha() {
return shouldPerform(Sonar.get().getConfig().getVerification().getMap().getTiming());
}
private boolean performTimingCheck(final SonarConfiguration.Verification.Timing timing) {
public boolean shouldPerformVehicleCheck() {
return shouldPerform(Sonar.get().getConfig().getVerification().getVehicle().getTiming());
}
private static boolean shouldPerform(final SonarConfiguration.Verification.Timing timing) {
return timing == SonarConfiguration.Verification.Timing.ALWAYS
|| (timing == SonarConfiguration.Verification.Timing.DURING_ATTACK
&& Sonar.get().getAttackTracker().isCurrentlyUnderAttack());
&& Sonar.get().getAttackTracker().getCurrentAttack() != null);
}
}
@@ -26,15 +26,20 @@ import java.net.InetAddress;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ForkJoinPool;
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public final class FallbackQueue {
static final FallbackQueue INSTANCE = new FallbackQueue();
private final Map<InetAddress, Runnable> queuedPlayers = new ConcurrentHashMap<>(64);
// Async executor for all new verifications
public static final ExecutorService QUEUE_EXECUTOR = new ForkJoinPool(
Math.min(0x7fff, Runtime.getRuntime().availableProcessors()),
ForkJoinPool.defaultForkJoinWorkerThreadFactory, null, true);
// Runnable task executed for polling the queue
private final Runnable pollTask = () -> {
public void poll() {
final int maxQueuePolls = Sonar.get().getConfig().getQueue().getMaxQueuePolls();
int index = 0;
@@ -47,30 +52,9 @@ public final class FallbackQueue {
}
// Run the cached runnable
final Map.Entry<InetAddress, Runnable> entry = iterator.next();
entry.getValue().run();
QUEUE_EXECUTOR.execute(entry.getValue());
// Remove runnable from iterator
iterator.remove();
}
// Run the attack check task
Sonar.get().getAttackTracker().checkIfUnderAttack();
// Clean up the cache of rate-limited IPs
Sonar.get().getFallback().getRatelimiter().cleanUpCache();
};
/**
* @param inetAddress IP address of the player
* @param runnable Queued action on the netty thread
* @see #remove
*/
public void queue(final InetAddress inetAddress, final Runnable runnable) {
queuedPlayers.put(inetAddress, runnable);
}
/**
* @param inetAddress IP address of the player
*/
public void remove(final InetAddress inetAddress) {
queuedPlayers.remove(inetAddress);
}
}
@@ -19,6 +19,7 @@ package xyz.jonesdev.sonar.api.fallback;
import com.github.benmanes.caffeine.cache.Cache;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.jetbrains.annotations.NotNull;
@@ -29,7 +30,10 @@ import java.net.InetAddress;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class FallbackRatelimiter {
static final FallbackRatelimiter INSTANCE = new FallbackRatelimiter();
private Cache<InetAddress, Byte> expiringCache;
private Cache<InetAddress, Byte> attemptCache;
@Getter
private Cache<InetAddress, Integer> failCountCache;
/**
* We don't want to clean up the cache in the
@@ -37,7 +41,8 @@ public final class FallbackRatelimiter {
* it would take up too many resources.
*/
public void cleanUpCache() {
expiringCache.cleanUp();
attemptCache.cleanUp();
failCountCache.cleanUp();
}
/**
@@ -49,12 +54,27 @@ public final class FallbackRatelimiter {
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean attempt(final @NotNull InetAddress inetAddress) {
// Cache the IP address if it's not already cached
if (!expiringCache.asMap().containsKey(inetAddress)) {
if (!attemptCache.asMap().containsKey(inetAddress)) {
// Cache the IP address
expiringCache.put(inetAddress, (byte) 0);
attemptCache.put(inetAddress, (byte) 0);
return true;
}
// Deny the connection attempt
return false;
}
/**
* Checks if the player is failing verifications too often
* Increments the number of times this user has failed the verification
*
* @param inetAddress IP address of the player
* @param count Count of previously failed verifications
*/
public void incrementFails(final @NotNull InetAddress inetAddress, final int count) {
// Make sure to remove the old values from the cache
if (count > 0) {
failCountCache.invalidate(inetAddress);
}
failCountCache.put(inetAddress, count + 1);
}
}
@@ -17,18 +17,14 @@
package xyz.jonesdev.sonar.api.fallback;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import io.netty.channel.Channel;
import io.netty.util.ReferenceCountUtil;
import net.kyori.adventure.text.Component;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import java.net.InetAddress;
import java.time.Duration;
import java.util.Objects;
import java.util.UUID;
public interface FallbackUser {
@@ -38,12 +34,17 @@ public interface FallbackUser {
@NotNull ProtocolVersion getProtocolVersion();
@NotNull FallbackUserState getState();
void setState(final FallbackUserState state);
/**
* Disconnect the player during/after verification
* using our custom Disconnect packet.
*
* @param reason Disconnect message component
*/
@ApiStatus.Internal
void disconnect(final @NotNull Component reason);
/**
@@ -54,11 +55,11 @@ public interface FallbackUser {
* @param encoder Name of the encoder pipeline
* @param decoder Name of the decoder pipeline
* @param timeout Name of the read timeout pipeline
* @param boss Name of the main pipeline
* @param handler Name of the main pipeline
*/
void hijack(final @NotNull String username, final @NotNull UUID uuid,
final @NotNull String encoder, final @NotNull String decoder,
final @NotNull String timeout, final @NotNull String boss);
final @NotNull String timeout, final @NotNull String handler);
/**
* Sends a packet/message to the player
@@ -85,40 +86,6 @@ public interface FallbackUser {
}
}
Cache<InetAddress, Integer> GLOBAL_FAIL_COUNT = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5)) // expire after 5 minutes
.build();
/**
* Increments the number of times this user (by IP address) has failed the verification
*/
default void incrementFails() {
final InetAddress inetAddress = Objects.requireNonNull(getInetAddress());
final int failCount = getFailedCount();
// Make sure to remove the old values from the cache
if (failCount > 0) {
GLOBAL_FAIL_COUNT.invalidate(inetAddress);
}
GLOBAL_FAIL_COUNT.put(inetAddress, failCount + 1);
}
/**
* @return The number of times this user (by IP address) has failed the verification
* @apiNote Returns 0 if no key/value is present in the cache
*/
default int getFailedCount() {
// Make sure to clean up the cache before we try to get cached values
GLOBAL_FAIL_COUNT.cleanUp();
return GLOBAL_FAIL_COUNT.asMap().getOrDefault(Objects.requireNonNull(getInetAddress()), 0);
}
/**
* Removes all previously failed verifications
*/
default void invalidateFails() {
GLOBAL_FAIL_COUNT.invalidate(Objects.requireNonNull(getInetAddress()));
}
/**
* Disconnects the player who failed the verification
* and caches them in FAILED_VERIFICATIONS.
@@ -126,6 +93,8 @@ public interface FallbackUser {
* the player will be temporarily denied from verifying.
*
* @param reason Reason for failing the verification
* @apiNote The {@link xyz.jonesdev.sonar.api.event.impl.UserVerifyFailedEvent}
* will not be thrown if no reason is given
*/
void fail(final @Nullable String reason);
void fail(final @NotNull String reason);
}
@@ -15,16 +15,27 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.map;
package xyz.jonesdev.sonar.api.fallback;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
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;
@Getter
@RequiredArgsConstructor
public final class MapInfo {
private final String answer;
private final int columns, rows;
private final int x, y;
private final byte[] buffer;
public boolean canReceivePackets() {
return this != FAILED && this != SUCCESS;
}
}
@@ -20,13 +20,15 @@ package xyz.jonesdev.sonar.api.fallback.protocol;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.*;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@Getter
@RequiredArgsConstructor
public enum ProtocolVersion {
UNKNOWN(-1),
LEGACY(-2),
MINECRAFT_1_7_2(4),
MINECRAFT_1_7_6(5),
MINECRAFT_1_8(47),
@@ -70,10 +72,8 @@ public enum ProtocolVersion {
private final int protocol;
public static final ProtocolVersion MINIMUM_VERSION = MINECRAFT_1_7_2;
public static final ProtocolVersion LATEST_VERSION;
public static final Map<Integer, ProtocolVersion> ID_TO_PROTOCOL_CONSTANT;
public static final Set<ProtocolVersion> SUPPORTED_VERSIONS;
static {
{
@@ -90,12 +90,11 @@ public enum ProtocolVersion {
final Set<ProtocolVersion> versions = EnumSet.noneOf(ProtocolVersion.class);
for (final ProtocolVersion value : values()) {
if (!value.isUnknown() && !value.isLegacy()) {
if (!value.isUnknown()) {
versions.add(value);
}
}
SUPPORTED_VERSIONS = Collections.unmodifiableSet(versions);
LATEST_VERSION = (ProtocolVersion) versions.toArray()[versions.size() - 1];
}
}
@@ -111,9 +110,4 @@ public enum ProtocolVersion {
public boolean isUnknown() {
return this == UNKNOWN;
}
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean isLegacy() {
return this == LEGACY;
}
}
@@ -21,60 +21,94 @@ import com.sun.management.OperatingSystemMXBean;
import lombok.experimental.UtilityClass;
import java.lang.management.ManagementFactory;
import java.text.DecimalFormat;
@UtilityClass
public class JVMProcessInformation {
private final Runtime RUNTIME = Runtime.getRuntime();
private final OperatingSystemMXBean MX = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
private final char[] MEMORY_UNITS = {'K', 'M', 'G', 'T', 'P', 'E'};
private final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#.#");
public String formatMemory(final long size) {
if (size < 1024L) {
return size + " B";
}
// https://stackoverflow.com/questions/3758606/how-can-i-convert-byte-size-into-a-human-readable-format-in-java
final int group = (63 - Long.numberOfLeadingZeros(size)) / 10;
final double formattedSize = (double) size / (1L << (group * 10));
final char unit = MEMORY_UNITS[group - 1];
// https://en.wikipedia.org/wiki/Byte#Multiple-byte_units
return String.format("%.1f %siB", formattedSize, unit);
return DECIMAL_FORMAT.format(formattedSize) + " " + unit + "iB";
}
/**
* @return Number of available processors for the JVM
*/
public int getVirtualCores() {
return RUNTIME.availableProcessors();
}
/**
* @return Current process CPU usage in percent
*/
public double getProcessCPUUsage() {
return MX.getProcessCpuLoad() * 100;
}
/**
* @return Current per-core process CPU usage in percent
*/
public double getAverageProcessCPUUsage() {
return getProcessCPUUsage() / getVirtualCores();
}
/**
* @return Current system CPU usage in percent
*/
public double getSystemCPUUsage() {
return MX.getSystemCpuLoad() * 100;
}
/**
* @return Current per-core system CPU usage in percent
*/
public double getAverageSystemCPUUsage() {
return getSystemCPUUsage() / getVirtualCores();
}
/**
* @return Current system load average in percent
* @apiNote This returns the global process load, not the CPU usage
*/
public double getSystemLoadAverage() {
return MX.getSystemLoadAverage() * 100;
}
/**
* @return Memory (in bytes) the JVM will attempt to use
*/
public long getMaxMemory() {
return RUNTIME.maxMemory();
}
/**
* @return Memory (in bytes) available to the host machine
*/
public long getTotalMemory() {
return RUNTIME.totalMemory();
}
/**
* @return Memory (in bytes) the JVM can allocate
*/
public long getFreeMemory() {
return RUNTIME.freeMemory();
}
/**
* @return Memory (in bytes) the JVM is using
*/
public long getUsedMemory() {
return getTotalMemory() - getFreeMemory();
}
@@ -15,18 +15,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.command;
package xyz.jonesdev.sonar.api.ormlite;
import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.proxy.Player;
import com.j256.ormlite.jdbc.db.MariaDbDatabaseType;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.command.InvocationSource;
public final class VelocityInvocationSource extends InvocationSource {
public VelocityInvocationSource(final @NotNull CommandSource sender) {
super(sender instanceof Player ? ((Player) sender).getUniqueId() : null,
sender,
sender instanceof Player,
sender::hasPermission);
public final class MariaDbDatabaseTypeAdapter extends MariaDbDatabaseType {
/**
* We need to override the default driver class name
* in order to use the custom relocated MariaDB driver
* Read more: {@link MysqlDatabaseTypeAdapter}
*/
@Override
protected String @NotNull [] getDriverClassNames() {
return new String[]{"xyz.jonesdev.sonar.libs.mariadb.jdbc.Driver"};
}
}
@@ -15,19 +15,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bukkit.command;
package xyz.jonesdev.sonar.api.ormlite;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import com.j256.ormlite.jdbc.db.MysqlDatabaseType;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.bukkit.SonarBukkit;
public final class BukkitInvocationSource extends InvocationSource {
public BukkitInvocationSource(final @NotNull CommandSender sender) {
super(sender instanceof Player ? ((Player) sender).getUniqueId() : null,
SonarBukkit.INSTANCE.getBukkitAudiences().sender(sender),
sender instanceof Player,
sender::hasPermission);
public final class MysqlDatabaseTypeAdapter extends MysqlDatabaseType {
/**
* We need to override the default driver class name
* in order to use the custom relocated MySQL driver
* Read more: {@link MariaDbDatabaseTypeAdapter}
*/
@Override
protected String @NotNull [] getDriverClassNames() {
return new String[]{"xyz.jonesdev.sonar.libs.mysql.cj.jdbc.Driver"};
}
}
@@ -20,40 +20,96 @@ package xyz.jonesdev.sonar.api.statistics;
import org.jetbrains.annotations.ApiStatus;
public interface SonarStatistics {
/**
* @return Number of connection establishments per second
*/
long getConnectionsPerSecond();
/**
* @return Number of logins per second
*/
long getLoginsPerSecond();
/**
* @return Currently incoming bytes per second
*/
long getCurrentIncomingBandwidth();
/**
* @return Currently outgoing bytes per second
*/
long getCurrentOutgoingBandwidth();
/**
* @return Total incoming bytes per second
*/
long getTotalIncomingBandwidth();
/**
* @return Total outgoing bytes per second
*/
long getTotalOutgoingBandwidth();
/**
* @return Same as {@link SonarStatistics#getCurrentIncomingBandwidth} but formatted
*/
String getCurrentIncomingBandwidthFormatted();
/**
* @return Same as {@link SonarStatistics#getCurrentOutgoingBandwidth} but formatted
*/
String getCurrentOutgoingBandwidthFormatted();
/**
* @return Same as {@link SonarStatistics#getTotalIncomingBandwidth} but formatted
*/
String getTotalIncomingBandwidthFormatted();
/**
* @return Same as {@link SonarStatistics#getTotalOutgoingBandwidth} but formatted
*/
String getTotalOutgoingBandwidthFormatted();
/**
* @return Total number of players who logged into the server
*/
int getTotalPlayersJoined();
/**
* @return Total number of players who tried to verify
*/
int getTotalPlayersVerified();
/**
* @return Total number of players who passed the verification
*/
int getTotalSuccessfulVerifications();
/**
* @return Total number of players who failed the verification
*/
int getTotalFailedVerifications();
/**
* @return Total number of players who passed the verification
*/
long getCurrentAttemptedVerifications();
/**
* @return Total number of players who tried verifying
*/
int getTotalAttemptedVerifications();
/**
* @return Current number of IP addresses that are blacklisted
*/
long getCurrentBlacklistSize();
/**
* @return Total number of cases where an IP was blacklisted
* @apiNote This does not describe the current amount of blacklisted IPs
*/
long getTotalBlacklistSize();
@ApiStatus.Internal
@@ -18,13 +18,12 @@
package xyz.jonesdev.sonar.api.timer;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import java.text.SimpleDateFormat;
@Getter
public final class SystemTimer {
private static final SimpleDateFormat FORMATTER = new SimpleDateFormat("mm:ss");
public static final SimpleDateFormat DATE_FORMATTER = new SimpleDateFormat("mm:ss");
private long start = System.currentTimeMillis();
@@ -32,18 +31,20 @@ public final class SystemTimer {
start = System.currentTimeMillis();
}
/**
* @return Time between the current timestamp and the start timestamp
*/
public long delay() {
return System.currentTimeMillis() - start;
}
/**
* @apiNote Read more: {@link SystemTimer#delay}
*/
public boolean elapsed(final long amount) {
return delay() >= amount;
}
public @NotNull String formattedDelay() {
return FORMATTER.format(delay());
}
@Override
public String toString() {
return String.format("%.3f", delay() / 1000D);
@@ -20,57 +20,33 @@ package xyz.jonesdev.sonar.api.verbose;
import lombok.Getter;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.title.Title;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.statistics.SonarStatistics;
import java.util.Collection;
import java.util.UUID;
import java.util.Vector;
import static xyz.jonesdev.sonar.api.Sonar.DECIMAL_FORMAT;
@Getter
public final class Notification implements Observable {
private final @NotNull Collection<UUID> subscribers = new Vector<>(0);
public void sendAttackNotification() {
// No need to do anything if we have no subscribers
if (subscribers.isEmpty()) return;
final SonarStatistics statistics = Sonar.get().getStatistics();
// Prepare the title
// TODO: Why do custom title times not work?
// Title.Times.times(Ticks.duration(5L), Ticks.duration(60L), Ticks.duration(10L));
final Title title = Title.title(
Sonar.get().getConfig().getNotifications().getNotificationTitle(),
Sonar.get().getConfig().getNotifications().getNotificationSubtitle());
// Prepare the chat message
final Component chat = MiniMessage.miniMessage().deserialize(
Sonar.get().getConfig().getNotifications().getNotificationChat()
.replace("%queued%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getQueue().getQueuedPlayers().size()))
.replace("%verifying%", DECIMAL_FORMAT.format(Sonar.get().getFallback().getConnected().size()))
.replace("%blacklisted%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getBlacklist().estimatedSize()))
.replace("%total-joins%", DECIMAL_FORMAT.format(statistics.getTotalPlayersJoined()))
.replace("%logins-per-second%", DECIMAL_FORMAT.format(statistics.getLoginsPerSecond()))
.replace("%connections-per-second%", DECIMAL_FORMAT.format(statistics.getConnectionsPerSecond()))
.replace("%verify-total%", DECIMAL_FORMAT.format(statistics.getTotalAttemptedVerifications()))
.replace("%verify-success%",
DECIMAL_FORMAT.format(Sonar.get().getVerifiedPlayerController().estimatedSize()))
.replace("%verify-failed%", DECIMAL_FORMAT.format(statistics.getTotalFailedVerifications()))
.replace("%incoming-traffic%", statistics.getCurrentIncomingBandwidthFormatted())
.replace("%outgoing-traffic%", statistics.getCurrentOutgoingBandwidthFormatted())
.replace("%incoming-traffic-ttl%", statistics.getTotalIncomingBandwidthFormatted())
.replace("%outgoing-traffic-ttl%", statistics.getTotalOutgoingBandwidthFormatted()));
// Don't prepare component if there are no subscribers
Component chat = null;
// Send the title and chat messages to all online players
for (final UUID subscriber : Sonar.get().getNotificationHandler().getSubscribers()) {
final Audience audience = Sonar.get().audience(subscriber);
if (audience == null) continue;
audience.showTitle(title);
// Only prepare component if there are subscribers
if (chat == null) {
chat = replaceStatistic(Sonar.get().getConfig().getNotifications().getNotificationChat());
}
// Send the message to all online subscribers
audience.sendMessage(chat);
// Send the title to all online subscribers
audience.showTitle(Sonar.get().getConfig().getNotifications().getTitle());
}
}
}
@@ -17,11 +17,18 @@
package xyz.jonesdev.sonar.api.verbose;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextReplacementConfig;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.statistics.SonarStatistics;
import java.util.Collection;
import java.util.UUID;
import static xyz.jonesdev.sonar.api.Sonar.DECIMAL_FORMAT;
import static xyz.jonesdev.sonar.api.jvm.JVMProcessInformation.*;
public interface Observable {
Collection<UUID> getSubscribers();
@@ -46,4 +53,61 @@ public interface Observable {
default void unsubscribe(final UUID uuid) {
getSubscribers().remove(uuid);
}
// TODO: Make this a bit prettier and better for performance
default @NotNull Component replaceStatistic(final @NotNull Component component) {
final SonarStatistics statistics = Sonar.get().getStatistics();
return component
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%queued%")
.replacement(DECIMAL_FORMAT.format(Sonar.get().getFallback().getQueue().getQueuedPlayers().size()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%verifying%")
.replacement(DECIMAL_FORMAT.format(Sonar.get().getFallback().getConnected().size()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%blacklisted%")
.replacement(DECIMAL_FORMAT.format(Sonar.get().getFallback().getBlacklist().estimatedSize()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%total-joins%")
.replacement(DECIMAL_FORMAT.format(statistics.getTotalPlayersJoined()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%logins-per-second%")
.replacement(DECIMAL_FORMAT.format(statistics.getLoginsPerSecond()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%connections-per-second%")
.replacement(DECIMAL_FORMAT.format(statistics.getConnectionsPerSecond()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%verify-total%")
.replacement(DECIMAL_FORMAT.format(statistics.getTotalAttemptedVerifications()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%verify-success%")
.replacement(DECIMAL_FORMAT.format(statistics.getTotalSuccessfulVerifications()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%verify-failed%")
.replacement(DECIMAL_FORMAT.format(statistics.getTotalFailedVerifications()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%incoming-traffic%")
.replacement(statistics.getCurrentIncomingBandwidthFormatted())
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%outgoing-traffic%")
.replacement(statistics.getCurrentOutgoingBandwidthFormatted())
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%incoming-traffic-ttl%")
.replacement(statistics.getTotalIncomingBandwidthFormatted())
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%outgoing-traffic-ttl%")
.replacement(statistics.getTotalOutgoingBandwidthFormatted())
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%used-memory%")
.replacement(formatMemory(getUsedMemory()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%free-memory%")
.replacement(formatMemory(getFreeMemory()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%total-memory%")
.replacement(formatMemory(getTotalMemory()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%max-memory%")
.replacement(formatMemory(getMaxMemory()))
.build());
}
}
@@ -21,19 +21,17 @@ import lombok.Getter;
import lombok.val;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.TextReplacementConfig;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.attack.AttackTracker;
import xyz.jonesdev.sonar.api.statistics.SonarStatistics;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.util.Collection;
import java.util.UUID;
import java.util.Vector;
import static xyz.jonesdev.sonar.api.Sonar.DECIMAL_FORMAT;
import static xyz.jonesdev.sonar.api.jvm.JVMProcessInformation.*;
import static xyz.jonesdev.sonar.api.timer.SystemTimer.DATE_FORMATTER;
@Getter
public final class Verbose implements Observable {
@@ -42,52 +40,31 @@ public final class Verbose implements Observable {
// Run action bar verbose
public void update() {
// Make sure to clean up the cached statistics
Sonar.get().getStatistics().cleanUpCache();
// Don't prepare component if there are no subscribers
if (subscribers.isEmpty()) return;
// Prepare the action bar format component
final Component component = prepareActionBarFormat();
// Send the action bar to all online players
Component component = null;
for (final UUID subscriber : subscribers) {
final Audience audience = Sonar.get().audience(subscriber);
if (audience == null) continue;
// Only prepare component if there are subscribers
if (component == null) {
final AttackTracker.AttackStatistics attackStatistics = Sonar.get().getAttackTracker().getCurrentAttack();
final SystemTimer attackDuration = attackStatistics == null ? null : attackStatistics.getDuration();
component = replaceStatistic(attackDuration != null
? Sonar.get().getConfig().getVerbose().getActionBarLayoutDuringAttack()
: Sonar.get().getConfig().getVerbose().getActionBarLayout())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%attack-duration%")
.replacement(attackDuration == null ? "00:00" : DATE_FORMATTER.format(attackDuration.delay()))
.build())
.replaceText(TextReplacementConfig.builder().once().matchLiteral("%animation%")
.replacement(nextAnimation())
.build());
}
// Send the action bar to all online subscribers
audience.sendActionBar(component);
}
}
public @NotNull Component prepareActionBarFormat() {
final SonarStatistics statistics = Sonar.get().getStatistics();
final AttackTracker.AttackStatistics attackStatistics = Sonar.get().getAttackTracker().getCurrentAttack();
final SystemTimer attackDuration = attackStatistics == null ? null : attackStatistics.getDuration();
return MiniMessage.miniMessage().deserialize((attackDuration != null
? Sonar.get().getConfig().getVerbose().getActionBarLayoutDuringAttack()
: Sonar.get().getConfig().getVerbose().getActionBarLayout())
.replace("%queued%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getQueue().getQueuedPlayers().size()))
.replace("%verifying%", DECIMAL_FORMAT.format(Sonar.get().getFallback().getConnected().size()))
.replace("%blacklisted%",
DECIMAL_FORMAT.format(Sonar.get().getFallback().getBlacklist().estimatedSize()))
.replace("%blacklisted%", DECIMAL_FORMAT.format(statistics.getCurrentBlacklistSize()))
.replace("%total-joins%", DECIMAL_FORMAT.format(statistics.getTotalPlayersJoined()))
.replace("%logins-per-second%", DECIMAL_FORMAT.format(statistics.getLoginsPerSecond()))
.replace("%connections-per-second%", DECIMAL_FORMAT.format(statistics.getConnectionsPerSecond()))
.replace("%verify-total%", DECIMAL_FORMAT.format(statistics.getTotalAttemptedVerifications()))
.replace("%verify-success%",
DECIMAL_FORMAT.format(Sonar.get().getVerifiedPlayerController().estimatedSize()))
.replace("%verify-failed%", DECIMAL_FORMAT.format(statistics.getTotalFailedVerifications()))
.replace("%attack-duration%", attackDuration == null ? "00:00" : attackDuration.formattedDelay())
.replace("%incoming-traffic%", statistics.getCurrentIncomingBandwidthFormatted())
.replace("%outgoing-traffic%", statistics.getCurrentOutgoingBandwidthFormatted())
.replace("%incoming-traffic-ttl%", statistics.getTotalIncomingBandwidthFormatted())
.replace("%outgoing-traffic-ttl%", statistics.getTotalOutgoingBandwidthFormatted())
.replace("%used-memory%", formatMemory(getUsedMemory()))
.replace("%free-memory%", formatMemory(getFreeMemory()))
.replace("%total-memory%", formatMemory(getTotalMemory()))
.replace("%max-memory%", formatMemory(getMaxMemory()))
.replace("%animation%", nextAnimation()));
}
public String nextAnimation() {
val animations = Sonar.get().getConfig().getVerbose().getAnimation();
final int nextIndex = ++animationIndex % animations.size();
@@ -34,14 +34,18 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
@RequiredArgsConstructor
public final class DiscordWebhook {
private final String url;
private static final Gson GSON = new GsonBuilder().disableInnerClassSerialization().create();
private static final Gson GSON = new GsonBuilder().create();
private static final ExecutorService HTTP_REQUEST_SERVICE = Executors.newSingleThreadExecutor();
/**
* Asynchronously posts a Discord webhook via HTTPS
*/
public void post(final Supplier<SonarConfiguration.Webhook.Embed> embed) {
HTTP_REQUEST_SERVICE.execute(() -> {
try {
@@ -51,9 +55,12 @@ public final class DiscordWebhook {
try (final OutputStream outputStream = urlConnection.getOutputStream()) {
outputStream.write(serializedContent.getBytes(StandardCharsets.UTF_8));
}
// Check if the connection was a success
if (urlConnection.getResponseCode() != 204) {
throw new IllegalStateException("Unexpected response code " + urlConnection.getResponseCode());
// Warn the user if the connection might've not been successful
// I don't know why Discord doesn't respond with OK, but I guess this should be fine
final int code = urlConnection.getResponseCode();
if (code != HTTP_NO_CONTENT) {
Sonar.get().getLogger().warn("Unexpected Discord webhook response code {}", code);
}
} catch (Exception exception) {
Sonar.get().getLogger().error("Could not send webhook: {}", exception);
@@ -61,7 +68,7 @@ public final class DiscordWebhook {
});
}
private String prepareSerializedContent(final SonarConfiguration.Webhook.@NotNull Embed embed) {
private static String prepareSerializedContent(final SonarConfiguration.Webhook.@NotNull Embed embed) {
final String content = Sonar.get().getConfig().getWebhook().getContent();
final String username = Sonar.get().getConfig().getWebhook().getUsername();
final String avatarUrl = Sonar.get().getConfig().getWebhook().getAvatarUrl();
+18 -3
View File
@@ -14,6 +14,7 @@ bukkit {
website = "https://jonesdev.xyz/discord/"
load = BukkitPluginDescription.PluginLoadOrder.POSTWORLD
softDepend = listOf("Geyser-Spigot", "floodgate", "Protocolize", "packetevents", "ProtocolLib", "ViaVersion")
foliaSupported = true
commands {
register("sonar") {
@@ -28,11 +29,19 @@ repositories {
}
dependencies {
compileOnly(project(":api"))
compileOnly(project(":common"))
implementation(project(":api"))
implementation(project(":common"))
// MiniMessage platform support
// 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 minimessage
compileOnly("net.kyori:adventure-text-minimessage:4.16.0")
compileOnly("net.kyori:adventure-text-serializer-gson:4.16.0")
// adventure nbt
implementation("net.kyori:adventure-nbt:4.16.0")
// We have to use 1.8 for backwards compatibility
compileOnly("org.spigotmc:spigot-api:1.8.8-R0.1-SNAPSHOT")
@@ -44,5 +53,11 @@ dependencies {
implementation("com.alessiodp.libby:libby-bukkit:2.0.0-SNAPSHOT")
}
tasks {
shadowJar {
relocate("net.kyori", "xyz.jonesdev.sonar.libs.kyori")
}
}
java.sourceCompatibility = JavaVersion.VERSION_11
java.targetCompatibility = JavaVersion.VERSION_11
@@ -22,15 +22,16 @@ import lombok.Getter;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.platform.bukkit.BukkitAudiences;
import org.bstats.bukkit.Metrics;
import org.bstats.charts.SimplePie;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.bukkit.command.BukkitSonarCommand;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import xyz.jonesdev.sonar.common.statistics.CachedBandwidthStatistics;
import java.net.InetAddress;
import java.util.Objects;
import java.util.UUID;
@@ -51,13 +52,27 @@ public final class SonarBukkit extends SonarBootstrap<SonarBukkitPlugin> {
private final BukkitAudiences bukkitAudiences = BukkitAudiences.create(getPlugin());
@Override
public @NotNull Audience audience(final @Nullable UUID uniqueId) {
public @Nullable Audience audience(final @Nullable UUID uniqueId) {
if (uniqueId == null) {
return bukkitAudiences.console();
return null;
}
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.
@@ -82,25 +97,26 @@ public final class SonarBukkit extends SonarBootstrap<SonarBukkitPlugin> {
}
};
private Metrics metrics;
@Override
public void enable() {
// Initialize bStats.org metrics
new Metrics(getPlugin(), getPlatform().getMetricsId());
metrics = new Metrics(getPlugin(), getPlatform().getMetricsId());
// Add charts for some configuration options
metrics.addCustomChart(new SimplePie("verification",
() -> getConfig().getVerification().getTiming().getDisplayName()));
metrics.addCustomChart(new SimplePie("captcha",
() -> getConfig().getVerification().getMap().getTiming().getDisplayName()));
// Register Sonar command
Objects.requireNonNull(getPlugin().getCommand("sonar")).setExecutor(new BukkitSonarCommand());
}
// Register traffic service
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(),
CachedBandwidthStatistics::reset, 20L, 20L);
// Register queue service
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(),
getFallback().getQueue().getPollTask(), 10L, 10L);
// Register verbose service
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(),
Sonar.get().getVerboseHandler()::update, 4L, 4L);
@Override
public void disable() {
// Make sure to properly shutdown bStats metrics
metrics.shutdown();
}
}
@@ -21,9 +21,11 @@ import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.api.command.SonarCommand;
import xyz.jonesdev.sonar.bukkit.SonarBukkit;
import java.util.List;
@@ -37,7 +39,10 @@ public final class BukkitSonarCommand implements CommandExecutor, TabExecutor, S
final String label,
final String[] args) {
// Create our own invocation source wrapper to handle messages properly
final InvocationSource invocationSource = new BukkitInvocationSource(sender);
final InvocationSource invocationSource = new InvocationSource(
sender instanceof Player ? ((Player) sender).getUniqueId() : null,
SonarBukkit.INSTANCE.getBukkitAudiences().sender(sender),
sender::hasPermission);
// Pass the invocation source and command arguments to our command handler
handle(invocationSource, args);
return true; // Valid
+17 -3
View File
@@ -13,14 +13,22 @@ bungee {
}
dependencies {
compileOnly(project(":api"))
compileOnly(project(":common"))
implementation(project(":api"))
implementation(project(":common"))
compileOnly("net.md_5:bungeecord-proxy:master-SNAPSHOT")
testCompileOnly("net.md_5:bungeecord-proxy:master-SNAPSHOT")
// MiniMessage platform support
// 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 minimessage
compileOnly("net.kyori:adventure-text-minimessage:4.16.0")
compileOnly("net.kyori:adventure-text-serializer-gson:4.16.0")
// adventure nbt
implementation("net.kyori:adventure-nbt:4.16.0")
// Implement bStats.org for metrics
implementation("org.bstats:bstats-bungeecord:3.0.2")
@@ -29,5 +37,11 @@ dependencies {
implementation("com.alessiodp.libby:libby-bungee:2.0.0-SNAPSHOT")
}
tasks {
shadowJar {
relocate("net.kyori", "xyz.jonesdev.sonar.libs.kyori")
}
}
java.sourceCompatibility = JavaVersion.VERSION_11
java.targetCompatibility = JavaVersion.VERSION_11
@@ -21,20 +21,20 @@ import com.alessiodp.libby.BungeeLibraryManager;
import lombok.Getter;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.platform.bungeecord.BungeeAudiences;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import org.bstats.bungeecord.Metrics;
import org.bstats.charts.SimplePie;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.bungee.command.BungeeSonarCommand;
import xyz.jonesdev.sonar.bungee.fallback.FallbackInjectionHelper;
import xyz.jonesdev.sonar.bungee.fallback.FallbackLoginListener;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import xyz.jonesdev.sonar.common.statistics.CachedBandwidthStatistics;
import java.net.InetAddress;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Getter
public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
@@ -51,13 +51,26 @@ public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
private final BungeeAudiences bungeeAudiences = BungeeAudiences.create(getPlugin());
@Override
public @NotNull Audience audience(final @Nullable UUID uniqueId) {
public @Nullable Audience audience(final @Nullable UUID uniqueId) {
if (uniqueId == null) {
return bungeeAudiences.console();
return null;
}
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.
@@ -82,31 +95,29 @@ public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
}
};
private Metrics metrics;
@Override
public void enable() {
// Initialize bStats.org metrics
new Metrics(getPlugin(), getPlatform().getMetricsId());
metrics = new Metrics(getPlugin(), getPlatform().getMetricsId());
// Add charts for some configuration options
metrics.addCustomChart(new SimplePie("verification",
() -> getConfig().getVerification().getTiming().getDisplayName()));
metrics.addCustomChart(new SimplePie("captcha",
() -> getConfig().getVerification().getMap().getTiming().getDisplayName()));
// Register Sonar command
getPlugin().getServer().getPluginManager().registerCommand(getPlugin(), new BungeeSonarCommand());
// Register Fallback listener
getPlugin().getServer().getPluginManager().registerListener(getPlugin(), new FallbackLoginListener());
// Register traffic service
getPlugin().getServer().getScheduler().schedule(getPlugin(), CachedBandwidthStatistics::reset,
1L, 1L, TimeUnit.SECONDS);
// Register queue service
getPlugin().getServer().getScheduler().schedule(getPlugin(), getFallback().getQueue().getPollTask(),
500L, 500L, TimeUnit.MILLISECONDS);
// Register verbose service
getPlugin().getServer().getScheduler().schedule(getPlugin(), Sonar.get().getVerboseHandler()::update,
200L, 200L, TimeUnit.MILLISECONDS);
// Make sure to inject into the server's connection handler
FallbackInjectionHelper.inject();
}
@Override
public void disable() {
// Make sure to properly shutdown bStats metrics
metrics.shutdown();
}
}
@@ -18,11 +18,13 @@
package xyz.jonesdev.sonar.bungee.command;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.api.plugin.TabExecutor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.api.command.SonarCommand;
import xyz.jonesdev.sonar.bungee.SonarBungee;
import static java.util.Collections.emptyList;
@@ -34,7 +36,10 @@ public final class BungeeSonarCommand extends Command implements TabExecutor, So
@Override
public void execute(final @NotNull CommandSender sender, final String[] args) {
// Create our own invocation source wrapper to handle messages properly
final InvocationSource invocationSource = new BungeeInvocationSource(sender);
final InvocationSource invocationSource = new InvocationSource(
sender instanceof ProxiedPlayer ? ((ProxiedPlayer) sender).getUniqueId() : null,
SonarBungee.INSTANCE.getBungeeAudiences().sender(sender),
sender::hasPermission);
// Pass the invocation source and command arguments to our command handler
handle(invocationSource, args);
}
@@ -19,7 +19,6 @@ package xyz.jonesdev.sonar.bungee.fallback;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.CorruptedFrameException;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.netty.HandlerBoss;
import net.md_5.bungee.protocol.DefinedPacket;
@@ -28,24 +27,12 @@ import net.md_5.bungee.protocol.packet.Handshake;
import net.md_5.bungee.protocol.packet.LoginRequest;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.FallbackBandwidthHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandlerAdapter;
import xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static net.md_5.bungee.netty.PipelineUtils.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_BANDWIDTH;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.customDisconnect;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter {
@@ -74,11 +61,18 @@ public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter
if (wrappedPacket != null) {
// Intercept any handshake packet by the client
if (wrappedPacket instanceof Handshake) {
handleHandshake((Handshake) wrappedPacket);
final Handshake handshake = (Handshake) wrappedPacket;
handleHandshake(handshake.getHost(), handshake.getProtocolVersion());
}
// Intercept any server login packet by the client
if (wrappedPacket instanceof LoginRequest) {
handleLogin(ctx, (LoginRequest) wrappedPacket, msg);
// Make sure to use the potentially modified, real IP
final LoginRequest loginRequest = (LoginRequest) wrappedPacket;
final HandlerBoss handlerBoss = channel.pipeline().get(HandlerBoss.class);
final ChannelWrapper channelWrapper = (ChannelWrapper) CHANNEL_WRAPPER_FIELD.get(handlerBoss);
final InetSocketAddress socketAddress = (InetSocketAddress) channelWrapper.getRemoteAddress();
handleLogin(ctx, msg, loginRequest.getData(), socketAddress,
PACKET_ENCODER, PACKET_DECODER, TIMEOUT_HANDLER, BOSS_HANDLER);
return;
}
}
@@ -86,121 +80,4 @@ public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter
// Make sure to let the server handle the rest
ctx.fireChannelRead(msg);
}
private void handleHandshake(final @NotNull Handshake handshake) throws Exception {
// Check if the player has already sent a handshake packet
if (protocolVersion != null) {
throw new CorruptedFrameException("Already sent handshake");
}
// Check if the hostname is invalid
if (handshake.getHost().isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
// Connections from unknown protocol versions will be discarded
// as this is the safest way of handling unwanted connections
if (protocolVersion.isUnknown()) {
// Sonar does NOT support snapshots or unknown versions;
// I'll try my best to stay up-to-date!
throw new CorruptedFrameException("Unknown protocol");
}
// Store the protocol version
protocolVersion = ProtocolVersion.fromId(handshake.getProtocolVersion());
// Hook the traffic listener
// TODO: Can we implement this in channelActive?
channel.pipeline().addFirst(FALLBACK_BANDWIDTH, FallbackBandwidthHandler.INSTANCE);
}
private void handleLogin(final @NotNull ChannelHandlerContext ctx,
final @NotNull LoginRequest loginRequest,
final @NotNull Object wrappedMessage) throws Exception {
// Check if the player has already sent a login packet
if (username != null) {
throw new CorruptedFrameException("Already logged on");
}
// Increase joins per second for the action bar verbose
GlobalSonarStatistics.countLogin();
// Store the username
username = loginRequest.getData();
// Make sure to use the potentially modified, real IP
final HandlerBoss handlerBoss = channel.pipeline().get(HandlerBoss.class);
final ChannelWrapper channelWrapper = (ChannelWrapper) CHANNEL_WRAPPER_FIELD.get(handlerBoss);
final InetSocketAddress socketAddress = (InetSocketAddress) channelWrapper.getRemoteAddress();
inetAddress = socketAddress.getAddress();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklist().asMap().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, blacklisted, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
ctx.fireChannelRead(wrappedMessage);
return;
}
// Completely skip Geyser connections
if (GeyserUtil.isGeyserConnection(channel, socketAddress)) {
ctx.fireChannelRead(wrappedMessage);
return;
}
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyQueued, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(loginRequest.getData())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyVerifying, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols()
.contains(protocolVersion.getProtocol())) {
customDisconnect(channel, protocolVersion, protocolBlacklisted, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Make sure we actually have to verify the player
final String offlineUUIDString = "OfflinePlayer:" + loginRequest.getData();
final UUID offlineUUID = UUID.nameUUIDFromBytes(offlineUUIDString.getBytes(StandardCharsets.UTF_8));
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, offlineUUID)) {
ctx.fireChannelRead(wrappedMessage);
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
customDisconnect(channel, protocolVersion, reconnectedTooFast, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols()
.contains(protocolVersion.getProtocol())) {
ctx.fireChannelRead(wrappedMessage);
return;
}
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex()
.matcher(loginRequest.getData()).matches()) {
customDisconnect(channel, protocolVersion, invalidUsername, PACKET_ENCODER, BOSS_HANDLER);
return;
}
// Create an instance for the Fallback connection
final FallbackUser user = new FallbackUserWrapper(channel, inetAddress, protocolVersion);
// Let the verification handler take over the channel
user.hijack(loginRequest.getData(), offlineUUID, PACKET_ENCODER, PACKET_DECODER, TIMEOUT_HANDLER, BOSS_HANDLER);
}));
}
}
@@ -1,84 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bungee.fallback;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.md_5.bungee.api.connection.PendingConnection;
import net.md_5.bungee.api.event.PreLoginEvent;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.chat.ComponentSerializer;
import net.md_5.bungee.connection.InitialHandler;
import net.md_5.bungee.event.EventHandler;
import net.md_5.bungee.event.EventPriority;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.protocol.packet.Kick;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.bungee.SonarBungee;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.util.Objects;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.closeWith;
@RequiredArgsConstructor
public final class FallbackLoginListener implements Listener {
private static final Field CHANNEL_FIELD;
static {
try {
CHANNEL_FIELD = InitialHandler.class.getDeclaredField("ch");
CHANNEL_FIELD.setAccessible(true);
} catch (NoSuchFieldException exception) {
throw new ReflectiveOperationException(exception);
}
}
@SuppressWarnings("deprecation")
@EventHandler(priority = EventPriority.LOWEST)
public void handle(final @NotNull PreLoginEvent event) throws IllegalAccessException {
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
// Don't do anything if the setting is disabled
if (maxOnlinePerIp <= 0) return;
final InetAddress inetAddress = event.getConnection().getAddress().getAddress();
final long onlinePerIp = SonarBungee.INSTANCE.getPlugin().getServer().getPlayers().stream()
.filter(player -> Objects.equals(player.getAddress().getAddress(), inetAddress))
.count()
+ 1 /* add 1 because the player hasn't been added to the list of online players yet */;
// We use '>=' because the player connecting to the server hasn't joined yet
if (onlinePerIp >= maxOnlinePerIp) {
final Component component = Sonar.get().getConfig().getTooManyOnlinePerIp();
final String serialized = GsonComponentSerializer.gson().serialize(component);
final PendingConnection connection = event.getConnection();
closeWith(((ChannelWrapper) CHANNEL_FIELD.get(connection)).getHandle(),
ProtocolVersion.fromId(connection.getVersion()),
new Kick(ComponentSerializer.deserialize(serialized)));
}
}
}
+6
View File
@@ -1,5 +1,11 @@
dependencies {
compileOnly(project(":api"))
// adventure nbt for packets
compileOnly("net.kyori:adventure-nbt:4.16.0")
// CAPTCHA image generation
implementation("xyz.jonesdev.capja:capja:0.1.2")
}
java.sourceCompatibility = JavaVersion.VERSION_11
@@ -21,6 +21,8 @@ import com.alessiodp.libby.Library;
import com.alessiodp.libby.LibraryManager;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
@UtilityClass
class LibraryLoader {
@@ -61,13 +63,33 @@ class LibraryLoader {
.groupId("com{}mysql")
.artifactId("mysql-connector-j")
.version("8.3.0")
.relocate("com{}mysql", "xyz{}jonesdev{}sonar{}libs{}mysql")
.build(),
// MariaDB driver
Library.builder()
.groupId("org{}mariadb{}jdbc")
.artifactId("mariadb-java-client")
.version("3.3.3")
.relocate("org{}mariadb", "xyz{}jonesdev{}sonar{}libs{}mariadb")
.build()
);
// Only load adventure dependencies if needed
if (Sonar.get().getPlatform() != SonarPlatform.VELOCITY) {
libraryManager.loadLibraries(
Library.builder()
.groupId("net{}kyori")
.artifactId("adventure-text-minimessage")
.version("4.16.0")
.relocate("net{}kyori", "xyz{}jonesdev{}sonar{}libs{}kyori")
.build(),
Library.builder()
.groupId("net{}kyori")
.artifactId("adventure-text-serializer-gson")
.version("4.16.0")
.relocate("net{}kyori", "xyz{}jonesdev{}sonar{}libs{}kyori")
.build()
);
}
}
}
@@ -36,6 +36,7 @@ import xyz.jonesdev.sonar.api.timer.SystemTimer;
import xyz.jonesdev.sonar.api.verbose.Notification;
import xyz.jonesdev.sonar.api.verbose.Verbose;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer;
import xyz.jonesdev.sonar.common.service.SonarServiceThreadManager;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.subcommand.SubcommandRegistryHolder;
import xyz.jonesdev.sonar.common.update.UpdateChecker;
@@ -68,12 +69,13 @@ public abstract class SonarBootstrap<T> implements Sonar {
SonarSupplier.set(this);
// Store our library manager for the API
this.libraryManager = libraryManager;
// Load all libraries before anything else
LibraryLoader.loadLibraries(libraryManager);
// Set the plugin instance
this.plugin = plugin;
this.dataDirectory = dataDirectory;
this.platform = platform;
// Load all libraries before anything else
LibraryLoader.loadLibraries(libraryManager);
// Load the rest of the components
this.statistics = new GlobalSonarStatistics();
this.verboseHandler = new Verbose();
this.notificationHandler = new Notification();
@@ -104,6 +106,10 @@ public abstract class SonarBootstrap<T> implements Sonar {
// Run the per-platform initialization method
enable();
// Start threads
getLogger().info("Starting all managed threads...");
SonarServiceThreadManager.start();
// Done
getLogger().info("Done ({}s)!", launchTimer);
} catch (Throwable throwable) {
@@ -132,10 +138,13 @@ public abstract class SonarBootstrap<T> implements Sonar {
// Prepare cached packets
FallbackPreparer.prepare();
// Update ratelimiter cache
getFallback().getRatelimiter().setExpiringCache(Caffeine.newBuilder()
// Update ratelimiter caches
getFallback().getRatelimiter().setAttemptCache(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMillis(getConfig().getVerification().getReconnectDelay()))
.build());
getFallback().getRatelimiter().setFailCountCache(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMillis(getConfig().getVerification().getRememberTime()))
.build());
// Update blacklist cache
final long blacklistTime = getConfig().getVerification().getBlacklistTime();
@@ -156,14 +165,25 @@ public abstract class SonarBootstrap<T> implements Sonar {
}
// Reinitialize database controller
if (verifiedPlayerController != null) {
// Close the old connection first
verifiedPlayerController.close();
}
verifiedPlayerController = new VerifiedPlayerController();
}
public void shutdown() {
public final void shutdown() {
// Initialize shutdown process
getLogger().info("Starting shutdown process...");
// ...
// Interrupt threads
SonarServiceThreadManager.stop();
// Close database connection
verifiedPlayerController.close();
// Run the per-platform disable method
disable();
// Thank the user for using Sonar
getLogger().info("Successfully shut down. Goodbye!");
}
public abstract void disable();
}
@@ -20,15 +20,27 @@ package xyz.jonesdev.sonar.common.fallback;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.CorruptedFrameException;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_BANDWIDTH;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.customDisconnect;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.deject;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
@RequiredArgsConstructor
public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter {
@@ -36,6 +48,7 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
protected @Nullable String username;
protected InetAddress inetAddress;
protected ProtocolVersion protocolVersion;
protected @Nullable FallbackUser user;
protected static final Fallback FALLBACK = Sonar.get().getFallback();
@@ -57,7 +70,7 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
// The player cannot be in the queue if the IP address is invalid
if (inetAddress != null) {
// Remove the IP address from the queue
FALLBACK.getQueue().remove(inetAddress);
FALLBACK.getQueue().getQueuedPlayers().remove(inetAddress);
}
// Make sure to let the server handle the rest
ctx.fireChannelInactive();
@@ -72,4 +85,162 @@ public class FallbackChannelHandlerAdapter extends ChannelInboundHandlerAdapter
// Simply close the channel if we encounter any errors.
ctx.close();
}
/**
* Validates and handles incoming handshake packets
*
* @param hostname Hostname (server address) sent by the client
* @param protocol Protocol version number sent by the client
*/
protected final void handleHandshake(final @NotNull String hostname, final int protocol) throws Exception {
// Check if the player has already sent a handshake packet
if (protocolVersion != null) {
throw new CorruptedFrameException("Already sent handshake");
}
// Check if the hostname is invalid
if (hostname.isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
// Store the protocol version
protocolVersion = ProtocolVersion.fromId(protocol);
// Hook the traffic listener
// TODO: Can we implement this in channelActive?
channel.pipeline().addFirst(FALLBACK_BANDWIDTH, FallbackBandwidthHandler.INSTANCE);
}
/**
* Validates and handles incoming login packets
*
* @param ctx Forwarded channel handler context
* @param loginPacket Login packet sent by the client
* @param username Username sent by the client
* @param socketAddress Socket address of the client
*/
protected final void handleLogin(final @NotNull ChannelHandlerContext ctx,
final @NotNull Object loginPacket,
final @NotNull String username,
final @NotNull InetSocketAddress socketAddress,
final @NotNull String encoder,
final @NotNull String decoder,
final @NotNull String timeout,
final @NotNull String handler) throws Exception {
// Check if the player has already sent a login packet
if (this.username != null) {
throw new CorruptedFrameException("Already sent login");
}
// Connections from unknown protocol versions will be discarded
// as this is the safest way of handling unwanted connections
if (protocolVersion.isUnknown()) {
// Sonar does NOT support snapshots or unknown versions;
// I'll try my best to stay up-to-date!
throw new CorruptedFrameException("Unknown protocol version");
}
// Increase joins per second for the action bar verbose
GlobalSonarStatistics.countLogin();
// Store the username and IP address
this.username = username;
this.inetAddress = socketAddress.getAddress();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklist().asMap().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, blacklisted, encoder, handler);
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
initialLogin(ctx, loginPacket, encoder, handler);
return;
}
// Completely skip Geyser connections
if (GeyserUtil.isGeyserConnection(channel, socketAddress)) {
initialLogin(ctx, loginPacket, encoder, handler);
return;
}
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyQueued, encoder, handler);
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(username)
|| FALLBACK.getConnected().containsValue(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyVerifying, encoder, handler);
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols()
.contains(protocolVersion.getProtocol())) {
customDisconnect(channel, protocolVersion, protocolBlacklisted, encoder, handler);
return;
}
// Make sure we actually have to verify the player
final String offlineUUIDString = "OfflinePlayer:" + username;
final UUID offlineUUID = UUID.nameUUIDFromBytes(offlineUUIDString.getBytes(StandardCharsets.UTF_8));
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, offlineUUID)) {
initialLogin(ctx, loginPacket, encoder, handler);
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
customDisconnect(channel, protocolVersion, reconnectedTooFast, encoder, handler);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols()
.contains(protocolVersion.getProtocol())) {
initialLogin(ctx, loginPacket, encoder, handler);
return;
}
// Queue the connection for further processing
FALLBACK.getQueue().getQueuedPlayers().put(inetAddress, () -> {
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex()
.matcher(username).matches()) {
customDisconnect(channel, protocolVersion, invalidUsername, encoder, handler);
return;
}
// Create an instance for the Fallback connection
user = new FallbackUserWrapper(channel, inetAddress, protocolVersion);
// Let the verification handler take over the channel
user.hijack(username, offlineUUID, encoder, decoder, timeout, handler);
});
}
/**
* Executes the maximum accounts per IP limit check before letting the player join
*
* @param ctx Forwarded channel handler context
* @param loginPacket Login packet sent by the client
*/
protected final void initialLogin(final @NotNull ChannelHandlerContext ctx,
final @NotNull Object loginPacket,
final @NotNull String encoder,
final @NotNull String handler) throws Exception {
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
// Skip the maximum online per IP check if it's disabled in the configuration
if (maxOnlinePerIp > 0) {
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
if (Sonar.get().hasTooManyAccounts(inetAddress, maxOnlinePerIp)) {
customDisconnect(channel, protocolVersion, FallbackPreparer.tooManyOnlinePerIP, encoder, handler);
return;
}
}
ctx.fireChannelRead(loginPacket);
// Deject the channel since we don't need it anymore
deject(channel.pipeline());
}
}
@@ -22,15 +22,16 @@ import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelPipeline;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import net.kyori.adventure.text.Component;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
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.common.fallback.protocol.FallbackPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
@@ -44,8 +45,8 @@ import java.net.InetAddress;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
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.FallbackPipelines.*;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_20_2;
@Getter
@ToString(of = {"protocolVersion", "inetAddress"})
@@ -54,6 +55,8 @@ public final class FallbackUserWrapper implements FallbackUser {
private final ChannelPipeline pipeline;
private final InetAddress inetAddress;
private final ProtocolVersion protocolVersion;
@Setter
private FallbackUserState state = FallbackUserState.LOGIN_ACK; // 1.20.2+
public FallbackUserWrapper(final @NotNull Channel channel,
final @NotNull InetAddress inetAddress,
@@ -72,26 +75,14 @@ public final class FallbackUserWrapper implements FallbackUser {
@Override
public void hijack(final @NotNull String username, final @NotNull UUID uuid,
final @NotNull String encoder, final @NotNull String decoder,
final @NotNull String timeout, final @NotNull String boss) {
// This rarely happens when the channel hangs, but the player is still connecting
// This also fixes a unique issue with TCPShield and other reverse proxies
if (pipeline.get(encoder) == null || pipeline.get(decoder) == null) {
channel.close();
return;
}
// Remove main pipeline to completely take over the channel
if (pipeline.get(boss) != null) {
pipeline.remove(boss);
}
final @NotNull String timeout, final @NotNull String handler) {
// The player has joined the verification
GlobalSonarStatistics.totalAttemptedVerifications++;
if (Sonar.get().getConfig().getVerification().isLogConnections()) {
// Only log the processing message if the server isn't under attack.
// We let the user override this through the configuration.
if (!Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
if (Sonar.get().getAttackTracker().getCurrentAttack() == null
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
Sonar.get().getFallback().getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", username)
@@ -115,33 +106,57 @@ public final class FallbackUserWrapper implements FallbackUser {
final FallbackPacketEncoder newEncoder = new FallbackPacketEncoder(protocolVersion);
pipeline.replace(encoder, FALLBACK_PACKET_ENCODER, newEncoder);
// Send LoginSuccess packet to make the client think they are joining the server
write(new LoginSuccessPacket(username, uuid));
channel.eventLoop().execute(() -> {
// Remove main pipeline to completely take over the channel
if (pipeline.get(handler) != null) {
pipeline.remove(handler);
}
// The LoginSuccess packet has been sent, now we can change the registry state
newEncoder.updateRegistry(protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
// Send LoginSuccess packet to make the client think they are joining the server
write(new LoginSuccessPacket(username, uuid));
// Replace normal decoder to allow custom packets
pipeline.replace(decoder, FALLBACK_PACKET_DECODER,
new FallbackPacketDecoder(this, new FallbackVerificationHandler(this, username, uuid)));
// The LoginSuccess packet has been sent, now we can change the registry state
newEncoder.updateRegistry(protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
try {
// Prepare custom packet decoder and verification handler
final FallbackVerificationHandler verification = new FallbackVerificationHandler(this, username, uuid);
final FallbackPacketDecoder fallbackPacketDecoder = new FallbackPacketDecoder(this, verification);
// Replace normal decoder to allow custom packets
pipeline.replace(decoder, FALLBACK_PACKET_DECODER, fallbackPacketDecoder);
if (protocolVersion.compareTo(MINECRAFT_1_20_2) < 0) {
// Start initializing the actual join process for pre-1.20.2 clients
verification.initialJoinProcess();
}
} catch (Throwable throwable) {
// This rarely happens when the channel hangs and the player is still connecting
// I honestly have no idea how else I'm supposed to fix this
channel.close();
}
});
}
@Override
public void fail(@Nullable String reason) {
public void fail(final @NotNull String reason) {
// Make sure to set the state to failed to drop all handlers
setState(FallbackUserState.FAILED);
// 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 (reason != null) {
// Only log the failed message if the server isn't under attack.
// We let the user override this through the configuration.
if (!Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
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));
}
}
@@ -151,35 +166,40 @@ public final class FallbackUserWrapper implements FallbackUser {
// Call the VerifyFailedEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyFailedEvent(this, reason));
// Use a label, so we can easily add more code beneath this method
// Use a label, so we can easily add more code beneath this method in the future
blacklist: {
// Check if the player has too many failed attempts
final int blacklistThreshold = Sonar.get().getConfig().getVerification().getBlacklistThreshold();
// The user is allowed to disable the blacklist entirely by setting the threshold to 0
if (blacklistThreshold <= 0) break blacklist;
// Add 1 to the amount of fails since we haven't updated the cached entries yet (for performance)
final int failCount = getFailedCount() + 1;
// 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);
// Now we simply need to check if the threshold is reached
if (failCount >= blacklistThreshold) {
// Call the BotBlacklistedEvent for external API usage
Sonar.get().getEventManager().publish(new UserBlacklistedEvent(this));
// Increment amount of total blacklisted players
GlobalSonarStatistics.totalBlacklistedPlayers++;
Sonar.get().getFallback().getBlacklist().put(getInetAddress(), (byte) 0);
Sonar.get().getFallback().getLogger().info(Sonar.get().getConfig().getVerification().getBlacklistLog()
.replace("%ip%", Sonar.get().getConfig().formatAddress(getInetAddress()))
.replace("%protocol%", String.valueOf(getProtocolVersion().getProtocol())));
// Invalidate the cached entry to ensure memory safety
invalidateFails();
if (failCount < blacklistThreshold) {
// Make sure we increment the number of fails
Sonar.get().getFallback().getRatelimiter().incrementFails(inetAddress, failCount);
break blacklist;
}
// Make sure we increment the number of fails
incrementFails();
// Call the BotBlacklistedEvent for external API usage
Sonar.get().getEventManager().publish(new UserBlacklistedEvent(this));
// Increment amount of total blacklisted players
GlobalSonarStatistics.totalBlacklistedPlayers++;
Sonar.get().getFallback().getBlacklist().put(getInetAddress(), (byte) 0);
if (shouldLog) {
Sonar.get().getFallback().getLogger().info(Sonar.get().getConfig().getVerification().getBlacklistLog()
.replace("%ip%", Sonar.get().getConfig().formatAddress(getInetAddress()))
.replace("%protocol%", String.valueOf(getProtocolVersion().getProtocol())));
}
// Invalidate the cached entry to ensure memory safety
Sonar.get().getFallback().getRatelimiter().getFailCountCache().invalidate(inetAddress);
}
}
@@ -212,7 +232,7 @@ public final class FallbackUserWrapper implements FallbackUser {
*
* @param packet Disconnect packet
* @param encoder Encoder to replace
* @param boss Main pipeline to remove
* @param handler Main pipeline to remove
* @param channel Channel of the player
* @param protocolVersion Protocol version of the player
*/
@@ -220,7 +240,19 @@ public final class FallbackUserWrapper implements FallbackUser {
final @NotNull ProtocolVersion protocolVersion,
final @NotNull FallbackPacket packet,
final @NotNull String encoder,
final @NotNull String boss) {
final @NotNull String handler) {
if (channel.eventLoop().inEventLoop()) {
_customDisconnect(channel, protocolVersion, packet, encoder, handler);
} else {
channel.eventLoop().execute(() -> _customDisconnect(channel, protocolVersion, packet, encoder, handler));
}
}
private static void _customDisconnect(final @NotNull Channel channel,
final @NotNull ProtocolVersion protocolVersion,
final @NotNull FallbackPacket packet,
final @NotNull String encoder,
final @NotNull String boss) {
// Remove main pipeline to completely take over the channel
if (channel.pipeline().get(boss) != null) {
channel.pipeline().remove(boss);
@@ -238,4 +270,17 @@ 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);
}
}
}
@@ -28,15 +28,16 @@ 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.map.ItemMapType;
import xyz.jonesdev.sonar.common.fallback.protocol.map.MapInfoPreparer;
import xyz.jonesdev.sonar.common.fallback.protocol.map.PreparedMapInfo;
import xyz.jonesdev.sonar.common.fallback.protocol.map.ItemType;
import xyz.jonesdev.sonar.common.fallback.protocol.map.MapCaptchaInfo;
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.fallback.protocol.vehicle.EntityType;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.utility.protocol.ProtocolUtil;
@@ -48,6 +49,7 @@ 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.*;
@@ -55,41 +57,28 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
private static final Random RANDOM = new Random();
private static final Fallback FALLBACK = Sonar.get().getFallback();
// General
private final SystemTimer login = new SystemTimer();
private final @NotNull FallbackUser user;
private final String username;
private final UUID playerUuid;
private @NotNull State state = State.LOGIN_ACK; // 1.20.2
// Checks
private short expectedTransactionId;
private int expectedKeepAliveId, expectedTeleportId = -1;
private int tick, totalReceivedPackets, ignoredMovementTicks;
private int tick, totalReceivedPackets;
private double posX, posY, posZ, lastY, spawnYPosition;
private boolean resolvedClientBrand, resolvedClientSettings;
private boolean listenForMovements;
private boolean receivedSteerBoat, receivedPlayerInput;
// Map captcha
// CAPTCHA
private final SystemTimer keepAlive = new SystemTimer();
private final SystemTimer actionBar = new SystemTimer();
private @Nullable PreparedMapInfo captcha;
private @Nullable MapCaptchaInfo captcha;
private int captchaTriesLeft;
public enum State {
// 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,
// Captcha
MAP_CAPTCHA,
// Done
SUCCESS
}
// Cached options to make sure the original values don't change throughout the verification
private final boolean performVehicle, performCaptcha, performGravity, performCollisions;
public FallbackVerificationHandler(final @NotNull FallbackUser user,
final @NotNull String username,
@@ -97,16 +86,15 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
this.user = user;
this.username = username;
this.playerUuid = playerUuid;
if (user.getProtocolVersion().compareTo(MINECRAFT_1_20_2) < 0) {
// Start initializing the actual join process
initialJoinProcess();
}
this.performGravity = Sonar.get().getConfig().getVerification().getGravity().isEnabled();
this.performCollisions = Sonar.get().getConfig().getVerification().getGravity().isCheckCollisions();
this.performCaptcha = FALLBACK.shouldPerformCaptcha();
this.performVehicle = FALLBACK.shouldPerformVehicleCheck();
}
private void configure() {
// Set the state to CONFIGURE to avoid false positives
state = State.CONFIGURE;
user.setState(CONFIGURE);
// Send the necessary configuration packets to the client
user.delayedWrite(REGISTRY_SYNC);
user.delayedWrite(FINISH_CONFIGURATION);
@@ -131,13 +119,13 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
}
}
private void initialJoinProcess() {
public void initialJoinProcess() {
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) < 0) {
// 1.7 players don't have KeepAlive packets in the login process
sendJoinGamePacket();
} else {
// Set the state to KEEP_ALIVE to avoid false positives
state = State.KEEP_ALIVE;
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
@@ -148,7 +136,7 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
private void sendTransaction() {
// Set the state to TRANSACTION to avoid false positives
// and go on with the flow of the verification.
state = State.TRANSACTION;
user.setState(TRANSACTION);
// Generate a random transaction ID
expectedTransactionId = (short) RANDOM.nextInt();
// Send a transaction with the given ID
@@ -161,7 +149,7 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// 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.
state = State.CLIENT_SETTINGS;
user.setState(CLIENT_SETTINGS);
}
// Send the JoinGame packet so we can continue the verification
user.write(joinGame);
@@ -175,7 +163,7 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
private void sendAbilitiesAndTeleport() {
// Set the state to TELEPORT to avoid false positives
// and go on with the flow of the verification.
state = State.TELEPORT;
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);
@@ -195,16 +183,16 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
}
private void sendChunkData() {
// If we don't have gravity and captcha enabled, simply finish verification
if (!Sonar.get().getConfig().getVerification().getGravity().isEnabled()
&& !FALLBACK.shouldDoMapCaptcha()) {
// 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.
state = State.POSITION;
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
@@ -213,9 +201,9 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// Teleport player into the fake lobby by sending an empty chunk
user.delayedWrite(EMPTY_CHUNK_DATA);
// Checking gravity is disabled, just finish verification
if (!Sonar.get().getConfig().getVerification().getGravity().isEnabled()) {
if (!performGravity) {
// Switch to captcha state if needed
captchaOrFinish();
vehicleCheckOrNext();
return;
}
// Send an UpdateSectionBlocks packet with a platform of blocks
@@ -255,16 +243,16 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
}
}
private void handlePacketDuringCaptcha(final @NotNull FallbackPacket packet) {
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) {
checkFrame(captcha != null, "Captcha not sent yet");
final UniversalChatPacket chat = (UniversalChatPacket) packet;
Objects.requireNonNull(captcha);
if (!chat.getMessage().equals(captcha.getInfo().getAnswer())) {
if (!chat.getMessage().equals(captcha.getAnswer())) {
// Captcha is incorrect
checkFrame(captchaTriesLeft-- > 0, "failed captcha too often");
user.write(incorrectCaptcha);
@@ -276,7 +264,7 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
}
// Every second
if (actionBar.elapsed(1000L)) {
if (actionBar.elapsed(user.getProtocolVersion().compareTo(MINECRAFT_1_8) < 0 ? 10_000L : 1_000L)) {
final String actionBarMessage = Sonar.get().getConfig().getVerification().getMap().getEnterCodeActionBar();
// Only send action bar if the message is actually supposed to be sent
if (!actionBarMessage.isEmpty()) {
@@ -291,9 +279,7 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// Every 10 seconds
if (keepAlive.elapsed(10_000L)) {
// Send a KeepAlive packet to prevent timeout
user.delayedWrite(CAPTCHA_KEEP_ALIVE);
// Send both packets in one flush
user.getChannel().flush();
user.write(CAPTCHA_KEEP_ALIVE);
// Make sure to reset the timer
keepAlive.reset();
}
@@ -301,34 +287,78 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
@Override
public void handle(final @NotNull FallbackPacket packet) {
// The player has already been verified, drop all other packets
if (state == State.SUCCESS) return;
// The player has already been verified or failed the verification -> drop packet
if (!user.getState().canReceivePackets()) return;
// We are expecting a captcha code in chat, drop all other packets
if (state == State.MAP_CAPTCHA) {
handlePacketDuringCaptcha(packet);
// 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;
}
// Check if the player is not sending a ton of packets to the server
final int maxPackets = Sonar.get().getConfig().getVerification().getMaxLoginPackets() + maxMovementTick;
checkFrame(++totalReceivedPackets < maxPackets, "too many packets");
if (packet instanceof PaddleBoatPacket) {
// Check if we are currently expecting a PaddleBoatPacket packet
assertState(VEHICLE);
// Check for timeout since the player could be sending packets but not important ones
final long timeout = Sonar.get().getConfig().getVerification().getMaxPing();
checkFrame(!login.elapsed(timeout), "time limit exceeded");
// 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 {
// 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(State.LOGIN_ACK);
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(State.CONFIGURE);
assertState(CONFIGURE);
// Check if the client has already sent valid ClientSettings and PluginMessage packets
checkFrame(resolvedClientBrand, "did not resolve client brand");
@@ -337,6 +367,7 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// Start initializing the actual join process
updateEncoderDecoderState(FallbackPacketRegistry.GAME);
initialJoinProcess();
return;
}
if (packet instanceof KeepAlivePacket) {
@@ -346,12 +377,13 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
if (keepAlive.getId() == 0 && user.getProtocolVersion().compareTo(MINECRAFT_1_8) <= 0) return;
// Check if we are currently expecting a KeepAlive packet
assertState(State.KEEP_ALIVE);
assertState(KEEP_ALIVE);
checkFrame(keepAlive.getId() == expectedKeepAliveId, "invalid KeepAlive ID");
// The correct KeepAlive packet has been received
sendJoinGamePacket();
return;
}
if (packet instanceof ClientSettingsPacket) {
@@ -361,12 +393,14 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
checkFrame(validateClientLocale(user, clientSettings.getLocale()), "invalid locale");
// Clients sometimes mess up the ClientSettings or PluginMessage packet.
if (state == State.CLIENT_SETTINGS) {
state = State.PLUGIN_MESSAGE;
// Probably caused by some weird 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) {
@@ -387,11 +421,11 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// Validate the client branding using a regex to filter unwanted characters.
if (Sonar.get().getConfig().getVerification().getBrand().isEnabled()) {
checkFrame(validateClientBrand(user, pluginMessage.content()), "invalid client brand");
checkFrame(validateClientBrand(user, pluginMessage.getSlicedBuffer()), "invalid client brand");
}
// Clients sometimes mess up the ClientSettings or PluginMessage packet.
if (state == State.PLUGIN_MESSAGE) {
if (user.getState() == PLUGIN_MESSAGE) {
// Send the transaction packet
sendTransaction();
}
@@ -399,11 +433,12 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// 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(State.TRANSACTION);
assertState(TRANSACTION);
final TransactionPacket transaction = (TransactionPacket) packet;
@@ -426,11 +461,12 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// 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(State.TELEPORT);
assertState(TELEPORT);
// Check if the teleport ID is correct
final TeleportConfirmPacket teleportConfirm = (TeleportConfirmPacket) packet;
@@ -443,17 +479,20 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
// Now we can send the chunk data
sendChunkData();
return;
}
if (state != State.LOGIN_ACK) {
if (user.getState() != LOGIN_ACK) {
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 PlayerTickPacket) {
@@ -464,139 +503,170 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
}
private void handlePositionUpdate(final double x, final double y, final double z, final boolean ground) {
// 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 = -1;
expectedTeleportId = -1;
}
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;
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 = -1;
expectedTeleportId = -1;
// Check for ground state in the first packet
checkFrame(!ground, "invalid ground state");
}
lastY = spawnYPosition;
return;
}
posX = x;
lastY = posY;
posY = y;
posZ = z;
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) {
// Check for too many ignored Y ticks
final int maxIgnoredTicks = Sonar.get().getConfig().getVerification().getGravity().getMaxIgnoredTicks();
checkFrame(++ignoredMovementTicks < maxIgnoredTicks, "too many ignored ticks");
return;
}
// 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)");
// 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 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;
}
// 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 (!Sonar.get().getConfig().getVerification().getGravity().isCheckCollisions()) {
captchaOrFinish();
lastY = spawnYPosition;
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
captchaOrFinish();
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 {
// 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) {
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 player is spoofing the ground state
checkFrame(!ground, "spoofed ground state");
}
// Check if the y motion is roughly equal to the predicted value
checkFrame(offsetY < 0.005, String.format("invalid gravity: %d, %.7f, %.10f, %.10f != %.10f",
tick, y, offsetY, deltaY, predictedY));
}
tick++;
}
// Make sure we don't run out of predicted Y motions
checkFrame(tick < preparedCachedYMotions.length, "too many movements");
private void captchaOrFinish() {
if (FALLBACK.shouldDoMapCaptcha()) {
if (MapInfoPreparer.getPreparedCAPTCHAs() == 0) {
// This should not happen, but we have to return if there is no captcha prepared
user.disconnect(Sonar.get().getConfig().getVerification().getCurrentlyPreparing());
if (!ground) {
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 < 0.005, 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;
}
// Set the state to MAP_CAPTCHA, so we don't handle any unnecessary packets
state = State.MAP_CAPTCHA;
if (!Sonar.get().getConfig().getVerification().getGravity().isEnabled()
&& 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);
}
// Initialize the map captcha
handleMapCaptcha();
} else {
// Finish the verification
finish();
// Interrupt the netty eventLoop to immediately stop further processing
throw new DecoderException(exception);
}
}
private void handleMapCaptcha() {
// 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(0, 36, 1, 0,
ItemMapType.FILLED_MAP.getId(user.getProtocolVersion()), SetSlotPacket.MAP_NBT));
ItemType.FILLED_MAP.getId(user.getProtocolVersion()), SetSlotPacket.MAP_NBT));
// Send random captcha to the player
captcha = MapInfoPreparer.getRandomCaptcha();
Objects.requireNonNull(captcha).write(user);
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
@@ -608,7 +678,8 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
}
private void finish() {
state = State.SUCCESS;
// Make sure we drop all further packets
user.setState(SUCCESS);
// Increment amount of total successful verifications
GlobalSonarStatistics.totalSuccessfulVerifications++;
@@ -634,7 +705,8 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
*
* @param expectedState Expected state
*/
private void assertState(final @NotNull State expectedState) {
private void assertState(final @NotNull FallbackUserState expectedState) {
final FallbackUserState state = user.getState();
checkFrame(state == expectedState, "expected " + expectedState + ", got " + state);
}
@@ -644,9 +716,14 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
* @param condition Condition to fail if it's false
* @param message Messages displayed in the stacktrace
*/
private void checkFrame(final boolean condition, final String message) {
private void checkFrame(final boolean condition, final @NotNull String message) {
if (!condition) {
user.fail(message);
// 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);
}
}
@@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.netty;
package xyz.jonesdev.sonar.common.fallback.netty;
import org.jetbrains.annotations.NotNull;
@@ -24,6 +24,7 @@ import java.util.UUID;
// Taken from
// https://github.com/PaperMC/Velocity/blob/dev/3.0.0/api/src/main/java/com/velocitypowered/api/util/FastUuidSansHyphens.java
public final class FastUUID {
public static final UUID EMPTY_UUID = new UUID(0L, 0L);
private static final int MOJANG_BROKEN_UUID_LENGTH = 32;
private static final char[] HEX_DIGITS =
@@ -98,10 +98,6 @@ public final class FallbackPacketDecoder extends ChannelInboundHandlerAdapter {
// Release the ByteBuf to avoid memory leaks
byteBuf.release();
}
} else {
// Packets are always ByteBufs - if we detect an unknown object, fail verification
user.fail("packet is not a ByteBuf");
throw new CorruptedFrameException("Packet is not a ByteBuf?!");
}
}
@@ -157,6 +157,41 @@ public enum FallbackPacketRegistry {
map(0x20, MINECRAFT_1_19_3, true),
map(0x24, MINECRAFT_1_19_4, true),
map(0x25, MINECRAFT_1_20_2, true));
clientbound.register(RemoveEntitiesPacket.class, RemoveEntitiesPacket::new,
map(0x13, MINECRAFT_1_7_2, true),
map(0x30, MINECRAFT_1_9, true),
map(0x31, MINECRAFT_1_12, true),
map(0x32, MINECRAFT_1_12_2, true),
map(0x35, MINECRAFT_1_13, true),
map(0x37, MINECRAFT_1_14, true),
map(0x38, MINECRAFT_1_15, true),
map(0x37, MINECRAFT_1_16, true),
map(0x36, MINECRAFT_1_16_2, true),
map(0x3A, MINECRAFT_1_17, true),
map(0x38, MINECRAFT_1_19, true),
map(0x3B, MINECRAFT_1_19_1, true),
map(0x3A, MINECRAFT_1_19_3, true),
map(0x3E, MINECRAFT_1_19_4, true),
map(0x3E, MINECRAFT_1_19_4, true),
map(0x40, MINECRAFT_1_20_2, true));
clientbound.register(SetPassengersPacket.class, SetPassengersPacket::new,
map(0x1B, MINECRAFT_1_7_2, true),
map(0x40, MINECRAFT_1_9, true),
map(0x42, MINECRAFT_1_12, true),
map(0x43, MINECRAFT_1_12_1, true),
map(0x46, MINECRAFT_1_13, true),
map(0x4A, MINECRAFT_1_14, true),
map(0x4B, MINECRAFT_1_15, true),
map(0x54, MINECRAFT_1_17, true),
map(0x57, MINECRAFT_1_19_1, true),
map(0x55, MINECRAFT_1_19_3, true),
map(0x59, MINECRAFT_1_19_4, true),
map(0x5B, MINECRAFT_1_20_2, true),
map(0x5D, MINECRAFT_1_20_3, true));
clientbound.register(SpawnEntityPacket.class, SpawnEntityPacket::new,
map(0x0E, MINECRAFT_1_7_2, true),
map(0x00, MINECRAFT_1_9, true),
map(0x01, MINECRAFT_1_19_4, true));
clientbound.register(UpdateSectionBlocksPacket.class, UpdateSectionBlocksPacket::new,
map(0x22, MINECRAFT_1_7_2, true),
map(0x10, MINECRAFT_1_9, true),
@@ -242,6 +277,34 @@ public enum FallbackPacketRegistry {
clientbound.register(GameEventPacket.class, GameEventPacket::new,
map(0x20, MINECRAFT_1_20_3, true));
serverbound.register(PaddleBoatPacket.class, PaddleBoatPacket::new,
map(0x11, MINECRAFT_1_9, false),
map(0x12, MINECRAFT_1_12, false),
map(0x11, MINECRAFT_1_12_1, false),
map(0x14, MINECRAFT_1_13, false),
map(0x16, MINECRAFT_1_14, false),
map(0x17, MINECRAFT_1_16, false),
map(0x16, MINECRAFT_1_17, false),
map(0x18, MINECRAFT_1_19, false),
map(0x19, MINECRAFT_1_19_1, false),
map(0x18, MINECRAFT_1_19_3, false),
map(0x19, MINECRAFT_1_19_4, false),
map(0x1B, MINECRAFT_1_20_2, false),
map(0x1C, MINECRAFT_1_20_3, false));
serverbound.register(PlayerInputPacket.class, PlayerInputPacket::new,
map(0x0C, MINECRAFT_1_7_2, false),
map(0x15, MINECRAFT_1_9, false),
map(0x16, MINECRAFT_1_12, false),
map(0x1A, MINECRAFT_1_13, false),
map(0x1C, MINECRAFT_1_14, false),
map(0x1D, MINECRAFT_1_16, false),
map(0x1C, MINECRAFT_1_17, false),
map(0x1E, MINECRAFT_1_19, false),
map(0x1F, MINECRAFT_1_19_1, false),
map(0x1E, MINECRAFT_1_19_3, false),
map(0x1F, MINECRAFT_1_19_4, false),
map(0x22, MINECRAFT_1_20_2, false),
map(0x23, MINECRAFT_1_20_3, false));
serverbound.register(UniversalChatPacket.class, UniversalChatPacket::new,
map(0x01, MINECRAFT_1_7_2, false),
map(0x02, MINECRAFT_1_9, false),
@@ -373,7 +436,7 @@ public enum FallbackPacketRegistry {
PacketRegistry() {
final Map<ProtocolVersion, ProtocolRegistry> mutableVersions = new EnumMap<>(ProtocolVersion.class);
for (final ProtocolVersion version : ProtocolVersion.values()) {
if (!version.isLegacy() && !version.isUnknown()) {
if (!version.isUnknown()) {
mutableVersions.put(version, new ProtocolRegistry(version));
}
}
@@ -23,11 +23,13 @@ import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.common.fallback.protocol.block.BlockPosition;
import xyz.jonesdev.sonar.common.fallback.protocol.block.BlockType;
import xyz.jonesdev.sonar.common.fallback.protocol.block.ChangedBlock;
import xyz.jonesdev.sonar.common.fallback.protocol.map.MapInfoPreparer;
import xyz.jonesdev.sonar.common.fallback.protocol.map.CaptchaPreparer;
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.play.*;
import java.util.concurrent.ThreadLocalRandom;
@UtilityClass
public class FallbackPreparer {
@@ -48,6 +50,7 @@ public class FallbackPreparer {
public FallbackPacket enterCodeMessage;
public FallbackPacket incorrectCaptcha;
// JoinGame
public final int PLAYER_ENTITY_ID = ThreadLocalRandom.current().nextInt(10);
public FallbackPacket joinGame;
// Update Section Blocks
public FallbackPacket updateSectionBlocks;
@@ -61,6 +64,12 @@ public class FallbackPreparer {
public FallbackPacket reconnectedTooFast;
public FallbackPacket protocolBlacklisted;
public FallbackPacket invalidUsername;
public FallbackPacket tooManyOnlinePerIP;
// Vehicle
public FallbackPacket removeEntities;
public FallbackPacket setPassengers;
public static final int VEHICLE_ENTITY_ID = PLAYER_ENTITY_ID + 1;
// Collisions
public final int BLOCKS_PER_ROW = 8; // 8 * 8 = 64 (protocol maximum)
@@ -82,8 +91,11 @@ public class FallbackPreparer {
public double[] preparedCachedYMotions;
public double maxFallDistance;
// CAPTCHA
public final CaptchaPreparer MAP_INFO_PREPARER = new CaptchaPreparer();
public void prepare() {
joinGame = new JoinGamePacket(1,
joinGame = new JoinGamePacket(PLAYER_ENTITY_ID,
Sonar.get().getConfig().getVerification().getGravity().getGamemode().getId(),
0, false, 0,
true, false, false,
@@ -126,14 +138,20 @@ public class FallbackPreparer {
protocolBlacklisted = DisconnectPacket.create(Sonar.get().getConfig().getVerification().getProtocolBlacklisted(), true);
reconnectedTooFast = DisconnectPacket.create(Sonar.get().getConfig().getVerification().getTooFastReconnect(), true);
invalidUsername = DisconnectPacket.create(Sonar.get().getConfig().getVerification().getInvalidUsername(), true);
tooManyOnlinePerIP = DisconnectPacket.create(Sonar.get().getConfig().getTooManyOnlinePerIp(), true);
// Precompute captcha answers
if (Sonar.get().getConfig().getVerification().getMap().getTiming() != SonarConfiguration.Verification.Timing.NEVER) {
if (Sonar.get().getConfig().getVerification().getMap().getTiming() != SonarConfiguration.Verification.Timing.NEVER
|| Sonar.get().getConfig().getVerification().getGravity().isCaptchaOnFail()) {
enterCodeMessage = new UniversalChatPacket(Sonar.get().getConfig().getVerification().getMap().getEnterCode(), UniversalChatPacket.SYSTEM_TYPE);
incorrectCaptcha = new UniversalChatPacket(
Sonar.get().getConfig().getVerification().getMap().getFailedCaptcha(), UniversalChatPacket.SYSTEM_TYPE);
MapInfoPreparer.prepare();
MAP_INFO_PREPARER.prepare();
}
// Prepare packets for the vehicle check
removeEntities = new RemoveEntitiesPacket(VEHICLE_ENTITY_ID);
setPassengers = new SetPassengersPacket(VEHICLE_ENTITY_ID, PLAYER_ENTITY_ID);
}
}
@@ -0,0 +1,87 @@
/*
* 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.map;
import xyz.jonesdev.capja.CaptchaGenerator;
import xyz.jonesdev.capja.CaptchaHolder;
import xyz.jonesdev.capja.config.CaptchaConfiguration;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.awt.*;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class CaptchaPreparer {
private static final ExecutorService PREPARATION_SERVICE = Executors.newSingleThreadExecutor();
private static final Random RANDOM = new Random();
private MapCaptchaInfo[] cached;
private int preparedAmount;
public void prepare() {
// Make sure we're not preparing when Sonar is already preparing answers
if (cached != null && preparedAmount != cached.length) return;
preparedAmount = 0;
final SystemTimer timer = new SystemTimer();
Sonar.get().getLogger().info("Asynchronously preparing CAPTCHA answers...");
Sonar.get().getLogger().info("Players will be able to join even if the preparation isn't finished");
// Prepare cache
final SonarConfiguration.Verification.Map config = Sonar.get().getConfig().getVerification().getMap();
cached = new MapCaptchaInfo[config.getPrecomputeAmount()];
// Prepare everything asynchronously
PREPARATION_SERVICE.execute(() -> {
final List<String> fonts = config.getFonts();
if (fonts.isEmpty()) {
throw new IllegalArgumentException("No fonts specified!");
}
// Create the configuration for the image generation
final String[] fontNames = fonts.toArray(new String[0]);
final CaptchaConfiguration configuration = new CaptchaConfiguration(
128, 128, config.getDictionary().toCharArray(), 5,
config.isFlare(), config.isScratches(), config.isRipple(), config.isSmear(), config.isPinch(),
config.getSaturation(), config.getDistortion(), new int[]{Font.PLAIN, Font.BOLD}, fontNames);
for (int i = 0; i < config.getPrecomputeAmount(); i++) {
final CaptchaGenerator captchaGenerator = new CaptchaGenerator(configuration);
final CaptchaHolder captchaHolder = captchaGenerator.generate();
final byte[] buffer = MapColorPalette.getBufferFromImage(captchaHolder.getImage());
cached[i] = new MapCaptchaInfo(captchaHolder.getAnswer(), buffer);
preparedAmount++;
}
Sonar.get().getLogger().info("Finished preparing {} CAPTCHA answers ({}s)!", preparedAmount, timer);
});
}
public boolean isCaptchaAvailable() {
return cached != null && cached[0] != null;
}
public MapCaptchaInfo getRandomCaptcha() {
// Give the player a random CAPTCHA out of the ones that we've already prepared
return cached[RANDOM.nextInt(preparedAmount)];
}
}
@@ -25,7 +25,7 @@ import java.util.function.Function;
@SuppressWarnings("unused")
@RequiredArgsConstructor
public enum ItemMapType {
public enum ItemType {
FILLED_MAP(protocolVersion -> {
// Useful resources:
// - https://github.com/PrismarineJS/minecraft-data/blob/master/data/pc/
@@ -25,41 +25,41 @@ import xyz.jonesdev.sonar.common.fallback.protocol.packets.play.MapDataPacket;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_8;
@Getter
public final class PreparedMapInfo {
public static final int DIMENSIONS = (int) Math.pow(2, 7);
public static final int SCALE = DIMENSIONS * DIMENSIONS;
public final class MapCaptchaInfo {
private final String answer;
private final byte[] buffer;
private final MapInfo info;
private final MapDataPacket[] legacy;
private final MapDataPacket modern;
public PreparedMapInfo(final String answer,
final int columns, final int rows,
final byte @NotNull [] buffer) {
this.info = new MapInfo(answer, columns, rows, 0, 0, buffer);
public MapCaptchaInfo(final @NotNull String answer, final byte @NotNull [] buffer) {
this.answer = answer;
this.buffer = buffer;
// Prepare 1.7 map data using a grid
final byte[][] grid = new byte[DIMENSIONS][DIMENSIONS];
final byte[][] grid = new byte[128][128];
for (int i = 0; i < buffer.length; i++) {
final byte buf = buffer[i];
grid[i & Byte.MAX_VALUE][i >> 7] = buf;
}
this.legacy = new MapDataPacket[grid.length];
for (int i = 0; i < grid.length; i++) {
this.legacy[i] = new MapDataPacket(new MapInfo(answer, DIMENSIONS, DIMENSIONS, i, 0, grid[i]));
this.legacy[i] = new MapDataPacket(grid[i], i, 0);
}
// Prepare 1.8+ map data
this.modern = new MapDataPacket(info);
this.modern = new MapDataPacket(buffer, 0, 0);
}
public void write(final @NotNull FallbackUser user) {
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) < 0) {
for (final MapDataPacket data : legacy) {
user.delayedWrite(data);
}
} else {
public void delayedWrite(final @NotNull FallbackUser user) {
if (user.getProtocolVersion().compareTo(MINECRAFT_1_8) >= 0) {
user.delayedWrite(modern);
return;
}
// 1.7.2-1.7.10
for (final MapDataPacket legacyPacket : legacy) {
user.delayedWrite(legacyPacket);
}
}
}
@@ -0,0 +1,198 @@
/*
* 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.map;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import java.awt.image.BufferedImage;
@UtilityClass
public class MapColorPalette {
// https://minecraft.wiki/w/Map_item_format#Full_color_tables
public static final int[] COLOR_MAP = {
0,
0,
0,
0,
-10912473,
-9594576,
-8408520,
-12362211,
-5331853,
-2766452,
-530013,
-8225962,
-7697782,
-5658199,
-3815995,
-9934744,
-4980736,
-2359296,
-65536,
-7929856,
-9408332,
-7697700,
-6250241,
-11250553,
-9079435,
-7303024,
-5789785,
-10987432,
-16754944,
-16750080,
-16745472,
-16760576,
-4934476,
-2302756,
-1,
-7895161,
-9210239,
-7499618,
-5986120,
-11118495,
-8304095,
-6464728,
-4756945,
-10471400,
-11579569,
-10461088,
-9408400,
-12895429,
-10136525,
-8689858,
-7375032,
-11780314,
-13816396,
-13158436,
-12566273,
-14605945,
-4935252,
-2303533,
-779,
-7895679,
-6792924,
-4559572,
-2588877,
-9288933,
-8571496,
-6733382,
-5092136,
-10606478,
-12030824,
-10976070,
-10053160,
-13217422,
-6184668,
-3816148,
-1710797,
-8816357,
-10907631,
-9588715,
-8401895,
-12358643,
-5613196,
-3117682,
-884827,
-8371369,
-13290187,
-12500671,
-11776948,
-14145496,
-9671572,
-8092540,
-6710887,
-11447983,
-13280916,
-12489340,
-11763815,
-14138543,
-10933123,
-9619815,
-8437838,
-12377762,
-14404227,
-13876839,
-13415246,
-14997410,
-12045020,
-10993364,
-10073037,
-13228005,
-12035804,
-10982100,
-10059981,
-13221093,
-9690076,
-8115156,
-6737101,
-11461861,
-15658735,
-15395563,
-15132391,
-15921907,
-5199818,
-2634430,
-332211,
-8094168,
-12543338,
-11551561,
-10691627,
};
public byte[] getBufferFromImage(final @NotNull BufferedImage bufferedImage) {
final int width = bufferedImage.getWidth();
final int height = bufferedImage.getHeight();
final int[] pixels = new int[128 * 128];
final byte[] buffer = new byte[128 * 128];
bufferedImage.getRGB(0, 0, width, height, pixels, 0, width);
for (int i = 0; i < pixels.length; i++) {
buffer[i] = mapColorToMinecraft(pixels[i]);
}
return buffer;
}
private byte mapColorToMinecraft(final int argb) {
int minDistance = Integer.MAX_VALUE;
byte closestIndex = 0;
for (byte i = 0; i < COLOR_MAP.length; i++) {
final int distance = calculateColorDistance(argb, COLOR_MAP[i]);
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
private int calculateColorDistance(final int argb0, final int argb1) {
final int red0 = (argb0 >> 16) & 0xFF;
final int green0 = (argb0 >> 8) & 0xFF;
final int blue0 = argb0 & 0xFF;
final int red1 = (argb1 >> 16) & 0xFF;
final int green1 = (argb1 >> 8) & 0xFF;
final int blue1 = argb1 & 0xFF;
// Color distance metric (e.g. squared Euclidean distance)
return (red0 - red1) * (red0 - red1)
+ (green0 - green1) * (green0 - green1)
+ (blue0 - blue1) * (blue0 - blue1);
}
}
@@ -1,248 +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.protocol.map;
import lombok.Getter;
import lombok.experimental.UtilityClass;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.awt.*;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static java.awt.Font.*;
import static xyz.jonesdev.sonar.common.fallback.protocol.map.PreparedMapInfo.DIMENSIONS;
@UtilityClass
public class MapInfoPreparer {
private final Random RANDOM = new Random();
private final ExecutorService PREPARATION_SERVICE = Executors.newSingleThreadExecutor();
private final int[] FONT_STYLES = new int[]{
PLAIN,
BOLD,
ITALIC,
ITALIC | BOLD
};
private final int[][] COLOR_PALETTE = new int[][]{
new int[]{ // Blue
48,
49,
50,
51
},
new int[]{ // Orange
60,
61,
62,
63,
},
new int[]{ // Green
4,
28,
29,
30,
},
new int[]{ // Black
27,
45,
46,
47,
},
new int[]{ // Red
16,
17,
18,
19
}
};
private PreparedMapInfo[] cached;
@Getter
private int preparedCAPTCHAs;
private boolean currentlyPreparing;
public PreparedMapInfo getRandomCaptcha() {
return cached[RANDOM.nextInt(preparedCAPTCHAs)];
}
public void prepare() {
final SystemTimer timer = new SystemTimer();
Sonar.get().getLogger().info("Precomputing map captcha answers...");
// Make sure we're not running into concurrency issues
if (currentlyPreparing) {
Sonar.get().getLogger().warn("Did not precompute map captcha answers as another task is running.");
return;
}
// Reset all statistics
preparedCAPTCHAs = 0;
currentlyPreparing = true;
final SonarConfiguration.Verification.Map config = Sonar.get().getConfig().getVerification().getMap();
cached = null;
cached = new PreparedMapInfo[config.getPrecomputeAmount()];
// Prepare fonts from config
final String dictionary = config.getDictionary();
final List<String> fonts = config.getFonts();
if (fonts.isEmpty()) {
Sonar.get().getLogger().warn("No fonts found, using fallback font...");
fonts.add(DIALOG);
}
final String[] fontTypes = fonts.toArray(new String[0]);
// Prepare distortions values from config
final double distortionsFactorX = config.getDistortionsFactorX();
final double distortionsFactorY = config.getDistortionsFactorY();
final double halfDistortionsFactorX = distortionsFactorX / 2D;
final double halfDistortionsFactorY = distortionsFactorY / 2D;
for (int _i = 0; _i < cached.length; _i++) {
final int currentIndex = _i;
final byte[] buffer = new byte[PreparedMapInfo.SCALE];
PREPARATION_SERVICE.execute(() -> {
// Create image
final BufferedImage image = new BufferedImage(DIMENSIONS, DIMENSIONS, BufferedImage.TYPE_3BYTE_BGR);
final Graphics2D graphics = (Graphics2D) image.getGraphics();
try {
// Create random font
final String fontType = fontTypes[RANDOM.nextInt(fontTypes.length)];
final int fontStyle = FONT_STYLES[RANDOM.nextInt(FONT_STYLES.length)];
final int fontSize = 27
+ (config.isRandomizeFontSize()
? RANDOM.nextInt(6) - 2 : 5);
@SuppressWarnings("all") final Font answerFont = new Font(fontType, fontStyle, fontSize);
graphics.setFont(answerFont);
// Build answer to the captcha
final StringBuilder answerBuilder = new StringBuilder();
for (int _j = 0; _j < 5; _j++) {
answerBuilder.append(dictionary.charAt(RANDOM.nextInt(dictionary.length())));
}
final String answer = answerBuilder.toString();
// Calculate text position
final int stringWidth = graphics.getFontMetrics().stringWidth(answer);
final int halfWidth = image.getWidth() / 2;
final int halfHeight = image.getHeight() / 2;
final int spacing = 5;
double _x = halfWidth - stringWidth / 2f - spacing;
double _y = halfHeight + fontSize / 3f;
// Draw each character one by one
final FontRenderContext fontRenderContext = graphics.getFontRenderContext();
for (final char c : answer.toCharArray()) {
// Randomize x and y
if (config.isRandomizePositions()) {
_y += RANDOM.nextInt(8) - 4;
}
// Apply random distortion
final double distortionFactorX = RANDOM.nextDouble() * distortionsFactorX - halfDistortionsFactorX;
final double distortionFactorY = RANDOM.nextDouble() * distortionsFactorY - halfDistortionsFactorY;
// Create a GlyphVector for the character
final String character = String.valueOf(c);
final GlyphVector glyphVector = graphics.getFont().createGlyphVector(fontRenderContext, character);
// Apply the distortion to the GlyphVector using AffineTransform
final AffineTransform transform = AffineTransform.getTranslateInstance(_x, _y);
transform.shear(distortionFactorX, distortionFactorY);
final Shape distortedCharacter = glyphVector.getOutline();
final Shape distorted = transform.createTransformedShape(distortedCharacter);
// Draw the distorted character
graphics.fill(distorted);
// Update x by width
_x += glyphVector.getVisualBounds().getWidth() + spacing;
}
// TODO: Color converter
graphics.setColor(Color.RED);
// Draw random lines
for (int i = 0; i < config.getRandomLinesAmount(); i++) {
final int startX = RANDOM.nextInt(halfWidth);
final int startY = RANDOM.nextInt(halfHeight);
final int endX = halfWidth + RANDOM.nextInt(halfWidth);
final int endY = halfHeight + RANDOM.nextInt(halfHeight);
graphics.drawLine(startX, startY, endX, endY);
}
// Draw random ovals
for (int i = 0; i < config.getRandomOvalsAmount(); i++) {
final int startX = RANDOM.nextInt(halfWidth);
final int startY = RANDOM.nextInt(halfHeight);
final int endX = halfWidth + RANDOM.nextInt(halfWidth);
final int endY = halfHeight + RANDOM.nextInt(halfHeight);
graphics.drawOval(startX, startY, endX, endY);
}
// Select random color palette
final int[] colorPalette = COLOR_PALETTE[currentIndex % COLOR_PALETTE.length];
// Select the next color palette for geometry
final int[] nextColorPalette = COLOR_PALETTE[(currentIndex + 1) % COLOR_PALETTE.length];
// Clear background
Arrays.fill(buffer, (byte) 57);
// Color every pixel individually
final int spacingY = halfHeight - fontSize;
for (int x = spacing; x < image.getWidth() - spacing; x++) {
for (int y = spacingY; y < image.getHeight() - spacingY; y++) {
final int pixel = image.getRGB(x, y);
final int index = y * image.getWidth() + x;
if (pixel == -16777216 && RANDOM.nextInt(100) < 97) continue;
// Set color of pixel to random color from the palette
final byte color = (byte) (pixel == -16777216 ? RANDOM.nextInt(100) < 75 ? 14 :
RANDOM.nextInt(Byte.MAX_VALUE)
: pixel != -65536 ? colorPalette[RANDOM.nextInt(colorPalette.length)]
: nextColorPalette[RANDOM.nextInt(nextColorPalette.length)]);
// Write pixel color to buffer
buffer[index] = color;
}
}
// Cache buffer to map
cached[currentIndex] = new PreparedMapInfo(answer, image.getWidth(), image.getHeight(), buffer);
} finally {
// Dispose graphics
graphics.dispose();
// Finished?
if (currentIndex == cached.length - 1) {
Sonar.get().getLogger().info("Successfully precomputed map captcha answers in {}s!", timer);
currentlyPreparing = false;
}
preparedCAPTCHAs++;
}
});
}
}
}
@@ -1,136 +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.protocol.netty;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufHolder;
import io.netty.util.IllegalReferenceCountException;
import lombok.AllArgsConstructor;
import lombok.ToString;
// Taken from
// https://github.com/PaperMC/Velocity/blob/dev/3.0.0/proxy/src/main/java/com/velocitypowered/proxy/protocol/util/DeferredByteBufHolder.java
@ToString
@AllArgsConstructor
public class DeferredByteBufHolder implements ByteBufHolder {
private ByteBuf backing;
@Override
public ByteBuf content() {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
if (backing.refCnt() <= 0) {
throw new IllegalReferenceCountException(backing.refCnt());
}
return backing;
}
@Override
public ByteBufHolder copy() {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
return new DeferredByteBufHolder(backing.copy());
}
@Override
public ByteBufHolder duplicate() {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
return new DeferredByteBufHolder(backing.duplicate());
}
@Override
public ByteBufHolder retainedDuplicate() {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
return new DeferredByteBufHolder(backing.retainedDuplicate());
}
@Override
public ByteBufHolder replace(final ByteBuf content) {
if (content == null) {
throw new NullPointerException("content");
}
backing = content;
return this;
}
@Override
public int refCnt() {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
return backing.refCnt();
}
@Override
public ByteBufHolder retain() {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
backing.retain();
return this;
}
@Override
public ByteBufHolder retain(final int increment) {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
backing.retain(increment);
return this;
}
@Override
public ByteBufHolder touch() {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
backing.touch();
return this;
}
@Override
public ByteBufHolder touch(final Object hint) {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
backing.touch(hint);
return this;
}
@Override
public boolean release() {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
return backing.release();
}
@Override
public boolean release(final int decrement) {
if (backing == null) {
throw new IllegalStateException("Trying to obtain contents of holder with a null buffer");
}
return backing.release(decrement);
}
}
@@ -30,4 +30,10 @@ public final class LoginAcknowledgedPacket implements FallbackPacket {
@Override
public void decode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
}
@Override
public int expectedMaxLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
// https://github.com/PaperMC/Velocity/blob/dev/3.0.0/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/LoginAcknowledgedPacket.java#L41
return 0;
}
}
@@ -24,8 +24,8 @@ 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.netty.FastUUID;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
import xyz.jonesdev.sonar.common.fallback.protocol.netty.FastUUID;
import java.util.UUID;
@@ -43,8 +43,6 @@ public class ClientAbilitiesPacket implements FallbackPacket {
@Override
public void decode(final @NotNull ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
encodedFlags = byteBuf.readByte();
flySpeed = byteBuf.readFloat();
walkSpeed = byteBuf.readFloat();
throw new UnsupportedOperationException();
}
}
@@ -46,13 +46,8 @@ public final class ClientSettingsPacket implements FallbackPacket {
private boolean clientListingAllowed; // Added in 1.18, overwrites server-list "anonymous" mode
@Override
public int expectedMinLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
return 2;
}
@Override
public int expectedMaxLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
return 0xff + 1; // 256 as a hard-limit
public void encode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
@Override
@@ -82,7 +77,12 @@ public final class ClientSettingsPacket implements FallbackPacket {
}
@Override
public void encode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
public int expectedMinLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
return 2;
}
@Override
public int expectedMaxLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
return 0xff + 1; // 256 as a hard-limit
}
}
@@ -25,7 +25,6 @@ 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 xyz.jonesdev.sonar.common.fallback.protocol.map.MapInfo;
import static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.writeVarInt;
@@ -34,19 +33,20 @@ import static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.writeVarInt;
@NoArgsConstructor
@AllArgsConstructor
public final class MapDataPacket implements FallbackPacket {
private MapInfo mapInfo;
private byte[] buffer;
private int x, y;
@Override
public void encode(final @NotNull ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
writeVarInt(byteBuf, 0);
if (protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0) {
byteBuf.writeShort(mapInfo.getBuffer().length + 3);
byteBuf.writeShort(buffer.length + 3);
byteBuf.writeByte(0); // scaling
byteBuf.writeByte(mapInfo.getX());
byteBuf.writeByte(mapInfo.getY());
byteBuf.writeByte(x);
byteBuf.writeByte(y);
byteBuf.writeBytes(mapInfo.getBuffer());
byteBuf.writeBytes(buffer);
} else {
byteBuf.writeByte(0); // scaling
@@ -65,13 +65,13 @@ public final class MapDataPacket implements FallbackPacket {
writeVarInt(byteBuf, 0);
}
byteBuf.writeByte(mapInfo.getColumns());
byteBuf.writeByte(mapInfo.getRows());
byteBuf.writeByte(mapInfo.getX());
byteBuf.writeByte(mapInfo.getY());
byteBuf.writeByte(128); // rows
byteBuf.writeByte(128); // columns
byteBuf.writeByte(x);
byteBuf.writeByte(y);
writeVarInt(byteBuf, mapInfo.getBuffer().length);
byteBuf.writeBytes(mapInfo.getBuffer());
writeVarInt(byteBuf, buffer.length);
byteBuf.writeBytes(buffer);
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.packets.play;
import io.netty.buffer.ByteBuf;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public final class PaddleBoatPacket implements FallbackPacket {
private boolean leftPaddle, rightPaddle;
@Override
public void encode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
@Override
public void decode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
leftPaddle = byteBuf.readBoolean();
rightPaddle = byteBuf.readBoolean();
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.fallback.protocol.packets.play;
import io.netty.buffer.ByteBuf;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class PlayerInputPacket implements FallbackPacket {
private float sideways, forward;
private boolean jump, unmount;
@Override
public void encode(final @NotNull ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
@Override
public void decode(final @NotNull ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
sideways = byteBuf.readFloat();
forward = byteBuf.readFloat();
if (protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0) {
jump = byteBuf.readBoolean();
unmount = byteBuf.readBoolean();
} else {
final byte flags = byteBuf.readByte();
jump = (flags & 0x01) != 0;
unmount = (flags & 0x02) != 0;
}
}
}
@@ -39,19 +39,6 @@ public final class PlayerPositionLookPacket implements FallbackPacket {
private int teleportId;
private boolean onGround;
@Override
public void decode(final @NotNull ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
x = byteBuf.readDouble();
y = byteBuf.readDouble();
if (protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0) {
y = byteBuf.readDouble(); // Account for weird y positions on 1.7
}
z = byteBuf.readDouble();
yaw = byteBuf.readFloat();
pitch = byteBuf.readFloat();
onGround = byteBuf.readBoolean();
}
@Override
public void encode(final @NotNull ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
byteBuf.writeDouble(x);
@@ -77,7 +64,20 @@ public final class PlayerPositionLookPacket implements FallbackPacket {
}
@Override
public int expectedMaxLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
public void decode(final @NotNull ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
x = byteBuf.readDouble();
y = byteBuf.readDouble();
if (protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0) {
y = byteBuf.readDouble(); // Account for weird y positions on 1.7
}
z = byteBuf.readDouble();
yaw = byteBuf.readFloat();
pitch = byteBuf.readFloat();
onGround = byteBuf.readBoolean();
}
@Override
public int expectedMaxLength(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
return protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0 ? 41 : 33;
}
@@ -34,6 +34,11 @@ public final class PlayerPositionPacket implements FallbackPacket {
private double x, y, z;
private boolean onGround;
@Override
public void encode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
@Override
public void decode(final @NotNull ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
x = byteBuf.readDouble();
@@ -45,11 +50,6 @@ public final class PlayerPositionPacket implements FallbackPacket {
onGround = byteBuf.readBoolean();
}
@Override
public void encode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
@Override
public int expectedMaxLength(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
return protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0 ? 33 : 25;
@@ -33,13 +33,13 @@ import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
public final class PlayerTickPacket implements FallbackPacket {
private boolean onGround;
@Override
public void decode(final @NotNull ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
onGround = byteBuf.readBoolean();
}
@Override
public void encode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
@Override
public void decode(final @NotNull ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
onGround = byteBuf.readBoolean();
}
}
@@ -23,50 +23,44 @@ 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 xyz.jonesdev.sonar.common.fallback.protocol.netty.DeferredByteBufHolder;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_13;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_8;
import static xyz.jonesdev.sonar.common.utility.protocol.ProtocolUtil.*;
// Mostly taken from
// https://github.com/PaperMC/Velocity/blob/dev/3.0.0/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/PluginMessage.java
@Getter
@ToString
public final class PluginMessagePacket extends DeferredByteBufHolder implements FallbackPacket {
public final class PluginMessagePacket implements FallbackPacket {
private String channel;
public PluginMessagePacket() {
super(null);
}
private ByteBuf slicedBuffer;
@Override
public int expectedMinLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
return 8;
}
@Override
public int expectedMaxLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
return 0xfff + 1; // limit packet size to 4096
public void encode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
@Override
public void decode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
channel = readString(byteBuf);
channel = readString(byteBuf, 512);
if (protocolVersion.compareTo(MINECRAFT_1_13) >= 0) {
channel = transformLegacyToModernChannel(channel);
}
if (protocolVersion.compareTo(MINECRAFT_1_8) >= 0) {
replace(byteBuf.readRetainedSlice(byteBuf.readableBytes()));
slicedBuffer = byteBuf.readRetainedSlice(byteBuf.readableBytes());
} else {
replace(readRetainedByteBufSlice17(byteBuf));
slicedBuffer = readRetainedByteBufSlice17(byteBuf);
}
}
@Override
public void encode(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
public int expectedMinLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
return 4;
}
@Override
public int expectedMaxLength(final ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
return 0xFFF; // strict size limit
}
}
@@ -0,0 +1,56 @@
/*
* 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 static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.writeVarInt;
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public final class RemoveEntitiesPacket implements FallbackPacket {
private int entityId; // We only need one entityId
@Override
public void encode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
// No idea why Mojang decided that this is a good idea, but whatever
if (protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_17) != 0) {
writeVarInt(byteBuf, 1); // size
}
if (protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0) {
byteBuf.writeInt(entityId);
} else {
writeVarInt(byteBuf, entityId);
}
}
@Override
public void decode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
}
@@ -0,0 +1,59 @@
/*
* 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 static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.writeVarInt;
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public final class SetPassengersPacket implements FallbackPacket {
private int entityId;
private int passengerId;
@Override
public void encode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
// You can find this in the EntityAttach packet,
// which was later replaced by SetPassengers in 1.9+
if (protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_9) < 0) {
byteBuf.writeInt(passengerId);
byteBuf.writeInt(entityId);
byteBuf.writeByte(0); // leash
return;
}
writeVarInt(byteBuf, entityId);
writeVarInt(byteBuf, 1); // passenger count
writeVarInt(byteBuf, passengerId);
}
@Override
public void decode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
}
@@ -49,11 +49,6 @@ public final class SetSlotPacket implements FallbackPacket {
.put("map", IntBinaryTag.intBinaryTag(0)) // map type
.build();
@Override
public void decode(final @NotNull ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
@Override
public void encode(final @NotNull ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
byteBuf.writeByte(windowId);
@@ -92,4 +87,9 @@ public final class SetSlotPacket implements FallbackPacket {
}
}
}
@Override
public void decode(final @NotNull ByteBuf byteBuf, final ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
}
@@ -0,0 +1,94 @@
/*
* 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 static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
import static xyz.jonesdev.sonar.common.fallback.netty.FastUUID.EMPTY_UUID;
import static xyz.jonesdev.sonar.common.utility.protocol.ProtocolUtil.writeUUID;
import static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.writeVarInt;
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public final class SpawnEntityPacket implements FallbackPacket {
private int entityId, entityType;
private double x, y, z;
@Override
public void encode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
writeVarInt(byteBuf, entityId);
final boolean v1_9orHigher = protocolVersion.compareTo(MINECRAFT_1_8) > 0;
if (v1_9orHigher) {
writeUUID(byteBuf, EMPTY_UUID);
}
if (protocolVersion.compareTo(MINECRAFT_1_14) >= 0) {
writeVarInt(byteBuf, entityType);
} else {
byteBuf.writeByte(entityType);
}
if (v1_9orHigher) {
byteBuf.writeDouble(x);
byteBuf.writeDouble(y);
byteBuf.writeDouble(z);
} else {
byteBuf.writeInt(floor(x * 32D));
byteBuf.writeInt(floor(y * 32D));
byteBuf.writeInt(floor(z * 32D));
}
byteBuf.writeByte(0); // pitch or yaw
byteBuf.writeByte(0); // yaw or pitch
if (protocolVersion.compareTo(MINECRAFT_1_19) >= 0) {
byteBuf.writeByte(0); // head yaw
writeVarInt(byteBuf, 0); // data
} else {
byteBuf.writeInt(0); // data
}
if (v1_9orHigher) {
byteBuf.writeShort(0); // velocity X
byteBuf.writeShort(0); // velocity Y
byteBuf.writeShort(0); // velocity Z
}
}
private static int floor(final double value) {
final int __value = (int) value;
return value < (double) __value ? __value - 1 : __value;
}
@Override
public void decode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
throw new UnsupportedOperationException();
}
}
@@ -36,19 +36,6 @@ public final class TransactionPacket implements FallbackPacket {
private int windowId, id;
private boolean accepted;
@Override
public void encode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
if (protocolVersion.compareTo(MINECRAFT_1_17) < 0) {
byteBuf.writeByte(windowId);
byteBuf.writeShort((short) id);
// The "accepted" field is actually really unnecessary since
// it's never even used in the client.
byteBuf.writeBoolean(accepted);
} else {
byteBuf.writeInt(id);
}
}
@Override
public void decode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
if (protocolVersion.compareTo(MINECRAFT_1_17) < 0) {
@@ -62,4 +49,17 @@ public final class TransactionPacket implements FallbackPacket {
accepted = true;
}
}
@Override
public void encode(final ByteBuf byteBuf, final @NotNull ProtocolVersion protocolVersion) {
if (protocolVersion.compareTo(MINECRAFT_1_17) < 0) {
byteBuf.writeByte(windowId);
byteBuf.writeShort((short) id);
// The "accepted" field is actually really unnecessary since
// it's never even used in the client.
byteBuf.writeBoolean(accepted);
} else {
byteBuf.writeInt(id);
}
}
}
@@ -29,9 +29,9 @@ import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacket;
import xyz.jonesdev.sonar.common.utility.component.ComponentHolder;
import java.time.Instant;
import java.util.UUID;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.*;
import static xyz.jonesdev.sonar.common.fallback.netty.FastUUID.EMPTY_UUID;
import static xyz.jonesdev.sonar.common.utility.protocol.ProtocolUtil.*;
import static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.readVarInt;
import static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.writeVarInt;
@@ -40,7 +40,6 @@ import static xyz.jonesdev.sonar.common.utility.protocol.VarIntUtil.writeVarInt;
@NoArgsConstructor
@AllArgsConstructor
public final class UniversalChatPacket implements FallbackPacket {
private static final UUID PLACEHOLDER_UUID = new UUID(0L, 0L);
private static final int DIV_FLOOR = -Math.floorDiv(-20, 8);
public static final byte CHAT_TYPE = (byte) 0;
@@ -85,7 +84,7 @@ public final class UniversalChatPacket implements FallbackPacket {
// Sender
if (protocolVersion.compareTo(MINECRAFT_1_16) >= 0
&& protocolVersion.compareTo(MINECRAFT_1_19) < 0) {
writeUUID(byteBuf, PLACEHOLDER_UUID);
writeUUID(byteBuf, EMPTY_UUID);
}
}
@@ -0,0 +1,88 @@
/*
* 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.vehicle;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import java.util.function.Function;
@RequiredArgsConstructor
public enum EntityType {
BOAT(protocolVersion -> {
// Useful resources:
// - https://github.com/PrismarineJS/minecraft-data/blob/master/data/pc/
// - https://pokechu22.github.io/Burger/1.20.4.html
switch (protocolVersion) {
default:
case MINECRAFT_1_7_2:
case MINECRAFT_1_7_6:
case MINECRAFT_1_8:
case MINECRAFT_1_9:
case MINECRAFT_1_9_1:
case MINECRAFT_1_9_2:
case MINECRAFT_1_9_4:
case MINECRAFT_1_10:
case MINECRAFT_1_11:
case MINECRAFT_1_11_1:
case MINECRAFT_1_12:
case MINECRAFT_1_12_1:
case MINECRAFT_1_12_2:
case MINECRAFT_1_13:
case MINECRAFT_1_13_1:
case MINECRAFT_1_13_2:
return 1;
case MINECRAFT_1_14:
case MINECRAFT_1_14_1:
case MINECRAFT_1_14_2:
case MINECRAFT_1_14_3:
case MINECRAFT_1_14_4:
return 5;
case MINECRAFT_1_15:
case MINECRAFT_1_15_1:
case MINECRAFT_1_15_2:
case MINECRAFT_1_16:
case MINECRAFT_1_16_1:
case MINECRAFT_1_16_2:
case MINECRAFT_1_16_3:
case MINECRAFT_1_16_4:
return 6;
case MINECRAFT_1_17:
case MINECRAFT_1_17_1:
case MINECRAFT_1_18:
case MINECRAFT_1_18_2:
return 7;
case MINECRAFT_1_19:
case MINECRAFT_1_19_1:
case MINECRAFT_1_19_3:
return 8;
case MINECRAFT_1_19_4:
case MINECRAFT_1_20:
case MINECRAFT_1_20_2:
case MINECRAFT_1_20_3:
return 9;
}
});
private final Function<ProtocolVersion, Integer> function;
public int getId(final @NotNull ProtocolVersion protocolVersion) {
return function.apply(protocolVersion);
}
}
@@ -15,19 +15,28 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.bungee.command;
package xyz.jonesdev.sonar.common.service;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.bungee.SonarBungee;
public final class SonarServiceThread extends Thread {
private final long delay;
private final Runnable runnable;
public final class BungeeInvocationSource extends InvocationSource {
public BungeeInvocationSource(final @NotNull CommandSender sender) {
super(sender instanceof ProxiedPlayer ? ((ProxiedPlayer) sender).getUniqueId() : null,
SonarBungee.INSTANCE.getBungeeAudiences().sender(sender),
sender instanceof ProxiedPlayer,
sender::hasPermission);
public SonarServiceThread(final String name, final long delay, final Runnable runnable) {
setName(name);
this.delay = delay;
this.runnable = runnable;
}
@Override
public void run() {
try {
while (!isInterrupted()) {
runnable.run();
// Sleep thread by specified delay
Thread.sleep(delay);
}
} catch (InterruptedException interruptedException) {
//
}
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.common.service;
import lombok.experimental.UtilityClass;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.common.statistics.CachedBandwidthStatistics;
@UtilityClass
public final class SonarServiceThreadManager {
private static final SonarServiceThread VERBOSE = new SonarServiceThread("sonar-verbose-thread", 250L,
() -> {
// Make sure to clean up the cached statistics
// since we don't want to display wrong values.
Sonar.get().getStatistics().cleanUpCache();
// Update the attack tracker
Sonar.get().getAttackTracker().checkIfUnderAttack();
// Update the action bar verbose
Sonar.get().getVerboseHandler().update();
});
private static final SonarServiceThread FALLBACK_QUEUE = new SonarServiceThread("sonar-fallback-queue-thread", 1000L,
() -> Sonar.get().getFallback().getQueue().poll());
private static final SonarServiceThread STATISTICS = new SonarServiceThread("sonar-statistics-thread", 1000L,
() -> {
// Clean up bandwidth statistics
CachedBandwidthStatistics.reset();
// Clean up the cache of rate-limited IPs
Sonar.get().getFallback().getRatelimiter().cleanUpCache();
});
public void start() {
VERBOSE.start();
FALLBACK_QUEUE.start();
STATISTICS.start();
}
public void stop() {
VERBOSE.interrupt();
FALLBACK_QUEUE.interrupt();
STATISTICS.interrupt();
}
}
@@ -47,10 +47,9 @@ public enum CachedBandwidthStatistics {
* (per-second) value and resets the current value.
* This method is called every second.
*/
public static synchronized void reset() {
for (final CachedBandwidthStatistics value : values()) {
value.cacheAndReset();
}
public static void reset() {
INCOMING.cacheAndReset();
OUTGOING.cacheAndReset();
}
private void cacheAndReset() {
@@ -24,28 +24,31 @@ import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.statistics.SonarStatistics;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
public final class GlobalSonarStatistics implements SonarStatistics {
private static final Cache<Long, Byte> LOGINS_PER_SECOND = Caffeine.newBuilder()
private static final Cache<Integer, Byte> LOGINS_PER_SECOND = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(1))
.build();
private static final Cache<Long, Byte> CONNECTIONS_PER_SECOND = Caffeine.newBuilder()
private static final Cache<Integer, Byte> CONNECTIONS_PER_SECOND = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(1))
.build();
private static final AtomicInteger ACTION_COUNTER = new AtomicInteger(Integer.MIN_VALUE);
/**
* Helper methods that make it easier to count new statistics
*/
@ApiStatus.Internal
public static void countConnection() {
CONNECTIONS_PER_SECOND.put(System.nanoTime(), (byte) 0);
CONNECTIONS_PER_SECOND.put(ACTION_COUNTER.getAndIncrement(), (byte) 0);
}
@ApiStatus.Internal
public static void countLogin() {
LOGINS_PER_SECOND.put(System.nanoTime(), (byte) 0);
LOGINS_PER_SECOND.put(ACTION_COUNTER.getAndIncrement(), (byte) 0);
totalJoinedPlayers++;
}
@@ -60,7 +63,7 @@ public final class GlobalSonarStatistics implements SonarStatistics {
* Called every time the action bar updates to ensure fresh values
*/
@Override
public synchronized void cleanUpCache() {
public void cleanUpCache() {
LOGINS_PER_SECOND.cleanUp();
CONNECTIONS_PER_SECOND.cleanUp();
// Also clean up the blacklist
@@ -60,7 +60,7 @@ public final class BlacklistCommand extends Subcommand {
}
// Display a warning if the IP is verified but being added to the blacklist
if (SONAR.getVerifiedPlayerController().has(inetAddress)) {
if (SONAR.getVerifiedPlayerController().has(inetAddress.toString())) {
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getBlacklistAddWarning()
.replace("%ip%", rawInetAddress));
}
@@ -17,6 +17,7 @@
package xyz.jonesdev.sonar.common.subcommand.impl;
import net.kyori.adventure.text.Component;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.command.CommandInvocation;
@@ -62,7 +63,7 @@ public final class StatisticsCommand extends Subcommand {
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getStatisticsHeader()
.replace("%type%", type.name().toLowerCase()));
invocation.getSender().sendMessage();
invocation.getSender().sendMessage(Component.empty());
switch (type) {
case GENERAL: {
@@ -23,8 +23,10 @@ import xyz.jonesdev.sonar.api.command.CommandInvocation;
import xyz.jonesdev.sonar.api.command.subcommand.Subcommand;
import xyz.jonesdev.sonar.api.command.subcommand.SubcommandInfo;
import xyz.jonesdev.sonar.api.command.subcommand.argument.Argument;
import xyz.jonesdev.sonar.api.model.VerifiedPlayer;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.Queue;
import java.util.UUID;
import java.util.concurrent.LinkedBlockingQueue;
@@ -35,6 +37,7 @@ import java.util.concurrent.LinkedBlockingQueue;
arguments = {
@Argument("history"),
@Argument("remove"),
@Argument("add"),
@Argument("clear"),
@Argument("size")
}
@@ -107,6 +110,51 @@ public final class VerifiedCommand extends Subcommand {
break;
}
case "add": {
if (invocation.getRawArguments().length <= 3) {
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getIncorrectCommandUsage()
.replace("%usage%", "verified add <IP address> <UUID/username>"));
return;
}
final String rawInetAddress = invocation.getRawArguments()[2];
// Make sure we aren't currently locking the IP address to avoid a double I/O operation
if (LOCK.contains(rawInetAddress)) {
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getVerifiedBlocked());
return;
}
final InetAddress inetAddress = getInetAddressIfValid(invocation.getSender(), rawInetAddress);
// Make sure the given IP address is valid
if (inetAddress == null) return;
// Try to parse the UUID (from the username, if needed)
final String rawUUID = invocation.getRawArguments()[3];
final UUID uuid = rawUUID.length() == 36 ? UUID.fromString(rawUUID)
: UUID.nameUUIDFromBytes(("OfflinePlayer:" + rawUUID).getBytes(StandardCharsets.UTF_8));
// Make sure the player is verified already
if (SONAR.getVerifiedPlayerController().has(inetAddress.toString(), uuid)) {
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getVerifiedAlready());
return;
}
// Lock the IP address
// Make sure we don't accidentally run 2 operations at the same time
LOCK.add(rawInetAddress);
// Add verified player to the database
final long timestamp = System.currentTimeMillis();
final VerifiedPlayer verifiedPlayer = new VerifiedPlayer(inetAddress.toString(), uuid, timestamp);
Sonar.get().getVerifiedPlayerController().add(verifiedPlayer);
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getVerifiedAdd()
.replace("%ip%", rawInetAddress));
// Unlock the IP address
LOCK.remove(rawInetAddress);
break;
}
case "clear": {
final int verified = SONAR.getVerifiedPlayerController().estimatedSize();
@@ -176,7 +176,7 @@ public class ProtocolUtil {
}
}
private static int readExtendedForgeShort(@NotNull final ByteBuf byteBuf) {
private static int readExtendedForgeShort(final @NotNull ByteBuf byteBuf) {
int low = byteBuf.readUnsignedShort();
int high = 0;
if ((low & 0x8000) != 0) {
@@ -56,9 +56,9 @@ database:
maximum-age: 5
# Každý nový login bude přidán do řady kvuli prevenci spam útoků
# Řada je aktulizována každých 500 milisekund (10 ticků)
# Řada je aktulizována každých 1 sekund (20 ticků)
queue:
# Maximum součastných aktualizací řady za 500 milisekund
# Maximum součastných aktualizací řady za 1 sekund
max-polls: 30
# Každý připojený hráč bude přepojen do
@@ -78,10 +78,10 @@ verification:
enabled: true
# Měl by Sonar zkoušet jestli se hráč dotýka bloku? (Doporučováno)
check-collisions: true
# Měl by Sonar přimět hráče, aby vyřešil CAPTCHA, pokud neuspějí při kontrole gravitace?
captcha-on-fail: false
# Maximální počet ticků, který má hráč padat aby byl puštěn na verifikační platformu
max-movement-ticks: 8
# Maximum ignorovaných změn Y-nové souřadnice hráče, aby neprošel verifikací
max-ignored-ticks: 5
# Gamemode hráče při verifikaci
# Typy: SURVIVAL, CREATIVE, ADVENTURE
# - SURVIVAL: všechny komponenty UI jsou zobrazeny
@@ -89,6 +89,14 @@ verification:
# - ADVENTURE: všechny komponenty UI jsou zobrazeny
gamemode: ADVENTURE
# Checks if the client sends proper packets when mounting a vehicle
vehicle:
# Possible types: ALWAYS, DURING_ATTACK, NEVER
# - ALWAYS: New players will always be mounted on a vehicle
# - DURING_ATTACK: New players will only be mounted on a vehicle during an attack
# - NEVER: New players will never be mounted on a vehicle (Recommended)
timing: ALWAYS
# Nechá hráče verifikovat pomocí kódu na mapě ve hře
map-captcha:
# Kdy by měl Sonar nechat hráče verifikovat pomocí mapy?
@@ -97,34 +105,39 @@ verification:
# - DURING_ATTACK: Noví hráči dostanou map captchu jenom při útoku
# - NEVER: Noví hráči nikdy nedostanou map CAPTCHU (Doporučeno)
timing: NEVER
# Měl by Sonar randomizovat pozici X a Y na CAPTCHE?
random-position: true
# Jak moc by měl Sonar znehodnotit charaktery (faktor pro randomizaci)
distortions-factor-x: 50
distortions-factor-y: 50
# Kolik náhodných čar by měl Sonar vykreslit za CAPTCHOU
random-lines: 4
# Kolik náhodných oválů by měl Sonar vykreslit za CAPTCHOU
random-ovals: 1
# Měl by Sonar randomizovat font pro vykreslování textu v CAPTCHE?
random-font-size: true
# Options for the effects on the image shown to the player
effects:
# Adds a glow effect to the image
flare: true
# Adds random lines to the image
scratches: true
# Applies a ripple (sine) distortion filter
ripple: true
# Applies a horizontal distortion filter
smear: true
# Adds a bit of blurriness/distortion to the image
pinch: true
# Applies color correction (color saturation)
saturation: 0.15
# Applies a horizontal triangular ripple filter
distortion: 2
# Kolik odpovědí by měl Sonar předgenerovat?
# Tento úkol běží souběžně v pozadí;
# Hráči jsou schopni se připojit pokud je CAPTCHA připravena
precompute: 1000
# Tento úkol běží souběžně v pozadí
precompute: 500
# Jak dlouho by měl Sonar čekat než hráč selže CAPTCHU?
# (Hodnota udávaná v milisekundách: 1 sekunda = 1000 milisekund)
max-duration: 45000
# Kolikrát musí hráč selhat CAPTCHU před celkovém selhání verifikace?
max-tries: 3
# Znaky (čísla a písmena), které jsou povoleny k zobrazování v CAPTCHE
dictionary: '123456789'
# It is not recommended to add numbers or letters that might look like one another
dictionary: '1235689'
# Jaké typy fontů by měl Sonar používat pro map CAPTCHU?
fonts:
font-names:
- Dialog
- DialogInput
- Serif
- SansSerif
- Monospaced
# Zkouší pokuď hráč posílá správný "client brand packet" serveru
client-brand:
@@ -154,10 +167,13 @@ verification:
debug-xyz-positions: false
# Množství času který musí uběhnout aby hráč byl odpojen
# (Hodnota udávaná v milisekundách: 1 sekunda = 1000 milisekund)
read-timeout: 3500
read-timeout: 8000
# How long should a player wait before reconnecting during verification Jak dlouho by měl hráč
# (Hodnota udávaná v milisekundách: 1 sekunda = 1000 milisekund)
rejoin-delay: 8000
# How long should Sonar remember the amount of failed verifications for a player?
# (Hodnota udávaná v milisekundách: 1 sekunda = 1000 milisekund)
remember-time: 120000
# Jak dlouho by měla být IP adresa zakázána k připojení po selhání verifikace mockrát?
# (Hodnota udávaná v milisekundách: 1 sekunda = 1000 milisekund)
blacklist-time: 600000
@@ -53,9 +53,9 @@ database:
maximum-age: 5
# Jede neue Login-Anfrage wird in eine Warteschlange eingereiht, um Spam-Beitrittsangriffe zu vermeiden
# Die Warteschlange wird alle 500 Millisekunden (10 Ticks) aktualisiert
# Die Warteschlange wird alle 1 Sekunde (20 Ticks) aktualisiert
queue:
# Maximale Anzahl an gleichzeitigen Abfragen der Warteschlange pro 500 Millisekunden
# Maximale Anzahl an gleichzeitigen Abfragen der Warteschlange pro 1 Sekunde
max-polls: 30
# Jeder neue Spieler, der das erste Mal beitritt, wird zu
@@ -75,10 +75,10 @@ verification:
enabled: true
# Soll Sonar prüfen, ob der Spieler mit Blöcken kollidiert? (Empfohlen)
check-collisions: true
# Soll Sonar den Spieler dazu bringen, ein CAPTCHA zu lösen, wenn er die Schwerkraftprüfung nicht besteht?
captcha-on-fail: false
# Maximale Anzahl an Ticks, die der Spieler fallen muss, um die Plattform erreichen zu dürfen
max-movement-ticks: 8
# Maximale Anzahl an ignorierten Y-Bewegungsänderungen, bevor ein Spieler die Überprüfung nicht besteht
max-ignored-ticks: 5
# Der Spielmodus des Spielers während der Überprüfung
# Mögliche Typen: SURVIVAL, CREATIVE, ADVENTURE
# - SURVIVAL: alle UI-Komponenten sind sichtbar
@@ -86,6 +86,14 @@ verification:
# - ADVENTURE: alle UI-Komponenten sind sichtbar
gamemode: ADVENTURE
# Checks if the client sends proper packets when mounting a vehicle
vehicle:
# Possible types: ALWAYS, DURING_ATTACK, NEVER
# - ALWAYS: New players will always be mounted on a vehicle
# - DURING_ATTACK: New players will only be mounted on a vehicle during an attack
# - NEVER: New players will never be mounted on a vehicle (Recommended)
timing: ALWAYS
# Lässt den Spieler einen Code von einer virtuellen Karte im Chat eingeben
map-captcha:
# Wann sollte Sonar den Spieler ein CAPTCHA lösen lassen?
@@ -94,36 +102,40 @@ verification:
# - DURING_ATTACK: Neue Spieler erhalten nur während eines Angriffs ein CAPTCHA
# - NEVER: Neue Spieler erhalten nie ein CAPTCHA (Empfohlen)
timing: NEVER
# Soll Sonar die X- und Y-Position des CAPTCHAs zufällig wählen?
random-position: true
# Wie stark sollen Zeichen verzerrt werden (Faktor für Zufälligkeit)?
distortions-factor-x: 50
# Wie stark sollen Zeichen verzerrt werden (Faktor für Zufälligkeit)?
distortions-factor-y: 50
# Wie viele zufällige Linien hinter dem CAPTCHA soll Sonar zeichnen?
random-lines: 4
# Wie viele zufällige Ovale hinter dem CAPTCHA soll Sonar zeichnen?
random-ovals: 1
# Soll Sonar die Schriftgröße für das Rendern des CAPTCHAs zufällig wählen?
random-font-size: true
# Options for the effects on the image shown to the player
effects:
# Adds a glow effect to the image
flare: true
# Adds random lines to the image
scratches: true
# Applies a ripple (sine) distortion filter
ripple: true
# Applies a horizontal distortion filter
smear: true
# Adds a bit of blurriness/distortion to the image
pinch: true
# Applies color correction (color saturation)
saturation: 0.15
# Applies a horizontal triangular ripple filter
distortion: 2
# Wie viele Antworten soll Sonar vorbereiten?
# Dieser Vorgang erfolgt asynchron im Hintergrund;
# Spieler können beitreten, sobald ein CAPTCHA vorbereitet wurde
precompute: 1000
# Dieser Vorgang erfolgt asynchron im Hintergrund
precompute: 500
# Wie lange soll Sonar warten, bis der Spieler das CAPTCHA nicht besteht?
# (Dieser Wert stellt die Zeit in Millisekunden dar: 1 Sekunde = 1000 Millisekunden)
max-duration: 45000
# Wie oft muss ein Spieler das CAPTCHA nicht bestehen, bevor er die Überprüfung nicht besteht?
max-tries: 3
# Zeichen (Buchstaben und Zahlen), die im Antwort-CAPTCHA erscheinen dürfen
dictionary: '123456789'
# It is not recommended to add numbers or letters that might look like one another
dictionary: '1235689'
# Welche Schriftarten soll Sonar für die Map-CAPTCHA-Codes verwenden?
fonts:
font-names:
- Dialog
- DialogInput
- Serif
- SansSerif
- Monospaced
# Überprüft, ob die Spieler eine gültige Client-Marke an den Server senden
client-brand:
# Soll Sonar auf gültige Client-Marken prüfen? (Empfohlen)
@@ -152,10 +164,13 @@ verification:
debug-xyz-positions: false
# Zeit, die verstreichen muss, bevor ein Spieler ein Timeout erleidet
# (Dieser Wert stellt die Zeit in Millisekunden dar: 1 Sekunde = 1000 Millisekunden)
read-timeout: 3500
read-timeout: 8000
# Wie lange sollte ein Spieler warten, bevor er während der Überprüfung erneut verbinden kann
# (Dieser Wert stellt die Zeit in Millisekunden dar: 1 Sekunde = 1000 Millisekunden)
rejoin-delay: 8000
# How long should Sonar remember the amount of failed verifications for a player?
# (Dieser Wert stellt die Zeit in Millisekunden dar: 1 Sekunde = 1000 Millisekunden)
remember-time: 120000
# Wie lange sollte eine IP-Adresse vom Einloggen ausgeschlossen werden, wenn die Überprüfung zu oft nicht bestanden wird?
# (Dieser Wert stellt die Zeit in Millisekunden dar: 1 Sekunde = 1000 Millisekunden)
blacklist-time: 600000
@@ -55,9 +55,9 @@ database:
maximum-age: 5
# Every new login request will be queued to avoid spam join attacks
# The queue is updated every 500 milliseconds (10 ticks)
# The queue is updated every 1 second (20 ticks)
queue:
# Maximum number of concurrent queue polls per 500 milliseconds
# Maximum number of concurrent queue polls per 1 second
max-polls: 30
# Every new player that joins for the first time will be sent to
@@ -77,10 +77,10 @@ verification:
enabled: true
# Should Sonar check if the player collides with blocks? (Recommended)
check-collisions: true
# Should Sonar make the player solve a CAPTCHA if they fail the gravity check?
captcha-on-fail: false
# Maximum number of ticks the player has to fall in order to be allowed to hit the platform
max-movement-ticks: 8
# Maximum number of ignored Y movement changes before a player fails verification
max-ignored-ticks: 5
# The gamemode of the player during verification
# Possible types: SURVIVAL, CREATIVE, ADVENTURE
# - SURVIVAL: all UI components are visible
@@ -88,6 +88,14 @@ verification:
# - ADVENTURE: all UI components are visible
gamemode: ADVENTURE
# Checks if the client sends proper packets when mounting a vehicle
vehicle:
# Possible types: ALWAYS, DURING_ATTACK, NEVER
# - ALWAYS: New players will always be mounted on a vehicle
# - DURING_ATTACK: New players will only be mounted on a vehicle during an attack
# - NEVER: New players will never be mounted on a vehicle (Recommended)
timing: ALWAYS
# Make the player type a code from a virtual map in chat
map-captcha:
# When should Sonar make the player solve a CAPTCHA?
@@ -96,34 +104,39 @@ verification:
# - DURING_ATTACK: New players will only receive a CAPTCHA during an attack
# - NEVER: New players will never receive a CAPTCHA (Recommended)
timing: NEVER
# Should Sonar randomize the X and Y position of the CAPTCHA?
random-position: true
# How much should Sonar distort characters (factor for randomization)?
distortions-factor-x: 50
distortions-factor-y: 50
# How many random lines behind the CAPTCHA should Sonar draw?
random-lines: 4
# How many random ovals behind the CAPTCHA should Sonar draw?
random-ovals: 1
# Should Sonar randomize the size of the font used for rendering the CAPTCHA?
random-font-size: true
# Options for the effects on the image shown to the player
effects:
# Adds a glow effect to the image
flare: true
# Adds random lines to the image
scratches: true
# Applies a ripple (sine) distortion filter
ripple: true
# Applies a horizontal distortion filter
smear: true
# Adds a bit of blurriness/distortion to the image
pinch: true
# Applies color correction (color saturation)
saturation: 0.15
# Applies a horizontal triangular ripple filter
distortion: 2
# How many answers should Sonar prepare?
# This task happens asynchronously in the background;
# Players are able to join once one CAPTCHA has been prepared
precompute: 1000
# This task happens asynchronously in the background
precompute: 500
# How long should Sonar wait until the player fails the CAPTCHA?
# (This value represents the time in milliseconds: 1 second = 1000 milliseconds)
max-duration: 45000
# How many times must a player fail the CAPTCHA before failing the verification?
max-tries: 3
# Characters (letters and numbers) that are allowed to appear in the answer to the CAPTCHA
dictionary: '123456789'
# Which font types should Sonar use for the map CAPTCHA codes?
fonts:
# It is not recommended to add numbers or letters that might look like one another
dictionary: '1235689'
# Which fonts should Sonar use for the map CAPTCHA codes?
font-names:
- Dialog
- DialogInput
- Serif
- SansSerif
- Monospaced
# Checks if the players is sending a valid client brand to the server
client-brand:
@@ -153,10 +166,13 @@ verification:
debug-xyz-positions: false
# Amount of time that has to pass before a player times out
# (This value represents the time in milliseconds: 1 second = 1000 milliseconds)
read-timeout: 3500
read-timeout: 8000
# How long should a player wait before reconnecting during verification
# (This value represents the time in milliseconds: 1 second = 1000 milliseconds)
rejoin-delay: 8000
# How long should Sonar remember the amount of failed verifications for a player?
# (This value represents the time in milliseconds: 1 second = 1000 milliseconds)
remember-time: 120000
# How long should an IP address be denied from logging in when failing the verification too often?
# (This value represents the time in milliseconds: 1 second = 1000 milliseconds)
blacklist-time: 600000
@@ -55,9 +55,9 @@ database:
maximum-age: 5
# ყოველი ახალი შესვლის მოთხოვნა იქნება რიგში, რათა თავიდან აიცილოთ სპამის მიერთების შეტევები
# რიგი განახლდება ყოველ 500 მილიწამში (10 ტიკი)
# რიგი განახლდება ყოველ 1000 მილიწამში (20 ტიკი)
queue:
# ახალი მოთამაშეების მაქსიმალური რაოდენობა, რომლებსაც შეუძლიათ შესვლა სერვერზე ყოველ 500 მილიწამში
# ახალი მოთამაშეების მაქსიმალური რაოდენობა, რომლებსაც შეუძლიათ შესვლა სერვერზე ყოველ 1000 მილიწამში
max-polls: 30
# ყოველი ახალი მოთამაშე, რომელიც პირველად შეუერთდება, გაიგზავნება
@@ -77,10 +77,10 @@ verification:
enabled: true
# უნდა შეამოწმოს თუ არა Sonar-მა, ეტაკება თუ არა მოთამაშე ბლოკებს?
check-collisions: true
# უნდა აიძულოს თუ არა Sonar-მა მოთამაშეს გადაჭრას CAPTCHA, თუ ისინი ვერ შეასრულებენ სიმძიმის შემოწმებას?
captcha-on-fail: false
# ტიკების მაქსიმალური რაოდენობა რომელითაც მოთამაშე უნდა დაეცეს, რათა პლატფორმაზე დაცემა შეეძლოს
max-movement-ticks: 8
# იგნორირებული Y მოძრაობის მაქსიმალური რაოდენობა სანამ, მოთამაშე ვერიფიკაციას ვერ გაივლის
max-ignored-ticks: 5
# მოთამაშის თამაშის რეჟიმი გადამოწმების დროს
# შესაძლო ტიპები: SURVIVAL, CREATIVE, ADVENTURE
# - SURVIVAL: ყველა UI კომპონენტი ჩანს
@@ -88,6 +88,14 @@ verification:
# - ADVENTURE: ყველა UI კომპონენტი ჩანს
gamemode: ADVENTURE
# Checks if the client sends proper packets when mounting a vehicle
vehicle:
# Possible types: ALWAYS, DURING_ATTACK, NEVER
# - ALWAYS: New players will always be mounted on a vehicle
# - DURING_ATTACK: New players will only be mounted on a vehicle during an attack
# - NEVER: New players will never be mounted on a vehicle (Recommended)
timing: ALWAYS
# დააწერინეთ მოთამაშეს კოდი ჩატში, რომელიც იქნება ნაჩვენები ვირტუალურ მაპზე, რომელიც ხელში ეჭირებად
map-captcha:
# როდის უნდა გააკეთებინოს Sonar-მა მოთამაშეს CAPTCHA?
@@ -96,34 +104,39 @@ verification:
# - DURING_ATTACK: ახალი მოთამაშებს მოუწევთ CAPTCHA-ს გაკეთება მხოლოდ შეტევის დროს
# - NEVER: ახალ მოთამაშეებს არასდროს მოუწევთ CAPTCHA-ს გაკეთება (Recommended)
timing: NEVER
# მოახდინოს თუ არა Sonar-მა CAPTCHA-ს X და Y პოზიციების რანდომიზაცია?
random-position: true
# რამდენად უნდა დაამახინჯოს Sonar-მა სიმბოლოები (რანდომიზაციის ფაქტორი)?
distortions-factor-x: 50
distortions-factor-y: 50
# რამდენი რანდომული ხაზი უნდა დახატოს Sonar-მა CAPTCHA-ს უკან?
random-lines: 4
# რამდენი რანდომული ოვალი უნდა დახატოს სონარმა CAPTCHA-ს უკან?
random-ovals: 1
# დაარანდომაიზოს Sonar-მა თუ არა შერჩევისას შრიფტის ზომა, რომელიც გამოიყენება CAPTCHA-ზე?
random-font-size: true
# Options for the effects on the image shown to the player
effects:
# Adds a glow effect to the image
flare: true
# Adds random lines to the image
scratches: true
# Applies a ripple (sine) distortion filter
ripple: true
# Applies a horizontal distortion filter
smear: true
# Adds a bit of blurriness/distortion to the image
pinch: true
# Applies color correction (color saturation)
saturation: 0.15
# Applies a horizontal triangular ripple filter
distortion: 2
# რამდენი პასუხი უნდა მოამზადოს Sonar-მა?
# ეს ხდება ასინქრონულად უკანა ფონზე;
# მოთამაშეებს შეუძლიათ შესვლა, როდესაც ერთი CAPTCHA მზად არის
precompute: 1000
# ეს ხდება ასინქრონულად უკანა ფონზე
precompute: 500
# რამდენ ხანს უნდა დაელოდოს Sonar-ი, სანამ მოთამაშე ვერ გააკეთებს წარმატებულად CAPTCHA-ს?
# (ეს არის ნაჩვენები მილიწამებში: 1 წამი = 1000 მილიწამი)
max-duration: 45000
# რამდენჯერ ვერ უდნა გაიაროს CAPTCHA მოთამაშემ სანამ ვერიფიკაციას ვერ გაივლის?
max-tries: 3
# სიმბოლოები (ასოები და რიცხვები), რომლებიც დაშვებულია გამოჩნდეს CAPTCHA-ს პასუხში
dictionary: '123456789'
# რომელი შრიფტის ტიპები უნდა გამოიყენოს Sonar-მა რუკის CAPTCHA კოდებისთვის?
fonts:
# It is not recommended to add numbers or letters that might look like one another
dictionary: '1235689'
# Which fonts should Sonar use for the map CAPTCHA codes?
font-names:
- Dialog
- DialogInput
- Serif
- SansSerif
- Monospaced
#ამოწმებს, აგზავნიან თუ არა მოთამაშეები სერვერზე რეალურ კლიენტის სახელს
client-brand:
@@ -153,10 +166,13 @@ verification:
debug-xyz-positions: false
# დროის ოდენობა, რომელიც უნდა გავიდეს, სანამ მოთამაშე ტაიმაუტდება სერვერიდან.
# (ეს არის ნაჩვენები მილიწამებში: 1 წამი = 1000 მილიწამი)
read-timeout: 3500
read-timeout: 8000
# რამდენ ხანს უნდა დაელოდოს მოთამაშე ვერიფიკაციის დროს ხელახლა დაკავშირებამდე?
# (ეს არის ნაჩვენები მილიწამებში: 1 წამი = 1000 მილიწამი)
rejoin-delay: 8000
# How long should Sonar remember the amount of failed verifications for a player?
# (ეს არის ნაჩვენები მილიწამებში: 1 წამი = 1000 მილიწამი)
remember-time: 120000
# რამდენ ხნით უნდა აეკრძალოს IP მისამართს შესვლა, როდესაც ვერიფიკაციას ძალიან ხშირად ვერ აკეთებს?
# (ეს არის ნაჩვენები მილიწამებში: 1 წამი = 1000 მილიწამი)
blacklist-time: 600000
@@ -55,9 +55,9 @@ database:
maximum-age: 5
# Каждый новый запрос на вход на сервер будет ставиться в очередь, чтобы избежать спам-атак.
# Очередь обновляется каждые 500 миллисекунд (10 тиков)
# Очередь обновляется каждые 1 секунд (20 тиков)
queue:
# Максимальное количество одновременных опросов очереди за 500 миллисекунд
# Максимальное количество одновременных опросов очереди за 1 секунд
max-polls: 30
# Каждый новый игрок, впервые присоединившийся на сервер, будет отправлен на
@@ -77,10 +77,10 @@ verification:
enabled: true
# Должен ли Sonar проверять, сталкивается ли игрок с блоками? (Рекомендуется)
check-collisions: true
# Должен ли Sonar заставлять игрока решать CAPTCHA, если он не проходит проверку на гравитацию?
captcha-on-fail: false
# Максимальное количество тиков, на которое должен упасть игрок, чтобы ему было разрешено упасть на платформу
max-movement-ticks: 8
# Максимальное количество проигнорированных изменений движения Y, прежде чем игрок не пройдет проверку
max-ignored-ticks: 5
# Режим игрока во время проверки
# Возможные типы: SURVIVAL, CREATIVE, ADVENTURE
# - SURVIVAL: все компоненты пользовательского интерфейса видны
@@ -88,6 +88,14 @@ verification:
# - ADVENTURE: все компоненты пользовательского интерфейса видны
gamemode: ADVENTURE
# Checks if the client sends proper packets when mounting a vehicle
vehicle:
# Possible types: ALWAYS, DURING_ATTACK, NEVER
# - ALWAYS: New players will always be mounted on a vehicle
# - DURING_ATTACK: New players will only be mounted on a vehicle during an attack
# - NEVER: New players will never be mounted on a vehicle (Recommended)
timing: ALWAYS
# Заставить игрока ввести код с картинки в чат
map-captcha:
# Когда Sonar должен заставить игрока решить CAPTCHA?
@@ -96,34 +104,39 @@ verification:
# - DURING_ATTACK: Новые игроки будут получать капчу только во время атаки
# - NEVER: Новые игроки никогда не получат капчу (Рекомендуется)
timing: NEVER
# Должен ли Sonar рандомизировать положение X и Y капчи?
random-position: true
# Насколько сильно Sonar должен искажать символы (фактор для рандомизации)?
distortions-factor-x: 50
distortions-factor-y: 50
# Сколько случайных линий за капчей должен нарисовать Sonar?
random-lines: 4
# Сколько случайных овалов за капчей должен нарисовать Sonar?
random-ovals: 1
# Должен ли Sonar рандомизировать размер шрифта, используемого для отображения капчи?
random-font-size: true
# Кастомизация эффектов на картинке, которые показываются игроку
effects:
# Добавляет свечение к изображению
flare: true
# Добавляет случайные линии к изображению
scratches: true
# Применяет искажение ряби (синусоидальное)
ripple: true
# Применяет горизонтальное искажение
smear: true
# Добавляет немного размытости/искажения к изображению
pinch: true
# Применяет цветокоррекцию (насыщенность цвета)
saturation: 0.15
# Применяет горизонтальное треугольное искажение
distortion: 2
# Сколько капч должен подготовить Sonar?
# Эта задача выполняется асинхронно в фоновом режиме
# Игроки смогут присоединиться, как только будет подготовлена одна капча
precompute: 1000
precompute: 500
# Как долго Sonar должен ждать, пока игрок не пройдет капча?
# (Это значение представляет время в миллисекундах: 1 секунда = 1000 миллисекунд)
max-duration: 45000
# Сколько раз игрок должен завалить капчу, чтобы не пройти проверку?
max-tries: 3
# Символы (буквы и цифры), которые разрешено использовать в ответе на капчу
dictionary: '123456789'
# It is not recommended to add numbers or letters that might look like one another
dictionary: '1235689'
# Какие типы шрифтов следует использовать в Sonar для текста в капче?
fonts:
font-names:
- Dialog
- DialogInput
- Serif
- SansSerif
- Monospaced
# Проверяет, отправляет ли игрок на сервер действительный клиентский бренд.
client-brand:
@@ -153,10 +166,13 @@ verification:
debug-xyz-positions: false
# Количество времени, которое должно пройти, прежде чем игрока кикнет.
# (Это значение представляет время в миллисекундах: 1 секунда = 1000 миллисекунд)
read-timeout: 3500
read-timeout: 8000
# Сколько времени игрок должен ждать перед повторным подключением во время проверки
# (Это значение представляет время в миллисекундах: 1 секунда = 1000 миллисекунд)
rejoin-delay: 8000
# How long should Sonar remember the amount of failed verifications for a player?
# (Это значение представляет время в миллисекундах: 1 секунда = 1000 миллисекунд)
remember-time: 120000
# Как долго IP-адресу должно быть запрещено входить на сервер, если он слишком часто не проходит проверку?
# (Это значение представляет время в миллисекундах: 1 секунда = 1000 миллисекунд)
blacklist-time: 600000
@@ -39,8 +39,6 @@ commands:
incorrect-usage: '%prefix%<red>Benutzung: /sonar %usage%'
# Nachricht, die angezeigt wird, wenn jemand eine ungültige IP-Adresse angibt (Ungültiges Format)
invalid-ip-address: '%prefix%Die angegebene IP-Adresse scheint ungültig zu sein.'
# Nachricht, die angezeigt wird, wenn jemand eine illegale IP-Adresse angibt (Lokale IP)
illegal-ip-address: '%prefix%Die angegebene IP-Adresse scheint entweder eine lokale oder eine Schleifenrückführungs-IP zu sein.'
# Nachricht, die angezeigt wird, wenn die Konsole einen Befehl ausführt, der nur für Spieler ist
player-only: '%prefix%<red>Diesen Befehl kannst du nur als Spieler ausführen.'
# Nachricht, die angezeigt wird, wenn ein Spieler einen Befehl ausführt, der nur für die Konsole ist
@@ -123,6 +121,10 @@ commands:
size: '%prefix%Es sind derzeit %amount% einzigartige Spieler verifiziert.'
# Nachricht, die angezeigt wird, wenn jemand eine IP-Adresse unverifiziert macht
removed: '%prefix%Erfolgreich %ip% unverifiziert.'
# Nachricht, die angezeigt wird, wenn jemand eine IP-Adresse verifiziert macht
added: '%prefix%Erfolgreich %ip% verifiziert.'
# Message that is shown when someone tries to verify an IP address/UUID that are already verified
already: '%prefix%The IP address and UUID you provided are already verified.'
# Nachricht, die angezeigt wird, wenn jemand eine IP-Adresse unverifiziert macht, die nicht verifiziert ist
ip-not-found: '%prefix%Die angegebene IP-Adresse ist nicht verifiziert.'
# Nachricht, die angezeigt wird, wenn jemand versucht, die gleiche IP-Adresse zweimal zu unverifizieren (doppelte Operation)
@@ -238,7 +240,7 @@ verification:
# Nachricht, die angezeigt wird, wenn jemand die Verifizierung nicht besteht
failed:
- '%header%'
- <red>Du hast die Verifizierung nicht bestanden.
- <red>Du hast die Bot-Verifizierung nicht bestanden.
- <gray>Bitte warte einige Sekunden bevor du es erneut versuchst.
- <gold>Brauchst du Hilfe? <gray>%support-url%
- '%footer%'
@@ -38,9 +38,6 @@ commands:
incorrect-usage: '%prefix%<red>Usage: /sonar %usage%'
# Message that is shown when someone provides an invalid IP address (Invalid format)
invalid-ip-address: '%prefix%The IP address you provided seems to be invalid.'
# Message that is shown when someone provides an illegal IP address (Local IP)
illegal-ip-address: '%prefix%The IP address you provided seems to be either a local
or loopback IP.'
# Message that is shown when the console runs a command that is player-only
player-only: '%prefix%<red>You can only execute this command as a player.'
# Message that is shown when a player runs a command that is console-only
@@ -127,6 +124,10 @@ commands:
size: '%prefix%There are currently %amount% unique player(s) verified.'
# Message that is shown when someone un-verifies an IP address
removed: '%prefix%Successfully unverified %ip%.'
# Message that is shown when someone verifies an IP address
added: '%prefix%Successfully verified %ip%.'
# Message that is shown when someone tries to verify an IP address/UUID that are already verified
already: '%prefix%The IP address and UUID you provided are already verified.'
# Message that is shown when someone un-verifies an IP address, but it is not verified
ip-not-found: '%prefix%The IP address you provided is not verified.'
# Message that is shown when someone tries un-verifying the same IP address twice (double operation)
@@ -244,7 +245,7 @@ verification:
# Disconnect message that is shown when someone fails verification
failed:
- '%header%'
- <red>You have failed the verification.
- <red>You have failed the bot verification.
- <gray>Please wait a few seconds before trying to verify again.
- <gold>Need help? <gray>%support-url%
- '%footer%'
@@ -13,24 +13,24 @@
# Copyright Jones Development and Sonar Contributors, licensed under GPL-3.0
#
# Плейсхолдер для каждого '%prefix%' в файле конфигурации
# Плейсхолдер для '%prefix%' в конфиге
prefix: <b><yellow>Sonar</b> <gray>» <white>
# Плейсхолдер для каждого '%support-url%' в файле конфигурации
# Плейсхолдер для '%support-url%' в конфиге
support-url: https://jonesdev.xyz/discord/
# Плейсхолдер для каждого '%header%' в файле конфигурации
# Плейсхолдер для '%header%' в конфиге
header:
- <b><yellow>Sonar</b><reset>
- ''
# Плейсхолдер для каждого '%footer%' в файле конфигурации
# Плейсхолдер для '%footer%' в конфиге
footer:
- <gray>Если вы считаете что это ошибка, обратитесь к администратору.
# Сообщение отключения, которое показывается, когда кто-то присоединяется, но в сети слишком много игроков с его IP-адресом
# Сообщение, которое показывается когда кто-то заходит на сервер, но в сети слишком много игроков с его IP-адресом
too-many-online-per-ip:
- '%header%'
- <red>С вашего IP-адреса в сети слишком много игроков.
- '%footer%'
# Перевод для команды
# Перевод для команд
commands:
# Сообщение, которое будет показано, когда игрок попытается выполнить команду /sonar без разрешения
no-permission: '%prefix%<red>У вас нет прав на использование этой команды.'
@@ -38,8 +38,6 @@ commands:
incorrect-usage: '%prefix%<red>Применение: /sonar %usage%'
# Сообщение, которое отображается, когда кто-то предоставляет неверный формат IP-адреса
invalid-ip-address: '%prefix%Указанный вами IP-адрес недействителен'
# Сообщение, которое отображается, когда кто-то предоставляет локальный IP-адрес
illegal-ip-address: '%prefix%IP-адрес который вы указали является локальным.'
# Сообщение, которое отображается, когда консоль выполняет команду, предназначенную только для игроков
player-only: '%prefix%<red>Эта команда может быть выполнена только игроком.'
# Сообщение, которое отображается, когда игрок выполняет команду, доступную только для консоли
@@ -79,9 +77,9 @@ commands:
# Перевод для '/sonar verbose'
verbose:
# Сообщение, которое показывается, когда игрок подписывается на Sonar verbose
# Сообщение, которое отображается, когда игрок прослушивает Sonar verbose
subscribed: '%prefix%Вы просматриваете подробную информацию о Sonar.'
# Сообщение, которое показывается, когда игрок отписывается от Sonar verbose
# Сообщение, которое отображается, когда игрок перестает прослушивать Sonar verbose
unsubscribed: '%prefix%Вы больше не просматриваете подробную информацию о Sonar.'
# Перевод для '/sonar notify'
@@ -104,8 +102,8 @@ commands:
added: '%prefix%Успешно добавлен %ip% в черный список.'
# Сообщение, которое отображается, когда кто-то добавляет IP-адрес в черный список, который находится в белом списке
added-warning: '%prefix%<red>Предупреждение: <white>%ip% находится в белом списке.
Рассмотрите возможность удалить IP-адрес из белого списка игроков, чтобы
избежать потенциальных ошибок.'
Рассмотрите возможность удалить IP-адрес из белого списка игроков, чтобы избежать
потенциальных ошибок.'
# Сообщение, которое показывается, когда кто-то удаляет IP-адрес из черного списка
removed: '%prefix%Успешно удален %ip% из черного списка.'
# Сообщение, которое отображается, когда кто-то добавляет IP-адрес в черный список, но он уже находится в черном списке
@@ -116,16 +114,21 @@ commands:
# Перевод для '/sonar verified'
verified:
# Сообщение, которое отображается, когда кто-то пытается очистить список проверенных игроков, но он пустой
empty: '%prefix%Список проверенных игроков в настоящее время пуст. Поэтому, ни один игрок не был удален.'
# Сообщение, которое отображается, когда кто-то очищает список проверенных игроков
empty: '%prefix%Список проверенных игроков в настоящее время пуст. Поэтому, ни
один игрок не был удален.'
# Сообщение, которое отображается когда кто-то отчистил список уникальных игроков
cleared: '%prefix%Вы успешно очистили %removed% уникального игрока(ов).'
# Сообщение, которое показывается, когда кто-то проверяет размер списка проверенных игроков
# Сообщение, которое отображается когда кто-то проверяет размер списка проврененных игроков
size: '%prefix%В настоящее время проверено %amount% уникальных игроков.'
# Сообщение, которое отображается при удалении IP-адреса из списка проверенных игроков
removed: '%prefix%Успешно удален %ip%.'
# Сообщение, которое отображается, когда кто-то удаляет IP-адрес из списка, но его нет в списке
# Message that is shown when someone verifies an IP address
added: '%prefix%Successfully verified %ip%.'
# Message that is shown when someone tries to verify an IP address/UUID that are already verified
already: '%prefix%The IP address and UUID you provided are already verified.'
# Сообщение, которое показывается когда кто-то удаляет IP из списка но его небыло в списке
ip-not-found: '%prefix%Указанный вами IP-адрес не в списке.'
# Сообщение, которое отображается, когда кто-то пытается дважды удалить один и тот же IP-адрес (двойная операция)
# Сообщение, которое показывается когда выполняю сразу две операции (двойная операция)
blocked: '%prefix%Пожалуйста, дождитесь завершения текущей операции.'
# Перевод для '/sonar statistics'
@@ -165,15 +168,15 @@ commands:
- ' <dark_aqua>▪ <gray>Общая входящая использованная полоса пропускания: <white>%ttl_incoming%'
- ' <dark_aqua>▪ <gray>Общая исходящая использованная полоса пропускания: <white>%ttl_outgoing%'
# Переводы всех сообщений в процессе проверки
# Переводы всех сообщений во время проверки
verification:
# Переводы всех отладочных сообщений во время проверки
# Переводы всех консольных сообщений во время проверки
logs:
# Сообщение, которое выводится в консоль каждый раз, когда к серверу присоединяется новый игрок
# Сообщение, которое показыватся когда на сервер заходит новый игрок
connection: '%name%%ip% (%protocol%) подключился.'
# Сообщение, которое выводится в консоль при неудачной проверке игрока
# Сообщение, которое показывается когда кто-то не прошел проверку на бота
failed: '%ip% (%protocol%) не прошел проверку бота на: %reason%'
# Сообщение, которое выводится в консоль, когда игрок попадает в черный список
# Сообщение, которое показывается когда кто-то было занесен в черный список
blacklisted: '%ip% (%protocol%) был занесен в черный список за слишком большое
количество неудачных попыток'
# Сообщение, которое выводится в консоль при успешной проверке игрока
@@ -182,71 +185,70 @@ verification:
captcha:
# Сообщение, которое показывается игроку, когда он должен ввести ответ на капчу
enter-code: '%prefix%<green>Введите код в чате, который отображается на карте.'
# Таймер, который показывается игроку, когда он должен ввести ответ на капчу
# Таймер, который показывается игроку, когда он должен ввести капчу
# (Установите это значение на '' что-бы отключить эту функцию)
action-bar: '%prefix%<green>У вас осталось %time-left% секунд, чтобы ввести код
в чате'
# Сообщение, которое показывается игроку, когда он вводит неправильный код капчи в чате
incorrect: '%prefix%<red>Вы ввели неправильный код. Пожалуйста, попробуйте еще
раз.'
# Сообщение, которое показывается игроку, когда он вводит неправильную капчу в чат
incorrect: '%prefix%<red>Вы ввели неправильный код. Пожалуйста, попробуйте еще раз.'
# Сообщение об отключении, которое отображается, когда кто-то присоединяется но капча еще не подготовлена
# Сообщение, которое показывается когда капча еще не подготовлена
currently-preparing:
- '%header%'
- <yellow>Капча еще не подготовлена.
- <gray>Пожалуйста, подождите несколько секунд, прежде чем повторить попытку.
- '%footer%'
# Сообщение об отключении, которое отображается при одновременной верификации слишком большого количества игроков
# Сообщение, которое показывается когда слишком много людей проходят проверку
too-many-players:
- '%header%'
- <gold>Слишком много игроков пытаются войти на сервер.
- <gray>Пожалуйста, подождите несколько секунд, прежде чем повторить попытку.
- '%footer%'
# Сообщение об отключении, которое отображается, когда кто-то слишком быстро перезаходит во время проверки
# Сообщение, которое показывается когда кто-то слишком быстро перезаходит
too-fast-reconnect:
- '%header%'
- <gold>Вы перезаходите слишком быстро, повторите попытку позже.
- <gray>Пожалуйста, подождите несколько секунд, прежде чем повторить попытку.
- '%footer%'
# Сообщение об отключении, которое отображается, когда кто-то присоединяется, но его IP-адрес уже проходит верификацию
# Сообщение, которое показывается когда чей то IP-адрес уже проходит верефикацию
already-verifying:
- '%header%'
- <red>Ваш IP-адрес в настоящее время проверяется.
- <gray>Пожалуйста, подождите несколько секунд, прежде чем повторить попытку.
- '%footer%'
# Сообщение об отключении, которое показывается, когда кто-то присоединяется, но уже находится в очереди на проверку
# Сообщение, которое показывается когда кто-то находится в очереди на проверку
already-queued:
- '%header%'
- <red>Ваш IP-адрес в настоящее время находится в очереди на проверку.
- <gray>Пожалуйста, подождите несколько секунд, прежде чем повторить попытку.
- '%footer%'
# Сообщение об отключении, которое показывается, когда кто-то присоединяется, но временно находится в черном списке
# Сообщение, которое показывается когда кто-то временно заблокирован на сервере
blacklisted:
- '%header%'
- <red>Вам запрещено входить на сервер.
- <red>Пожалуйста, подождите несколько минут, чтобы снова присоединиться к серверу.
- <gold>Вы считаете что это ошибка? <gray>%support-url%
- '%footer%'
# Сообщение об отключении, которое показывается, когда кто-то присоединяется с неправильным ником
# Сообщение, которое показывается когда кто-то входит на сервер с некорректным ником
invalid-username:
- '%header%'
- <red>Ваш ник содержит недопустимые символы.
- '%footer%'
# Сообщение об отключении, которое показывается, когда кто-то заходит на сервер версией, внесенной в черный список
# Сообщение, которое показывается когда кто-то входит на сервер с заблокированной версии
blacklisted-protocol:
- '%header%'
- <red>Вы используете версию, которая запрещена на нашем сервере.
- <gold>Нужна помощь? <gray>%support-url%
- '%footer%'
# Сообщение об отключении, которое показывается при успешной верификации
# Сообщение которое показывается при успешной верификации
success:
- '%header%'
- <green>Вы успешно прошли проверку.
- <white>Пожалуйста, перезайдите.
# Сообщение об отключении, которое показывается, когда кто-то не прошел верификацию
# Сообщение которое показывается, когда кто-то не прошел верификацию
failed:
- '%header%'
- <red>Вы не прошли проверку.
- <red>Вы не прошли проверку бота.
- <gray>Пожалуйста, подождите несколько секунд, прежде чем повторить попытку.
- <gold>Нужна помощь? <gray>%support-url%
- '%footer%'
@@ -254,7 +256,7 @@ verification:
# Перевод для action bar (verbose)
verbose:
layout:
# Макет для панели действий Sonar verbose
# Панель действий Sonar verbose
# Placeholders:
# - '%queued%' Количество игроков в очереди
# - '%verifying%' Количество проверяемых игроков
@@ -275,16 +277,16 @@ verbose:
# - '%max-memory%' Объем максимальной памяти
# - '%free-memory%' Количество свободной памяти
# - '%animation%' Настраиваемый анимированный символ
# Перевод статистики в режиме реального времени (verbose)
# Статистика в режиме реального времени (verbose)
normal: '%prefix%<gray>CPS <white>%connections-per-second% <dark_aqua>╺ <gray>Logins/s
<white>%logins-per-second% <dark_aqua>╺ <gray>На проверке <white>%verifying%
<dark_aqua>| <green>⬆ <white>%outgoing-traffic%/s <red>⬇ <white>%incoming-traffic%/s <green><bold>%animation%<reset>'
# Макет action bar, отображающей информацию во время атаки
# Action bar отображающей информацию во время атаки
attack: '%prefix%<gray>CPS <white>%connections-per-second% <dark_aqua>╺ <gray>Logins/s
<white>%logins-per-second% <dark_aqua>╺ <gray>В очереди <white>%queued% <dark_aqua>╺
<gray>На проверке <white>%verifying% <dark_aqua>╺ <gray>Продолжительность <white>%attack-duration%
<dark_aqua>| <green>⬆ <white>%outgoing-traffic%/s <red>⬇ <white>%incoming-traffic%/s <green><bold>%animation%<reset>'
# Анимация для action bar (текст с названием элемента)
# Анимация для загрузки
# Альтернативы:
# - ▙, ▛, ▜, ▟
# - ⬈, ⬊, ⬋, ⬉
@@ -296,11 +298,11 @@ verbose:
# Перевод сообщений об атаках
notifications:
# Макет для заголовка уведомления об атаке
# Заголовок уведомления об атаке
title: <yellow><b>Sonar<reset>
# Макет для подзаголовка уведомления об атаке
# Подзаголовок уведомления об атаке
subtitle: <gray>Обнаружена бот-атака
# Макет для уведомления об атаке в чате
# Уведомление об атаке в чате
chat:
- ''
- <yellow><b>Sonar<reset>
@@ -38,8 +38,6 @@ commands:
incorrect-usage: '%prefix%<red>Kullanım: /sonar %usage%'
# Birisi geçersiz bir IP adresi sağladığında gösterilen mesaj (Geçersiz format)
invalid-ip-address: '%prefix%Sağladığınız IP adresi geçersiz görünüyor.'
# Birisi yasal olmayan bir IP adresi sağladığında gösterilen mesaj (Yerel IP)
illegal-ip-address: '%prefix%Sağladığınız IP adresi yerel veya döngüsel bir IP gibi görünüyor.'
# Konsol, sadece oyuncunun çalıştırabileceği bir komut çalıştırdığında gösterilen mesaj
player-only: '%prefix%<red>Bu komutu sadece bir oyuncu olarak çalıştırabilirsiniz.'
# Bir oyuncu, sadece konsolun çalıştırabileceği bir komutu çalıştırdığında gösterilen mesaj
@@ -115,6 +113,10 @@ commands:
size: '%prefix%Şu anda %amount% benzersiz oyuncu doğrulanmış.'
# Birisi bir IP adresini doğrulanmamış olarak işaretlediğinde gösterilen mesaj
removed: '%prefix%Başarıyla %ip% doğrulanmamış olarak işaretlendi.'
# Message that is shown when someone verifies an IP address
added: '%prefix%Successfully verified %ip%.'
# Message that is shown when someone tries to verify an IP address/UUID that are already verified
already: '%prefix%The IP address and UUID you provided are already verified.'
# Birisi bir IP adresini doğrulanmamış olarak işaretlemeye çalıştığında ama doğrulanmamış olduğunda gösterilen mesaj
ip-not-found: '%prefix%Sağladığınız IP adresi doğrulanmış olarak işaretlenmemiş.'
# Birisi aynı IP adresini iki kez (çift işlem) doğrulanmamış olarak işaretlemeye çalıştığında gösterilen mesaj
+2 -2
View File
@@ -1,8 +1,8 @@
val velocityVersion = "3.3.0-SNAPSHOT"
dependencies {
compileOnly(project(":api"))
compileOnly(project(":common"))
implementation(project(":api"))
implementation(project(":common"))
compileOnly("com.velocitypowered:velocity-proxy:$velocityVersion")
testCompileOnly("com.velocitypowered:velocity-proxy:$velocityVersion")
@@ -18,24 +18,22 @@
package xyz.jonesdev.sonar.velocity;
import com.alessiodp.libby.VelocityLibraryManager;
import com.velocitypowered.api.proxy.Player;
import lombok.Getter;
import net.kyori.adventure.audience.Audience;
import org.bstats.charts.SimplePie;
import org.bstats.velocity.Metrics;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import xyz.jonesdev.sonar.common.statistics.CachedBandwidthStatistics;
import xyz.jonesdev.sonar.velocity.audience.AudienceListener;
import xyz.jonesdev.sonar.velocity.command.VelocitySonarCommand;
import xyz.jonesdev.sonar.velocity.fallback.FallbackInjectionHelper;
import xyz.jonesdev.sonar.velocity.fallback.FallbackLoginListener;
import java.util.Map;
import java.net.InetAddress;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@Getter
public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
@@ -49,16 +47,26 @@ public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
}
/**
* Custom wrapper for Velocity audiences
* Wrapper for Velocity audiences
*/
public static final Map<UUID, Audience> AUDIENCES = new ConcurrentHashMap<>();
@Override
public @Nullable Audience audience(final @Nullable UUID uniqueId) {
if (uniqueId == null) {
return getPlugin().getServer().getConsoleCommandSource();
return null;
}
return AUDIENCES.get(uniqueId);
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;
}
/**
@@ -85,38 +93,29 @@ public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
}
};
private Metrics metrics;
@Override
public void enable() {
// Initialize bStats.org metrics
getPlugin().getMetricsFactory().make(getPlugin(), getPlatform().getMetricsId());
metrics = getPlugin().getMetricsFactory().make(getPlugin(), getPlatform().getMetricsId());
// Add charts for some configuration options
metrics.addCustomChart(new SimplePie("verification",
() -> getConfig().getVerification().getTiming().getDisplayName()));
metrics.addCustomChart(new SimplePie("captcha",
() -> getConfig().getVerification().getMap().getTiming().getDisplayName()));
// Register Sonar command
getPlugin().getServer().getCommandManager().register("sonar", new VelocitySonarCommand());
// Register Fallback listener
getPlugin().getServer().getEventManager().register(getPlugin(), new FallbackLoginListener());
// TODO: find a proper way of handling Audiences on Velocity
// Register audience register listener
getPlugin().getServer().getEventManager().register(getPlugin(), new AudienceListener());
// Register traffic service
getPlugin().getServer().getScheduler().buildTask(getPlugin(), CachedBandwidthStatistics::reset)
.repeat(1L, TimeUnit.SECONDS)
.schedule();
// Register queue service
getPlugin().getServer().getScheduler().buildTask(getPlugin(), getFallback().getQueue().getPollTask())
.repeat(500L, TimeUnit.MILLISECONDS)
.schedule();
// Register verbose service
getPlugin().getServer().getScheduler().buildTask(getPlugin(), Sonar.get().getVerboseHandler()::update)
.repeat(200L, TimeUnit.MILLISECONDS)
.schedule();
// Make sure to inject into the server's connection handler
FallbackInjectionHelper.inject(getPlugin().getServer());
}
@Override
public void disable() {
// Make sure to properly shutdown bStats metrics
metrics.shutdown();
}
}
@@ -1,38 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.audience;
import com.velocitypowered.api.event.PostOrder;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.connection.PostLoginEvent;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.velocity.SonarVelocity;
public final class AudienceListener {
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull PostLoginEvent event) {
SonarVelocity.AUDIENCES.put(event.getPlayer().getUniqueId(), event.getPlayer());
}
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull DisconnectEvent event) {
SonarVelocity.AUDIENCES.remove(event.getPlayer().getUniqueId());
}
}
@@ -18,6 +18,7 @@
package xyz.jonesdev.sonar.velocity.command;
import com.velocitypowered.api.command.SimpleCommand;
import com.velocitypowered.api.proxy.Player;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.command.InvocationSource;
import xyz.jonesdev.sonar.api.command.SonarCommand;
@@ -31,7 +32,10 @@ public final class VelocitySonarCommand implements SimpleCommand, SonarCommand {
@Override
public void execute(final @NotNull Invocation invocation) {
// Create our own invocation source wrapper to handle messages properly
final InvocationSource invocationSource = new VelocityInvocationSource(invocation.source());
final InvocationSource invocationSource = new InvocationSource(
invocation.source() instanceof Player ? ((Player) invocation.source()).getUniqueId() : null,
invocation.source(),
invocation.source()::hasPermission);
// Pass the invocation source and command arguments to our command handler
handle(invocationSource, invocation.arguments());
}
@@ -22,26 +22,12 @@ import com.velocitypowered.proxy.protocol.packet.HandshakePacket;
import com.velocitypowered.proxy.protocol.packet.ServerLoginPacket;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.CorruptedFrameException;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.common.fallback.FallbackBandwidthHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandlerAdapter;
import xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper;
import xyz.jonesdev.sonar.common.statistics.GlobalSonarStatistics;
import xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static com.velocitypowered.proxy.network.Connections.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.FALLBACK_BANDWIDTH;
import static xyz.jonesdev.sonar.common.fallback.FallbackUserWrapper.customDisconnect;
import static xyz.jonesdev.sonar.common.fallback.protocol.FallbackPreparer.*;
public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter {
@@ -49,137 +35,22 @@ public final class FallbackChannelHandler extends FallbackChannelHandlerAdapter
super(channel);
}
private @ApiStatus.Internal HandshakePacket handshakePacket;
@Override
public void channelRead(final @NotNull ChannelHandlerContext ctx, final Object msg) throws Exception {
// Intercept any handshake packet by the client
if (msg instanceof HandshakePacket handshake) {
handleHandshake(handshake);
handleHandshake(handshake.getServerAddress(), handshake.getProtocolVersion().getProtocol());
}
// Intercept any server login packet by the client
if (msg instanceof ServerLoginPacket serverLogin) {
handleLogin(ctx, serverLogin);
// Make sure to use the potentially modified, original IP
final MinecraftConnection minecraftConnection = (MinecraftConnection) channel.pipeline().get(HANDLER);
final InetSocketAddress socketAddress = (InetSocketAddress) minecraftConnection.getRemoteAddress();
handleLogin(ctx, serverLogin, serverLogin.getUsername(), socketAddress,
MINECRAFT_ENCODER, MINECRAFT_DECODER, READ_TIMEOUT, HANDLER);
return;
}
// Make sure to let the server handle the rest
ctx.fireChannelRead(msg);
}
private void handleHandshake(final @NotNull HandshakePacket handshake) throws Exception {
// Check if the player has already sent a handshake packet
if (handshakePacket != null) {
throw new CorruptedFrameException("Already sent handshake");
}
// Check if the hostname is invalid
if (handshake.getServerAddress().isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
// Store the protocol version
protocolVersion = ProtocolVersion.fromId(handshake.getProtocolVersion().getProtocol());
// Connections from unknown protocol versions will be discarded
// as this is the safest way of handling unwanted connections
if (protocolVersion.isUnknown()) {
// Sonar does NOT support snapshots or unknown versions;
// I'll try my best to stay up-to-date!
throw new CorruptedFrameException("Unknown protocol");
}
// Store the handshake packet
handshakePacket = handshake;
// Hook the traffic listener
// TODO: Can we implement this in channelActive?
channel.pipeline().addFirst(FALLBACK_BANDWIDTH, FallbackBandwidthHandler.INSTANCE);
}
private void handleLogin(final @NotNull ChannelHandlerContext ctx,
final @NotNull ServerLoginPacket serverLogin) throws Exception {
// Check if the player has already sent a login packet
if (username != null) {
throw new CorruptedFrameException("Already logged on");
}
// Increase joins per second for the action bar verbose
GlobalSonarStatistics.countLogin();
// Store the username
username = serverLogin.getUsername();
// Make sure to use the potentially modified, original IP
final MinecraftConnection minecraftConnection = (MinecraftConnection) channel.pipeline().get(HANDLER);
final InetSocketAddress socketAddress = (InetSocketAddress) minecraftConnection.getRemoteAddress();
inetAddress = socketAddress.getAddress();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklist().asMap().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, blacklisted, MINECRAFT_DECODER, HANDLER);
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
ctx.fireChannelRead(serverLogin);
return;
}
// Completely skip Geyser connections
if (GeyserUtil.isGeyserConnection(channel, socketAddress)) {
ctx.fireChannelRead(serverLogin);
return;
}
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyQueued, MINECRAFT_DECODER, HANDLER);
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(serverLogin.getUsername())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
customDisconnect(channel, protocolVersion, alreadyVerifying, MINECRAFT_DECODER, HANDLER);
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols()
.contains(protocolVersion.getProtocol())) {
customDisconnect(channel, protocolVersion, protocolBlacklisted, MINECRAFT_DECODER, HANDLER);
return;
}
// Make sure we actually have to verify the player
final String offlineUUIDString = "OfflinePlayer:" + serverLogin.getUsername();
final UUID offlineUUID = UUID.nameUUIDFromBytes(offlineUUIDString.getBytes(StandardCharsets.UTF_8));
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, offlineUUID)) {
ctx.fireChannelRead(serverLogin);
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
customDisconnect(channel, protocolVersion, reconnectedTooFast, MINECRAFT_DECODER, HANDLER);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols()
.contains(protocolVersion.getProtocol())) {
ctx.fireChannelRead(serverLogin);
return;
}
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex()
.matcher(serverLogin.getUsername()).matches()) {
customDisconnect(channel, protocolVersion, invalidUsername, MINECRAFT_DECODER, HANDLER);
return;
}
// Create an instance for the Fallback connection
final FallbackUser user = new FallbackUserWrapper(channel, inetAddress, protocolVersion);
// Let the verification handler take over the channel
user.hijack(serverLogin.getUsername(), offlineUUID, MINECRAFT_ENCODER, MINECRAFT_DECODER, READ_TIMEOUT, HANDLER);
}));
}
}
@@ -1,51 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.fallback;
import com.velocitypowered.api.event.PostOrder;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.PreLoginEvent;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.velocity.SonarVelocity;
import java.net.InetAddress;
import java.util.Objects;
public final class FallbackLoginListener {
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull PreLoginEvent event) {
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
// Don't do anything if the setting is disabled
if (maxOnlinePerIp <= 0) return;
final InetAddress inetAddress = event.getConnection().getRemoteAddress().getAddress();
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
final long onlinePerIp = SonarVelocity.INSTANCE.getPlugin().getServer().getAllPlayers().stream()
.filter(player -> Objects.equals(player.getRemoteAddress().getAddress(), inetAddress))
.count();
// We use '>=' because the player connecting to the server hasn't joined yet
if (onlinePerIp >= maxOnlinePerIp) {
event.setResult(PreLoginEvent.PreLoginComponentResult.denied(Sonar.get().getConfig().getTooManyOnlinePerIp()));
}
}
}