Compare commits

..
19 Commits
Author SHA1 Message Date
Jones 18bb9557b1 Release 2.0.16 (#183) 2024-01-28 15:00:42 +01:00
Jones 5d02050815 Load Sonar after some other plugins for compatibility (#182) 2024-01-26 15:06:29 +01:00
Jones 5914b23ae5 Fix relocations breaking dynamic library loading (#180) 2024-01-26 14:58:23 +01:00
Jones 7ce56a79ab Remove unused relocations (#178) 2024-01-25 21:39:51 +01:00
Jones 7b17ba7e53 Bump adventure-platform to 4.3.2 (#177) 2024-01-25 20:24:10 +01:00
AkkariinandMichel Elkenwaat 8268cda660 Recode packet handling on BungeeCord (#174)
* feat: switch to HandlerBoss instead of using InitialHandler.

* feat: recode channel wrapper initialization structure

* fix: remove unnecessary/duplicate methods

* fix: update initial BungeeCord methods (check upstream)

---------

Co-authored-by: Michel Elkenwaat <admin@jonesdev.xyz>
2024-01-25 19:28:37 +01:00
Jones 89d0c7346f Use scheduler instead of custom threads for services (#176)
* fix(bungee): kyori title being broken on BungeeCord

* fix(all): use server's scheduler instead of own services

* fix(velocity): tasks not being scheduled correctly

* fix(velocity): too-many-online-per-IP check breaking on 1.20.2+
2024-01-23 19:36:59 +01:00
Jones 512faa956e Fix protocol check not using the correct disconnect method (#168)
Related to https://github.com/jonesdevelopment/sonar/issues/167
2024-01-22 21:12:02 +01:00
Jones 1f6781aaa3 Load all libraries dynamically during runtime (#166)
* feat: use libby for dynamic dependency loading

* fix: MySQL driver not working with new library loading

* style: remove debug messages

* style: make setup and invoke void static
2024-01-22 20:35:22 +01:00
Jones bf40333de5 Fix incompatibility with LibreLogin on BungeeCord (#164) 2024-01-22 14:41:23 +01:00
dependabot[bot] 31b961b577 Bump netty-all from 4.1.104.Final to 4.1.106.Final (#162)
Bumps [io.netty:netty-all](https://github.com/netty/netty) from 4.1.104.Final to 4.1.106.Final.
- [Commits](https://github.com/netty/netty/compare/netty-4.1.104.Final...netty-4.1.106.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-01-22 13:52:22 +01:00
Jones e4821781fa Make /sonar notify only executable by players (#161) 2024-01-21 15:05:23 +01:00
Jones bdcb7d28a7 Fix broken disconnect packets during login on 1.20.2+ (#160) 2024-01-21 14:51:05 +01:00
Jones 433fab10f1 Implement chat and title attack notifications (#158)
* feat: move audiences outside the verbose handler

* build(deps): only relocate audience class instead of package

* feat: implement chat and title notifications

* feat: allow chat notifications to contain traffic
2024-01-21 14:44:52 +01:00
Jones 014b956f9e Fix compiler errors since Velocity 3.3.0-329 (#157) 2024-01-20 11:42:27 +01:00
Jones 4be1a970d0 Remove some duplicate code in the API (#156)
* fix(api): remove duplicate code in FallbackUser

* style(api): schedule FallbackUser#disconnect(String) for removal
2024-01-16 17:19:19 +01:00
Jones bea1d292ee Release 2.0.15 (#154) 2024-01-11 18:30:20 +01:00
Jones 468a11e0be Fix Geyser profiles by reverting parts of the new injection (#153)
* fix: check for Geyser by using the port instead of channel attribute

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

No files matched your search

+1 -1
View File
@@ -41,8 +41,8 @@
- [Fallback](https://github.com/jonesdevelopment/sonar#fallback) is Sonar's main anti-bot component
- Sonar queues new player logins to prevent spam login attacks
- Sonar checks the handshake packets for legitimacy
- Protective components only on BungeeCord:
- Sonar checks the handshake packets for legitimacy
- Sonar validates the login/server ping packets
- Sonar handles all server pings asynchronously
- The Varint decoder is updated to Velocity's
+18 -9
View File
@@ -21,8 +21,9 @@ versioner {
allprojects {
repositories {
mavenCentral() // Lombok
maven(url = "https://jitpack.io") // simple-yaml
maven(url = "https://jitpack.io/") // simple-yaml
maven(url = "https://repo.jonesdev.xyz/releases/") // Bungee & Velocity proxy module
maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots/") // libby
}
apply(plugin = "java")
@@ -43,10 +44,13 @@ allprojects {
// adventure nbt
implementation("net.kyori:adventure-nbt:$adventureVersion")
implementation("com.j256.ormlite:ormlite-jdbc:6.1") // ORM
compileOnly("com.j256.ormlite:ormlite-jdbc:6.1") // ORMLite
implementation("xyz.jonesdev:cappuccino:0.1.6-SNAPSHOT") // expiring cache
compileOnly("io.netty:netty-all:4.1.104.Final") // netty
compileOnly("io.netty:netty-all:4.1.106.Final") // netty
// Library/dependency loading
compileOnly("com.alessiodp.libby:libby-core:2.0.0-SNAPSHOT")
}
}
@@ -80,17 +84,22 @@ tasks {
// https://github.com/jonesdevelopment/sonar/issues/46
// Relocate some packages, so we don't run into issues where we accidentally use Velocity's classes
relocate("org.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.alessiodp.libby", "xyz.jonesdev.sonar.libs.libby")
relocate("xyz.jonesdev.cappuccino", "xyz.jonesdev.sonar.libs.cappuccino")
relocate("net.kyori.examination", "xyz.jonesdev.sonar.libs.examination")
relocate("net.kyori.option", "xyz.jonesdev.sonar.libs.option")
// We have to be careful here, so we don't accidentally break adventure
relocate("net.kyori.adventure", "xyz.jonesdev.sonar.libs.adventure") {
exclude("net.kyori.adventure.text.**")
exclude("net.kyori.adventure.audience.**")
exclude("net.kyori.adventure.audience.Audience")
exclude("net.kyori.adventure.title.*")
}
relocate("net.kyori.examination", "xyz.jonesdev.sonar.libs.examination")
relocate("net.kyori.option", "xyz.jonesdev.sonar.libs.option")
// Relocate dynamically loaded libraries
relocate("com.simpleyaml", "xyz.jonesdev.sonar.libs.yaml")
relocate("com.google.code.gson", "xyz.jonesdev.sonar.libs.gson")
relocate("com.j256.ormlite", "xyz.jonesdev.sonar.libs.ormlite")
// We want to load our own Gson dynamically
exclude("com/google/gson/**")
// Exclude unnecessary metadata information
exclude("META-INF/versions/**")
+1 -1
View File
@@ -1,3 +1,3 @@
group=xyz.jonesdev.sonar
version=2.0.14
version=2.0.16
description=Effective Anti-bot plugin for Velocity, BungeeCord, and Bukkit (1.7-latest)
+1 -1
View File
@@ -1,5 +1,5 @@
dependencies {
implementation("com.github.Carleslc.Simple-YAML:Simple-Yaml:1.8.4") {
compileOnly("com.github.Carleslc.Simple-YAML:Simple-Yaml:1.8.4") {
exclude(group = "org.yaml")
}
@@ -17,8 +17,10 @@
package xyz.jonesdev.sonar.api;
import com.alessiodp.libby.LibraryManager;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import net.kyori.adventure.audience.Audience;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.attack.AttackTracker;
import xyz.jonesdev.sonar.api.command.subcommand.SubcommandRegistry;
@@ -28,10 +30,13 @@ import xyz.jonesdev.sonar.api.event.SonarEventManager;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import xyz.jonesdev.sonar.api.verbose.Notification;
import xyz.jonesdev.sonar.api.verbose.Verbose;
import java.io.File;
import java.text.DecimalFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public interface Sonar {
DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#,###.##");
@@ -41,6 +46,12 @@ public interface Sonar {
.disableInnerClassSerialization()
.create();
/**
* Since we want to use Adventure on every server platform,
* we have to use their platform module to support BungeeCord and Bukkit
*/
Map<String, Audience> AUDIENCES = new ConcurrentHashMap<>();
/**
* @return The platform the plugin is being run on
*/
@@ -61,14 +72,24 @@ public interface Sonar {
@NotNull Verbose getVerboseHandler();
@NotNull Notification getNotificationHandler();
@NotNull SystemTimer getLaunchTimer();
@NotNull LibraryManager getLibraryManager();
/**
* Set a custom verbose handler
*/
@SuppressWarnings("unused")
void setVerboseHandler(final @NotNull Verbose verboseHandler);
/**
* Set a custom notification handler
*/
@SuppressWarnings("unused")
void setNotificationHandler(final @NotNull Notification notificationHandler);
/**
* Reloads the entire plugin
*/
@@ -69,6 +69,7 @@ public final class AttackTracker implements JVMProfiler {
currentAttack.successfulVerifications = Sonar.get().getVerifiedPlayerController().estimatedSize();
currentAttack.failedVerifications = Statistics.FAILED_VERIFICATIONS.get();
Sonar.get().getEventManager().publish(new AttackDetectedEvent());
Sonar.get().getNotificationHandler().sendAttackNotification();
} else {
// Reset attack timer if we're still under attack
currentAttack.timer.reset();
@@ -24,7 +24,6 @@ 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.dependencies.Dependency;
import xyz.jonesdev.sonar.api.webhook.DiscordWebhook;
import java.awt.*;
@@ -53,6 +52,8 @@ public final class SonarConfiguration {
private final Database database = new Database();
@Getter
private final Webhook webhook = new Webhook();
@Getter
private final Notifications notifications = new Notifications();
public SonarConfiguration(final @NotNull File pluginFolder) {
this.pluginFolder = pluginFolder;
@@ -86,6 +87,14 @@ public final class SonarConfiguration {
private List<String> animation;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Notifications {
private Component notificationTitle;
private Component notificationSubtitle;
private String notificationChat;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Queue {
@@ -219,6 +228,9 @@ public final class SonarConfiguration {
private String verboseSubscribed;
private String verboseUnsubscribed;
private String notificationsSubscribed;
private String notificationsUnsubscribed;
private String reloading;
private String reloaded;
}
@@ -230,10 +242,9 @@ public final class SonarConfiguration {
@Getter
@RequiredArgsConstructor
public enum Type {
MYSQL(Dependency.MYSQL, "com.mysql.cj.jdbc.NonRegisteringDriver"),
NONE(null, null);
MYSQL("com.mysql.cj.jdbc.NonRegisteringDriver"),
NONE(null);
private final Dependency dependency;
private final String driverClassName;
}
@@ -754,6 +765,18 @@ public final class SonarConfiguration {
commands.verboseUnsubscribed = formatString(messagesConfig.getString("commands.verbose.unsubscribed",
"%prefix%You are no longer viewing Sonar verbose."));
messagesConfig.getYaml().setComment("commands.notify",
"Translations for '/sonar notify'");
messagesConfig.getYaml().setComment("commands.notify.subscribed",
"Message that is shown when a player subscribes to Sonar attack notifications");
commands.notificationsSubscribed = formatString(messagesConfig.getString("commands.notify.subscribed",
"%prefix%You are now viewing Sonar attack notifications."));
messagesConfig.getYaml().setComment("commands.notify.unsubscribed",
"Message that is shown when a player unsubscribes from Sonar attack notifications");
commands.notificationsUnsubscribed = formatString(messagesConfig.getString("commands.notify.unsubscribed",
"%prefix%You are no longer viewing Sonar attack notifications."));
messagesConfig.getYaml().setComment("commands.blacklist",
"Translations for '/sonar blacklist'");
messagesConfig.getYaml().setComment("commands.blacklist.empty",
@@ -1053,9 +1076,9 @@ public final class SonarConfiguration {
))));
messagesConfig.getYaml().setComment("verbose",
"Translations for all messages regarding Sonar's verbose output");
"Translations for Sonar's actionbar verbose messages");
messagesConfig.getYaml().setComment("verbose.layout.normal",
"General layout for the verbose action-bar"
"Layout for Sonar's actionbar verbose"
+ LINE_SEPARATOR + "Placeholders:"
+ LINE_SEPARATOR + "- '%queued%' Number of queued connections"
+ LINE_SEPARATOR + "- '%verifying%' Number of verifying connections"
@@ -1085,7 +1108,7 @@ public final class SonarConfiguration {
+ " <dark_aqua>| <green>⬆ <white>%outgoing-traffic%/s <red>⬇ <white>%incoming-traffic%/s"
+ " <green><bold>%animation%<reset>"))));
messagesConfig.getYaml().setComment("verbose.layout.attack",
"Translations for Sonar's verbose output during an active attack");
"Layout for Sonar's actionbar verbose during an attack");
verbose.actionBarLayoutDuringAttack = formatString(messagesConfig.getString("verbose.layout.attack",
String.join(" <dark_aqua>╺ ", Arrays.asList(
"%prefix%<gray>CPS <white>%connections-per-second%",
@@ -1103,6 +1126,39 @@ public final class SonarConfiguration {
Arrays.asList("◜", "◝", "◞", "◟")
));
messagesConfig.getYaml().setComment("notifications",
"Translations for Sonar's attack notification messages");
messagesConfig.getYaml().setComment("notifications.title",
"Layout for Sonar's attack notification title");
notifications.notificationTitle = deserialize(formatString(messagesConfig.getString("notifications.title",
"<yellow><b>Sonar<reset>")));
messagesConfig.getYaml().setComment("notifications.subtitle",
"Layout for Sonar's attack notification subtitle");
notifications.notificationSubtitle = deserialize(formatString(messagesConfig.getString("notifications.subtitle",
"<gray>A bot attack has been detected")));
messagesConfig.getYaml().setComment("notifications.chat",
"Layout for Sonar's attack notification in chat");
notifications.notificationChat = formatString(fromList(
messagesConfig.getStringList("notifications.chat",
Arrays.asList(
"",
"<yellow><b>Sonar<reset>",
"",
"<gray>A bot attack has been detected.",
" <dark_aqua>▪ <gray>Logins per second: <white>%logins-per-second%",
" <dark_aqua>▪ <gray>Verifying players: <white>%verifying%",
" <dark_aqua>▪ <gray>Queued players: <white>%queued%",
"",
"<gray>View more information using " +
"<white><click:run_command:'/sonar verbose'><hover:show_text:'(Click to run)'>/sonar verbose</hover></click>" +
"<gray> or " +
"<white><click:run_command:'/sonar stats'><hover:show_text:'(Click to run)'>/sonar stats</hover></click>" +
"<gray>.",
//"<gray>If you have any questions regarding attack mitigation or experience issues,",
//"<gray>you can join the Jones Development Discord server or open a GitHub issue.",
""
))));
generalConfig.save();
messagesConfig.save();
}
@@ -19,6 +19,7 @@ package xyz.jonesdev.sonar.api.controller;
import com.j256.ormlite.dao.Dao;
import com.j256.ormlite.dao.DaoManager;
import com.j256.ormlite.jdbc.JdbcSingleConnectionSource;
import com.j256.ormlite.stmt.QueryBuilder;
import com.j256.ormlite.support.ConnectionSource;
import com.j256.ormlite.table.TableUtils;
@@ -28,12 +29,11 @@ import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Range;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.dependencies.DependencyLoader;
import xyz.jonesdev.sonar.api.model.VerifiedPlayer;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.*;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
@@ -61,7 +61,7 @@ public final class VerifiedPlayerController {
return;
}
try (final ConnectionSource connectionSource = DependencyLoader.setUpDriverAndConnect()) {
try (final ConnectionSource connectionSource = setupDriverAndConnect()) {
this.connectionSource = connectionSource;
// Create table
@@ -76,16 +76,89 @@ public final class VerifiedPlayerController {
dao = DaoManager.createDao(connectionSource, VerifiedPlayer.class);
queryBuilder = dao.queryBuilder();
// Make sure to clear all outdated entries first
clearOld(Sonar.get().getConfig().getDatabase().getMaximumAge());
// Add all entries from the database to the cache
dao.queryForAll().forEach(this::_add);
// Make sure to run the clean task and the caching task in the same thread
// https://github.com/jonesdevelopment/sonar/issues/150
DB_UPDATE_SERVICE.execute(() -> {
// Make sure to clear all outdated entries first
clearOld(Sonar.get().getConfig().getDatabase().getMaximumAge());
// Add all entries from the database to the cache
try {
dao.queryForAll().forEach(this::_add);
} catch (SQLException exception) {
Sonar.get().getLogger().error("Could not cache entries: {}", exception);
}
});
} catch (Throwable throwable) {
Sonar.get().getLogger().error("Error setting up database connection: {}", throwable);
Sonar.get().getLogger().error("Error setting up database: {}", throwable);
throwable.printStackTrace(System.err);
}
}
/**
* Automatically sets up the database connection using the configured credentials
*/
private static @NotNull ConnectionSource setupDriverAndConnect() throws Throwable {
final SonarConfiguration.Database database = Sonar.get().getConfig().getDatabase();
// Prepare the JDBC connection string
// TODO: automatically build best connection string for database type
final String jdbcURL = String.format("jdbc:%s://%s:%d/%s",
database.getType().name().toLowerCase(), database.getUrl(), database.getPort(), database.getName());
// Use reflection to uncover the driver class
final Class<?> driverClass = Class.forName(database.getType().getDriverClassName());
// Make sure we send the username and password
final Properties credentials = new Properties();
credentials.put("user", database.getUsername());
credentials.put("password", database.getPassword());
// Return new JDBC connection instance for our controller to handle
return new JdbcSingleConnectionSource(jdbcURL, invoke(jdbcURL, driverClass, credentials));
}
/**
* Uses reflection to load the database driver from the injected library
*
* @param connectionString URL of the database
* @param driverClass Class of the database driver
* @param credentials Credentials for the database
*/
private static Connection invoke(final @NotNull String connectionString,
final @NotNull Class<?> driverClass,
final @NotNull Properties credentials) throws Throwable {
final Object driver = driverClass.getDeclaredConstructor().newInstance();
DriverManager.registerDriver((Driver) driver);
final Method connect = driverClass.getDeclaredMethod("connect", String.class, Properties.class);
connect.setAccessible(true);
return (Connection) connect.invoke(driver, connectionString, credentials);
}
/**
* Clear all old entries using the given timestamp.
*/
private void clearOld(final @Range(from = 1, to = 365) int maximumAge) {
try {
final long timestamp = Instant.now()
.minus(maximumAge, ChronoUnit.DAYS)
.getEpochSecond() * 1000L; // convert to ms
final List<VerifiedPlayer> oldEntries = queryBuilder.where()
.lt("timestamp", new Timestamp(timestamp))
.query();
if (oldEntries != null) {
for (final VerifiedPlayer player : oldEntries) {
dao.delete(player);
}
Sonar.get().getLogger().info("Removed {} database entries older than {} days.",
oldEntries.size(), maximumAge);
}
} catch (SQLException exception) {
Sonar.get().getLogger().error("Error trying to clear old entries: {}", exception);
}
}
/**
* First, remove the player from the local cache and then,
* secondly, asynchronously add the player to the database.
@@ -204,44 +277,6 @@ public final class VerifiedPlayerController {
}
}
/**
* Clear all old entries using the given timestamp.
*/
public void clearOld(final @Range(from = 1, to = 365) int maximumAge) {
// Don't try to update the column if the database type is NONE
if (cachedDatabaseType == SonarConfiguration.Database.Type.NONE) {
return;
}
DB_UPDATE_SERVICE.execute(() -> {
// We cannot throw a NullPointerException within the executor service
// because we want to handle the error instead of simply throwing an exception
if (connectionSource == null) {
return;
}
try {
final long timestamp = Instant.now()
.minus(maximumAge, ChronoUnit.DAYS)
.getEpochSecond() * 1000L; // convert to ms
final List<VerifiedPlayer> oldEntries = queryBuilder.where()
.lt("timestamp", new Timestamp(timestamp))
.query();
if (oldEntries != null) {
for (final VerifiedPlayer player : oldEntries) {
dao.delete(player);
}
Sonar.get().getLogger().info("Removed {} database entries older than {} days.",
oldEntries.size(), maximumAge);
}
} catch (SQLException exception) {
Sonar.get().getLogger().error("Error trying to clear old entries: {}", exception);
}
});
}
/**
* @param inetAddress IP address
* @param uuid UUID associated to the IP
@@ -1,90 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.api.dependencies;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
// Mostly taken from
// https://github.com/Elytrium/LimboAuth/blob/master/src/main/java/net/elytrium/limboauth/dependencies/BaseLibrary.java
public enum Dependency {
// https://mvnrepository.com/artifact/com.mysql/mysql-connector-j
MYSQL(
"com/mysql",
"mysql-connector-j",
"8.2.0"
),
// TODO: MariaDB still seems to have some issues; investigate!
// https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client
MARIADB(
"org/mariadb/jdbc",
"mariadb-java-client",
"3.1.4"
);
private final Path tempFilePath;
private final URL mvnRepoURL;
Dependency(final @NotNull String groupId,
final @NotNull String artifactId,
final @NotNull String version) {
final String archiveFileName = String.format("%s-%s.jar", artifactId, version);
this.tempFilePath = new File(Sonar.get().getDataDirectory(), "libraries/" + archiveFileName).toPath();
try {
final String mvnCentralPath = String.format("%s/%s/%s/%s-%s.jar",
groupId, artifactId, version, artifactId, version);
this.mvnRepoURL = new URL("https://repo1.maven.org/maven2/" + mvnCentralPath);
} catch (MalformedURLException exception) {
throw new IllegalStateException(exception);
}
}
/**
* @return URL for the local file used for the external URLClassLoader
*/
public @NotNull URL getClassLoaderURL() throws MalformedURLException {
if (!Files.exists(tempFilePath)) {
final SystemTimer timer = new SystemTimer();
Sonar.get().getLogger().info("Downloading dependency {}...", tempFilePath.getFileName());
try (final InputStream inputStream = mvnRepoURL.openStream()) {
Files.createDirectories(tempFilePath.getParent());
Files.copy(inputStream, Files.createFile(tempFilePath), REPLACE_EXISTING);
} catch (IOException exception) {
throw new IllegalStateException(exception);
}
Sonar.get().getLogger().info("Finished downloading {} ({}s)!", tempFilePath.getFileName(), timer);
}
return tempFilePath.toUri().toURL();
}
}
@@ -1,81 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.api.dependencies;
import com.j256.ormlite.jdbc.JdbcSingleConnectionSource;
import com.j256.ormlite.support.ConnectionSource;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import java.io.File;
import java.lang.reflect.Method;
import java.net.URL;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
import java.util.Properties;
@UtilityClass
public class DependencyLoader {
public ConnectionSource setUpDriverAndConnect() throws Throwable {
final SonarConfiguration.Database database = Sonar.get().getConfig().getDatabase();
final ClassLoader currentClassLoader = DependencyLoader.class.getClassLoader();
ClassLoader classLoader = currentClassLoader;
// Velocity doesn't include the JDBC driver, which is why we need to load it manually
if (Sonar.get().getPlatform() == SonarPlatform.VELOCITY) {
final URL url = database.getType().getDependency().getClassLoaderURL();
final Method addPath = currentClassLoader.getClass().getDeclaredMethod("addPath", Path.class);
addPath.setAccessible(true);
addPath.invoke(currentClassLoader, new File(url.toURI()).toPath());
classLoader = new ExternalClassLoader(new URL[]{url});
}
final String jdbcURL = String.format("jdbc:%s://%s:%d/%s",
database.getType().name().toLowerCase(), database.getUrl(), database.getPort(), database.getName());
final Connection connection = connect(classLoader, jdbcURL, database);
return new JdbcSingleConnectionSource(jdbcURL, connection);
}
// Mostly taken from
// https://github.com/Elytrium/LimboAuth/blob/master/src/main/java/net/elytrium/limboauth/dependencies/DatabaseLibrary.java#L137
private Connection connect(final @NotNull ClassLoader classLoader,
final @NotNull String databaseURL,
final @NotNull SonarConfiguration.Database database) throws Throwable {
final Class<?> driverClass = classLoader.loadClass(database.getType().getDriverClassName());
final Object driver = driverClass.getDeclaredConstructor().newInstance();
DriverManager.registerDriver((Driver) driver);
final Properties properties = new Properties();
properties.put("user", database.getUsername());
properties.put("password", database.getPassword());
final Method connect = driverClass.getDeclaredMethod("connect", String.class, Properties.class);
connect.setAccessible(true);
return (Connection) connect.invoke(driver, databaseURL, properties);
}
}
@@ -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.api.dependencies;
import java.net.URL;
import java.net.URLClassLoader;
final class ExternalClassLoader extends URLClassLoader {
/**
* Create a URLClassLoader with the system ClassLoader as parent
*
* @param url URL for of the local file
*/
public ExternalClassLoader(final URL[] url) {
super(url, ClassLoader.getSystemClassLoader().getParent());
}
// Register this URLClassLoader as parallel capable
static {
ClassLoader.registerAsParallelCapable();
}
}
@@ -31,25 +31,10 @@ import java.util.concurrent.ConcurrentHashMap;
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public final class FallbackQueue {
public static final FallbackQueue INSTANCE = new FallbackQueue();
private final Map<InetAddress, Runnable> queuedPlayers = new ConcurrentHashMap<>(16, 0.5f);
private final Map<InetAddress, Runnable> queuedPlayers = new ConcurrentHashMap<>(64);
/**
* @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);
}
public void poll() {
// Runnable task executed for polling the queue
private final Runnable pollTask = () -> {
final int maxQueuePolls = Sonar.get().getConfig().getQueue().getMaxQueuePolls();
int index = 0;
@@ -71,5 +56,21 @@ public final class FallbackQueue {
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,7 +19,9 @@ package xyz.jonesdev.sonar.api.fallback;
import io.netty.channel.Channel;
import io.netty.channel.ChannelPipeline;
import io.netty.util.ReferenceCountUtil;
import net.kyori.adventure.text.Component;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.cappuccino.Cappuccino;
@@ -36,6 +38,9 @@ import java.util.concurrent.TimeUnit;
public interface FallbackUser<T> {
@NotNull Fallback getFallback();
/**
* @return Per-platform connection object used internally
*/
@NotNull T getConnection();
@NotNull Channel getChannel();
@@ -53,6 +58,8 @@ public interface FallbackUser<T> {
* @param reason Legacy disconnect message string
* @see #disconnect(Component)
*/
@Deprecated
@ApiStatus.ScheduledForRemoval(inVersion = "2.0.17")
default void disconnect(final @NotNull String reason) {
disconnect(Component.text(reason));
}
@@ -70,13 +77,25 @@ public interface FallbackUser<T> {
*
* @param msg Message to send to the player
*/
void write(final @NotNull Object msg);
default void write(final @NotNull Object msg) {
if (getChannel().isActive()) {
getChannel().writeAndFlush(msg, getChannel().voidPromise());
} else {
ReferenceCountUtil.release(msg);
}
}
/**
* Queues a buffered message that will be
* sent once all messages are flushed.
*/
void delayedWrite(final @NotNull Object msg);
default void delayedWrite(final @NotNull Object msg) {
if (getChannel().isActive()) {
getChannel().write(msg, getChannel().voidPromise());
} else {
ReferenceCountUtil.release(msg);
}
}
ExpiringCache<String> PREVIOUS_FAILS = Cappuccino.buildExpiring(3L, TimeUnit.MINUTES);
@@ -0,0 +1,79 @@
/*
* Copyright (C) 2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.api.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.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import java.util.Collection;
import java.util.Vector;
import static xyz.jonesdev.sonar.api.Sonar.DECIMAL_FORMAT;
import static xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter.INCOMING;
import static xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter.OUTGOING;
@Getter
public final class Notification implements Observable, Counters {
private final @NotNull Collection<String> subscribers = new Vector<>(0);
public void sendAttackNotification() {
// No need to do anything if we have no subscribers
if (subscribers.isEmpty()) {
return;
}
// 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().getBlacklisted().estimatedSize()))
.replace("%total-joins%", DECIMAL_FORMAT.format(Statistics.TOTAL_TRAFFIC.get()))
.replace("%logins-per-second%", DECIMAL_FORMAT.format(LOGINS_PER_SECOND.estimatedSize()))
.replace("%connections-per-second%", DECIMAL_FORMAT.format(CONNECTIONS_PER_SECOND.estimatedSize()))
.replace("%verify-total%", DECIMAL_FORMAT.format(Statistics.REAL_TRAFFIC.get()))
.replace("%verify-success%",
DECIMAL_FORMAT.format(Sonar.get().getVerifiedPlayerController().estimatedSize()))
.replace("%verify-failed%", DECIMAL_FORMAT.format(Statistics.FAILED_VERIFICATIONS.get()))
.replace("%incoming-traffic%", INCOMING.getCachedSecond())
.replace("%outgoing-traffic%", OUTGOING.getCachedSecond())
.replace("%incoming-traffic-ttl%", INCOMING.getCachedTtl())
.replace("%outgoing-traffic-ttl%", OUTGOING.getCachedTtl()));
// Send the title and chat messages to all online players
for (final String subscriber : Sonar.get().getNotificationHandler().getSubscribers()) {
final Audience audience = Sonar.AUDIENCES.get(subscriber);
if (audience == null) continue;
audience.showTitle(title);
audience.sendMessage(chat);
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (C) 2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.api.verbose;
import org.jetbrains.annotations.NotNull;
import java.util.Collection;
public interface Observable {
Collection<String> getSubscribers();
/**
* @param name Name of the audience
* @return Whether the audience is subscribed or not
*/
default boolean isSubscribed(final @NotNull String name) {
return getSubscribers().contains(name);
}
/**
* @param name Name of the audience to subscribe
*/
default void subscribe(final String name) {
getSubscribers().add(name);
}
/**
* @param name Name of the audience to unsubscribe
*/
default void unsubscribe(final String name) {
getSubscribers().remove(name);
}
}
@@ -31,18 +31,15 @@ import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.util.Collection;
import java.util.Map;
import java.util.Vector;
import java.util.concurrent.ConcurrentHashMap;
import static xyz.jonesdev.sonar.api.Sonar.DECIMAL_FORMAT;
import static xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter.INCOMING;
import static xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter.OUTGOING;
@Getter
public final class Verbose implements JVMProfiler, Counters {
public final class Verbose implements Observable, JVMProfiler, Counters {
private final @NotNull Collection<String> subscribers = new Vector<>(0);
private final @NotNull Map<String, Audience> audiences = new ConcurrentHashMap<>();
private int animationIndex;
// Run action bar verbose
@@ -59,7 +56,7 @@ public final class Verbose implements JVMProfiler, Counters {
final Component component = prepareActionBarFormat();
// Send the action bar to all online players
for (final String subscriber : subscribers) {
final Audience audience = audiences.get(subscriber);
final Audience audience = Sonar.AUDIENCES.get(subscriber);
if (audience == null) continue;
audience.sendActionBar(component);
}
@@ -100,26 +97,4 @@ public final class Verbose implements JVMProfiler, Counters {
final int nextIndex = ++animationIndex % animations.size();
return animations.toArray(new String[0])[nextIndex];
}
/**
* @param name Name of the audience
* @return Whether the audience is subscribed or not
*/
public boolean isSubscribed(final @NotNull String name) {
return subscribers.contains(name);
}
/**
* @param name Name of the audience to subscribe
*/
public void subscribe(final String name) {
subscribers.add(name);
}
/**
* @param name Name of the audience to unsubscribe
*/
public void unsubscribe(final String name) {
subscribers.remove(name);
}
}
+5 -1
View File
@@ -13,6 +13,7 @@ bukkit {
authors = listOf("Jones Development", "Sonar Contributors")
website = "https://jonesdev.xyz/discord/"
load = BukkitPluginDescription.PluginLoadOrder.POSTWORLD
softDepend = listOf("Geyser-Spigot", "floodgate", "Protocolize", "packetevents", "ProtocolLib", "ViaVersion")
commands {
register("sonar") {
@@ -31,13 +32,16 @@ dependencies {
compileOnly(project(":common"))
// MiniMessage platform support
implementation("net.kyori:adventure-platform-bukkit:4.3.1")
implementation("net.kyori:adventure-platform-bukkit:4.3.2")
// We have to use 1.8 for backwards compatibility
compileOnly("org.spigotmc:spigot-api:1.8.8-R0.1-SNAPSHOT")
// Implement bStats.org for metrics
implementation("org.bstats:bstats-bukkit:3.0.2")
// Library/dependency loading
implementation("com.alessiodp.libby:libby-bukkit:2.0.0-SNAPSHOT")
}
java.sourceCompatibility = JavaVersion.VERSION_1_8
@@ -17,11 +17,14 @@
package xyz.jonesdev.sonar.bukkit;
import com.alessiodp.libby.BukkitLibraryManager;
import lombok.Getter;
import net.kyori.adventure.platform.bukkit.BukkitAudiences;
import org.bstats.bukkit.Metrics;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.bukkit.audience.AudienceListener;
import xyz.jonesdev.sonar.bukkit.command.BukkitSonarCommand;
@@ -34,7 +37,9 @@ public final class SonarBukkit extends SonarBootstrap<SonarBukkitPlugin> {
public static SonarBukkit INSTANCE;
public SonarBukkit(final @NotNull SonarBukkitPlugin plugin) {
super(plugin, plugin.getDataFolder(), SonarPlatform.BUKKIT);
super(plugin, new BukkitLibraryManager(
plugin, plugin.getDataFolder().getName()),
plugin.getDataFolder(), SonarPlatform.BUKKIT);
INSTANCE = this;
}
@@ -78,5 +83,17 @@ public final class SonarBukkit extends SonarBootstrap<SonarBukkitPlugin> {
// Register audience register listener
getPlugin().getServer().getPluginManager().registerEvents(new AudienceListener(), getPlugin());
// Register traffic service
getPlugin().getServer().getScheduler().runTaskTimerAsynchronously(getPlugin(),
TrafficCounter::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);
}
}
@@ -32,11 +32,11 @@ public final class AudienceListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST)
public void handle(final @NotNull PlayerJoinEvent event) {
final Audience audience = SonarBukkit.INSTANCE.getBukkitAudiences().player(event.getPlayer());
Sonar.get().getVerboseHandler().getAudiences().put(event.getPlayer().getName(), audience);
Sonar.AUDIENCES.put(event.getPlayer().getName(), audience);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void handle(final @NotNull PlayerQuitEvent event) {
Sonar.get().getVerboseHandler().getAudiences().remove(event.getPlayer().getName());
Sonar.AUDIENCES.remove(event.getPlayer().getName());
}
}
+5 -1
View File
@@ -9,6 +9,7 @@ bungee {
version = rootProject.version.toString().split("-")[0]
main = "xyz.jonesdev.sonar.bungee.SonarBungeePlugin"
author = "Jones Development, Sonar Contributors"
softDepends = setOf("Geyser-BungeeCord", "floodgate", "Protocolize", "ViaVersion")
}
dependencies {
@@ -19,10 +20,13 @@ dependencies {
testCompileOnly("net.md_5:bungeecord-proxy:master-SNAPSHOT")
// MiniMessage platform support
implementation("net.kyori:adventure-platform-bungeecord:4.3.1")
implementation("net.kyori:adventure-platform-bungeecord:4.3.2")
// Implement bStats.org for metrics
implementation("org.bstats:bstats-bungeecord:3.0.2")
// Library/dependency loading
implementation("com.alessiodp.libby:libby-bungee:2.0.0-SNAPSHOT")
}
java.sourceCompatibility = JavaVersion.VERSION_1_8
@@ -17,11 +17,14 @@
package xyz.jonesdev.sonar.bungee;
import com.alessiodp.libby.BungeeLibraryManager;
import lombok.Getter;
import net.kyori.adventure.platform.bungeecord.BungeeAudiences;
import org.bstats.bungeecord.Metrics;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.bungee.audience.AudienceListener;
import xyz.jonesdev.sonar.bungee.command.BungeeSonarCommand;
@@ -30,12 +33,14 @@ import xyz.jonesdev.sonar.bungee.fallback.injection.BaseInjectionHelper;
import xyz.jonesdev.sonar.bungee.fallback.injection.ChildChannelInitializer;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import java.util.concurrent.TimeUnit;
@Getter
public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
public static SonarBungee INSTANCE;
public SonarBungee(final @NotNull SonarBungeePlugin plugin) {
super(plugin, plugin.getDataFolder(), SonarPlatform.BUNGEE);
super(plugin, new BungeeLibraryManager(plugin), plugin.getDataFolder(), SonarPlatform.BUNGEE);
INSTANCE = this;
}
@@ -85,5 +90,17 @@ public final class SonarBungee extends SonarBootstrap<SonarBungeePlugin> {
// Inject base into ProtocolUtils
BaseInjectionHelper.inject(ChildChannelInitializer.INSTANCE);
// Register traffic service
getPlugin().getServer().getScheduler().schedule(getPlugin(), TrafficCounter::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);
}
}
@@ -32,11 +32,11 @@ public final class AudienceListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST)
public void handle(final @NotNull PostLoginEvent event) {
final Audience audience = SonarBungee.INSTANCE.getBungeeAudiences().player(event.getPlayer());
Sonar.get().getVerboseHandler().getAudiences().put(event.getPlayer().getName(), audience);
Sonar.AUDIENCES.put(event.getPlayer().getName(), audience);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void handle(final @NotNull PlayerDisconnectEvent event) {
Sonar.get().getVerboseHandler().getAudiences().remove(event.getPlayer().getName());
Sonar.AUDIENCES.remove(event.getPlayer().getName());
}
}
@@ -17,20 +17,86 @@
package xyz.jonesdev.sonar.bungee.fallback;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.haproxy.HAProxyMessage;
import lombok.Getter;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.json.JSONComponentSerializer;
import net.md_5.bungee.BungeeCord;
import net.md_5.bungee.EncryptionUtil;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.chat.ComponentSerializer;
import net.md_5.bungee.connection.DownstreamBridge;
import net.md_5.bungee.connection.UpstreamBridge;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.netty.HandlerBoss;
import net.md_5.bungee.netty.PacketHandler;
import net.md_5.bungee.protocol.PacketWrapper;
import net.md_5.bungee.protocol.PlayerPublicKey;
import net.md_5.bungee.protocol.packet.Handshake;
import net.md_5.bungee.protocol.packet.Kick;
import net.md_5.bungee.protocol.packet.LoginRequest;
import net.md_5.bungee.protocol.packet.StatusRequest;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifyJoinEvent;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.statistics.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackVerificationHandler;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketEncoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccess;
import xyz.jonesdev.sonar.common.fallback.traffic.TrafficChannelHooker;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import static net.md_5.bungee.netty.PipelineUtils.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.*;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_19_1;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_19_3;
import static xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil.isGeyserConnection;
public final class FallbackHandlerBoss extends HandlerBoss {
private ChannelWrapper channel;
private static final @NotNull Fallback FALLBACK = Sonar.get().getFallback();
private static final @NotNull BungeeCord BUNGEE = BungeeCord.getInstance();
private static final Field CHANNEL_WRAPPER_FIELD;
static {
try {
CHANNEL_WRAPPER_FIELD = HandlerBoss.class.getDeclaredField("channel");
CHANNEL_WRAPPER_FIELD.setAccessible(true);
} catch (Exception exception) {
throw new ReflectiveOperationException(exception);
}
}
private ChannelWrapper channelWrapper;
private PacketHandler handler;
private @Nullable FallbackUserWrapper user;
@Getter
private ProtocolVersion protocolVersion;
private boolean receivedLoginPacket;
private boolean receivedStatusPacket;
@Override
public void setHandler(final @NotNull PacketHandler handler) {
@@ -41,36 +107,250 @@ public final class FallbackHandlerBoss extends HandlerBoss {
@Override
public void channelActive(final ChannelHandlerContext ctx) throws Exception {
if (handler != null) {
channel = new ChannelWrapper(ctx);
super.channelActive(ctx);
}
}
@Override
public void channelInactive(final ChannelHandlerContext ctx) throws Exception {
if (handler != null) {
channel.markClosed();
super.channelInactive(ctx);
}
}
@Override
public void channelWritabilityChanged(final ChannelHandlerContext ctx) throws Exception {
if (handler != null) {
handler.writabilityChanged(channel);
super.channelWritabilityChanged(ctx);
// Use the same channel wrapper as the "real" HandlerBoss
channelWrapper = (ChannelWrapper) CHANNEL_WRAPPER_FIELD.get(this);
// Increase connections per second for the action bar verbose
Counters.CONNECTIONS_PER_SECOND.put(System.nanoTime());
}
}
@Override
public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
if (msg instanceof HAProxyMessage || msg instanceof PacketWrapper) {
if (msg instanceof PacketWrapper) {
final PacketWrapper packetWrapper = (PacketWrapper) msg;
// Handle Handshake packets
if (packetWrapper.packet instanceof Handshake) {
final Handshake handshake = (Handshake) packetWrapper.packet;
// Check if the host is invalid
if (handshake.getHost().isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
// Set protocol version
protocolVersion = ProtocolVersion.fromId(handshake.getProtocolVersion());
}
// Handle StatusRequest packets
if (packetWrapper.packet instanceof StatusRequest) {
// Fix status packet spam exploit
if (receivedStatusPacket) {
throw new CorruptedFrameException("Duplicate status packet");
}
receivedStatusPacket = true;
}
// Handle LoginRequest packets
if (packetWrapper.packet instanceof LoginRequest) {
final LoginRequest loginRequest = (LoginRequest) packetWrapper.packet;
// Increase joins per second for the action bar verbose
Counters.LOGINS_PER_SECOND.put(System.nanoTime());
// Fix login packet spam exploit
if (receivedLoginPacket || user != null) {
throw new CorruptedFrameException("Duplicate login packet");
}
receivedLoginPacket = true;
// Cache protocol version so other handlers don't throw NPEs
final Channel channel = channelWrapper.getHandle();
// Run in the channel's event loop
channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (channelWrapper.isClosed() || channelWrapper.isClosing()) return;
try {
// Hook the custom traffic pipeline, so we can count the incoming and outgoing traffic
final ChannelPipeline pipeline = channel.pipeline();
TrafficChannelHooker.hook(pipeline, PACKET_DECODER, PACKET_ENCODER);
// Increase total traffic statistic
Statistics.TOTAL_TRAFFIC.increment();
// Use the ChannelWrapper's remote address to automatically handle proxy protocol
final InetAddress inetAddress = ((InetSocketAddress) channelWrapper.getRemoteAddress()).getAddress();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklisted().has(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getBlacklisted()));
return;
}
// Check if the verification is enabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
super.channelRead(ctx, msg);
return;
}
// Completely skip Geyser connections
if (isGeyserConnection(channel)) {
FALLBACK.getLogger().info("Skipping Geyser player: {}{}",
loginRequest.getData(), Sonar.get().getConfig().formatAddress(inetAddress));
super.channelRead(ctx, msg);
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())) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getProtocolBlacklisted()));
return;
}
final UUID uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + loginRequest.getData())
.getBytes(StandardCharsets.UTF_8));
// Check if the player is already verified
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, uuid)) {
super.channelRead(ctx, msg);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols()
.contains(protocolVersion.getProtocol())) {
super.channelRead(ctx, msg);
return;
}
// Create wrapped Fallback user
user = new FallbackUserWrapper(
FALLBACK, this, channel, channel.pipeline(),
inetAddress, protocolVersion);
// Perform default BungeeCord checks
if (BUNGEE.config.isEnforceSecureProfile()
&& user.getProtocolVersion().compareTo(MINECRAFT_1_19_3) < 0) {
final PlayerPublicKey publicKey = loginRequest.getPublicKey();
if (publicKey == null) {
closeWith(getKickPacket(Component.text(BUNGEE.getTranslation("secure_profile_required"))));
return;
}
if (Instant.ofEpochMilli(publicKey.getExpiry()).isBefore(Instant.now())) {
closeWith(getKickPacket(BUNGEE.getTranslation("secure_profile_expired")));
return;
}
if (user.getProtocolVersion().compareTo(MINECRAFT_1_19_1) < 0
&& !EncryptionUtil.check(publicKey, null)) {
closeWith(getKickPacket(BUNGEE.getTranslation("secure_profile_invalid")));
return;
}
}
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyQueued()));
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)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyVerifying()));
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getTooFastReconnect()));
return;
}
// We have to add this pipeline to monitor whenever the client disconnects
// to remove them from the list of connected and queued players
pipeline.addFirst(FALLBACK_HANDLER, new FallbackChannelHandler(loginRequest.getData(), inetAddress));
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (channelWrapper.isClosed() || channelWrapper.isClosing()) return;
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex().matcher(loginRequest.getData()).matches()) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getInvalidUsername()));
return;
}
// Add better timeout handler to avoid known exploits or issues
// We also want to timeout bots quickly to avoid flooding
final int readTimeout = Sonar.get().getConfig().getVerification().getReadTimeout();
pipeline.replace(TIMEOUT_HANDLER, TIMEOUT_HANDLER,
new FallbackTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS));
// Disconnect if the protocol version could not be resolved
if (user.getProtocolVersion().isUnknown()) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getInvalidProtocol()));
return;
}
// Check if the player is already connected to the proxy but still tries to verify
final int limit = BUNGEE.config.getPlayerLimit();
if (limit > 0 && BUNGEE.getOnlineCount() >= limit) {
closeWith(getKickPacket(BUNGEE.getTranslation("proxy_full")));
return;
} else if (!BUNGEE.getConfig().isOnlineMode() && BUNGEE.getPlayer(loginRequest.getData()) != null) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyConnected()));
return;
}
// The player joined the verification
Statistics.REAL_TRAFFIC.increment();
if (Sonar.get().getConfig().getVerification().isLogConnections()) {
// Only log the processing message if the server isn't under attack.
// We let the user override this through the configuration.
if (!Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
FALLBACK.getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", loginRequest.getData())
.replace("%ip%", Sonar.get().getConfig().formatAddress(inetAddress))
.replace("%protocol%", String.valueOf(user.getProtocolVersion().getProtocol())));
}
}
// Call the VerifyJoinEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyJoinEvent(loginRequest.getData(), user));
// Mark the player as connected → verifying players
FALLBACK.getConnected().put(loginRequest.getData(), inetAddress);
// This sometimes happens when the channel hangs, but the player is still connecting
// This also fixes a unique issue with TCPShield and other reverse proxies
if (user.getPipeline().get(PACKET_ENCODER) == null
|| user.getPipeline().get(PACKET_DECODER) == null) {
channelWrapper.close();
return;
}
// Replace normal encoder to allow custom packets
final FallbackPacketEncoder encoder = new FallbackPacketEncoder(user.getProtocolVersion());
user.getPipeline().replace(PACKET_ENCODER, FALLBACK_PACKET_ENCODER, encoder);
// Send LoginSuccess packet to make the client think they are joining the server
user.write(new LoginSuccess(loginRequest.getData(), uuid));
// The LoginSuccess packet has been sent, now we can change the registry state
encoder.updateRegistry(user.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
// Replace normal decoder to allow custom packets
user.getPipeline().replace(PACKET_DECODER, FALLBACK_PACKET_DECODER,
new FallbackPacketDecoder(user, new FallbackVerificationHandler(user, loginRequest.getData(), uuid)));
}));
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
});
return;
}
}
super.channelRead(ctx, msg);
}
}
@Override
public void exceptionCaught(final @NotNull ChannelHandlerContext ctx, final Throwable throwable) throws Exception {
public void exceptionCaught(final @NotNull ChannelHandlerContext ctx, final Throwable throwable) {
if (ctx.channel().isActive()) {
if (handler instanceof DownstreamBridge || handler instanceof UpstreamBridge) {
try {
@@ -80,8 +360,50 @@ public final class FallbackHandlerBoss extends HandlerBoss {
}
}
channel.markClosed();
channelWrapper.markClosed();
ctx.close();
}
}
private static final Map<Component, Kick> CACHED_KICK_PACKETS = new ConcurrentHashMap<>(10);
public static @NotNull Kick getKickPacket(final @NotNull String reason) {
final Component component = Component.text(reason);
return getKickPacket(component);
}
public static @NotNull Kick getKickPacket(final @NotNull Component component) {
Kick cachedKickPacket = CACHED_KICK_PACKETS.get(component);
if (cachedKickPacket == null) {
final String serialized = JSONComponentSerializer.json().serialize(component);
cachedKickPacket = new Kick(ComponentSerializer.deserialize(serialized));
CACHED_KICK_PACKETS.put(component, cachedKickPacket);
}
return cachedKickPacket;
}
public void closeWith(final Object msg) {
closeWith(channelWrapper, protocolVersion, msg);
}
// Mostly taken from Velocity
public static void closeWith(final @NotNull ChannelWrapper channelWrapper,
final @NotNull ProtocolVersion protocolVersion,
final @NotNull Object msg) {
if (channelWrapper.getHandle().isActive()) {
if (protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0
&& protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_7_2) >= 0) {
channelWrapper.getHandle().eventLoop().execute(() -> {
channelWrapper.getHandle().config().setAutoRead(false);
channelWrapper.getHandle().eventLoop().schedule(() -> {
channelWrapper.markClosed();
channelWrapper.getHandle().writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE);
}, 250L, TimeUnit.MILLISECONDS);
});
} else {
channelWrapper.markClosed();
channelWrapper.getHandle().writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE);
}
}
}
}
@@ -1,364 +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 io.netty.channel.Channel;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.DecoderException;
import lombok.Getter;
import lombok.val;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.json.JSONComponentSerializer;
import net.md_5.bungee.BungeeCord;
import net.md_5.bungee.EncryptionUtil;
import net.md_5.bungee.api.config.ListenerInfo;
import net.md_5.bungee.chat.ComponentSerializer;
import net.md_5.bungee.connection.InitialHandler;
import net.md_5.bungee.netty.ChannelWrapper;
import net.md_5.bungee.protocol.PlayerPublicKey;
import net.md_5.bungee.protocol.packet.Handshake;
import net.md_5.bungee.protocol.packet.Kick;
import net.md_5.bungee.protocol.packet.LoginRequest;
import net.md_5.bungee.protocol.packet.StatusRequest;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifyJoinEvent;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.statistics.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackVerificationHandler;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketEncoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccess;
import xyz.jonesdev.sonar.common.fallback.traffic.TrafficChannelHooker;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import static net.md_5.bungee.netty.PipelineUtils.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.*;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_19_1;
import static xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion.MINECRAFT_1_19_3;
import static xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil.isGeyserConnection;
public final class FallbackInitialHandler extends InitialHandler {
public FallbackInitialHandler(final @NotNull BungeeCord bungee, final ListenerInfo listener) {
super(bungee, listener);
this.bungee = bungee;
}
@Getter
private ChannelWrapper channelWrapper;
private final @NotNull BungeeCord bungee;
private @Nullable FallbackUserWrapper user;
@Getter
private ProtocolVersion protocolVersion;
private boolean receivedLoginPacket;
private boolean receivedStatusPacket;
private static final @NotNull Fallback FALLBACK = Objects.requireNonNull(Sonar.get().getFallback());
@Override
public void connected(final ChannelWrapper channelWrapper) throws Exception {
this.channelWrapper = channelWrapper;
super.connected(channelWrapper);
// Increase connections per second for the action bar verbose
Counters.CONNECTIONS_PER_SECOND.put(System.nanoTime());
}
@Override
public void handle(final @NotNull Handshake handshake) throws Exception {
// Check if the host is invalid
if (handshake.getHost().isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
super.handle(handshake);
}
@Override
public void handle(final StatusRequest statusRequest) throws Exception {
// Fix status packet spam exploit
if (receivedStatusPacket) {
throw new CorruptedFrameException("Duplicate status packet");
}
receivedStatusPacket = true;
// Run the rest of the method asynchronously
CompletableFuture.runAsync(() -> {
// The channel always stays open because the client sends
// a StatusRequest and a Ping packet after one another
if (!isConnected()) {
throw new CorruptedFrameException("Not connected anymore");
}
try {
super.handle(statusRequest);
} catch (Exception exception) {
throw new DecoderException(exception);
}
});
}
@Override
public void handle(final LoginRequest loginRequest) throws Exception {
// Increase joins per second for the action bar verbose
Counters.LOGINS_PER_SECOND.put(System.nanoTime());
// Fix login packet spam exploit
if (receivedLoginPacket || user != null) {
throw new CorruptedFrameException("Duplicate login packet");
}
receivedLoginPacket = true;
// Cache protocol version so other handlers don't throw NPEs
final int protocolId = getHandshake().getProtocolVersion();
protocolVersion = ProtocolVersion.fromId(protocolId);
final Channel channel = channelWrapper.getHandle();
// Run in the channel's event loop
channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (channelWrapper.isClosed() || channelWrapper.isClosing()) return;
try {
// Hook the custom traffic pipeline, so we can count the incoming and outgoing traffic
final ChannelPipeline pipeline = channel.pipeline();
TrafficChannelHooker.hook(pipeline, PACKET_DECODER, PACKET_ENCODER);
// Increase total traffic statistic
Statistics.TOTAL_TRAFFIC.increment();
final InetAddress inetAddress = getAddress().getAddress();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklisted().has(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getBlacklisted()));
return;
}
// Check if the verification is enabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
super.handle(loginRequest);
return;
}
// Completely skip Geyser connections
if (isGeyserConnection(channel)) {
FALLBACK.getLogger().info("Skipping Geyser player: {}{}",
loginRequest.getData(), Sonar.get().getConfig().formatAddress(inetAddress));
super.handle(loginRequest);
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols().contains(protocolId)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getProtocolBlacklisted()));
return;
}
val uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + loginRequest.getData()).getBytes(StandardCharsets.UTF_8));
// Check if the player is already verified
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, uuid)) {
super.handle(loginRequest);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols().contains(protocolId)) {
super.handle(loginRequest);
return;
}
// Create wrapped Fallback user
user = new FallbackUserWrapper(
FALLBACK, this, channel, channel.pipeline(),
inetAddress, protocolVersion
);
// Perform default BungeeCord checks
if (bungee.config.isEnforceSecureProfile()
&& user.getProtocolVersion().compareTo(MINECRAFT_1_19_3) < 0) {
final PlayerPublicKey publicKey = loginRequest.getPublicKey();
if (publicKey == null) {
disconnect(bungee.getTranslation("secure_profile_required"));
return;
}
if (Instant.ofEpochMilli(publicKey.getExpiry()).isBefore(Instant.now())) {
disconnect(bungee.getTranslation("secure_profile_expired"));
return;
}
if (user.getProtocolVersion().compareTo(MINECRAFT_1_19_1) < 0
&& !EncryptionUtil.check(publicKey, null)) {
disconnect(bungee.getTranslation("secure_profile_invalid"));
return;
}
}
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyQueued()));
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)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyVerifying()));
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getTooFastReconnect()));
return;
}
// We have to add this pipeline to monitor whenever the client disconnects
// to remove them from the list of connected and queued players
pipeline.addFirst(FALLBACK_HANDLER, new FallbackChannelHandler(loginRequest.getData(), inetAddress));
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (channelWrapper.isClosed() || channelWrapper.isClosing()) return;
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex().matcher(loginRequest.getData()).matches()) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getInvalidUsername()));
return;
}
// Add better timeout handler to avoid known exploits or issues
// We also want to timeout bots quickly to avoid flooding
final int readTimeout = Sonar.get().getConfig().getVerification().getReadTimeout();
pipeline.replace(TIMEOUT_HANDLER, TIMEOUT_HANDLER,
new FallbackTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS));
// Disconnect if the protocol version could not be resolved
if (user.getProtocolVersion().isUnknown()) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getInvalidProtocol()));
return;
}
// Check if the player is already connected to the proxy but still tries to verify
final int limit = bungee.config.getPlayerLimit();
if (limit > 0 && bungee.getOnlineCount() >= limit) {
disconnect(bungee.getTranslation("proxy_full"));
return;
} else if (!isOnlineMode() && bungee.getPlayer(loginRequest.getData()) != null) {
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyConnected()));
return;
}
// The player joined the verification
Statistics.REAL_TRAFFIC.increment();
if (Sonar.get().getConfig().getVerification().isLogConnections()) {
// Only log the processing message if the server isn't under attack.
// We let the user override this through the configuration.
if (!Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
FALLBACK.getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", loginRequest.getData())
.replace("%ip%", Sonar.get().getConfig().formatAddress(inetAddress))
.replace("%protocol%", String.valueOf(user.getProtocolVersion().getProtocol())));
}
}
// Call the VerifyJoinEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyJoinEvent(loginRequest.getData(), user));
// Mark the player as connected → verifying players
FALLBACK.getConnected().put(loginRequest.getData(), inetAddress);
// This sometimes happens when the channel hangs, but the player is still connecting
// This also fixes a unique issue with TCPShield and other reverse proxies
if (user.getPipeline().get(PACKET_ENCODER) == null
|| user.getPipeline().get(PACKET_DECODER) == null) {
channelWrapper.close();
return;
}
// Replace normal encoder to allow custom packets
final FallbackPacketEncoder encoder = new FallbackPacketEncoder(user.getProtocolVersion());
user.getPipeline().replace(PACKET_ENCODER, FALLBACK_PACKET_ENCODER, encoder);
// Send LoginSuccess packet to make the client think they are joining the server
user.write(new LoginSuccess(loginRequest.getData(), uuid));
// The LoginSuccess packet has been sent, now we can change the registry state
encoder.updateRegistry(user.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
// Replace normal decoder to allow custom packets
user.getPipeline().replace(PACKET_DECODER, FALLBACK_PACKET_DECODER,
new FallbackPacketDecoder(user, new FallbackVerificationHandler(user, loginRequest.getData(), uuid)));
}));
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
});
}
private static final Map<Component, Kick> CACHED_KICK_PACKETS = new ConcurrentHashMap<>(10);
public static @NotNull Kick getKickPacket(final @NotNull Component component) {
Kick cachedKickPacket = CACHED_KICK_PACKETS.get(component);
if (cachedKickPacket == null) {
final String serialized = JSONComponentSerializer.json().serialize(component);
cachedKickPacket = new Kick(ComponentSerializer.deserialize(serialized));
CACHED_KICK_PACKETS.put(component, cachedKickPacket);
}
return cachedKickPacket;
}
// Mostly taken from Velocity
public void closeWith(final Object msg) {
if (channelWrapper.getHandle().isActive()) {
boolean is17 = protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_8) < 0
&& protocolVersion.compareTo(ProtocolVersion.MINECRAFT_1_7_2) >= 0;
if (is17) {
channelWrapper.getHandle().eventLoop().execute(() -> {
channelWrapper.getHandle().config().setAutoRead(false);
channelWrapper.getHandle().eventLoop().schedule(() -> {
channelWrapper.markClosed();
channelWrapper.getHandle().writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE);
}, 250L, TimeUnit.MILLISECONDS);
});
} else {
channelWrapper.markClosed();
channelWrapper.getHandle().writeAndFlush(msg).addListener(ChannelFutureListener.CLOSE);
}
}
}
}
@@ -19,23 +19,40 @@ package xyz.jonesdev.sonar.bungee.fallback;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.Component;
import net.md_5.bungee.api.connection.PendingConnection;
import net.md_5.bungee.api.event.LoginEvent;
import net.md_5.bungee.api.plugin.Listener;
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 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;
@RequiredArgsConstructor
public final class FallbackListener 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 LoginEvent event) {
public void handle(final @NotNull LoginEvent event) throws IllegalAccessException {
final InetAddress inetAddress = event.getConnection().getAddress().getAddress();
// Check if the number of online players using the same IP address as
@@ -50,9 +67,10 @@ public final class FallbackListener implements Listener {
// We use '>=' because the player connecting to the server hasn't joined yet
if (onlinePerIp >= maxOnlinePerIp) {
final FallbackInitialHandler fallbackInitialHandler = (FallbackInitialHandler) event.getConnection();
final Component component = Sonar.get().getConfig().getTooManyOnlinePerIp();
fallbackInitialHandler.closeWith(FallbackInitialHandler.getKickPacket(component));
final PendingConnection connection = event.getConnection();
FallbackHandlerBoss.closeWith((ChannelWrapper) CHANNEL_FIELD.get(connection),
ProtocolVersion.fromId(connection.getVersion()), FallbackHandlerBoss.getKickPacket(component));
}
}
}
@@ -19,7 +19,6 @@ package xyz.jonesdev.sonar.bungee.fallback;
import io.netty.channel.Channel;
import io.netty.channel.ChannelPipeline;
import io.netty.util.ReferenceCountUtil;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.text.Component;
@@ -33,34 +32,22 @@ import java.net.InetAddress;
@Getter
@RequiredArgsConstructor
public final class FallbackUserWrapper implements FallbackUser<FallbackInitialHandler> {
public final class FallbackUserWrapper implements FallbackUser<FallbackHandlerBoss> {
private final Fallback fallback;
private final FallbackInitialHandler connection;
private final FallbackHandlerBoss connection;
private final Channel channel;
private final ChannelPipeline pipeline;
private final InetAddress inetAddress;
private final ProtocolVersion protocolVersion;
/**
* Disconnect the player during/after verification
* using our custom {@link Disconnect} packet.
*
* @param reason Disconnect message component
*/
@Override
public void disconnect(final @NotNull Component reason) {
connection.closeWith(Disconnect.create(reason));
}
@Override
public void write(final @NotNull Object packet) {
if (channel.isActive()) {
channel.writeAndFlush(packet, channel.voidPromise());
} else {
ReferenceCountUtil.release(packet);
}
}
@Override
public void delayedWrite(final @NotNull Object packet) {
if (channel.isActive()) {
channel.write(packet, channel.voidPromise());
} else {
ReferenceCountUtil.release(packet);
}
}
}
@@ -17,6 +17,7 @@
package xyz.jonesdev.sonar.bungee.fallback.injection;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.channel.*;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@@ -55,16 +56,18 @@ public final class BaseChannelInitializer extends ChannelInitializer<Channel> {
}
}
// Mostly taken from BungeeCord
// Taken from BungeeCord
// https://github.com/SpigotMC/BungeeCord/blob/master/proxy/src/main/java/net/md_5/bungee/netty/PipelineUtils.java#L162
@Override
protected void initChannel(final @NotNull Channel channel) throws Exception {
try {
channel.config().setOption(ChannelOption.IP_TOS, 0x18);
} catch (ChannelException ignored) {
// Use TCPNoDelay
channel.config().setOption(ChannelOption.TCP_NODELAY, true);
} catch (ChannelException exception) {
// IP_TOS is not supported (Windows XP / Windows Server 2003)
}
channel.config().setOption(ChannelOption.TCP_NODELAY, true);
channel.config().setAllocator(PooledByteBufAllocator.DEFAULT);
channel.config().setWriteBufferWaterMark(SERVER_WRITE_MARK);
channel.pipeline().addLast(FRAME_DECODER, REPLACE_VAR_INT_DECODER
@@ -25,13 +25,12 @@ import lombok.NoArgsConstructor;
import net.md_5.bungee.BungeeCord;
import net.md_5.bungee.api.config.ListenerInfo;
import net.md_5.bungee.api.event.ClientConnectEvent;
import net.md_5.bungee.connection.InitialHandler;
import net.md_5.bungee.netty.PipelineUtils;
import net.md_5.bungee.protocol.*;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.bungee.fallback.FallbackHandlerBoss;
import xyz.jonesdev.sonar.bungee.fallback.FallbackInitialHandler;
import java.lang.reflect.Field;
import java.net.SocketAddress;
@@ -56,7 +55,7 @@ public final class ChildChannelInitializer extends ChannelInitializer<Channel> {
}
}
// Mostly taken from BungeeCord
// Taken from BungeeCord
// https://github.com/SpigotMC/BungeeCord/blob/master/proxy/src/main/java/net/md_5/bungee/netty/PipelineUtils.java
@Override
protected void initChannel(final @NotNull Channel channel) throws Exception {
@@ -65,32 +64,27 @@ public final class ChildChannelInitializer extends ChannelInitializer<Channel> {
if (BUNGEE.getConnectionThrottle() != null && BUNGEE.getConnectionThrottle().throttle(remoteAddress)) {
channel.close();
} else {
final ListenerInfo listener = channel.attr(PipelineUtils.LISTENER).get();
return;
}
if (BungeeCord.getInstance().getPluginManager().callEvent(new ClientConnectEvent(remoteAddress, listener)).isCancelled()) {
channel.close();
} else {
try {
BASE.initChannel(channel);
} catch (Exception exception) {
exception.printStackTrace(System.err);
channel.close();
return;
}
final ListenerInfo listener = channel.attr(PipelineUtils.LISTENER).get();
channel.pipeline().addBefore(FRAME_DECODER, LEGACY_DECODER, new LegacyDecoder());
channel.pipeline().addAfter(FRAME_DECODER, PACKET_DECODER, new MinecraftDecoder(
Protocol.HANDSHAKE, true, ProtocolVersion.LATEST_VERSION.getProtocol()));
channel.pipeline().addAfter(FRAME_PREPENDER, PACKET_ENCODER, new MinecraftEncoder(
Protocol.HANDSHAKE, true, ProtocolVersion.LATEST_VERSION.getProtocol()));
channel.pipeline().addBefore(FRAME_PREPENDER, LEGACY_KICKER, LEGACY_KICK);
channel.pipeline().get(FallbackHandlerBoss.class).setHandler(new FallbackInitialHandler(BUNGEE, listener));
if (BungeeCord.getInstance().getPluginManager().callEvent(new ClientConnectEvent(remoteAddress, listener)).isCancelled()) {
channel.close();
return;
}
if (listener.isProxyProtocol()) {
channel.pipeline().addFirst(new HAProxyMessageDecoder());
}
}
BASE.initChannel(channel);
channel.pipeline().addBefore(FRAME_DECODER, LEGACY_DECODER, new LegacyDecoder());
channel.pipeline().addAfter(FRAME_DECODER, PACKET_DECODER, new MinecraftDecoder(
Protocol.HANDSHAKE, true, BUNGEE.getProtocolVersion()));
channel.pipeline().addAfter(FRAME_PREPENDER, PACKET_ENCODER, new MinecraftEncoder(
Protocol.HANDSHAKE, true, BUNGEE.getProtocolVersion()));
channel.pipeline().addBefore(FRAME_PREPENDER, LEGACY_KICKER, LEGACY_KICK);
channel.pipeline().get(FallbackHandlerBoss.class).setHandler(new InitialHandler(BUNGEE, listener));
if (listener.isProxyProtocol()) {
channel.pipeline().addFirst(new HAProxyMessageDecoder());
}
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (C) 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.boot;
import com.alessiodp.libby.Library;
import com.alessiodp.libby.LibraryManager;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
@UtilityClass
class LibraryLoader {
void loadLibraries(final @NotNull LibraryManager libraryManager) {
libraryManager.addMavenCentral();
libraryManager.addJitPack();
libraryManager.loadLibraries(
// Simple-YAML
// TODO: use a different library for config management?
Library.builder()
.groupId("com{}github{}Carleslc{}Simple-YAML")
.artifactId("Simple-Yaml")
.version("1.8.4")
.relocate("com{}simpleyaml", "xyz{}jonesdev{}sonar{}libs{}yaml")
.build(),
// Gson
Library.builder()
.groupId("com{}google{}code{}gson")
.artifactId("gson")
.version("2.10.1")
.relocate("com{}google{}code{}gson", "xyz{}jonesdev{}sonar{}libs{}gson")
.build(),
// ORMLite
Library.builder()
.groupId("com{}j256{}ormlite")
.artifactId("ormlite-jdbc")
.version("6.1")
.relocate("com{}j256{}ormlite", "xyz{}jonesdev{}sonar{}libs{}ormlite")
.build(),
// MySQL
Library.builder()
.groupId("com{}mysql")
.artifactId("mysql-connector-j")
.version("8.3.0")
.build()
);
}
}
@@ -17,6 +17,7 @@
package xyz.jonesdev.sonar.common.boot;
import com.alessiodp.libby.LibraryManager;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@@ -31,9 +32,9 @@ import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.controller.VerifiedPlayerController;
import xyz.jonesdev.sonar.api.fallback.FallbackRatelimiter;
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.ServiceRepository;
import xyz.jonesdev.sonar.common.subcommand.SubcommandRegistryHolder;
import xyz.jonesdev.sonar.common.update.UpdateChecker;
@@ -47,24 +48,32 @@ public abstract class SonarBootstrap<T> implements Sonar {
private T plugin;
@Setter
private Verbose verboseHandler;
@Setter
private Notification notificationHandler;
private SonarConfiguration config;
private VerifiedPlayerController verifiedPlayerController;
private File dataDirectory;
private final LibraryManager libraryManager;
private final SonarPlatform platform;
private final SubcommandRegistry subcommandRegistry;
private final SystemTimer launchTimer = new SystemTimer();
public SonarBootstrap(final @NotNull T plugin,
final LibraryManager libraryManager,
final File dataDirectory,
final SonarPlatform platform) {
// Set the Sonar API
SonarSupplier.set(this);
// Set the plugin instance before anything else
// 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;
this.verboseHandler = new Verbose();
this.notificationHandler = new Notification();
this.config = new SonarConfiguration(dataDirectory);
this.subcommandRegistry = new SubcommandRegistryHolder();
}
@@ -86,9 +95,6 @@ public abstract class SonarBootstrap<T> implements Sonar {
getLogger().info("Successfully initialized components in {}s!", launchTimer);
getLogger().info("Enabling all tasks and features...");
// Start all service threads
ServiceRepository.register();
try {
// Run the per-platform initialization method
enable();
@@ -134,8 +140,7 @@ public abstract class SonarBootstrap<T> implements Sonar {
public void shutdown() {
getLogger().info("Starting shutdown process...");
// Shut down service threads
ServiceRepository.shutdown();
// ...
getLogger().info("Successfully shut down. Goodbye!");
}
@@ -1,59 +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.service;
import lombok.experimental.UtilityClass;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@UtilityClass
public class ServiceRepository {
private final ScheduledExecutorService QUEUE_SERVICE = Executors.newSingleThreadScheduledExecutor();
private final ScheduledExecutorService TRAFFIC_SERVICE = Executors.newSingleThreadScheduledExecutor();
private final ScheduledExecutorService VERBOSE_SERVICE = Executors.newSingleThreadScheduledExecutor();
private boolean registered;
public void register() {
if (registered) {
throw new IllegalStateException("Already registered");
}
registered = true;
TRAFFIC_SERVICE.scheduleAtFixedRate(TrafficCounter::reset,
1L, 1L, TimeUnit.SECONDS);
QUEUE_SERVICE.scheduleAtFixedRate(Sonar.get().getFallback().getQueue()::poll,
500L, 500L, TimeUnit.MILLISECONDS);
VERBOSE_SERVICE.scheduleAtFixedRate(Sonar.get().getVerboseHandler()::update,
200L, 200L, TimeUnit.MILLISECONDS);
}
public synchronized void shutdown() {
if (!registered) {
throw new IllegalStateException("Not registered");
}
registered = false;
QUEUE_SERVICE.shutdown();
TRAFFIC_SERVICE.shutdown();
VERBOSE_SERVICE.shutdown();
}
}
@@ -28,7 +28,8 @@ public final class SubcommandRegistryHolder implements SubcommandRegistry {
new StatisticsCommand(),
new VerboseCommand(),
new ReloadCommand(),
new DumpCommand()
new DumpCommand(),
new NotifyCommand()
);
}
}
@@ -0,0 +1,43 @@
/*
* 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.subcommand.impl;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.command.CommandInvocation;
import xyz.jonesdev.sonar.api.command.subcommand.Subcommand;
import xyz.jonesdev.sonar.api.command.subcommand.SubcommandInfo;
@SubcommandInfo(
name = "notify",
description = "Enable or disable attack notifications",
onlyPlayers = true
)
public final class NotifyCommand extends Subcommand {
@Override
protected void execute(final @NotNull CommandInvocation invocation) {
if (SONAR.getNotificationHandler().isSubscribed(invocation.getSender().getName())) {
SONAR.getNotificationHandler().unsubscribe(invocation.getSender().getName());
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getNotificationsUnsubscribed());
return;
}
invocation.getSender().sendMessage(SONAR.getConfig().getCommands().getNotificationsSubscribed());
SONAR.getNotificationHandler().subscribe(invocation.getSender().getName());
}
}
@@ -18,23 +18,17 @@
package xyz.jonesdev.sonar.common.utility.geyser;
import io.netty.channel.Channel;
import io.netty.util.AttributeKey;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import java.net.InetSocketAddress;
/**
* Simple utility to determine if someone joins using GeyserMC
*/
@UtilityClass
public class GeyserUtil {
/*
* Geyser attribute key for every floodgate player
*/
private AttributeKey<Object> playerAttribute() {
return AttributeKey.valueOf("floodgate-player");
}
/**
* @param channel Channel of the player
* @return Whether the player is on GeyserMC or not
@@ -42,6 +36,9 @@ public class GeyserUtil {
public boolean isGeyserConnection(final @NotNull Channel channel) {
// https://discord.com/channels/613163671870242838/613170125696270357/1168599123889504266
// Every floodgate player has a channel attribute called "floodgate-player"
return channel.attr(playerAttribute()).get() != null;
// However, this is not safe to use as even Java players sometimes have this attribute.
// Instead, we should check if the port is 0, since TCP didn't resolve a port (UDP)
// Thanks to @dbruni on Discord who reported it in ticket-0072
return ((InetSocketAddress) channel.remoteAddress()).getPort() == 0;
}
}
+3
View File
@@ -9,6 +9,9 @@ dependencies {
// Implement bStats.org for metrics
implementation("org.bstats:bstats-velocity:3.0.2")
// Library/dependency loading
implementation("com.alessiodp.libby:libby-velocity:2.0.0-SNAPSHOT")
}
tasks {
@@ -17,24 +17,28 @@
package xyz.jonesdev.sonar.velocity;
import com.velocitypowered.proxy.VelocityServer;
import com.alessiodp.libby.VelocityLibraryManager;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.fallback.traffic.TrafficCounter;
import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
import xyz.jonesdev.sonar.velocity.audience.AudienceListener;
import xyz.jonesdev.sonar.velocity.command.VelocitySonarCommand;
import xyz.jonesdev.sonar.velocity.fallback.FallbackListener;
import xyz.jonesdev.sonar.velocity.fallback.injection.BaseChannelInitializer;
import xyz.jonesdev.sonar.velocity.fallback.injection.BaseInjectionHelper;
import java.util.concurrent.TimeUnit;
@Getter
public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
public static SonarVelocity INSTANCE;
public SonarVelocity(final @NotNull SonarVelocityPlugin plugin) {
super(plugin, plugin.getDataDirectory().toFile(), SonarPlatform.VELOCITY);
super(plugin, new VelocityLibraryManager<>(
plugin, plugin.getLogger(), plugin.getDataDirectory(), plugin.getServer().getPluginManager()),
plugin.getDataDirectory().toFile(), SonarPlatform.VELOCITY);
INSTANCE = this;
}
@@ -77,8 +81,19 @@ public final class SonarVelocity extends SonarBootstrap<SonarVelocityPlugin> {
// Register audience register listener
getPlugin().getServer().getEventManager().register(getPlugin(), new AudienceListener());
// Inject new channel initializer
final VelocityServer server = (VelocityServer) getPlugin().getServer();
BaseInjectionHelper.inject(server, new BaseChannelInitializer(server));
// Register traffic service
getPlugin().getServer().getScheduler().buildTask(getPlugin(), TrafficCounter::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();
}
}
@@ -28,11 +28,11 @@ public final class AudienceListener {
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull PostLoginEvent event) {
Sonar.get().getVerboseHandler().getAudiences().put(event.getPlayer().getUsername(), event.getPlayer());
Sonar.AUDIENCES.put(event.getPlayer().getUsername(), event.getPlayer());
}
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull DisconnectEvent event) {
Sonar.get().getVerboseHandler().getAudiences().remove(event.getPlayer().getUsername());
Sonar.AUDIENCES.remove(event.getPlayer().getUsername());
}
}
@@ -0,0 +1,248 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.fallback;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.*;
import io.netty.util.Attribute;
import io.netty.util.AttributeKey;
import org.jetbrains.annotations.NotNull;
import java.net.SocketAddress;
public final class FallbackClosedConnection extends MinecraftConnection {
public FallbackClosedConnection() {
super(new Channel() {
@Override
public ChannelId id() {
return null;
}
@Override
public EventLoop eventLoop() {
return null;
}
@Override
public Channel parent() {
return null;
}
@Override
public ChannelConfig config() {
return null;
}
@Override
public boolean isOpen() {
return false;
}
@Override
public boolean isRegistered() {
return false;
}
@Override
public boolean isActive() {
return false;
}
@Override
public ChannelMetadata metadata() {
return null;
}
@Override
public SocketAddress localAddress() {
return null;
}
@Override
public SocketAddress remoteAddress() {
return null;
}
@Override
public ChannelFuture closeFuture() {
return null;
}
@Override
public boolean isWritable() {
return false;
}
@Override
public long bytesBeforeUnwritable() {
return 0;
}
@Override
public long bytesBeforeWritable() {
return 0;
}
@Override
public Unsafe unsafe() {
return null;
}
@Override
public ChannelPipeline pipeline() {
return null;
}
@Override
public ByteBufAllocator alloc() {
return null;
}
@Override
public Channel read() {
return null;
}
@Override
public Channel flush() {
return null;
}
@Override
public ChannelFuture bind(SocketAddress socketAddress) {
return null;
}
@Override
public ChannelFuture connect(SocketAddress socketAddress) {
return null;
}
@Override
public ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1) {
return null;
}
@Override
public ChannelFuture disconnect() {
return null;
}
@Override
public ChannelFuture close() {
return null;
}
@Override
public ChannelFuture deregister() {
return null;
}
@Override
public ChannelFuture bind(SocketAddress socketAddress, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture connect(SocketAddress socketAddress, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture connect(SocketAddress socketAddress, SocketAddress socketAddress1, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture disconnect(ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture close(ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture deregister(ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture write(Object o) {
return null;
}
@Override
public ChannelFuture write(Object o, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture writeAndFlush(Object o, ChannelPromise channelPromise) {
return null;
}
@Override
public ChannelFuture writeAndFlush(Object o) {
return null;
}
@Override
public ChannelPromise newPromise() {
return null;
}
@Override
public ChannelProgressivePromise newProgressivePromise() {
return null;
}
@Override
public ChannelFuture newSucceededFuture() {
return null;
}
@Override
public ChannelFuture newFailedFuture(Throwable throwable) {
return null;
}
@Override
public ChannelPromise voidPromise() {
return null;
}
@Override
public <T> Attribute<T> attr(AttributeKey<T> attributeKey) {
return null;
}
@Override
public <T> boolean hasAttr(AttributeKey<T> attributeKey) {
return false;
}
@Override
public int compareTo(@NotNull Channel o) {
return 0;
}
}, null);
}
}
@@ -19,32 +19,290 @@ package xyz.jonesdev.sonar.velocity.fallback;
import com.velocitypowered.api.event.PostOrder;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.ConnectionHandshakeEvent;
import com.velocitypowered.api.event.connection.LoginEvent;
import com.velocitypowered.api.event.connection.PreLoginEvent;
import com.velocitypowered.api.util.GameProfile;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import com.velocitypowered.proxy.connection.MinecraftSessionHandler;
import com.velocitypowered.proxy.connection.client.ConnectedPlayer;
import com.velocitypowered.proxy.connection.client.InitialInboundConnection;
import com.velocitypowered.proxy.connection.client.InitialLoginSessionHandler;
import com.velocitypowered.proxy.connection.client.LoginInboundConnection;
import com.velocitypowered.proxy.protocol.packet.DisconnectPacket;
import io.netty.channel.Channel;
import io.netty.channel.ChannelPipeline;
import lombok.val;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifyJoinEvent;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.statistics.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackVerificationHandler;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketEncoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccess;
import xyz.jonesdev.sonar.common.fallback.traffic.TrafficChannelHooker;
import xyz.jonesdev.sonar.velocity.SonarVelocity;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import static com.velocitypowered.proxy.network.Connections.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.*;
import static xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil.isGeyserConnection;
public final class FallbackListener {
private static final Fallback FALLBACK = Sonar.get().getFallback();
private static final FallbackClosedConnection CLOSED_CONNECTION;
private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
private static final MethodHandle INITIAL_CONNECTION;
private static final Field CONNECTION_FIELD;
static {
CLOSED_CONNECTION = new FallbackClosedConnection();
try {
CONNECTION_FIELD = InitialLoginSessionHandler.class.getDeclaredField("mcConnection");
CONNECTION_FIELD.setAccessible(true);
INITIAL_CONNECTION = MethodHandles.privateLookupIn(LoginInboundConnection.class, LOOKUP)
.findGetter(LoginInboundConnection.class,
"delegate",
InitialInboundConnection.class);
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
}
private static void markConnectionAsDead(final MinecraftSessionHandler sessionHandler) throws Throwable {
// The AuthSessionHandler isn't supposed to continue the connection process,
// which is why we override the field value for the MinecraftConnection with
// a fake (closed) connection.
CONNECTION_FIELD.set(sessionHandler, CLOSED_CONNECTION);
}
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull PreLoginEvent event) {
final InetAddress inetAddress = event.getConnection().getRemoteAddress().getAddress();
public void handle(final @NotNull ConnectionHandshakeEvent event) throws Throwable {
// Increase connections per second for the action bar verbose
Counters.CONNECTIONS_PER_SECOND.put(System.nanoTime());
}
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull PreLoginEvent event) throws Throwable {
// Increase joins per second for the action bar verbose
Counters.LOGINS_PER_SECOND.put(System.nanoTime());
final LoginInboundConnection inboundConnection = (LoginInboundConnection) event.getConnection();
val initialConnection = (InitialInboundConnection) INITIAL_CONNECTION.invokeExact(inboundConnection);
final MinecraftConnection mcConnection = initialConnection.getConnection();
final Channel channel = mcConnection.getChannel();
// Run in the channel's event loop
channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (mcConnection.isClosed() || mcConnection.isKnownDisconnect()) return;
// Hook the custom traffic pipeline, so we can count the incoming and outgoing traffic
final ChannelPipeline pipeline = channel.pipeline();
TrafficChannelHooker.hook(pipeline, MINECRAFT_DECODER, MINECRAFT_ENCODER);
final InetAddress inetAddress = event.getConnection().getRemoteAddress().getAddress();
// Increase total traffic statistic
Statistics.TOTAL_TRAFFIC.increment();
try {
val activeSessionHandler = mcConnection.getActiveSessionHandler();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklisted().has(inetAddress)) {
// Mark the connection as dead to avoid unnecessary console logs
markConnectionAsDead(activeSessionHandler);
mcConnection.closeWith(DisconnectPacket.create(
Sonar.get().getConfig().getVerification().getBlacklisted(),
mcConnection.getProtocolVersion(), true));
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) return;
// Completely skip Geyser connections
if (isGeyserConnection(channel)) {
FALLBACK.getLogger().info("Skipping Geyser player: {}{}",
event.getUsername(), Sonar.get().getConfig().formatAddress(inetAddress));
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
final int protocolId = mcConnection.getProtocolVersion().getProtocol();
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols().contains(protocolId)) {
// Mark the connection as dead to avoid unnecessary console logs
markConnectionAsDead(activeSessionHandler);
mcConnection.closeWith(DisconnectPacket.create(
Sonar.get().getConfig().getVerification().getProtocolBlacklisted(),
mcConnection.getProtocolVersion(), true));
return;
}
// Check if the player is already verified.
// No one wants to be verified over and over again.
final GameProfile gameProfile = GameProfile.forOfflinePlayer(event.getUsername());
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, gameProfile.getId())) return;
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols().contains(protocolId)) return;
// We now mark the connection as dead by using our fake connection
markConnectionAsDead(activeSessionHandler);
// Don't allow exceptions or disconnect messages
mcConnection.setAssociation(null);
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
mcConnection.closeWith(DisconnectPacket.create(
Sonar.get().getConfig().getVerification().getAlreadyQueued(),
mcConnection.getProtocolVersion(), true));
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(event.getUsername())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
mcConnection.closeWith(DisconnectPacket.create(
Sonar.get().getConfig().getVerification().getAlreadyVerifying(),
mcConnection.getProtocolVersion(), true));
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
mcConnection.closeWith(DisconnectPacket.create(
Sonar.get().getConfig().getVerification().getTooFastReconnect(),
mcConnection.getProtocolVersion(), true));
return;
}
// We have to add this pipeline to monitor whenever the client disconnects
// to remove them from the list of connected and queued players
pipeline.addFirst(FALLBACK_HANDLER, new FallbackChannelHandler(event.getUsername(), inetAddress));
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (mcConnection.isClosed() || mcConnection.isKnownDisconnect()) return;
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex().matcher(event.getUsername()).matches()) {
mcConnection.closeWith(DisconnectPacket.create(Sonar.get().getConfig().getVerification().getInvalidUsername(),
mcConnection.getProtocolVersion(), true));
return;
}
// Add better timeout handler to avoid known exploits or issues
// We also want to timeout bots quickly to avoid flooding
final int readTimeout = Sonar.get().getConfig().getVerification().getReadTimeout();
pipeline.replace(READ_TIMEOUT, READ_TIMEOUT, new FallbackTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS));
// Create an instance for the Fallback connection
final FallbackUserWrapper user = new FallbackUserWrapper(
FALLBACK, mcConnection, mcConnection.getChannel(),
mcConnection.getChannel().pipeline(), inetAddress,
ProtocolVersion.fromId(protocolId));
// Disconnect if the protocol version could not be resolved
if (user.getProtocolVersion().isUnknown()) {
mcConnection.closeWith(DisconnectPacket.create(Sonar.get().getConfig().getVerification().getInvalidProtocol(),
mcConnection.getProtocolVersion(), true));
return;
}
// The player joined the verification
Statistics.REAL_TRAFFIC.increment();
if (Sonar.get().getConfig().getVerification().isLogConnections()) {
// Only log the processing message if the server isn't under attack.
// We let the user override this through the configuration.
if (!Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
FALLBACK.getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", gameProfile.getName())
.replace("%ip%", Sonar.get().getConfig().formatAddress(user.getInetAddress()))
.replace("%protocol%", String.valueOf(user.getProtocolVersion().getProtocol())));
}
}
// Call the VerifyJoinEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyJoinEvent(gameProfile.getName(), user));
// Mark the player as connected → verifying players
FALLBACK.getConnected().put(gameProfile.getName(), inetAddress);
// This sometimes happens when the channel hangs, but the player is still connecting
// This also fixes a unique issue with TCPShield and other reverse proxies
if (user.getPipeline().get(MINECRAFT_ENCODER) == null
|| user.getPipeline().get(MINECRAFT_DECODER) == null) {
mcConnection.close(true);
return;
}
// Replace normal encoder to allow custom packets
final FallbackPacketEncoder encoder = new FallbackPacketEncoder(user.getProtocolVersion());
user.getPipeline().replace(MINECRAFT_ENCODER, FALLBACK_PACKET_ENCODER, encoder);
// Send LoginSuccess packet to make the client think they are joining the server
user.write(new LoginSuccess(gameProfile.getName(), gameProfile.getId()));
// The LoginSuccess packet has been sent, now we can change the registry state
encoder.updateRegistry(user.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
// Replace normal decoder to allow custom packets
user.getPipeline().replace(MINECRAFT_DECODER, FALLBACK_PACKET_DECODER, new FallbackPacketDecoder(user,
new FallbackVerificationHandler(user, gameProfile.getName(), gameProfile.getId())));
}));
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
});
}
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull LoginEvent event) {
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
if (maxOnlinePerIp > 0) {
final ConnectedPlayer connectedPlayer = (ConnectedPlayer) event.getPlayer();
final InetAddress inetAddress = event.getPlayer().getRemoteAddress().getAddress();
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
final long onlinePerIp = SonarVelocity.INSTANCE.getPlugin().getServer().getAllPlayers().stream()
.filter(player -> Objects.equals(player.getRemoteAddress().getAddress(), inetAddress))
.count();
// We use '>=' because the player connecting to the server hasn't joined yet
if (onlinePerIp >= maxOnlinePerIp) {
event.setResult(PreLoginEvent.PreLoginComponentResult.denied(Sonar.get().getConfig().getTooManyOnlinePerIp()));
connectedPlayer.getConnection().closeWith(DisconnectPacket.create(
Sonar.get().getConfig().getTooManyOnlinePerIp(), connectedPlayer.getProtocolVersion(), true));
}
}
}
@@ -1,315 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.fallback;
import com.velocitypowered.api.proxy.crypto.IdentifiedKey;
import com.velocitypowered.api.util.GameProfile;
import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import com.velocitypowered.proxy.crypto.IdentifiedKeyImpl;
import com.velocitypowered.proxy.protocol.packet.Disconnect;
import com.velocitypowered.proxy.protocol.packet.Handshake;
import com.velocitypowered.proxy.protocol.packet.ServerLogin;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.CorruptedFrameException;
import net.kyori.adventure.text.Component;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.event.impl.UserVerifyJoinEvent;
import xyz.jonesdev.sonar.api.fallback.Fallback;
import xyz.jonesdev.sonar.api.fallback.protocol.ProtocolVersion;
import xyz.jonesdev.sonar.api.statistics.Counters;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.common.fallback.FallbackChannelHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
import xyz.jonesdev.sonar.common.fallback.FallbackVerificationHandler;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketDecoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketEncoder;
import xyz.jonesdev.sonar.common.fallback.protocol.FallbackPacketRegistry;
import xyz.jonesdev.sonar.common.fallback.protocol.packets.login.LoginSuccess;
import xyz.jonesdev.sonar.common.fallback.traffic.TrafficChannelHooker;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_1_19;
import static com.velocitypowered.api.network.ProtocolVersion.MINECRAFT_1_19_3;
import static com.velocitypowered.proxy.network.Connections.*;
import static xyz.jonesdev.sonar.api.fallback.FallbackPipelines.*;
import static xyz.jonesdev.sonar.common.utility.geyser.GeyserUtil.isGeyserConnection;
public final class FallbackMinecraftConnection extends MinecraftConnection {
public FallbackMinecraftConnection(final Channel channel, final VelocityServer server) {
super(channel, server);
this.channel = channel;
}
private final Channel channel;
private FallbackUserWrapper user;
private boolean receivedLoginPacket;
private static final @NotNull Fallback FALLBACK = Objects.requireNonNull(Sonar.get().getFallback());
private static final boolean FORCE_SECURE_PROFILES;
static {
FORCE_SECURE_PROFILES = Boolean.getBoolean("auth.forceSecureProfiles");
}
@Override
public void channelActive(final ChannelHandlerContext ctx) throws Exception {
super.channelActive(ctx);
// Increase connections per second for the action bar verbose
Counters.CONNECTIONS_PER_SECOND.put(System.nanoTime());
}
@Override
public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
if (msg instanceof Handshake handshake) {
// Check if the host is invalid
if (handshake.getServerAddress().isEmpty()) {
throw new CorruptedFrameException("Hostname is empty");
}
}
if (msg instanceof ServerLogin login) {
// Increase joins per second for the action bar verbose
Counters.LOGINS_PER_SECOND.put(System.nanoTime());
// Fix login packet spam exploit
if (receivedLoginPacket || user != null) {
throw new CorruptedFrameException("Duplicate login packet");
}
receivedLoginPacket = true;
// Run in the channel's event loop
channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (isClosed() || isKnownDisconnect()) return;
try {
// Hook the custom traffic pipeline, so we can count the incoming and outgoing traffic
final ChannelPipeline pipeline = channel.pipeline();
TrafficChannelHooker.hook(pipeline, MINECRAFT_DECODER, MINECRAFT_ENCODER);
final InetAddress inetAddress = ((InetSocketAddress) getRemoteAddress()).getAddress();
// Increase total traffic statistic
Statistics.TOTAL_TRAFFIC.increment();
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklisted().has(inetAddress)) {
// Mark the connection as dead to avoid unnecessary console logs
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getBlacklisted(),
getProtocolVersion(), false));
return;
}
// Don't continue the verification process if the verification is disabled
if (!Sonar.get().getFallback().shouldVerifyNewPlayers()) {
super.channelRead(ctx, msg);
return;
}
// Completely skip Geyser connections
if (isGeyserConnection(channel)) {
FALLBACK.getLogger().info("Skipping Geyser player: {}{}",
login.getUsername(), Sonar.get().getConfig().formatAddress(inetAddress));
return;
}
// Check if the protocol ID of the player is not allowed to enter the server
final int protocolId = getProtocolVersion().getProtocol();
if (Sonar.get().getConfig().getVerification().getBlacklistedProtocols().contains(protocolId)) {
// Mark the connection as dead to avoid unnecessary console logs
//markConnectionAsDead(activeSessionHandler);
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getProtocolBlacklisted(),
getProtocolVersion(), false));
return;
}
// Check if the player is already verified.
// No one wants to be verified over and over again.
final GameProfile gameProfile = GameProfile.forOfflinePlayer(login.getUsername());
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, gameProfile.getId())) {
super.channelRead(ctx, msg);
return;
}
// Check if the protocol ID of the player is allowed to bypass verification
if (Sonar.get().getConfig().getVerification().getWhitelistedProtocols().contains(protocolId)) {
super.channelRead(ctx, msg);
return;
}
// Don't allow exceptions or disconnect messages
setAssociation(null);
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getAlreadyQueued(),
getProtocolVersion(), false));
return;
}
// Check if Fallback is already verifying a player
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(login.getUsername())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getAlreadyVerifying(),
getProtocolVersion(), false));
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getTooFastReconnect(),
getProtocolVersion(), false));
return;
}
// We have to add this pipeline to monitor whenever the client disconnects
// to remove them from the list of connected and queued players
pipeline.addFirst(FALLBACK_HANDLER, new FallbackChannelHandler(login.getUsername(), inetAddress));
// Queue the connection for further processing
FALLBACK.getQueue().queue(inetAddress, () -> channel.eventLoop().execute(() -> {
// Do not continue if the connection is closed or marked as disconnected
if (isClosed() || isKnownDisconnect()) return;
// Check if the username matches the valid name regex to prevent
// UTF-16 names or other types of exploits
if (!Sonar.get().getConfig().getVerification().getValidNameRegex().matcher(login.getUsername()).matches()) {
closeWith(Disconnect.create(Sonar.get().getConfig().getVerification().getInvalidUsername(),
getProtocolVersion(), false));
return;
}
// Add better timeout handler to avoid known exploits or issues
// We also want to timeout bots quickly to avoid flooding
final int readTimeout = Sonar.get().getConfig().getVerification().getReadTimeout();
pipeline.replace(READ_TIMEOUT, READ_TIMEOUT, new FallbackTimeoutHandler(readTimeout,
TimeUnit.MILLISECONDS));
// Create an instance for the Fallback connection
user = new FallbackUserWrapper(FALLBACK, this, channel, channel.pipeline(),
inetAddress, ProtocolVersion.fromId(protocolId));
// Perform default Velocity checks
final IdentifiedKey playerKey = login.getPlayerKey();
if (playerKey != null) {
if (playerKey.hasExpired()) {
closeWith(Disconnect.create(
Component.translatable("multiplayer.disconnect.invalid_public_key_signature"),
getProtocolVersion(), false));
return;
}
boolean isKeyValid;
if (playerKey.getKeyRevision() == IdentifiedKey.Revision.LINKED_V2 && playerKey instanceof IdentifiedKeyImpl keyImpl) {
isKeyValid = keyImpl.internalAddHolder(login.getHolderUuid());
} else {
isKeyValid = playerKey.isSignatureValid();
}
if (!isKeyValid) {
closeWith(Disconnect.create(
Component.translatable("multiplayer.disconnect.invalid_public_key"),
getProtocolVersion(), false));
return;
}
} else if ((FORCE_SECURE_PROFILES || server.getConfiguration().isForceKeyAuthentication())
&& getProtocolVersion().compareTo(MINECRAFT_1_19) >= 0
&& getProtocolVersion().compareTo(MINECRAFT_1_19_3) < 0) {
closeWith(Disconnect.create(
Component.translatable("multiplayer.disconnect.missing_public_key"),
getProtocolVersion(), false));
return;
}
// Disconnect if the protocol version could not be resolved
if (user.getProtocolVersion().isUnknown()) {
user.disconnect(Sonar.get().getConfig().getVerification().getInvalidProtocol());
return;
}
// Check if the player is already connected to the proxy but still tries to verify
if (server.getPlayer(gameProfile.getId()).isPresent()
|| server.getPlayer(gameProfile.getName()).isPresent()) {
user.disconnect(Sonar.get().getConfig().getVerification().getAlreadyConnected());
return;
}
// The player joined the verification
Statistics.REAL_TRAFFIC.increment();
if (Sonar.get().getConfig().getVerification().isLogConnections()) {
// Only log the processing message if the server isn't under attack.
// We let the user override this through the configuration.
if (!Sonar.get().getAttackTracker().isCurrentlyUnderAttack()
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
FALLBACK.getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", login.getUsername())
.replace("%ip%", Sonar.get().getConfig().formatAddress(user.getInetAddress()))
.replace("%protocol%", String.valueOf(user.getProtocolVersion().getProtocol())));
}
}
// Call the VerifyJoinEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyJoinEvent(gameProfile.getName(), user));
// Mark the player as connected → verifying players
FALLBACK.getConnected().put(gameProfile.getName(), inetAddress);
// This sometimes happens when the channel hangs, but the player is still connecting
// This also fixes a unique issue with TCPShield and other reverse proxies
if (user.getPipeline().get(MINECRAFT_ENCODER) == null
|| user.getPipeline().get(MINECRAFT_DECODER) == null) {
close(true);
return;
}
// Replace normal encoder to allow custom packets
final FallbackPacketEncoder encoder = new FallbackPacketEncoder(user.getProtocolVersion());
user.getPipeline().replace(MINECRAFT_ENCODER, FALLBACK_PACKET_ENCODER, encoder);
// Send LoginSuccess packet to make the client think they are joining the server
user.write(new LoginSuccess(gameProfile.getName(), gameProfile.getId()));
// The LoginSuccess packet has been sent, now we can change the registry state
encoder.updateRegistry(user.getProtocolVersion().compareTo(ProtocolVersion.MINECRAFT_1_20_2) >= 0
? FallbackPacketRegistry.CONFIG : FallbackPacketRegistry.GAME);
// Replace normal decoder to allow custom packets
user.getPipeline().replace(MINECRAFT_DECODER, FALLBACK_PACKET_DECODER, new FallbackPacketDecoder(user,
new FallbackVerificationHandler(user, gameProfile.getName(), gameProfile.getId())));
}));
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
});
return;
}
super.channelRead(ctx, msg);
}
}
@@ -43,18 +43,14 @@ public final class FallbackUserWrapper implements FallbackUser<MinecraftConnecti
private final InetAddress inetAddress;
private final ProtocolVersion protocolVersion;
/**
* Disconnect the player during/after verification
* using our custom {@link Disconnect} packet.
*
* @param reason Disconnect message component
*/
@Override
public void disconnect(final @NotNull Component reason) {
connection.closeWith(Disconnect.create(reason));
}
@Override
public void write(final @NotNull Object packet) {
connection.write(packet);
}
@Override
public void delayedWrite(final @NotNull Object packet) {
connection.delayedWrite(packet);
}
}
@@ -1,66 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.fallback.injection;
import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.connection.MinecraftConnection;
import com.velocitypowered.proxy.connection.client.HandshakeSessionHandler;
import com.velocitypowered.proxy.network.Connections;
import com.velocitypowered.proxy.protocol.ProtocolUtils;
import com.velocitypowered.proxy.protocol.StateRegistry;
import com.velocitypowered.proxy.protocol.netty.*;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.handler.codec.haproxy.HAProxyMessageDecoder;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.common.fallback.FallbackTimeoutHandler;
import xyz.jonesdev.sonar.velocity.fallback.FallbackMinecraftConnection;
import java.util.concurrent.TimeUnit;
import static com.velocitypowered.proxy.network.Connections.*;
@RequiredArgsConstructor
public final class BaseChannelInitializer extends ChannelInitializer<Channel> {
private final VelocityServer server;
// Taken from
// https://github.com/PaperMC/Velocity/blob/dev/3.0.0/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java
@Override
protected void initChannel(final @NotNull Channel channel) {
channel.pipeline()
.addLast(LEGACY_PING_DECODER, new LegacyPingDecoder())
.addLast(FRAME_DECODER, new MinecraftVarintFrameDecoder())
.addLast(READ_TIMEOUT,
new FallbackTimeoutHandler(server.getConfiguration().getReadTimeout(), TimeUnit.MILLISECONDS))
.addLast(LEGACY_PING_ENCODER, LegacyPingEncoder.INSTANCE)
.addLast(FRAME_ENCODER, MinecraftVarintLengthEncoder.INSTANCE)
.addLast(MINECRAFT_DECODER, new MinecraftDecoder(ProtocolUtils.Direction.SERVERBOUND))
.addLast(MINECRAFT_ENCODER, new MinecraftEncoder(ProtocolUtils.Direction.CLIENTBOUND));
final MinecraftConnection connection = new FallbackMinecraftConnection(channel, server);
connection.setActiveSessionHandler(StateRegistry.HANDSHAKE,
new HandshakeSessionHandler(connection, server));
channel.pipeline().addLast(Connections.HANDLER, connection);
if (server.getConfiguration().isProxyProtocol()) {
channel.pipeline().addFirst(new HAProxyMessageDecoder());
}
}
}
@@ -1,45 +0,0 @@
/*
* Copyright (C) 2023-2024 Sonar Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.jonesdev.sonar.velocity.fallback.injection;
import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.network.ConnectionManager;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import lombok.experimental.UtilityClass;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import java.lang.reflect.Field;
@UtilityClass
public class BaseInjectionHelper {
@SuppressWarnings("deprecation")
public void inject(final VelocityServer server, final ChannelInitializer<Channel> serverInitializer) {
try {
final Field field = VelocityServer.class.getDeclaredField("cm");
field.setAccessible(true);
final ConnectionManager cm = (ConnectionManager) field.get(server);
cm.serverChannelInitializer.set(serverInitializer);
} catch (Throwable throwable) {
throw new ReflectiveOperationException(throwable);
}
}
}
@@ -8,6 +8,23 @@
"Jones Development",
"Sonar Contributors"
],
"dependencies": [],
"dependencies": [
{
"id": "Geyser-Velocity",
"optional": true
},
{
"id": "floodgate",
"optional": true
},
{
"id": "Protocolize",
"optional": true
},
{
"id": "ViaVersion",
"optional": true
}
],
"main": "$main"
}