Compare commits

..
8 Commits
Author SHA1 Message Date
Jones 5068ebcd91 Release 2.0.7 (#89) 2023-11-01 13:48:08 +01:00
Jones f20d162dcc Fix Geyser exemption not working on the standalone variant (#88)
* Revert "https://github.com/jonesdevelopment/sonar/pull/87/commits/648489b01bafcaf711316228fac6d93eac5eba2c"

* fix: avoid unnecessary performance loss by removing the floodgate check

* fix: move UserVerifyFailedEvent publishing after channel active check

* fix: make UserVerifyFailedEvent reason optional

* fix: use final field for UserVerifyFailedEvent reason

* fix: use channel attribute to detect floodgate players
2023-10-31 15:49:01 +01:00
Jones acc9df0b8d Recode attack detection algorithm to avoid spam (#87)
* feat: use floodgate API to determine if a player is joining through Geyser or not

* fix: normal floodgate players not being detected

* fix: Discord webhook spam

* feat: update value for minimum players for attack

* feat: make minimum attack duration configurable

* feat: implement attack start and stop thresholds

* fix: discord webhook embed description not being reusable

* feat: recode Discord webhook post timing algorithm
2023-10-28 22:37:20 +02:00
Jones 3636674eff Implement Discord webhook notifications (#86)
* fix: possible (unconfirmed) concurrency issues

* fix: don't ignore auto-closeable in UpdateChecker

* feat: [wip] add Discord Webhook support

* feat: recode attack detection system
feat: add AttackDetectedEvent, AttackMitigatedEvent
feat: allow more control over attack detection behavior

* fix: run most of the webhook attack handling async

* fix: reset discord webhook if they are disabled after reload

* fix: don't log too-short attacks

* fix: duration formatting showing milliseconds

* feat: implement webhook embed message footer

* fix: change misleading configuration comment
2023-10-28 15:04:44 +02:00
Jones d2b3b0adae Handle database errors instead of just throwing exceptions (#84)
* fix(db): handle the error instead of simply throwing an exception

* feat(db): revert support for multiple dependencies being loaded at once
2023-10-28 12:02:35 +02:00
Jones 194c7645ed Cache BungeeCord kick packets during verification (#82) 2023-10-28 01:32:46 +02:00
Jones 860594400a Implement max-online-per-ip check on BungeeCord (#81)
* feat(bungee): implement missing max online per ip check

* fix(bungee): use correct component serialization for the lockdown notification message

* fix(bungee): use correct disconnect message for max online per ip check

* fix(bungee): add 1 to current online per ip count because the player joining isn't counted

* fix(bungee): use adventure audience for sending the message
2023-10-28 01:20:25 +02:00
Jones 20f5b135aa Fix lockdown notification not working as intended (#80)
* fix: lockdown notification kicking administrators

* fix: lockdown notification not being deserialized properly
2023-10-28 01:04:01 +02:00
21 changed files with 648 additions and 116 deletions

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
group=xyz.jonesdev.sonar
version=2.0.6
version=2.0.7
description=Effective Anti-bot plugin for Velocity, BungeeCord, and Bukkit (1.7-latest)
@@ -17,7 +17,10 @@
package xyz.jonesdev.sonar.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.attack.AttackStatus;
import xyz.jonesdev.sonar.api.command.subcommand.SubcommandRegistry;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.controller.VerifiedPlayerController;
@@ -33,6 +36,10 @@ public interface Sonar {
DecimalFormat DECIMAL_FORMAT = new DecimalFormat("#,###.##");
String LINE_SEPARATOR = "\n"; // Using System.lineSeparator is broken, for some reason...
Gson GENERAL_GSON = new GsonBuilder()
.disableInnerClassSerialization()
.create();
/**
* @return The platform the plugin is being run on
*/
@@ -75,6 +82,12 @@ public interface Sonar {
return SonarEventManager.INSTANCE;
}
@NotNull
default AttackStatus getAttackStatus() {
// We don't want anyone else to create a new instance.
return AttackStatus.INSTANCE;
}
@NotNull
default Fallback getFallback() {
// We don't want anyone else to create a new instance.
@@ -0,0 +1,137 @@
/*
* Copyright (C) 2023 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.attack;
import lombok.*;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import xyz.jonesdev.sonar.api.event.impl.AttackDetectedEvent;
import xyz.jonesdev.sonar.api.event.impl.AttackMitigatedEvent;
import xyz.jonesdev.sonar.api.profiler.JVMProfiler;
import xyz.jonesdev.sonar.api.statistics.Statistics;
import xyz.jonesdev.sonar.api.timer.SystemTimer;
import java.util.Optional;
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class AttackStatus implements JVMProfiler {
public static final AttackStatus INSTANCE = new AttackStatus();
private @Nullable AttackStatistics currentAttack;
private int attackThreshold;
@Getter
@ToString
@RequiredArgsConstructor
public static final class AttackStatistics {
private final SystemTimer duration = new SystemTimer();
private final SystemTimer timer = new SystemTimer();
private int peakJoinsPerSecond;
private double peakProcessCPUUsage;
private long peakProcessMemoryUsage;
}
public void checkIfUnderAttack() {
final int joinsPerSecond = Sonar.get().getVerboseHandler().getJoinsPerSecond().estimatedSize();
final int verifyingPlayers = Sonar.get().getFallback().getConnected().size();
final int queuedPlayers = Sonar.get().getFallback().getQueue().getQueuedPlayers().size();
final int minPlayers = Sonar.get().getConfig().getMinPlayersForAttack();
if (joinsPerSecond > minPlayers // check the number of bots/joins per second
|| verifyingPlayers > minPlayers // check the number of verifying players
|| queuedPlayers > minPlayers) { // check the number of queued players
// Increment attack threshold
++attackThreshold;
// An attack has been detected
if (currentAttack == null) {
currentAttack = new AttackStatistics();
Sonar.get().getEventManager().publish(new AttackDetectedEvent());
} else {
// Reset attack timer if we're still under attack
currentAttack.timer.reset();
}
// Update attack statistics
if (joinsPerSecond > currentAttack.peakJoinsPerSecond) {
currentAttack.peakJoinsPerSecond = joinsPerSecond;
}
final double processCPUUsage = getProcessCPUUsage();
if (processCPUUsage > currentAttack.peakProcessCPUUsage) {
currentAttack.peakProcessCPUUsage = processCPUUsage;
}
final long processMemoryUsage = getUsedMemory();
if (processMemoryUsage > currentAttack.peakProcessMemoryUsage) {
currentAttack.peakProcessMemoryUsage = processMemoryUsage;
}
} else if (currentAttack != null) {
if (currentAttack.duration.delay() > Sonar.get().getConfig().getMinAttackDuration()
&& currentAttack.timer.delay() > Sonar.get().getConfig().getAttackCooldownDelay()) {
// An attack has stopped
Sonar.get().getEventManager().publish(new AttackMitigatedEvent(currentAttack));
if (++attackThreshold > Sonar.get().getConfig().getMinAttackThreshold()) {
// Save attack statistics
final long deltaInMillis = currentAttack.duration.delay();
final String peakCPU = Sonar.DECIMAL_FORMAT.format(currentAttack.peakProcessCPUUsage);
final String peakMem = formatMemory(currentAttack.peakProcessMemoryUsage);
final String peakBPS = Sonar.DECIMAL_FORMAT.format(currentAttack.peakJoinsPerSecond);
final long minutes = deltaInMillis / (60 * 1000); // Convert milliseconds to minutes
final double seconds = (deltaInMillis % (60 * 1000)) / 1000D; // Convert remaining milliseconds to seconds
final String formattedDuration = String.format("%d minutes, %.0f seconds", minutes, seconds);
final String startTimestamp = String.valueOf(currentAttack.duration.getStart() / 1000L);
final String endTimestamp = String.valueOf(System.currentTimeMillis() / 1000L);
// Post webhook to Discord
Optional.ofNullable(Sonar.get().getConfig().getDiscordWebhook()).ifPresent(webhook -> webhook.post(() -> {
final SonarConfiguration.Webhook.Embed config = Sonar.get().getConfig().getWebhook().getEmbed();
final String description = config.getDescription()
.replace("%start-timestamp%", startTimestamp)
.replace("%end-timestamp%", endTimestamp)
.replace("%duration%", formattedDuration)
.replace("%peak-cpu%", peakCPU)
.replace("%peak-memory%", peakMem)
.replace("%peak-bps%", peakBPS)
.replace("%total-blacklisted%",
Sonar.DECIMAL_FORMAT.format(Sonar.get().getFallback().getBlacklisted().estimatedSize()))
.replace("%total-failed%", Sonar.DECIMAL_FORMAT.format(Statistics.FAILED_VERIFICATIONS.get()))
.replace("%total-success%",
Sonar.DECIMAL_FORMAT.format(Sonar.get().getVerifiedPlayerController().estimatedSize()));
return new SonarConfiguration.Webhook.Embed(
config.getTitle(), config.getTitleUrl(), description, config.getR(), config.getG(), config.getB());
}));
}
// Reset the attack status
currentAttack = null;
// Reset threshold
attackThreshold = 0;
}
} else {
// Reset threshold
attackThreshold = 0;
}
}
@SuppressWarnings("all")
public boolean isCurrentlyUnderAttack() {
final int minDelay = Sonar.get().getConfig().getAttackCooldownDelay();
return currentAttack != null && currentAttack.timer.delay() < minDelay;
}
}
@@ -21,9 +21,11 @@ import lombok.*;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
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.io.File;
import java.net.InetAddress;
@@ -52,6 +54,8 @@ public final class SonarConfiguration {
private final Lockdown lockdown = new Lockdown();
@Getter
private final Database database = new Database();
@Getter
private final Webhook webhook = new Webhook();
public SonarConfiguration(final @NotNull File pluginFolder) {
this.pluginFolder = pluginFolder;
@@ -69,6 +73,12 @@ public final class SonarConfiguration {
@Getter
private int minPlayersForAttack;
@Getter
private int minAttackDuration;
@Getter
private int minAttackThreshold;
@Getter
private int attackCooldownDelay;
@Getter
private Component tooManyOnlinePerIp;
@Getter
@@ -175,11 +185,10 @@ public final class SonarConfiguration {
@Getter
@RequiredArgsConstructor
public enum Type {
MYSQL(new Dependency[]{Dependency.MYSQL}, "com.mysql.cj.jdbc.NonRegisteringDriver"),
MARIADB(new Dependency[]{Dependency.MYSQL, Dependency.MARIADB}, "org.mariadb.jdbc.Driver"),
MYSQL(Dependency.MYSQL, "com.mysql.cj.jdbc.NonRegisteringDriver"),
NONE(null, null);
private final Dependency[] dependencies;
private final Dependency dependency;
private final String driverClassName;
}
@@ -206,6 +215,40 @@ public final class SonarConfiguration {
private String consoleLog;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Webhook {
private String url;
private String username;
private String avatarUrl;
private String content;
private final Footer footer = new Footer();
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static final class Footer {
private String text;
private String iconUrl;
}
private final Embed embed = new Embed();
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static final class Embed {
private String title;
private String titleUrl;
@Setter
private String description;
private int r, g, b;
}
}
@Getter
private @Nullable DiscordWebhook discordWebhook;
public void load() {
if (generalConfig == null) {
generalConfig = new SimpleYamlConfig(pluginFolder, "config");
@@ -221,23 +264,32 @@ public final class SonarConfiguration {
// General options
generalConfig.getYaml().setComment("language",
"Name of the language file Sonar should use for messages"
);
"Name of the language file Sonar should use for messages");
String language = generalConfig.getString("language", "en");
generalConfig.getYaml().setComment("max-online-per-ip",
"Maximum number of players online with the same IP address"
);
"Maximum number of players online with the same IP address");
maxOnlinePerIp = clamp(generalConfig.getInt("max-online-per-ip", 3), 1, Byte.MAX_VALUE);
generalConfig.getYaml().setComment("min-players-for-attack",
"Minimum number of new players in order for an attack to be detected"
);
minPlayersForAttack = clamp(generalConfig.getInt("min-players-for-attack", 5), 2, 1024);
"Minimum number of new players in order for an attack to be detected");
minPlayersForAttack = clamp(generalConfig.getInt("min-players-for-attack", 8), 2, 1024);
generalConfig.getYaml().setComment("min-attack-duration",
"Amount of time (in milliseconds) that has to pass in order for an attack to be over");
minAttackDuration = clamp(generalConfig.getInt("min-attack-duration", 30000), 1000, 900000);
generalConfig.getYaml().setComment("min-attack-threshold",
"Number of times an incident has to be reported in order to be acknowledged as an attack"
+ LINE_SEPARATOR + "This number acts as a buffer to filter out false attack notifications");
minAttackThreshold = clamp(generalConfig.getInt("min-attack-threshold", 2), 0, 20);
generalConfig.getYaml().setComment("attack-cooldown-delay",
"Amount of time (in milliseconds) that has to pass in order for a new attack to be detected");
attackCooldownDelay = clamp(generalConfig.getInt("attack-cooldown-delay", 3000), 100, 30000);
generalConfig.getYaml().setComment("log-player-addresses",
"Should Sonar log players' IP addresses in console?"
);
"Should Sonar log players' IP addresses in console?");
logPlayerAddresses = generalConfig.getBoolean("log-player-addresses", true);
// Message settings
@@ -266,8 +318,7 @@ public final class SonarConfiguration {
// Database
generalConfig.getYaml().setComment("database.type",
"Type of database Sonar uses to store verified players"
+ LINE_SEPARATOR + "Possible types: NONE, MYSQL, MARIADB (experimental)"
);
+ LINE_SEPARATOR + "Possible types: NONE, MYSQL");
final String newDatabaseType = generalConfig.getString("database.type", Database.Type.NONE.name());
database.type = Database.Type.valueOf(newDatabaseType.toUpperCase());
@@ -415,6 +466,86 @@ public final class SonarConfiguration {
"Minimum number of rejoin delay during verification");
verification.reconnectDelay = clamp(generalConfig.getInt("verification.rejoin-delay", 8000), 0, 100000);
// Webhooks
generalConfig.getYaml().setComment("webhook",
"Bot attack notifications can also be sent to your Discord server using webhooks");
generalConfig.getYaml().setComment("webhook.url",
"URL of the Discord webhook (Set this to '' to disable webhooks)");
webhook.url = generalConfig.getString("webhook.url", "");
generalConfig.getYaml().setComment("webhook.username",
"Username of the Discord webhook sender");
webhook.username = generalConfig.getString("webhook.username", "Sonar");
generalConfig.getYaml().setComment("webhook.avatar-url",
"URL to the avatar of the Discord webhook sender (Set this to '' to disable)");
webhook.avatarUrl = generalConfig.getString("webhook.avatar-url", "");
generalConfig.getYaml().setComment("webhook.content",
"Content of the Discord webhook message (Set this to '' to disable)"
+ LINE_SEPARATOR + "You can use this to e.g. ping staff members using <@userId>"
+ LINE_SEPARATOR + "If you want to ping roles, you will need to use <@&roleId>");
webhook.content = generalConfig.getString("webhook.content", "");
generalConfig.getYaml().setComment("webhook.embed.footer",
"Small footer message of the Discord webhook embed");
generalConfig.getYaml().setComment("webhook.embed.footer.text",
"Content of the footer message of the Discord webhook embed");
webhook.footer.text = generalConfig.getString("webhook.embed.footer.text",
"© Jones Development and Sonar Contributors");
generalConfig.getYaml().setComment("webhook.embed.footer.icon-url",
"URL of the footer message icon of the Discord webhook embed");
webhook.footer.iconUrl = generalConfig.getString("webhook.embed.footer.icon-url", "");
final String realEmbedPath = "webhook.embed";
final String embedPath = realEmbedPath + ".";
generalConfig.getYaml().setComment(realEmbedPath,
"Embed Discord webhook message that is sent when an attack has stopped");
generalConfig.getYaml().setComment(embedPath + "title",
"Title of the Discord webhook embed");
webhook.embed.title = generalConfig.getString(embedPath + "title", ":white_check_mark: Attack mitigated");
generalConfig.getYaml().setComment(embedPath + "title-url",
"Clickable URL of the title of the Discord webhook embed");
webhook.embed.titleUrl = generalConfig.getString(embedPath + "title-url", "");
generalConfig.getYaml().setComment(embedPath + "description",
"Description (content) of the Discord webhook embed");
webhook.embed.description = fromList(generalConfig.getStringList(embedPath + "description", Arrays.asList(
"The attack on your server has been mitigated.",
"",
"Attack start: <t:%start-timestamp%:T>",
"Attack end: <t:%end-timestamp%:T>",
"Attack duration: %duration%",
"",
"Peak process CPU usage during the attack: %peak-cpu%%",
"Peak process memory usage during the attack: %peak-memory%",
"Peak bots per second during the attack: %peak-bps%",
"",
"Blacklisted IP addresses during the attack: %total-blacklisted%",
"Failed verifications during the attack: %total-failed%",
"Successful verifications during the attack: %total-success%"
)), LINE_SEPARATOR);
generalConfig.getYaml().setComment(embedPath + "color",
"RGB colors of the Discord webhook embed"
+ LINE_SEPARATOR + "Color picker: https://www.rapidtables.com/web/color/RGB_Color.html");
webhook.embed.r = generalConfig.getInt(embedPath + "color.red", 0);
webhook.embed.g = generalConfig.getInt(embedPath + "color.green", 255);
webhook.embed.b = generalConfig.getInt(embedPath + "color.blue", 0);
if (!webhook.url.isEmpty()) {
if (webhook.username.isEmpty()) {
throw new IllegalStateException("Webhook username cannot be empty");
}
discordWebhook = new DiscordWebhook(webhook.url);
} else if (discordWebhook != null) {
// Reset if Discord webhooks were disabled
discordWebhook = null;
}
// load this here otherwise it could cause issues
messagesConfig.getYaml().setComment("header",
"Placeholder for every '%header%' in this configuration file");
@@ -885,7 +1016,11 @@ public final class SonarConfiguration {
}
private @NotNull String fromList(final @NotNull Collection<String> list) {
return formatString(String.join("<newline>", list));
return fromList(list, "<newline>");
}
private @NotNull String fromList(final @NotNull Collection<String> list, final String newline) {
return formatString(String.join(newline, list));
}
private static @NotNull Component deserialize(final String legacy) {
@@ -65,6 +65,8 @@ public final class VerifiedPlayerController {
TableUtils.createTableIfNotExists(connectionSource, VerifiedPlayer.class);
} catch (SQLException exception) {
// Duplicate index
// I know this isn't the best method of handling it,
// but I don't know how else I could address this issue.
}
dao = DaoManager.createDao(connectionSource, VerifiedPlayer.class);
@@ -92,7 +94,12 @@ public final class VerifiedPlayerController {
}
DB_UPDATE_SERVICE.execute(() -> {
Objects.requireNonNull(connectionSource);
// 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) {
Sonar.get().getLogger().error("Error (DB_UPDATE_SERVICE): connectionSource is null");
return;
}
try {
final List<VerifiedPlayer> verifiedPlayer = queryBuilder.where()
@@ -134,7 +141,12 @@ public final class VerifiedPlayerController {
}
DB_UPDATE_SERVICE.execute(() -> {
Objects.requireNonNull(connectionSource);
// 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) {
Sonar.get().getLogger().error("Error (DB_UPDATE_SERVICE): connectionSource is null");
return;
}
try {
dao.create(player);
@@ -38,8 +38,9 @@ public enum Dependency {
MYSQL(
"com/mysql",
"mysql-connector-j",
"8.1.0"
"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",
@@ -47,10 +48,8 @@ public enum Dependency {
"3.1.4"
);
private @NotNull
final Path tempFilePath;
private @NotNull
final URL mvnRepoURL;
private final Path tempFilePath;
private final URL mvnRepoURL;
Dependency(final @NotNull String groupId,
final @NotNull String artifactId,
@@ -38,23 +38,18 @@ public class DependencyLoader {
public ConnectionSource setUpDriverAndConnect() throws Throwable {
final SonarConfiguration config = Sonar.get().getConfig();
final URL[] urls = new URL[config.getDatabase().getType().getDependencies().length];
for (final Dependency dependency : config.getDatabase().getType().getDependencies()) {
final URL url = dependency.getClassLoaderURL();
final ClassLoader currentClassLoader = DependencyLoader.class.getClassLoader();
final URL url = config.getDatabase().getType().getDependency().getClassLoaderURL();
final ClassLoader currentClassLoader = DependencyLoader.class.getClassLoader();
final Method addPath = currentClassLoader.getClass().getDeclaredMethod("addPath", Path.class);
addPath.setAccessible(true);
addPath.invoke(currentClassLoader, new File(url.toURI()).toPath());
urls[dependency.ordinal()] = url;
}
final Method addPath = currentClassLoader.getClass().getDeclaredMethod("addPath", Path.class);
addPath.setAccessible(true);
addPath.invoke(currentClassLoader, new File(url.toURI()).toPath());
final String type = config.getDatabase().getType().name().toLowerCase();
final String databaseURL = String.format("jdbc:%s://%s:%d/%s",
type, config.getDatabase().getUrl(), config.getDatabase().getPort(), config.getDatabase().getName());
final ExternalClassLoader classLoader = new ExternalClassLoader(urls);
final ExternalClassLoader classLoader = new ExternalClassLoader(new URL[] {url});
final Connection connection = connect(classLoader, databaseURL, config.getDatabase());
return new JdbcSingleConnectionSource(databaseURL, connection);
}
@@ -0,0 +1,23 @@
/*
* Copyright (C) 2023 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.event.impl;
import xyz.jonesdev.sonar.api.event.SonarEvent;
public final class AttackDetectedEvent implements SonarEvent {
}
@@ -0,0 +1,33 @@
/*
* Copyright (C) 2023 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.event.impl;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import xyz.jonesdev.sonar.api.attack.AttackStatus;
import xyz.jonesdev.sonar.api.event.SonarEvent;
@Getter
@ToString
@EqualsAndHashCode
@RequiredArgsConstructor
public final class AttackMitigatedEvent implements SonarEvent {
private final AttackStatus.AttackStatistics attackStatistics;
}
@@ -19,16 +19,23 @@ package xyz.jonesdev.sonar.api.event.impl;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.jetbrains.annotations.Nullable;
import xyz.jonesdev.sonar.api.event.SonarEvent;
import xyz.jonesdev.sonar.api.fallback.FallbackUser;
import java.util.Optional;
@Getter
@ToString
@EqualsAndHashCode
@RequiredArgsConstructor
public final class UserVerifyFailedEvent implements SonarEvent {
private final FallbackUser<?, ?> user;
private final String reason;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
private final Optional<String> reason;
public UserVerifyFailedEvent(final FallbackUser<?, ?> user, final @Nullable String reason) {
this.user = user;
this.reason = Optional.ofNullable(reason);
}
}
@@ -63,9 +63,4 @@ public final class Fallback {
SONAR.getLogger().error("[fallback] " + message, args);
}
};
public boolean isPotentiallyUnderAttack() {
final int min = SONAR.getConfig().getMinPlayersForAttack();
return Sonar.get().getVerboseHandler().getJoinsPerSecond().estimatedSize() > min;
}
}
@@ -50,14 +50,14 @@ public final class FallbackQueue {
}
public void poll() {
final int max = Sonar.get().getConfig().getQueue().getMaxQueuePolls();
final int maxQueuePolls = Sonar.get().getConfig().getQueue().getMaxQueuePolls();
int index = 0;
// We need to be very careful here since we don't want any concurrency issues.
// We need to be cautious here since we don't want any concurrency issues.
final Iterator<Map.Entry<InetAddress, Runnable>> iterator = queuedPlayers.entrySet().iterator();
while (iterator.hasNext()) {
// Break if we reached our maximum entries
if (++index > max) {
if (++index > maxQueuePolls) {
break;
}
// Run the cached runnable
@@ -66,5 +66,8 @@ public final class FallbackQueue {
// Remove runnable from iterator
iterator.remove();
}
// Run the attack check task
Sonar.get().getAttackStatus().checkIfUnderAttack();
}
}
@@ -92,9 +92,6 @@ public interface FallbackUser<X, Y> {
*/
default void fail(final @Nullable String reason) {
if (getChannel().isActive()) {
// Call the VerifyFailedEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyFailedEvent(this, reason));
disconnect(Sonar.get().getConfig().getVerification().getVerificationFailed());
if (reason != null) {
@@ -107,6 +104,9 @@ public interface FallbackUser<X, Y> {
Statistics.FAILED_VERIFICATIONS.increment();
// Call the VerifyFailedEvent for external API usage
Sonar.get().getEventManager().publish(new UserVerifyFailedEvent(this, reason));
// Make sure old entries are removed
PREVIOUS_FAILS.cleanUp();
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2023 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.webhook;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.config.SonarConfiguration;
import javax.net.ssl.HttpsURLConnection;
import java.io.OutputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
@RequiredArgsConstructor
public final class DiscordWebhook {
private final String url;
private static final ExecutorService HTTP_REQUEST_SERVICE = Executors.newSingleThreadExecutor();
public void post(final Supplier<SonarConfiguration.Webhook.Embed> embed) {
HTTP_REQUEST_SERVICE.execute(() -> {
try {
final String serializedContent = prepareSerializedContent(embed.get());
final HttpsURLConnection urlConnection = prepareConnection(new URL(url), serializedContent);
try (final OutputStream outputStream = urlConnection.getOutputStream()) {
outputStream.write(serializedContent.getBytes(StandardCharsets.UTF_8));
}
// Check if the connection was a success
if (urlConnection.getResponseCode() != 204) {
throw new IllegalStateException("Unexpected response code " + urlConnection.getResponseCode());
}
} catch (Exception exception) {
Sonar.get().getLogger().error("Could not send webhook: {}", exception);
}
});
}
private String prepareSerializedContent(final SonarConfiguration.Webhook.@NotNull Embed embed) {
final String content = Sonar.get().getConfig().getWebhook().getContent();
final String username = Sonar.get().getConfig().getWebhook().getUsername();
final String avatarUrl = Sonar.get().getConfig().getWebhook().getAvatarUrl();
int rgb = embed.getR();
rgb = (rgb << 8) + embed.getG();
rgb = (rgb << 8) + embed.getB();
final Webhook.EmbedMessage embedMessage = new Webhook.EmbedMessage(
embed.getTitle(), embed.getDescription(), embed.getTitleUrl(), rgb, new Webhook.Field[0],
new Webhook.Footer(Sonar.get().getConfig().getWebhook().getFooter().getText(),
Sonar.get().getConfig().getWebhook().getFooter().getIconUrl()));
final List<Webhook.EmbedMessage> embeds = Collections.singletonList(embedMessage);
final Webhook webhook = new Webhook(content, username, avatarUrl, false, embeds);
return Sonar.GENERAL_GSON.toJson(webhook);
}
private @NotNull HttpsURLConnection prepareConnection(final @NotNull URL url,
final @NotNull String serializedContent) throws Exception {
final HttpsURLConnection urlConnection = (HttpsURLConnection) url.openConnection();
// Set connection timeout
urlConnection.setConnectTimeout(15000);
// Set necessary properties
urlConnection.setRequestProperty("Content-Type", "application/json");
urlConnection.setRequestProperty("Content-Length", String.valueOf(serializedContent.length()));
urlConnection.setRequestProperty("User-Agent", "Sonar");
urlConnection.setDoOutput(true);
urlConnection.setRequestMethod("POST");
return urlConnection;
}
@SuppressWarnings("unused")
@RequiredArgsConstructor
static final class Webhook {
private final String content, username, avatar_url;
private final boolean tts;
private final List<EmbedMessage> embeds;
@RequiredArgsConstructor
static final class EmbedMessage {
private final String title;
private final String description;
private final String url;
private final int color;
private final Field[] fields;
private final Footer footer;
}
@RequiredArgsConstructor
static final class Field {
private final String name;
private final String value;
private final boolean inline;
}
@RequiredArgsConstructor
static final class Footer {
private final String text;
private final String icon_url;
}
}
}
@@ -54,9 +54,11 @@ 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.*;
@@ -86,11 +88,6 @@ public final class FallbackInitialHandler extends InitialHandler {
super.connected(channelWrapper);
}
private static @NotNull Kick generateKickPacket(final Component component) {
final String serialized = JSONComponentSerializer.json().serialize(component);
return new Kick(serialized);
}
@Override
public void handle(final StatusRequest statusRequest) throws Exception {
// Fix status packet spam exploit
@@ -152,14 +149,14 @@ public final class FallbackInitialHandler extends InitialHandler {
// Check the blacklist here since we cannot let the player "ghost join"
if (FALLBACK.getBlacklisted().has(inetAddress.toString())) {
closeWith(generateKickPacket(Sonar.get().getConfig().getVerification().getBlacklisted()));
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getBlacklisted()));
return;
}
// Completely skip Geyser connections (for now)
// Completely skip Geyser connections
if (isGeyserConnection(channel)) {
// TODO: Do we need to log this?
FALLBACK.getLogger().info("Allowing Geyser connection: {}", inetAddress);
FALLBACK.getLogger().info("Skipping Geyser player: {}{}",
loginRequest.getData(), Sonar.get().getConfig().formatAddress(inetAddress));
super.handle(loginRequest);
return;
}
@@ -191,7 +188,7 @@ public final class FallbackInitialHandler extends InitialHandler {
// Check if the player is already queued since we don't want bots to flood the queue
if (FALLBACK.getQueue().getQueuedPlayers().containsKey(inetAddress)) {
closeWith(generateKickPacket(Sonar.get().getConfig().getVerification().getAlreadyQueued()));
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyQueued()));
return;
}
@@ -199,20 +196,20 @@ public final class FallbackInitialHandler extends InitialHandler {
// → is another player with the same IP address connected to Fallback?
if (FALLBACK.getConnected().containsKey(loginRequest.getData())
|| FALLBACK.getConnected().containsValue(inetAddress)) {
closeWith(generateKickPacket(Sonar.get().getConfig().getVerification().getAlreadyVerifying()));
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyVerifying()));
return;
}
// We cannot allow too many players on our Fallback server
// There's technically no reason for limiting this, but we'll better stay safe.
if (FALLBACK.getConnected().size() > Sonar.get().getConfig().getVerification().getMaxVerifyingPlayers()) {
closeWith(generateKickPacket(Sonar.get().getConfig().getVerification().getTooManyPlayers()));
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getTooManyPlayers()));
return;
}
// Check if the IP address is currently being rate-limited
if (!FALLBACK.getRatelimiter().attempt(inetAddress)) {
closeWith(generateKickPacket(Sonar.get().getConfig().getVerification().getTooFastReconnect()));
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getTooFastReconnect()));
return;
}
@@ -229,7 +226,7 @@ public final class FallbackInitialHandler extends InitialHandler {
// 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(generateKickPacket(Sonar.get().getConfig().getVerification().getInvalidUsername()));
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getInvalidUsername()));
return;
}
@@ -244,7 +241,7 @@ public final class FallbackInitialHandler extends InitialHandler {
// Disconnect if the protocol version could not be resolved
if (player.getProtocolVersion().isUnknown()) {
closeWith(generateKickPacket(Sonar.get().getConfig().getVerification().getInvalidProtocol()));
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getInvalidProtocol()));
return;
}
@@ -254,7 +251,7 @@ public final class FallbackInitialHandler extends InitialHandler {
disconnect(bungee.getTranslation("proxy_full"));
return;
} else if (!isOnlineMode() && bungee.getPlayer(loginRequest.getData()) != null) {
closeWith(generateKickPacket(Sonar.get().getConfig().getVerification().getAlreadyConnected()));
closeWith(getKickPacket(Sonar.get().getConfig().getVerification().getAlreadyConnected()));
return;
}
@@ -264,7 +261,8 @@ public final class FallbackInitialHandler extends InitialHandler {
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 (!FALLBACK.isPotentiallyUnderAttack() || Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
if (!Sonar.get().getAttackStatus().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))
@@ -309,6 +307,18 @@ public final class FallbackInitialHandler extends InitialHandler {
});
}
private static final Map<Component, Kick> CACHED_KICK_PACKETS = new ConcurrentHashMap<>(16);
private 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(serialized);
CACHED_KICK_PACKETS.put(component, cachedKickPacket);
}
return cachedKickPacket;
}
// Mostly taken from Velocity
public void closeWith(final Object msg) {
if (channelWrapper.getHandle().isActive()) {
@@ -18,20 +18,51 @@
package xyz.jonesdev.sonar.bungee.fallback;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.serializer.json.JSONComponentSerializer;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.connection.PendingConnection;
import net.md_5.bungee.api.event.LoginEvent;
import net.md_5.bungee.api.event.PostLoginEvent;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.event.EventHandler;
import net.md_5.bungee.protocol.packet.Kick;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.bungee.SonarBungee;
import java.net.InetAddress;
import java.util.Objects;
@RequiredArgsConstructor
public final class FallbackListener implements Listener {
@EventHandler
@SuppressWarnings("deprecation")
public void handle(final @NotNull LoginEvent event) {
final InetAddress inetAddress = event.getConnection().getAddress().getAddress();
// Check if the number of online players using the same IP address as
// the connecting player is greater than the configured amount
final int maxOnlinePerIp = Sonar.get().getConfig().getMaxOnlinePerIp();
if (maxOnlinePerIp > 0) {
final long onlinePerIp = SonarBungee.INSTANCE.getPlugin().getServer().getPlayers().stream()
.filter(player -> Objects.equals(player.getAddress().getAddress(), inetAddress))
.count()
+ 1 /* add 1 because the player hasn't been added to the list of online players yet */;
// We use '>=' because the player connecting to the server hasn't joined yet
if (onlinePerIp >= maxOnlinePerIp) {
final FallbackInitialHandler fallbackInitialHandler = (FallbackInitialHandler) event.getConnection();
final Component component = Sonar.get().getConfig().getTooManyOnlinePerIp();
final String serialized = JSONComponentSerializer.json().serialize(component);
fallbackInitialHandler.closeWith(new Kick(serialized));
}
}
}
@EventHandler
@SuppressWarnings("deprecation")
public void handle(final @NotNull PostLoginEvent event) {
@@ -60,7 +91,12 @@ public final class FallbackListener implements Listener {
String.valueOf(event.getPlayer().getPendingConnection().getVersion())));
}
} else if (Sonar.get().getConfig().getLockdown().isNotifyAdmins()) {
event.getPlayer().sendMessage(new TextComponent(Sonar.get().getConfig().getLockdown().getNotification()));
final Audience audience = Sonar.get().getVerboseHandler().getAudiences().get(event.getPlayer().getName());
if (audience != null) {
final String notification = Sonar.get().getConfig().getLockdown().getNotification();
final Component deserialized = MiniMessage.miniMessage().deserialize(notification);
audience.sendMessage(deserialized);
}
}
}
}
@@ -90,7 +90,7 @@ public final class FallbackVerificationHandler implements FallbackPacketListener
private void configure() {
// Set the state to CONFIGURE to avoid false positives
state = State.CONFIGURE;
// Send necessary configuration packets to the client
// Send the necessary configuration packets to the client
user.delayedWrite(synchronizeRegistry);
user.delayedWrite(FINISH_CONFIGURATION);
// Send all packets in one flush
@@ -17,12 +17,11 @@
package xyz.jonesdev.sonar.common.subcommand.impl;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import xyz.jonesdev.sonar.api.Sonar;
import xyz.jonesdev.sonar.api.SonarPlatform;
import xyz.jonesdev.sonar.api.command.CommandInvocation;
import xyz.jonesdev.sonar.api.command.subcommand.Subcommand;
@@ -41,13 +40,10 @@ import java.util.WeakHashMap;
onlyConsole = true
)
public final class DumpCommand extends Subcommand implements JVMProfiler {
private static final Gson GSON = new GsonBuilder()
.disableInnerClassSerialization()
.create();
@Override
protected void execute(final @NotNull CommandInvocation invocation) {
final String json = GSON.toJson(collectMappedInformation());
final String json = Sonar.GENERAL_GSON.toJson(collectMappedInformation());
SONAR.getLogger().info("Generated dump: {}", json);
}
@@ -57,7 +57,7 @@ public class UpdateChecker {
try {
final URL url = new URL("https://api.github.com/repos/jonesdevelopment/sonar/releases/latest");
final HttpsURLConnection urlConnection = prepareConnection(url);
final JsonObject json = parseJson(urlConnection.getInputStream());
final JsonObject json = parseJson(urlConnection);
final String latestStableRelease = json.get("tag_name").getAsString();
final int convertedLatestVersion = convertVersion(latestStableRelease);
final int convertedCurrentVersion = convertVersion(Sonar.get().getVersion().getSemanticVersion());
@@ -78,22 +78,25 @@ public class UpdateChecker {
});
}
private @NotNull JsonObject parseJson(final @NotNull InputStream inputStream) throws IOException {
final InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
final StringBuilder content = new StringBuilder();
private @NotNull JsonObject parseJson(final @NotNull HttpsURLConnection urlConnection) throws IOException {
try (final InputStream inputStream = urlConnection.getInputStream()) {
try (final InputStreamReader inputStreamReader = new InputStreamReader(inputStream)) {
final StringBuilder content = new StringBuilder();
// Read the entire page content
try (final BufferedReader bufferedReader = new BufferedReader(inputStreamReader)) {
String line;
// Read the entire page content
try (final BufferedReader bufferedReader = new BufferedReader(inputStreamReader)) {
String line;
while ((line = bufferedReader.readLine()) != null) {
content.append(line);
while ((line = bufferedReader.readLine()) != null) {
content.append(line);
}
}
// Parse site content as JSON using Gson
final JsonParser jsonParser = new JsonParser();
return jsonParser.parse(content.toString()).getAsJsonObject();
}
}
// Parse site content as JSON using Gson
final JsonParser jsonParser = new JsonParser();
return jsonParser.parse(content.toString()).getAsJsonObject();
}
private @NotNull HttpsURLConnection prepareConnection(final @NotNull URL url) throws Exception {
@@ -18,6 +18,7 @@
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;
@@ -27,20 +28,20 @@ import org.jetbrains.annotations.NotNull;
@UtilityClass
public class GeyserUtil {
/*
* Geyser attribute key for every floodgate player
*/
private AttributeKey<Object> playerAttribute() {
return AttributeKey.valueOf("floodgate-player");
}
/**
* Checks if Geyser holds the parent channel of the player
*
* @param channel Channel of the player
* @return Whether the player is on GeyserMC or not
*/
public boolean isGeyserConnection(final @NotNull Channel channel) {
// Get the parent channel of the original channel
final Channel parent = channel.parent();
// This shouldn't happen, but we want to stay safe here
if (parent == null) return false;
final Class<? extends Channel> clazz = parent.getClass();
// Check if Geyser adapted the channel by checking for the package name
return clazz.getCanonicalName().startsWith("org.geysermc");
// https://discord.com/channels/613163671870242838/613170125696270357/1168599123889504266
// Every floodgate player has a channel attribute called "floodgate-player"
return channel.hasAttr(playerAttribute());
}
}
@@ -20,6 +20,7 @@ package xyz.jonesdev.sonar.velocity.fallback;
import com.velocitypowered.api.event.PostOrder;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.LoginEvent;
import com.velocitypowered.api.event.connection.PostLoginEvent;
import com.velocitypowered.api.event.connection.PreLoginEvent;
import com.velocitypowered.api.proxy.crypto.IdentifiedKey;
import com.velocitypowered.api.util.GameProfile;
@@ -36,6 +37,7 @@ import io.netty.channel.ChannelPipeline;
import lombok.RequiredArgsConstructor;
import lombok.val;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.jetbrains.annotations.NotNull;
import xyz.jonesdev.sonar.api.ReflectiveOperationException;
import xyz.jonesdev.sonar.api.Sonar;
@@ -172,10 +174,10 @@ public final class FallbackListener {
final GameProfile gameProfile = GameProfile.forOfflinePlayer(event.getUsername());
if (Sonar.get().getVerifiedPlayerController().has(inetAddress, gameProfile.getId())) return;
// Completely skip Geyser connections (for now)
// Completely skip Geyser connections
if (isGeyserConnection(channel)) {
// TODO: Do we need to log this?
fallback.getLogger().info("Allowing Geyser connection: {}", inetAddress);
fallback.getLogger().info("Skipping Geyser player: {}{}",
gameProfile.getName(), Sonar.get().getConfig().formatAddress(inetAddress));
return;
}
@@ -300,7 +302,8 @@ public final class FallbackListener {
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 (!fallback.isPotentiallyUnderAttack() || Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
if (!Sonar.get().getAttackStatus().isCurrentlyUnderAttack()
|| Sonar.get().getConfig().getVerification().isLogDuringAttack()) {
fallback.getLogger().info(Sonar.get().getConfig().getVerification().getConnectLog()
.replace("%name%", connectedPlayer.getUsername())
.replace("%ip%", Sonar.get().getConfig().formatAddress(fallbackPlayer.getInetAddress()))
@@ -349,23 +352,20 @@ public final class FallbackListener {
public void handle(final @NotNull LoginEvent event) {
val connectedPlayer = (ConnectedPlayer) event.getPlayer();
if (Sonar.get().getConfig().getLockdown().isEnabled()) {
if (!event.getPlayer().hasPermission(Sonar.get().getConfig().getLockdown().getBypassPermission())) {
connectedPlayer.getConnection().closeWith(Disconnect.create(
Sonar.get().getConfig().getLockdown().getDisconnect(), connectedPlayer.getProtocolVersion()));
if (Sonar.get().getConfig().getLockdown().isEnabled()
&& !event.getPlayer().hasPermission(Sonar.get().getConfig().getLockdown().getBypassPermission())) {
connectedPlayer.getConnection().closeWith(Disconnect.create(
Sonar.get().getConfig().getLockdown().getDisconnect(), connectedPlayer.getProtocolVersion()));
if (Sonar.get().getConfig().getLockdown().isLogAttempts()) {
Sonar.get().getLogger().info(Sonar.get().getConfig().getLockdown().getConsoleLog()
.replace("%player%", event.getPlayer().getUsername())
.replace("%ip%", Sonar.get().getConfig()
.formatAddress(event.getPlayer().getRemoteAddress().getAddress()))
.replace("%protocol%",
String.valueOf(event.getPlayer().getProtocolVersion().getProtocol())));
}
return;
} else if (Sonar.get().getConfig().getLockdown().isNotifyAdmins()) {
event.getPlayer().sendMessage(Component.text(Sonar.get().getConfig().getLockdown().getNotification()));
if (Sonar.get().getConfig().getLockdown().isLogAttempts()) {
Sonar.get().getLogger().info(Sonar.get().getConfig().getLockdown().getConsoleLog()
.replace("%player%", event.getPlayer().getUsername())
.replace("%ip%", Sonar.get().getConfig()
.formatAddress(event.getPlayer().getRemoteAddress().getAddress()))
.replace("%protocol%",
String.valueOf(event.getPlayer().getProtocolVersion().getProtocol())));
}
return;
}
final InetAddress inetAddress = event.getPlayer().getRemoteAddress().getAddress();
@@ -387,4 +387,15 @@ public final class FallbackListener {
}
}
}
@Subscribe(order = PostOrder.LAST)
public void handle(final @NotNull PostLoginEvent event) {
if (Sonar.get().getConfig().getLockdown().isEnabled() && Sonar.get().getConfig().getLockdown().isNotifyAdmins()) {
if (event.getPlayer().hasPermission(Sonar.get().getConfig().getLockdown().getBypassPermission())) {
final String notification = Sonar.get().getConfig().getLockdown().getNotification();
final Component deserialized = MiniMessage.miniMessage().deserialize(notification);
event.getPlayer().sendMessage(deserialized);
}
}
}
}