29 Commits
Author SHA1 Message Date
MineTec 7bd1e13660 reworked household and exemption dialogs 2026-10-14 17:16:13 +02:00
MineTec 974c5c5200 confirmed ban evasion cases stay closed 2026-10-14 17:16:13 +02:00
MineTec 66dace7f2a added timeouts to ban evasion lookups 2026-10-14 17:12:24 +02:00
MineTec f4faf763e3 fixed database queries never completing on runtime errors 2026-10-14 17:12:24 +02:00
MineTec ee265a858c made network migration repeatable 2026-10-14 17:06:18 +02:00
MineTec 84c3e7df13 added BanEvasion appliance 2026-10-14 17:04:33 +02:00
MineTec 675d87f6e9 added optional permission for appliance commands 2026-10-14 17:04:06 +02:00
MineTec 86ae261c60 added connection key and recorded event to LoginLog 2026-10-14 17:00:11 +02:00
MineTec 095db7e796 added website endpoint to LoginLog for reporting signup and login IPs 2026-10-14 14:47:59 +02:00
MineTec 490d8b9891 added floodgate uuid check to bedrock utils 2026-10-14 14:47:59 +02:00
MineTec 097222a565 fixed api error responses failing to serialize exceptions 2026-10-14 14:47:59 +02:00
MineTec 6bfb14db2b added LoginLog appliance recording login attempts with IP 2026-10-14 14:38:54 +02:00
MineTec e3de258124 added shared database access and file based migrations to core 2026-10-14 14:38:54 +02:00
MineTec d08fd927dc Merge pull request 'added Anti Player Loot despawn' (#16) from feature/AntiPlayerLootDespawn into master
Reviewed-on: #16
2026-10-14 10:31:28 +00:00
MineTec 067a6f41dc Merge branch 'master' into feature/AntiPlayerLootDespawn 2026-10-14 12:31:17 +02:00
MineTec 5b7910b697 Renamed method AboveMaxAge to isItemAboveMaxAge 2026-10-14 12:31:05 +02:00
jannis 1180af64a1 moved logic from listener to appliance 2026-10-14 01:23:32 +02:00
jannis 73fa6649af fixed issues in AntiPlayerLootDespawn 2026-10-14 00:14:08 +02:00
MineTec 9dbee7d63b Merge pull request 'added New Year Firework' (#15) from feature/NewYearFirework into master
Reviewed-on: #15
2026-10-13 21:58:03 +00:00
MineTec 2c8c4fbf87 Merge branch 'master' into feature/NewYearFirework 2026-10-13 21:57:52 +00:00
jannis 252758797d fixed issues in NewYearFirework and QuarzRecipe 2026-10-13 22:52:42 +02:00
MineTec 46ee734bbc Refactored game rule handling in ProjectStart to use a unified GameRuleSetting record, eliminating redundant methods and improving code clarity. 2026-10-13 22:41:14 +02:00
jannis a1ab06a80d fixed game rules 2026-10-13 22:29:33 +02:00
MineTec 1b79e35bd2 Merge branch 'master' into feature/NewYearFirework 2026-10-13 20:20:51 +00:00
MineTec c3b30e4329 Merge branch 'master' into feature/AntiPlayerLootDespawn 2026-10-13 20:05:19 +00:00
jannis cf3fd2f38f changed old gamerules to new and added quarz recipe 2026-10-13 21:41:00 +02:00
jannis c119913071 added Anti Player Loot despawn 2026-10-08 22:46:05 +02:00
MineTecandClaude Opus 5.5 7d0a28b015 updated to Paper 26.2, Java 25 and Gradle 9.6.1
- paper-api 26.2.build.129-stable (all modules), Java toolchain 25, shadow 9.6.1
- fixed NoSuchFieldError on join/quit/chat: Adventure 5 ClickEvent API
  (ClickEvent.suggestCommand / openUrl instead of ClickEvent.Action constants)
- fixed NBTComponent generics in AntiFormattedBook
- replaced javax.annotation with org.jetbrains.annotations (no longer provided by paper-api)
- local.gradle.example: Gradle 9 compatible (archiveFile, providers.exec)

The previous state is tagged as paper-1.21.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:44:13 +02:00
jannis 62a42e561e added New Year Firework 2026-10-06 20:48:33 +02:00
54 changed files with 2844 additions and 39 deletions

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id 'java'
id 'com.gradleup.shadow' version "8.3.5"
id 'com.gradleup.shadow' version "9.6.1"
}
allprojects {
@@ -29,7 +29,7 @@ subprojects {
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(25)
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
dependencies {
implementation project(':core')
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
@@ -25,7 +25,7 @@ public class ChatMessages extends Appliance {
public Component addReportActions(Component message, String username) {
return message
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesen Spieler zu reporten").color(NamedTextColor.GOLD)))
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.SUGGEST_COMMAND, String.format("/report %s ", username)));
.clickEvent(ClickEvent.suggestCommand(String.format("/report %s ", username)));
}
@Override
@@ -85,7 +85,7 @@ public class Report extends Appliance {
.append(
Component
.text(createdReport.data().url(), NamedTextColor.GRAY)
.clickEvent(ClickEvent.clickEvent(ClickEvent.Action.OPEN_URL, createdReport.data().url()))
.clickEvent(ClickEvent.openUrl(createdReport.data().url()))
)
.appendNewline()
.appendNewline()
@@ -32,7 +32,7 @@ public class AntiFormattedBook extends Appliance {
if (component instanceof TextComponent tc && tc.content().indexOf(SECTION) >= 0) return true;
if (component instanceof NBTComponent<?, ?> nbt) {
if (component instanceof NBTComponent<?> nbt) {
if (nbt.separator() != null && this.hasFormattingDeep(nbt.separator())) return true;
}
@@ -0,0 +1,32 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetAddress;
import java.time.Instant;
import java.util.UUID;
record LoginAttempt(
@NotNull Instant at,
@NotNull UUID uuid,
@NotNull String name,
@NotNull String ip,
boolean bedrock,
@NotNull String stage,
@NotNull String result,
@Nullable String reason
) {
static final String STAGE_PRE_LOGIN = "PRE_LOGIN";
static final String STAGE_LOGIN = "LOGIN";
static final String STAGE_WEBSITE = "WEBSITE";
static final String RESULT_ABORTED = "ABORTED";
static String ipOf(@Nullable InetAddress address) {
return address == null ? "unknown" : address.getHostAddress();
}
LoginAttempt withOutcome(@NotNull String stage, @NotNull String result, @Nullable String reason) {
return new LoginAttempt(this.at, this.uuid, this.name, this.ip, this.bedrock, stage, result, reason);
}
}
@@ -0,0 +1,195 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import com.google.common.net.InetAddresses;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.database.Database;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import eu.mhsl.craftattack.spawn.core.util.network.Networks;
import eu.mhsl.craftattack.spawn.core.util.server.Floodgate;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.event.Listener;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetAddress;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Records every login attempt (allowed or not) with its IP address in the table login_attempts
* (schema: resources/migrations/loginLog). Basis for detecting ban evasion.
* After each insert a LoginAttemptRecordedEvent is fired on the database thread.
* The website reports signups and logins of its users via POST /api/loginlog/website.
* Never blocks or delays a login: writes run on the database thread, failures are only logged.
*/
public class LoginLog extends Appliance {
private static final int MAX_REASON_LENGTH = 255;
private static final Duration PENDING_TIMEOUT = Duration.ofSeconds(60);
private static final long TICKS_PER_MINUTE = 20L * 60;
private final Map<UUID, LoginAttempt> pendingAttempts = new ConcurrentHashMap<>();
private final List<BukkitTask> tasks = new ArrayList<>();
private Database database;
private int retentionMonths;
public LoginLog() {
super("loginLog");
}
@Override
public void onEnable() {
this.database = Main.instance().getDatabase();
if(!this.database.isAvailable()) {
Main.logger().warning("LoginLog: no database configured, login attempts are not recorded");
return;
}
this.retentionMonths = this.localConfig().getInt("retentionMonths", 16);
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::flushStaleAttempts, TICKS_PER_MINUTE, TICKS_PER_MINUTE
));
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::deleteExpired, TICKS_PER_MINUTE, TICKS_PER_MINUTE * 60 * 24
));
}
@Override
public void onDisable() {
this.tasks.forEach(BukkitTask::cancel);
}
void onPreLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) {
if(!this.database.isAvailable()) return;
LoginAttempt attempt = new LoginAttempt(
Instant.now(), uuid, name, LoginAttempt.ipOf(address), Floodgate.isFloodgateUuid(uuid),
LoginAttempt.STAGE_PRE_LOGIN, result, plainText(kickMessage)
);
// allowed attempts are written after the login event, so they carry the final result
if(kickMessage == null) this.pendingAttempts.put(uuid, attempt);
else this.write(attempt);
}
void onLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) {
if(!this.database.isAvailable()) return;
LoginAttempt pending = this.pendingAttempts.remove(uuid);
LoginAttempt attempt = pending != null
? pending
: new LoginAttempt(Instant.now(), uuid, name, LoginAttempt.ipOf(address), Floodgate.isFloodgateUuid(uuid), "", "", null);
this.write(attempt.withOutcome(LoginAttempt.STAGE_LOGIN, result, plainText(kickMessage)));
}
private void flushStaleAttempts() {
Instant threshold = Instant.now().minus(PENDING_TIMEOUT);
this.pendingAttempts.values().removeIf(attempt -> {
if(attempt.at().isAfter(threshold)) return false;
this.write(attempt.withOutcome(LoginAttempt.STAGE_PRE_LOGIN, LoginAttempt.RESULT_ABORTED, null));
return true;
});
}
private void write(LoginAttempt attempt) {
String network = Networks.networkOf(attempt.ip());
String sql = "INSERT INTO login_attempts (at, uuid, name, ip, network, bedrock, stage, result, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
this.database.execute("recording login attempt of " + attempt.name(), connection -> {
long id;
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setObject(1, LocalDateTime.ofInstant(attempt.at(), ZoneOffset.UTC));
statement.setString(2, attempt.uuid().toString());
statement.setString(3, attempt.name());
statement.setString(4, attempt.ip());
statement.setString(5, network);
statement.setBoolean(6, attempt.bedrock());
statement.setString(7, attempt.stage());
statement.setString(8, attempt.result());
statement.setString(9, attempt.reason());
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys()) {
if(!keys.next()) return;
id = keys.getLong(1);
}
}
Bukkit.getPluginManager().callEvent(new LoginAttemptRecordedEvent(new LoginAttemptRecordedEvent.RecordedAttempt(
id, attempt.at(), attempt.uuid(), attempt.name(), attempt.ip(), network, attempt.stage(), attempt.result()
)));
});
}
private void deleteExpired() {
String sql = "DELETE FROM login_attempts WHERE at < UTC_TIMESTAMP() - INTERVAL ? MONTH";
this.database.execute("deleting expired login attempts", connection -> {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, this.retentionMonths);
int deleted = statement.executeUpdate();
if(deleted > 0) Main.logger().info(String.format("LoginLog: deleted %d expired login attempts", deleted));
}
});
}
private record WebsiteEvent(String event, String uuid, String username, String ip) {
}
private void recordWebsiteEvent(WebsiteEvent request) {
if(request == null) throw new IllegalArgumentException("Request body missing");
if(!this.database.isAvailable()) throw new IllegalStateException("No database configured");
String event = request.event() == null ? "" : request.event().trim().toUpperCase(Locale.ROOT);
if(!event.equals("SIGNUP") && !event.equals("LOGIN"))
throw new IllegalArgumentException("Field 'event' must be 'signup' or 'login'");
if(request.uuid() == null || request.uuid().isBlank())
throw new IllegalArgumentException("Field 'uuid' is required");
UUID uuid;
try {
uuid = UUID.fromString(request.uuid().trim());
} catch(IllegalArgumentException e) {
throw new IllegalArgumentException("Field 'uuid' is not a valid UUID");
}
if(request.username() == null || request.username().isBlank())
throw new IllegalArgumentException("Field 'username' is required");
if(request.ip() == null || !InetAddresses.isInetAddress(request.ip().trim()))
throw new IllegalArgumentException("Field 'ip' is not a valid IP address");
this.write(new LoginAttempt(
Instant.now(), uuid, request.username().trim(), request.ip().trim(), Floodgate.isFloodgateUuid(uuid),
LoginAttempt.STAGE_WEBSITE, event, null
));
}
@Override
public void httpApi(HttpServer.ApiBuilder apiBuilder) {
apiBuilder.post("website", WebsiteEvent.class, (request, original) -> {
this.recordWebsiteEvent(request);
return HttpServer.nothing;
});
}
private static @Nullable String plainText(@Nullable Component component) {
if(component == null) return null;
String text = PlainTextComponentSerializer.plainText().serialize(component)
.strip()
.replaceAll("\\s*\\n\\s*", " | ");
return text.length() > MAX_REASON_LENGTH ? text.substring(0, MAX_REASON_LENGTH) : text;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new LoginLogListener());
}
}
@@ -0,0 +1,34 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.event.player.PlayerLoginEvent;
/**
* Listens on MONITOR priority, so the final result of all other checks (whitelist, bans, maintenance, ...) is recorded.
*/
class LoginLogListener extends ApplianceListener<LoginLog> {
@EventHandler(priority = EventPriority.MONITOR)
public void onPreLogin(AsyncPlayerPreLoginEvent event) {
this.getAppliance().onPreLogin(
event.getUniqueId(),
event.getName(),
event.getAddress(),
event.getLoginResult().name(),
event.getLoginResult() == AsyncPlayerPreLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onLogin(PlayerLoginEvent event) {
this.getAppliance().onLogin(
event.getPlayer().getUniqueId(),
event.getPlayer().getName(),
event.getAddress(),
event.getResult().name(),
event.getResult() == PlayerLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
}
@@ -0,0 +1,15 @@
-- Every login attempt (allowed or not) with the player's IP, basis for detecting ban evasion.
CREATE TABLE login_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
at DATETIME(3) NOT NULL COMMENT 'UTC',
uuid CHAR(36) NOT NULL,
name VARCHAR(64) NOT NULL,
ip VARCHAR(45) NOT NULL,
bedrock BOOLEAN NOT NULL,
stage VARCHAR(16) NOT NULL COMMENT 'PRE_LOGIN or LOGIN',
result VARCHAR(32) NOT NULL COMMENT 'Bukkit login result, or ABORTED',
reason VARCHAR(255) NULL COMMENT 'kick message as plain text',
KEY idx_at (at),
KEY idx_uuid (uuid),
KEY idx_ip (ip)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,6 @@
-- Connection key per attempt: the IPv4 address or the IPv6 /64 prefix. Groups accounts by internet connection.
ALTER TABLE login_attempts
ADD COLUMN IF NOT EXISTS network VARCHAR(45) NULL COMMENT 'IPv4 address or IPv6 /64 prefix, NULL if unknown' AFTER ip,
ADD KEY IF NOT EXISTS idx_network_at (network, at);
UPDATE login_attempts SET network = ip WHERE ip REGEXP '^[0-9]{1,3}([.][0-9]{1,3}){3}$';
+1 -1
View File
@@ -1,5 +1,5 @@
dependencies {
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.api.client.RepositoryLoader;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.database.Database;
import org.bukkit.Bukkit;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.java.JavaPlugin;
@@ -23,6 +24,7 @@ public final class Main extends JavaPlugin {
private List<Appliance> appliances;
private RepositoryLoader repositoryLoader;
private Database database;
private Reflections reflections;
@Override
@@ -42,6 +44,8 @@ public final class Main extends JavaPlugin {
Configuration.readConfig();
List<String> disabledAppliances = Configuration.pluginConfig.getStringList("disabledAppliances");
this.database = new Database(Configuration.cfg.getConfigurationSection("database"));
Main.logger().info("Initializing reflections...");
this.reflections = new Reflections(projectPackage);
@@ -94,6 +98,8 @@ public final class Main extends JavaPlugin {
HandlerList.unregisterAll(this);
Bukkit.getScheduler().cancelTasks(this);
Main.logger().info("Disabled " + this.appliances.size() + " appliances!");
if(this.database != null) this.database.close();
}
public Appliance restartAppliance(Class<? extends Appliance> applianceClass) {
@@ -138,6 +144,10 @@ public final class Main extends JavaPlugin {
return this.appliances;
}
public Database getDatabase() {
return this.database;
}
public RepositoryLoader getRepositoryLoader() {
return this.repositoryLoader;
}
@@ -79,7 +79,7 @@ public class HttpServer {
try {
response = new Response(Response.Status.SUCCESS, null, exec.get());
} catch(Exception e) {
response = new Response(Response.Status.FAILURE, e, null);
response = new Response(Response.Status.FAILURE, e.getMessage(), null);
}
return HttpServer.this.gson.toJson(response);
}
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.client.Repository;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.database.Migrations;
import org.bukkit.Bukkit;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.ConfigurationSection;
@@ -107,6 +108,7 @@ public abstract class Appliance {
}
public void enableSequence(JavaPlugin plugin) {
Migrations.apply(Main.instance().getDatabase(), this.migrationsName());
this.onEnable();
this.initialize(plugin);
}
@@ -116,6 +118,14 @@ public abstract class Appliance {
this.destruct(plugin);
}
/**
* Name of the folder containing this appliance's SQL migrations: {@code migrations/<name>/} in the resources.
*/
private String migrationsName() {
String name = this.getClass().getSimpleName();
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
}
protected <T extends Appliance> T queryAppliance(Class<T> clazz) {
return Main.instance().getAppliance(clazz);
}
@@ -129,6 +139,7 @@ public abstract class Appliance {
PluginCommand command = this.createPluginCommand(name, plugin);
command.setExecutor(executor);
command.setTabCompleter(executor);
if(executor.permission() != null) command.setPermission(executor.permission());
plugin.getServer().getCommandMap().register(plugin.getName(), command);
} catch(Exception e) {
throw new RuntimeException(String.format("Failed to register command '%s'", name), e);
@@ -30,6 +30,14 @@ public abstract class ApplianceCommand<T extends Appliance> extends CachedApplia
this.errorMessage = errorMessage;
}
/**
* Permission needed to see and run the command. Paper leaves the command out of the command tree
* of players without it, so for them it does not exist. Null means everyone.
*/
public @Nullable String permission() {
return null;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
try {
@@ -0,0 +1,133 @@
package eu.mhsl.craftattack.spawn.core.database;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.configuration.ConfigurationSection;
import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
/**
* Shared MariaDB access for all appliances. Configured in the "database" section of the config.
* All statements run on a single background thread in submission order, so callers (e.g. login events)
* are never blocked. Without configuration the database is unavailable and all calls are ignored.
*/
public class Database {
@FunctionalInterface
public interface SqlAction {
void run(Connection connection) throws SQLException;
}
@FunctionalInterface
public interface SqlQuery<T> {
T run(Connection connection) throws SQLException;
}
private final @Nullable String jdbcUrl;
private final String user;
private final String password;
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1000),
runnable -> new Thread(runnable, "Spawn-Database"),
(runnable, executor) -> Main.logger().warning("Database queue full, dropping statement")
);
private @Nullable Connection connection;
public Database(@Nullable ConfigurationSection config) {
String host = config == null ? "" : config.getString("host", "");
if(host.isBlank()) {
this.jdbcUrl = null;
this.user = "";
this.password = "";
Main.logger().info("No database configured, database features are disabled");
return;
}
this.jdbcUrl = String.format(
"jdbc:mysql://%s:%d/%s?connectTimeout=3000&socketTimeout=10000&allowMultiQueries=true",
host,
config.getInt("port", 3306),
config.getString("name", "craftattack")
);
this.user = config.getString("user", "");
this.password = config.getString("password", "");
}
public boolean isAvailable() {
return this.jdbcUrl != null;
}
/**
* Runs the action in the background without waiting for it. Errors are logged.
*/
public void execute(String description, SqlAction action) {
if(!this.isAvailable()) return;
this.executor.execute(() -> {
try {
action.run(this.getConnection());
} catch(SQLException e) {
this.handleError(description, e);
}
});
}
/**
* Runs the query in the background. The future fails if the database is unavailable or the query fails.
*/
public <T> CompletableFuture<T> query(SqlQuery<T> query) {
CompletableFuture<T> future = new CompletableFuture<>();
if(!this.isAvailable()) {
future.completeExceptionally(new IllegalStateException("No database configured"));
return future;
}
this.executor.execute(() -> {
try {
future.complete(query.run(this.getConnection()));
} catch(SQLException e) {
this.closeConnection();
future.completeExceptionally(e);
} catch(RuntimeException e) {
future.completeExceptionally(e);
}
});
return future;
}
public void close() {
this.executor.shutdown();
try {
if(!this.executor.awaitTermination(5, TimeUnit.SECONDS))
Main.logger().warning("Database: pending statements were not finished before shutdown");
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
}
this.closeConnection();
}
private Connection getConnection() throws SQLException {
if(this.connection == null || !this.connection.isValid(2))
this.connection = DriverManager.getConnection(this.jdbcUrl, this.user, this.password);
return this.connection;
}
private void closeConnection() {
if(this.connection == null) return;
try {
this.connection.close();
} catch(SQLException ignored) {
}
this.connection = null;
}
private void handleError(String description, SQLException e) {
Main.logger().log(Level.WARNING, String.format("Database: %s failed: %s", description, e.getMessage()));
this.closeConnection();
}
}
@@ -0,0 +1,138 @@
package eu.mhsl.craftattack.spawn.core.database;
import eu.mhsl.craftattack.spawn.core.Main;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Applies SQL migration files shipped in the plugin jar.
* Files live in {@code migrations/<appliance>/NNN_description.sql} and run in ascending order of NNN.
* Applied versions are tracked per appliance in the table {@code schema_migrations}.
*/
public class Migrations {
private static final String DIRECTORY = "migrations/";
private static final Pattern FILE_PATTERN = Pattern.compile("^([^/]+)/(\\d+)_[^/]+\\.sql$");
private static Map<String, List<Migration>> migrationsByAppliance;
private record Migration(int version, String fileName, String path) {
}
/**
* Queues all pending migrations of the appliance on the database thread,
* so they run before any statement the appliance submits afterwards.
*/
public static void apply(Database database, String appliance) {
List<Migration> migrations = findMigrations().getOrDefault(appliance, List.of());
if(migrations.isEmpty()) return;
if(!database.isAvailable()) {
Main.logger().warning(String.format("Appliance '%s' has migrations, but no database is configured", appliance));
return;
}
database.execute("migrations of " + appliance, connection -> {
createMigrationTable(connection);
Set<Integer> applied = appliedVersions(connection, appliance);
for(Migration migration : migrations) {
if(applied.contains(migration.version())) continue;
Main.logger().info(String.format("Applying migration %s/%s", appliance, migration.fileName()));
try(Statement statement = connection.createStatement()) {
statement.execute(readResource(migration.path()));
}
markApplied(connection, appliance, migration);
}
});
}
private static synchronized Map<String, List<Migration>> findMigrations() {
if(migrationsByAppliance != null) return migrationsByAppliance;
Map<String, List<Migration>> migrations = new HashMap<>();
try(JarFile jar = new JarFile(pluginJar())) {
jar.stream()
.map(JarEntry::getName)
.filter(name -> name.startsWith(DIRECTORY))
.forEach(path -> {
Matcher matcher = FILE_PATTERN.matcher(path.substring(DIRECTORY.length()));
if(!matcher.matches()) return;
String fileName = path.substring(path.lastIndexOf('/') + 1);
migrations.computeIfAbsent(matcher.group(1), appliance -> new ArrayList<>())
.add(new Migration(Integer.parseInt(matcher.group(2)), fileName, path));
});
} catch(IOException e) {
throw new IllegalStateException("Could not read migrations from plugin jar", e);
}
migrations.values().forEach(list -> list.sort(Comparator.comparingInt(Migration::version)));
migrationsByAppliance = migrations;
return migrations;
}
private static void createMigrationTable(Connection connection) throws SQLException {
try(Statement statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
appliance VARCHAR(64) NOT NULL,
version INT NOT NULL,
file_name VARCHAR(255) NOT NULL,
applied_at DATETIME NOT NULL DEFAULT UTC_TIMESTAMP(),
PRIMARY KEY (appliance, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
}
}
private static Set<Integer> appliedVersions(Connection connection, String appliance) throws SQLException {
Set<Integer> versions = new HashSet<>();
try(PreparedStatement statement = connection.prepareStatement("SELECT version FROM schema_migrations WHERE appliance = ?")) {
statement.setString(1, appliance);
try(ResultSet result = statement.executeQuery()) {
while(result.next()) versions.add(result.getInt(1));
}
}
return versions;
}
private static void markApplied(Connection connection, String appliance, Migration migration) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("INSERT INTO schema_migrations (appliance, version, file_name) VALUES (?, ?, ?)")) {
statement.setString(1, appliance);
statement.setInt(2, migration.version());
statement.setString(3, migration.fileName());
statement.executeUpdate();
}
}
private static String readResource(String path) throws SQLException {
try(InputStream stream = Main.class.getClassLoader().getResourceAsStream(path)) {
if(stream == null) throw new SQLException("Migration file not found: " + path);
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch(IOException e) {
throw new SQLException("Could not read migration file " + path, e);
}
}
private static File pluginJar() {
try {
return new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI());
} catch(Exception e) {
throw new IllegalStateException("Could not locate plugin jar", e);
}
}
}
@@ -0,0 +1,49 @@
package eu.mhsl.craftattack.spawn.core.event;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.UUID;
/**
* Fired after a login attempt was stored in login_attempts. Called on the database thread:
* handlers must return quickly and must never wait for results of the shared Database.
*/
public class LoginAttemptRecordedEvent extends Event {
private static final HandlerList HANDLERS = new HandlerList();
@Override
public @NotNull HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
public record RecordedAttempt(
long id,
Instant at,
UUID uuid,
String name,
String ip,
@Nullable String network,
String stage,
String result
) {
}
private final RecordedAttempt attempt;
public LoginAttemptRecordedEvent(RecordedAttempt attempt) {
super(true);
this.attempt = attempt;
}
public RecordedAttempt getAttempt() {
return this.attempt;
}
}
@@ -32,7 +32,7 @@ public class IteratorUtil {
}
public static void times(int times, Runnable callback) {
IntStream.range(0, times).forEach(value -> callback.run());
IntStream.range(0, times).forEach(_ -> callback.run());
}
public static <T> void iterateListInGlobal(int sectionStart, List<T> list, BiConsumer<Integer, T> callback) {
@@ -0,0 +1,72 @@
package eu.mhsl.craftattack.spawn.core.util.network;
import org.jetbrains.annotations.Nullable;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.StringJoiner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Groups IP addresses by internet connection: an IPv4 address stands for itself,
* an IPv6 address for its /64 prefix (one prefix per household line).
*/
public final class Networks {
private static final Pattern IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$");
private static final Pattern IPV6 = Pattern.compile("^[0-9a-fA-F:.]*:[0-9a-fA-F:.]*$");
private static final String IPV6_SUFFIX = "::/64";
private Networks() {
}
/**
* @return the connection key ("93.204.10.20" or "2003:e1:1f00:ab00::/64"), or null if the text is no IP address
*/
public static @Nullable String networkOf(@Nullable String ip) {
if(ip == null) return null;
String text = ip.trim();
Matcher ipv4 = IPV4.matcher(text);
if(ipv4.matches()) return ipv4Of(ipv4);
if(!IPV6.matcher(text).matches()) return null;
InetAddress address;
try {
// the text contains a colon, so it is parsed as IPv6 literal and never resolved via DNS
address = InetAddress.getByName(text);
} catch(UnknownHostException e) {
return null;
}
if(address instanceof Inet4Address) return address.getHostAddress();
byte[] bytes = address.getAddress();
return String.format("%x:%x:%x:%x%s", group(bytes, 0), group(bytes, 2), group(bytes, 4), group(bytes, 6), IPV6_SUFFIX);
}
/**
* Shortened form for display: "93.204.x.x" or "2003:e1:x:x::/64".
*/
public static String mask(String network) {
if(network.endsWith(IPV6_SUFFIX)) {
String[] groups = network.substring(0, network.length() - IPV6_SUFFIX.length()).split(":");
return groups[0] + ":" + groups[1] + ":x:x" + IPV6_SUFFIX;
}
String[] octets = network.split("\\.");
return octets.length == 4 ? octets[0] + "." + octets[1] + ".x.x" : network;
}
private static @Nullable String ipv4Of(Matcher matcher) {
StringJoiner joiner = new StringJoiner(".");
for(int i = 1; i <= 4; i++) {
int octet = Integer.parseInt(matcher.group(i));
if(octet > 255) return null;
joiner.add(Integer.toString(octet));
}
return joiner.toString();
}
private static int group(byte[] bytes, int offset) {
return ((bytes[offset] & 0xff) << 8) | (bytes[offset + 1] & 0xff);
}
}
@@ -5,21 +5,32 @@ import org.geysermc.cumulus.form.SimpleForm;
import org.geysermc.floodgate.api.FloodgateApi;
import org.geysermc.floodgate.api.player.FloodgatePlayer;
import java.util.UUID;
import java.util.function.Consumer;
public class Floodgate {
private static final FloodgateApi instance = FloodgateApi.getInstance();
private static FloodgateApi api() {
return FloodgateApi.getInstance();
}
public static boolean isBedrock(Player p) {
return instance.isFloodgatePlayer(p.getUniqueId());
return api().isFloodgatePlayer(p.getUniqueId());
}
/**
* Whether the UUID has the format Floodgate uses for Bedrock players (most significant bits zero).
* Works without the player being online and without the Floodgate API.
*/
public static boolean isFloodgateUuid(UUID uuid) {
return uuid.getMostSignificantBits() == 0;
}
public static FloodgatePlayer getBedrockPlayer(Player p) {
return instance.getPlayer(p.getUniqueId());
return api().getPlayer(p.getUniqueId());
}
public static void runBedrockOnly(Player p, Consumer<FloodgatePlayer> callback) {
if(isBedrock(p)) callback.accept(instance.getPlayer(p.getUniqueId()));
if(isBedrock(p)) callback.accept(api().getPlayer(p.getUniqueId()));
}
public static void runJavaOnly(Player p, Consumer<Player> callback) {
+16
View File
@@ -7,6 +7,22 @@ api:
secret: secret
baseurl: https://mhsl.eu/craftattack/api/
database:
host:
port: 3306
name: craftattack
user:
password:
loginLog:
retentionMonths: 16
banEvasion:
windowDays: 14
rejectionWindowHours: 24
banCacheMinutes: 5
retentionMonths: 16
worldMuseum:
uuid:
connect-server-name: worldmuseum
+1 -1
View File
@@ -2,7 +2,7 @@ dependencies {
implementation project(':core')
implementation project(':common')
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
@@ -0,0 +1,48 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.newYearFirework;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.Color;
import org.bukkit.FireworkEffect;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Firework;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.inventory.meta.FireworkMeta;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
public class NewYearFirework extends Appliance {
private static final String namespace = NewYearFirework.class.getSimpleName().toLowerCase(Locale.ROOT);
public static NamespacedKey fireworkKey = new NamespacedKey(namespace, "newyear_firework".toLowerCase());
public void spawnFirework(Player player) {
for (int i = 0; i < 5; i++) {
Firework firework = player.getWorld().spawn(player.getLocation(), Firework.class);
firework.getPersistentDataContainer().set(fireworkKey, PersistentDataType.BOOLEAN, true);
FireworkMeta fireworkMeta = firework.getFireworkMeta();
fireworkMeta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.BALL)
.withColor(Color.AQUA, Color.RED, Color.LIME, Color.ORANGE, Color.YELLOW)
.withFade(Color.FUCHSIA, Color.WHITE)
.trail(true)
.flicker(true)
.build()
);
fireworkMeta.setPower((int) (i % 3 + 0.25));
firework.setFireworkMeta(fireworkMeta);
}
}
public boolean shouldSpawnFirework() {
return LocalDate.now().getDayOfYear() == 1;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new NewYearFireworkListener());
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.newYearFirework;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.entity.Firework;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.player.PlayerJoinEvent;
class NewYearFireworkListener extends ApplianceListener<NewYearFirework> {
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
if(!this.getAppliance().shouldSpawnFirework()) return;
this.getAppliance().spawnFirework(event.getPlayer());
}
@EventHandler
public void onDamage(EntityDamageByEntityEvent event) {
if(!(event.getDamager() instanceof Firework firework)) return;
if(!firework.getPersistentDataContainer().has(NewYearFirework.fireworkKey)) return;
event.setCancelled(true);
}
}
@@ -0,0 +1,42 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapelessRecipe;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class QuarzRecipe extends Appliance {
private NamespacedKey recipeKey;
@Override
public void onEnable() {
this.recipeKey = new NamespacedKey(Main.instance(), "quarz_block_to_quarz");
ShapelessRecipe recipe = new ShapelessRecipe(this.recipeKey, new ItemStack(Material.QUARTZ, 4));
recipe.addIngredient(Material.QUARTZ_BLOCK);
Bukkit.addRecipe(recipe);
Bukkit.getOnlinePlayers().forEach(this::unlock);
}
@Override
public void onDisable() {
Bukkit.removeRecipe(this.recipeKey);
}
public void unlock(Player player) {
player.discoverRecipe(this.recipeKey);
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new QuarzRecipeListener());
}
}
@@ -0,0 +1,12 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
class QuarzRecipeListener extends ApplianceListener<QuarzRecipe> {
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
this.getAppliance().unlock(event.getPlayer());
}
}
@@ -0,0 +1,45 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.antiPlayerLootDespawn;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Item;
import org.bukkit.event.Listener;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Locale;
public class AntiPlayerLootDespawn extends Appliance {
private static final int extraCycles = 5;
private static final String namespace = AntiPlayerLootDespawnListener.class.getSimpleName().toLowerCase(Locale.ROOT);
public static NamespacedKey cyclesKey = new NamespacedKey(namespace, "item_deaths_cycles".toLowerCase());
public void markItems(List<ItemStack> item) {
item.forEach(itemStack ->
itemStack.editPersistentDataContainer(persistentDataContainer ->
persistentDataContainer.set(cyclesKey, PersistentDataType.INTEGER, extraCycles)
)
);
}
public boolean isItemAboveMaxAge(Item item) {
ItemStack itemStack = item.getItemStack();
int cycles = itemStack.getPersistentDataContainer()
.getOrDefault(AntiPlayerLootDespawn.cyclesKey, PersistentDataType.INTEGER, 0);
itemStack.editPersistentDataContainer(data ->
data.set(AntiPlayerLootDespawn.cyclesKey, PersistentDataType.INTEGER, cycles - 1)
);
item.setItemStack(itemStack);
return cycles > 0;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new AntiPlayerLootDespawnListener());
}
}
@@ -0,0 +1,19 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.antiPlayerLootDespawn;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.ItemDespawnEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
class AntiPlayerLootDespawnListener extends ApplianceListener<AntiPlayerLootDespawn> {
@EventHandler
public void onDeath(PlayerDeathEvent event) {
this.getAppliance().markItems(event.getDrops());
}
@EventHandler
public void onItemDespawn(ItemDespawnEvent event) {
event.setCancelled(this.getAppliance().isItemAboveMaxAge(event.getEntity()));
}
}
@@ -20,7 +20,7 @@ import org.bukkit.event.Event;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import javax.annotation.Nullable;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
@@ -0,0 +1,35 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.util.server.Floodgate;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* /accountLinks – list of suspected cases; /accountLinks <player> – accounts sharing a connection with the player.
* Only visible to admins.
*/
class AccountLinksCommand extends ApplianceCommand.PlayerChecked<BanEvasion> {
AccountLinksCommand() {
super("accountLinks");
}
@Override
public @Nullable String permission() {
return BanEvasion.ADMIN_PERMISSION;
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
Player player = this.getPlayer();
if(!player.hasPermission(BanEvasion.ADMIN_PERMISSION)) return;
if(Floodgate.isBedrock(player)) throw new Error("Nur für Java-Clients");
if(!this.getAppliance().isActive()) throw new Error("Keine Datenbank konfiguriert");
if(args.length == 0) this.getAppliance().dialogs().openList(player, CaseStatus.OPEN);
else this.getAppliance().dialogs().openLinks(player, args[0]);
}
}
@@ -0,0 +1,77 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.Main;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.Comparator;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Chat messages to online admins. Never contains IP addresses.
*/
final class AdminNotifier {
record Notice(
long caseId,
String firstName,
boolean firstBanned,
String secondName,
boolean secondBanned,
Severity severity,
Set<Reason> reasons,
boolean reopened
) {
}
private final BanEvasion appliance;
AdminNotifier(BanEvasion appliance) {
this.appliance = appliance;
}
void announce(Notice notice) {
Bukkit.getScheduler().runTask(Main.instance(), () -> {
Component message = this.format(notice);
Bukkit.getOnlinePlayers().stream()
.filter(player -> player.hasPermission(BanEvasion.ADMIN_PERMISSION))
.forEach(player -> player.sendMessage(message));
});
}
void announceOpenCases(Player admin, BanEvasionRepository.OpenCount count) {
if(count.total() == 0) return;
String text = count.total() == 1 ? "1 offener Verdachtsfall" : count.total() + " offene Verdachtsfälle";
admin.sendMessage(Component.text()
.append(Component.text("⚑ " + text, count.high() > 0 ? NamedTextColor.RED : NamedTextColor.GRAY))
.append(Component.text(count.high() > 0 ? " (" + count.high() + " hoch)" : "", NamedTextColor.RED))
.append(Component.text(" [Liste]", NamedTextColor.GOLD)
.clickEvent(BanEvasionDialogs.adminClick(player -> this.appliance.dialogs().openList(player, CaseStatus.OPEN))))
.build());
}
private Component format(Notice notice) {
NamedTextColor color = notice.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.GRAY;
String reasons = notice.reasons().stream()
.filter(reason -> !reason.informational)
.sorted(Comparator.reverseOrder())
.map(reason -> reason.label)
.collect(Collectors.joining(", "));
return Component.text()
.append(Component.text(String.format("⚑ %s [%s]: ", notice.reopened() ? "Wieder offen" : "Verdacht", notice.severity().label), color))
.append(account(notice.firstName(), notice.firstBanned()))
.append(Component.text(" ↔ ", color))
.append(account(notice.secondName(), notice.secondBanned()))
.append(Component.text(" – " + reasons + " ", color))
.append(Component.text("[Öffnen]", NamedTextColor.GOLD)
.clickEvent(BanEvasionDialogs.adminClick(player -> this.appliance.dialogs().openCase(player, notice.caseId(), false))))
.build();
}
private static Component account(String name, boolean banned) {
Component component = Component.text(name, NamedTextColor.WHITE);
return banned ? component.append(Component.text(" (gebannt)", NamedTextColor.RED)) : component;
}
}
@@ -0,0 +1,22 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import java.time.Instant;
import java.util.UUID;
/**
* One row of login_attempts with a known connection key.
*/
record Attempt(long id, Instant at, UUID uuid, String name, String network, String stage, String result) {
static final String STAGE_WEBSITE = "WEBSITE";
boolean isWebsiteSignup() {
return STAGE_WEBSITE.equals(this.stage) && "SIGNUP".equals(this.result);
}
/**
* Rejected by the server (whitelist, ban, maintenance, ...), not merely aborted by the client.
*/
boolean isRejection() {
return !STAGE_WEBSITE.equals(this.stage) && this.result.startsWith("KICK_");
}
}
@@ -0,0 +1,302 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.HttpStatus;
import eu.mhsl.craftattack.spawn.core.api.client.ReqResp;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.database.Database;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import eu.mhsl.craftattack.spawn.core.util.network.Networks;
import eu.mhsl.craftattack.spawn.craftattack.api.repositories.WhitelistRepository;
import eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.strikes.Strikes;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetSocketAddress;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Clock;
import java.time.Duration;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import java.util.logging.Level;
/**
* Detects possible ban evasion: accounts that shared an internet connection (IPv4 address or IPv6 /64)
* within a time window, judged together with bans from the website. Only reports to admins
* (chat and dialogs via /accountLinks), never punishes and never changes the login of players.
* Rules: CaseEvaluator, deduplication: CaseDecision, storage: BanEvasionRepository.
* Evaluations and dialog queries run on an own worker thread, never on the main or database thread.
*/
public class BanEvasion extends Appliance {
static final String ADMIN_PERMISSION = "admin";
private static final long TICKS_PER_MINUTE = 20L * 60;
private static final long TICKS_PER_DAY = TICKS_PER_MINUTE * 60 * 24;
private static final Duration QUERY_TIMEOUT = Duration.ofSeconds(30);
private static final Duration BAN_LOOKUP_TIMEOUT = Duration.ofSeconds(5);
private static final Duration BAN_LOOKUP_BACKOFF = Duration.ofSeconds(30);
@FunctionalInterface
interface Loader<T> {
T load() throws Exception;
}
private record Households(Map<UUID, Long> ofAccount, Map<Long, Set<UUID>> members) {
}
private final BanEvasionRepository repository = new BanEvasionRepository();
private final OnlineNetworks onlineNetworks = new OnlineNetworks();
private Database database;
private ExecutorService worker;
private ExecutorService httpExecutor;
private BukkitTask retentionTask;
private BanStatusCache banStatus;
private AdminNotifier notifier;
private BanEvasionDialogs dialogs;
private Duration window;
private Duration rejectionWindow;
private int retentionMonths;
private volatile boolean active;
public BanEvasion() {
super("banEvasion");
}
@Override
public void onEnable() {
this.database = Main.instance().getDatabase();
if(!this.database.isAvailable()) {
Main.logger().warning("BanEvasion: no database configured, ban evasion detection is disabled");
return;
}
ConfigurationSection config = this.localConfig();
this.window = Duration.ofDays(config.getInt("windowDays", 14));
this.rejectionWindow = Duration.ofHours(config.getInt("rejectionWindowHours", 24));
this.retentionMonths = config.getInt("retentionMonths", 16);
this.banStatus = new BanStatusCache(this::lookupBanState, Duration.ofMinutes(config.getInt("banCacheMinutes", 5)), BAN_LOOKUP_BACKOFF, Clock.systemUTC());
this.httpExecutor = Executors.newCachedThreadPool(runnable -> {
Thread thread = new Thread(runnable, "Spawn-BanEvasion-Http");
thread.setDaemon(true);
return thread;
});
this.worker = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Spawn-BanEvasion");
thread.setDaemon(true);
return thread;
});
this.notifier = new AdminNotifier(this);
this.dialogs = new BanEvasionDialogs(this);
this.retentionTask = Bukkit.getScheduler().runTaskTimerAsynchronously(Main.instance(), this::deleteExpired, TICKS_PER_MINUTE, TICKS_PER_DAY);
this.active = true;
}
@Override
public void onDisable() {
this.active = false;
if(this.retentionTask != null) this.retentionTask.cancel();
if(this.worker != null) this.worker.shutdownNow();
if(this.httpExecutor != null) this.httpExecutor.shutdownNow();
}
boolean isActive() {
return this.active;
}
BanEvasionRepository repository() {
return this.repository;
}
BanEvasionDialogs dialogs() {
return this.dialogs;
}
/**
* Runs the query on the database thread and waits for it. Only for the worker thread.
*/
<T> T query(Database.SqlQuery<T> query) {
// a dropped statement (full database queue) never completes, the worker must not wait for it forever
return this.database.query(query).orTimeout(QUERY_TIMEOUT.toSeconds(), TimeUnit.SECONDS).join();
}
/**
* Blocks for HTTP requests. Only for the worker thread.
*/
Map<UUID, BanState> banStates(Collection<UUID> uuids) {
return this.banStatus.getAll(uuids);
}
/**
* Loads data on the worker thread and hands it to the main thread, if the admin is still online.
*/
<T> void loadFor(Player admin, Loader<T> loader, Consumer<T> onMainThread) {
if(!this.active) return;
this.submit(() -> {
T result;
try {
result = loader.load();
} catch(Exception e) {
Main.logger().log(Level.WARNING, "BanEvasion: loading data for " + admin.getName() + " failed", e);
Bukkit.getScheduler().runTask(Main.instance(), () -> admin.sendMessage(Component.text("Fehler beim Laden der Daten.", NamedTextColor.RED)));
return;
}
Bukkit.getScheduler().runTask(Main.instance(), () -> {
if(admin.isOnline()) onMainThread.accept(result);
});
});
}
/**
* Called on the database thread: hands the attempt to the worker without waiting.
*/
void onAttemptRecorded(LoginAttemptRecordedEvent.RecordedAttempt recorded) {
if(!this.active || recorded.network() == null) return;
Attempt attempt = new Attempt(recorded.id(), recorded.at(), recorded.uuid(), recorded.name(), recorded.network(), recorded.stage(), recorded.result());
Set<UUID> online = this.onlineNetworks.onlineOn(recorded.network());
this.submit(() -> this.evaluate(attempt, online));
}
void playerJoined(Player player) {
InetSocketAddress address = player.getAddress();
String network = address == null || address.getAddress() == null ? null : Networks.networkOf(address.getAddress().getHostAddress());
this.onlineNetworks.joined(player.getUniqueId(), network);
if(!this.active || !player.hasPermission(ADMIN_PERMISSION)) return;
this.loadFor(player, () -> this.query(this.repository::countOpen), count -> this.notifier.announceOpenCases(player, count));
}
void playerLeft(Player player) {
this.onlineNetworks.left(player.getUniqueId());
}
private void submit(Runnable task) {
try {
this.worker.execute(task);
} catch(RejectedExecutionException e) {
Main.logger().warning("BanEvasion: worker stopped, task dropped");
}
}
private void evaluate(Attempt attempt, Set<UUID> online) {
try {
if(this.query(connection -> this.repository.isExempt(connection, attempt.network(), attempt.at()))) return;
List<Attempt> partners = this.query(connection -> this.repository.partnerAttempts(connection, attempt, this.window));
if(partners.isEmpty()) return;
Set<UUID> involved = new HashSet<>();
involved.add(attempt.uuid());
partners.forEach(partner -> involved.add(partner.uuid()));
Households households = this.query(connection -> {
Map<UUID, Long> ofAccount = this.repository.householdsOf(connection, involved);
return new Households(ofAccount, this.repository.householdMembers(connection, new HashSet<>(ofAccount.values())));
});
Set<UUID> banStateNeeded = new HashSet<>(involved);
households.members().values().forEach(banStateNeeded::addAll);
Map<UUID, BanState> banStates = this.banStatus.getAll(banStateNeeded);
List<PairFinding> findings = CaseEvaluator.evaluate(new CaseEvaluator.Input(
attempt, partners, banStates, households.ofAccount(), households.members(), online, this.rejectionWindow
));
for(PairFinding finding : findings) {
AdminNotifier.Notice notice = this.query(connection -> this.applyFinding(connection, finding, attempt, banStates));
if(notice != null) this.notifier.announce(notice);
}
} catch(Exception e) {
Main.logger().log(Level.WARNING, String.format("BanEvasion: evaluating login attempt #%d failed", attempt.id()), e);
}
}
private @Nullable AdminNotifier.Notice applyFinding(Connection connection, PairFinding finding, Attempt attempt, Map<UUID, BanState> banStates) throws SQLException {
CaseDecision.ExistingCase existing = this.repository.findCase(connection, finding.first(), finding.second());
CaseDecision.Result decision = CaseDecision.decide(existing, finding);
long caseId;
if(existing == null) {
caseId = this.repository.createCase(connection, finding, attempt.at());
} else {
caseId = existing.id();
this.repository.updateCase(connection, caseId, finding, attempt.at(), decision.reopen());
}
this.repository.addReasons(connection, caseId, finding.reasons(), decision.newReasons(), attempt.id(), attempt.at());
if(existing == null || !decision.newReasons().isEmpty()) {
String change = existing == null ? "created" : decision.reopen() ? "reopened" : "updated";
Main.logger().info(String.format("BanEvasion: case #%d %s %s", caseId, change, decision.newReasons()));
}
if(!decision.announce()) return null;
return new AdminNotifier.Notice(
caseId,
this.repository.nameOf(connection, finding.first()),
banStates.get(finding.first()) == BanState.BANNED,
this.repository.nameOf(connection, finding.second()),
banStates.get(finding.second()) == BanState.BANNED,
finding.severity(),
finding.reasons().keySet(),
decision.reopen()
);
}
/**
* Asks the website with a time limit, so a hanging website cannot block the worker. A timed out request
* keeps running on its own daemon thread until the HTTP client gives up.
*/
private BanState lookupBanState(UUID uuid) {
try {
return CompletableFuture.supplyAsync(() -> this.fetchBanState(uuid), this.httpExecutor)
.get(BAN_LOOKUP_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
} catch(TimeoutException e) {
Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: website did not answer in time");
return BanState.UNKNOWN;
} catch(ExecutionException e) {
Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: " + e.getCause().getMessage());
return BanState.UNKNOWN;
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
return BanState.UNKNOWN;
}
}
private BanState fetchBanState(UUID uuid) {
ReqResp<WhitelistRepository.UserData> response = this.queryRepository(WhitelistRepository.class).getUserData(uuid);
if(response.status() == HttpStatus.NOT_FOUND) return BanState.NOT_BANNED;
if(response.status() != HttpStatus.OK || response.data() == null) return BanState.UNKNOWN;
List<WhitelistRepository.UserData.Strike> strikes = Objects.requireNonNullElse(response.data().strikes(), List.of());
return this.queryAppliance(Strikes.class).isBanned(strikes).isBanned() ? BanState.BANNED : BanState.NOT_BANNED;
}
private void deleteExpired() {
this.database.execute("deleting expired ban evasion data", connection -> {
int deleted = this.repository.deleteExpired(connection, this.retentionMonths);
if(deleted > 0) Main.logger().info(String.format("BanEvasion: deleted %d expired cases and exemptions", deleted));
});
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new BanEvasionListener());
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new AccountLinksCommand());
}
}
@@ -0,0 +1,409 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.util.network.Networks;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.dialog.DialogResponseView;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.input.DialogInput;
import io.papermc.paper.registry.data.dialog.input.SingleOptionDialogInput;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.event.ClickCallback;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Admin dialogs of the ban evasion detection. Every button checks the admin permission again on the server.
*/
final class BanEvasionDialogs {
private static final int LIST_LIMIT = 30;
private static final int LINK_LIMIT = 30;
private static final int TIMELINE_LIMIT = 20;
private static final int RECENT_NETWORKS = 5;
private static final int BUTTON_WIDTH = 310;
private static final int BODY_WIDTH = 400;
private static final Duration CALLBACK_LIFETIME = Duration.ofHours(12);
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("dd.MM.yy HH:mm").withZone(ZoneId.of("Europe/Berlin"));
private static final String NOTE_KEY = "note";
private static final String NETWORK_KEY = "network";
private static final String HOURS_KEY = "hours";
private record CaseView(BanEvasionRepository.CaseDetails details, Map<UUID, BanState> banStates, Map<UUID, Long> households) {
}
private record LinksView(String name, List<BanEvasionRepository.Link> links, List<String> networks, @Nullable Long household) {
}
private final BanEvasion appliance;
BanEvasionDialogs(BanEvasion appliance) {
this.appliance = appliance;
}
/**
* Click event for chat messages that only works for admins.
*/
static ClickEvent<?> adminClick(Consumer<Player> action) {
return ClickEvent.callback(
audience -> {
if(audience instanceof Player player && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player);
},
ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).lifetime(CALLBACK_LIFETIME).build()
);
}
void openList(Player admin, CaseStatus status) {
this.appliance.loadFor(admin,
() -> this.appliance.query(connection -> this.appliance.repository().listCases(connection, status, LIST_LIMIT)),
cases -> {
List<ActionButton> buttons = new ArrayList<>();
cases.forEach(summary -> buttons.add(button(caseLabel(summary), (player, response) -> this.openCase(player, summary.id(), false))));
CaseStatus other = status == CaseStatus.OPEN ? CaseStatus.CLOSED : CaseStatus.OPEN;
buttons.add(button(other == CaseStatus.OPEN ? "Offene Fälle" : "Geschlossene Fälle", player -> this.openList(player, other)));
buttons.add(button("Haushalte", this::openHouseholds));
buttons.add(button("IP-Ausnahmen", this::openExemptions));
Component body = cases.isEmpty()
? Component.text("Keine Fälle.")
: Component.text(String.format("%d Fälle (höchstens %d), hohe zuerst, dann neueste.", cases.size(), LIST_LIMIT));
show(admin, status == CaseStatus.OPEN ? "Verdachtsfälle – offen" : "Verdachtsfälle – geschlossen", body, List.of(), buttons);
});
}
void openCase(Player admin, long caseId, boolean fullNetworks) {
this.appliance.loadFor(admin, () -> {
BanEvasionRepository.CaseDetails details = this.appliance.query(connection -> this.appliance.repository().loadCase(connection, caseId, TIMELINE_LIMIT));
if(details == null) return null;
List<UUID> accounts = List.of(details.summary().first(), details.summary().second());
Map<UUID, Long> households = this.appliance.query(connection -> this.appliance.repository().householdsOf(connection, accounts));
return new CaseView(details, this.appliance.banStates(accounts), households);
}, view -> {
if(view == null) {
admin.sendMessage(Component.text("Fall #" + caseId + " gibt es nicht (mehr).", NamedTextColor.RED));
return;
}
this.showCase(admin, view, fullNetworks);
});
}
void openLinks(Player admin, String name) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> this.appliance.repository().uuidByName(connection, name)), uuid -> {
if(uuid == null) {
admin.sendMessage(Component.text("Keine Einträge zu " + name + ".", NamedTextColor.RED));
return;
}
this.openLinks(admin, uuid);
});
}
void openLinks(Player admin, UUID uuid) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> new LinksView(
this.appliance.repository().nameOf(connection, uuid),
this.appliance.repository().linksOf(connection, uuid, LINK_LIMIT),
this.appliance.repository().recentNetworks(connection, uuid, RECENT_NETWORKS),
this.appliance.repository().householdsOf(connection, List.of(uuid)).get(uuid)
)), view -> {
List<Component> lines = new ArrayList<>();
if(view.household() != null) lines.add(Component.text(view.name() + " gehört zu Haushalt #" + view.household() + "."));
if(view.links().isEmpty()) lines.add(Component.text("Kein anderer Account hat je einen Anschluss mit " + view.name() + " geteilt."));
view.links().forEach(link -> lines.add(Component.text(String.format("%s – %d Einträge an gemeinsamen Anschlüssen, %s bis %s%s",
link.name(), link.sharedAttempts(), TIME.format(link.firstAt()), TIME.format(link.lastAt()),
link.caseId() == null ? "" : " · Fall #" + link.caseId()))));
List<ActionButton> buttons = new ArrayList<>();
if(view.household() != null)
buttons.add(button("Haushalt #" + view.household(), player -> this.openHousehold(player, view.household())));
view.links().stream()
.filter(link -> link.caseId() != null)
.forEach(link -> buttons.add(button("Fall #" + link.caseId() + ": " + link.name(), player -> this.openCase(player, link.caseId(), false))));
if(!view.networks().isEmpty())
buttons.add(button("IP-Ausnahme für Anschluss von " + view.name(), player -> this.openNewExemption(player, view.networks())));
buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN)));
show(admin, "Accounts zu " + view.name(), lines(lines), List.of(), buttons);
});
}
void openNewExemption(Player admin, List<String> networks) {
List<SingleOptionDialogInput.OptionEntry> options = new ArrayList<>();
for(int i = 0; i < networks.size(); i++)
options.add(SingleOptionDialogInput.OptionEntry.create(networks.get(i), Component.text(networks.get(i)), i == 0));
List<DialogInput> inputs = List.of(
DialogInput.singleOption(NETWORK_KEY, Component.text("Anschluss"), options).width(BODY_WIDTH).build(),
DialogInput.numberRange(HOURS_KEY, Component.text("Dauer in Stunden"), 1, 168).step(1f).initial(12f).width(BODY_WIDTH).build(),
DialogInput.text(NOTE_KEY, Component.text("Notiz, z. B. LAN-Party bei …")).width(BODY_WIDTH).maxLength(255).build()
);
List<ActionButton> buttons = List.of(
button(Component.text("Ausnahme anlegen"), (player, response) -> this.createExemption(player, networks, response)),
button("Abbrechen", player -> this.openList(player, CaseStatus.OPEN))
);
show(admin, "IP-Ausnahme anlegen",
Component.text("Einträge von diesem Anschluss lösen in der gewählten Zeit keine Meldung aus. Sie werden weiter protokolliert."),
inputs, buttons);
}
void openExemptions(Player admin) {
Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> this.appliance.repository().activeExemptions(connection, now)), exemptions -> {
Component body = Component.text(exemptions.isEmpty()
? "Keine aktiven IP-Ausnahmen."
: exemptions.size() + " aktive IP-Ausnahmen, zuerst endende oben.");
List<ActionButton> buttons = new ArrayList<>();
exemptions.forEach(exemption -> buttons.add(button(
String.format("%s bis %s%s", Networks.mask(exemption.network()), TIME.format(exemption.validUntil()),
exemption.note() == null ? "" : " · " + exemption.note()),
player -> this.openExemption(player, exemption))));
buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN)));
show(admin, "IP-Ausnahmen", body, List.of(), buttons);
});
}
private void openExemption(Player admin, BanEvasionRepository.Exemption exemption) {
Component body = lines(List.of(
Component.text("Anschluss: " + exemption.network()),
Component.text("Gültig: " + TIME.format(exemption.validFrom()) + " bis " + TIME.format(exemption.validUntil())),
Component.text("Angelegt von: " + (exemption.createdByName() == null ? "?" : exemption.createdByName())),
Component.text("Notiz: " + (exemption.note() == null ? "–" : exemption.note()))
));
show(admin, "IP-Ausnahme", body, List.of(), List.of(
button("Jetzt beenden", player -> this.endExemption(player, exemption)),
button("Zurück", this::openExemptions)
));
}
void openHouseholds(Player admin) {
this.appliance.loadFor(admin, () -> this.appliance.query(this.appliance.repository()::listHouseholds), households -> {
String text = households.isEmpty() ? "Keine Haushalte." : households.size() + " Haushalte, neueste zuerst.";
if(households.size() > LIST_LIMIT)
text += " Gezeigt werden die neuesten " + LIST_LIMIT + " – ältere über /accountLinks <spieler> finden.";
List<ActionButton> buttons = new ArrayList<>();
households.stream().limit(LIST_LIMIT).forEach(household -> buttons.add(button(
"#" + household.id() + ": " + household.members().stream().map(BanEvasionRepository.Member::name).collect(Collectors.joining(", ")),
player -> this.openHousehold(player, household.id()))));
buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN)));
show(admin, "Haushalte", Component.text(text), List.of(), buttons);
});
}
private record HouseholdView(BanEvasionRepository.Household household, Map<UUID, BanState> banStates) {
}
void openHousehold(Player admin, long householdId) {
this.appliance.loadFor(admin, () -> {
BanEvasionRepository.Household household = this.appliance.query(connection -> this.appliance.repository().household(connection, householdId));
if(household == null) return null;
return new HouseholdView(household, this.appliance.banStates(household.members().stream().map(BanEvasionRepository.Member::uuid).toList()));
}, view -> {
if(view == null) {
admin.sendMessage(Component.text("Haushalt #" + householdId + " gibt es nicht (mehr).", NamedTextColor.GRAY));
this.openHouseholds(admin);
return;
}
BanEvasionRepository.Household household = view.household();
List<Component> lines = new ArrayList<>();
lines.add(Component.text("Angelegt " + TIME.format(household.createdAt()) + " von "
+ (household.createdByName() == null ? "?" : household.createdByName())));
lines.add(Component.text("Notiz: " + (household.note() == null ? "–" : household.note())));
lines.add(Component.empty());
household.members().forEach(member -> lines.add(accountLine(member.name(), view.banStates().get(member.uuid()), null)));
List<ActionButton> buttons = new ArrayList<>();
household.members().forEach(member -> buttons.add(button("Entfernen: " + member.name(), player -> this.removeMember(player, householdId, member))));
buttons.add(button("Zurück", this::openHouseholds));
show(admin, "Haushalt #" + householdId, lines(lines), List.of(), buttons);
});
}
private void showCase(Player admin, CaseView view, boolean fullNetworks) {
BanEvasionRepository.CaseDetails details = view.details();
BanEvasionRepository.CaseSummary summary = details.summary();
List<Component> lines = new ArrayList<>();
lines.add(Component.text(String.format("Status: %s%s · Stärke: %s · %d Treffer",
summary.status() == CaseStatus.OPEN ? "offen" : "geschlossen",
summary.outcome() == null ? "" : " (" + summary.outcome().label + ")",
summary.severity().label, summary.hits())));
lines.add(Component.text("Erstellt " + TIME.format(details.createdAt()) + " · zuletzt " + TIME.format(summary.lastSeenAt())));
if(details.banStatusUnknown())
lines.add(Component.text("Bann-Status war bei der letzten Prüfung nicht abrufbar.", NamedTextColor.GOLD));
lines.add(Component.empty());
lines.add(accountLine(summary.firstName(), view.banStates().get(summary.first()), view.households().get(summary.first())));
lines.add(accountLine(summary.secondName(), view.banStates().get(summary.second()), view.households().get(summary.second())));
lines.add(Component.empty());
lines.add(Component.text("Gründe:"));
details.reasons().forEach(reason -> lines.add(Component.text(
String.format("• %s (%s) seit %s", reason.reason().label, reason.severity().label, TIME.format(reason.firstSeenAt())),
reason.severity() == Severity.HIGH && !reason.reason().informational ? NamedTextColor.RED : NamedTextColor.GRAY)));
lines.add(Component.empty());
lines.add(Component.text("Gemeinsame Anschlüsse: " + networks(details.sharedNetworks(), fullNetworks)));
lines.add(Component.empty());
lines.add(Component.text("Letzte Einträge:"));
details.timeline().forEach(entry -> lines.add(Component.text(String.format("%s %s %s %s %s",
TIME.format(entry.at()), entry.name(), entry.stage(), entry.result(),
entry.network() == null ? "?" : network(entry.network(), fullNetworks)), NamedTextColor.GRAY)));
if(details.closedAt() != null) {
lines.add(Component.empty());
lines.add(Component.text(String.format("Zuletzt geschlossen %s von %s%s",
TIME.format(details.closedAt()),
details.closedByName() == null ? "?" : details.closedByName(),
details.note() == null ? "" : ": " + details.note()), NamedTextColor.GRAY));
}
long caseId = summary.id();
List<DialogInput> inputs = new ArrayList<>();
List<ActionButton> buttons = new ArrayList<>();
if(summary.status() == CaseStatus.OPEN) {
inputs.add(DialogInput.text(NOTE_KEY, Component.text("Notiz (optional)")).width(BODY_WIDTH).maxLength(255).build());
for(Outcome outcome : Outcome.values())
buttons.add(button(Component.text("Schließen: " + outcome.label), (player, response) -> this.close(player, summary, outcome, response.getText(NOTE_KEY))));
}
buttons.add(button(fullNetworks ? "Anschlüsse gekürzt" : "Volle Anschlüsse zeigen", player -> this.openCase(player, caseId, !fullNetworks)));
buttons.add(button("Accounts zu " + summary.firstName(), player -> this.openLinks(player, summary.first())));
buttons.add(button("Accounts zu " + summary.secondName(), player -> this.openLinks(player, summary.second())));
if(!details.sharedNetworks().isEmpty())
buttons.add(button("IP-Ausnahme anlegen", player -> this.openNewExemption(player, details.sharedNetworks())));
buttons.add(button("Zurück zur Liste", player -> this.openList(player, summary.status())));
show(admin, "Verdachtsfall #" + caseId, lines(lines), inputs, buttons);
}
private void close(Player admin, BanEvasionRepository.CaseSummary summary, Outcome outcome, @Nullable String rawNote) {
String note = rawNote == null || rawNote.isBlank() ? null : rawNote.strip();
UUID adminUuid = admin.getUniqueId();
Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().closeCase(connection, summary.id(), outcome, adminUuid, note, now);
if(outcome == Outcome.HOUSEHOLD)
this.appliance.repository().linkHousehold(connection, List.of(summary.first(), summary.second()), adminUuid, note, now);
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: case #%d closed as %s by %s", summary.id(), outcome, admin.getName()));
admin.sendMessage(Component.text(String.format("Fall #%d geschlossen: %s", summary.id(), outcome.label), NamedTextColor.GRAY));
this.openList(admin, CaseStatus.OPEN);
});
}
private void createExemption(Player admin, List<String> allowed, DialogResponseView response) {
String network = response.getText(NETWORK_KEY);
Float hours = response.getFloat(HOURS_KEY);
if(network == null || !allowed.contains(network) || hours == null || hours < 1) {
admin.sendMessage(Component.text("Ungültige Eingabe.", NamedTextColor.RED));
return;
}
String rawNote = response.getText(NOTE_KEY);
String note = rawNote == null || rawNote.isBlank() ? null : rawNote.strip();
Instant from = Instant.now();
Instant until = from.plus(Duration.ofHours(Math.round(hours)));
UUID adminUuid = admin.getUniqueId();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().addExemption(connection, network, from, until, adminUuid, note);
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: exemption until %s added by %s", until, admin.getName()));
admin.sendMessage(Component.text(String.format("IP-Ausnahme für %s bis %s angelegt.", Networks.mask(network), TIME.format(until)), NamedTextColor.GRAY));
});
}
private void endExemption(Player admin, BanEvasionRepository.Exemption exemption) {
Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().endExemption(connection, exemption.id(), now);
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: exemption #%d ended by %s", exemption.id(), admin.getName()));
this.openExemptions(admin);
});
}
private void removeMember(Player admin, long householdId, BanEvasionRepository.Member member) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().removeFromHousehold(connection, member.uuid());
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: %s removed from household #%d by %s", member.name(), householdId, admin.getName()));
this.openHousehold(admin, householdId);
});
}
private static Component caseLabel(BanEvasionRepository.CaseSummary summary) {
NamedTextColor color = summary.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.WHITE;
String outcome = summary.outcome() == null ? "" : " · " + summary.outcome().label;
return Component.text(String.format("#%d %s ↔ %s · %s%s",
summary.id(), summary.firstName(), summary.secondName(), TIME.format(summary.lastSeenAt()), outcome), color);
}
private static Component accountLine(String name, @Nullable BanState state, @Nullable Long household) {
String ban = switch(state == null ? BanState.UNKNOWN : state) {
case BANNED -> "gebannt";
case NOT_BANNED -> "nicht gebannt";
case UNKNOWN -> "Bann-Status unbekannt";
};
String householdText = household == null ? "" : " · Haushalt #" + household;
return Component.text(name + " – " + ban + householdText, state == BanState.BANNED ? NamedTextColor.RED : NamedTextColor.WHITE);
}
private static String network(String network, boolean full) {
return full ? network : Networks.mask(network);
}
private static String networks(List<String> networks, boolean full) {
if(networks.isEmpty()) return "keine";
return networks.stream().map(network -> network(network, full)).collect(Collectors.joining(", "));
}
private static Component lines(List<Component> lines) {
return Component.join(JoinConfiguration.newlines(), lines);
}
private static DialogAction adminAction(BiConsumer<Player, DialogResponseView> action) {
return DialogAction.customClick(
(response, audience) -> {
if(!(audience instanceof Player player)) return;
Bukkit.getScheduler().runTask(Main.instance(), () -> {
if(player.isOnline() && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player, response);
});
},
ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).lifetime(CALLBACK_LIFETIME).build()
);
}
private static ActionButton button(Component label, BiConsumer<Player, DialogResponseView> action) {
return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction(action)).build();
}
private static ActionButton button(String label, Consumer<Player> action) {
return button(Component.text(label), (player, response) -> action.accept(player));
}
private static void show(Player admin, String title, Component body, List<DialogInput> inputs, List<ActionButton> buttons) {
Dialog dialog = Dialog.create(factory -> factory.empty()
.base(DialogBase.builder(Component.text(title))
.canCloseWithEscape(true)
.afterAction(DialogBase.DialogAfterAction.CLOSE)
.body(List.of(DialogBody.plainMessage(body, BODY_WIDTH)))
.inputs(inputs)
.build())
.type(DialogType.multiAction(buttons)
.columns(1)
.exitAction(ActionButton.builder(Component.text("Schließen")).width(BUTTON_WIDTH).build())
.build()));
admin.showDialog(dialog);
}
}
@@ -0,0 +1,24 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
class BanEvasionListener extends ApplianceListener<BanEvasion> {
@EventHandler
public void onAttemptRecorded(LoginAttemptRecordedEvent event) {
this.getAppliance().onAttemptRecorded(event.getAttempt());
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
this.getAppliance().playerJoined(event.getPlayer());
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
this.getAppliance().playerLeft(event.getPlayer());
}
}
@@ -0,0 +1,634 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* All SQL of the ban evasion detection. Plain JDBC without Bukkit, every method runs on the caller's connection.
* Tables: resources/migrations/banEvasion, connection keys: login_attempts.network.
*/
final class BanEvasionRepository {
record CaseSummary(
long id,
UUID first,
String firstName,
UUID second,
String secondName,
CaseStatus status,
@Nullable Outcome outcome,
Severity severity,
Instant lastSeenAt,
int hits
) {
}
record CaseReason(Reason reason, Severity severity, Instant firstSeenAt) {
}
record TimelineEntry(Instant at, String name, @Nullable String network, String stage, String result) {
}
record CaseDetails(
CaseSummary summary,
boolean banStatusUnknown,
Instant createdAt,
@Nullable Instant closedAt,
@Nullable String closedByName,
@Nullable String note,
List<CaseReason> reasons,
List<String> sharedNetworks,
List<TimelineEntry> timeline
) {
}
record OpenCount(int total, int high) {
}
record Link(UUID uuid, String name, int sharedAttempts, Instant firstAt, Instant lastAt, @Nullable Long caseId) {
}
record Member(UUID uuid, String name) {
}
record Household(long id, Instant createdAt, @Nullable String createdByName, @Nullable String note, List<Member> members) {
}
record Exemption(long id, String network, Instant validFrom, Instant validUntil, @Nullable String createdByName, @Nullable String note) {
}
/**
* Latest known name of the account in the given column.
*/
private static final String NAME_OF = "(SELECT n.name FROM login_attempts n WHERE n.uuid = %s ORDER BY n.at DESC LIMIT 1)";
boolean isExempt(Connection connection, String network, Instant at) throws SQLException {
String sql = "SELECT 1 FROM ban_evasion_ip_exemptions WHERE network = ? AND ? BETWEEN valid_from AND valid_until LIMIT 1";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, network);
statement.setObject(2, toDb(at));
try(ResultSet result = statement.executeQuery()) {
return result.next();
}
}
}
List<Attempt> partnerAttempts(Connection connection, Attempt current, Duration window) throws SQLException {
String sql = """
SELECT a.id, a.at, a.uuid, a.name, a.network, a.stage, a.result
FROM login_attempts a
WHERE a.network = ? AND a.uuid <> ? AND a.at BETWEEN ? AND ?
AND NOT EXISTS (
SELECT 1 FROM ban_evasion_ip_exemptions e
WHERE e.network = a.network AND a.at BETWEEN e.valid_from AND e.valid_until
)
ORDER BY a.at
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, current.network());
statement.setString(2, current.uuid().toString());
statement.setObject(3, toDb(current.at().minus(window)));
statement.setObject(4, toDb(current.at()));
List<Attempt> attempts = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) attempts.add(new Attempt(
row.getLong("id"),
fromDb(row, "at"),
UUID.fromString(row.getString("uuid")),
row.getString("name"),
row.getString("network"),
row.getString("stage"),
row.getString("result")
));
}
return attempts;
}
}
Map<UUID, Long> householdsOf(Connection connection, Collection<UUID> uuids) throws SQLException {
if(uuids.isEmpty()) return Map.of();
String sql = "SELECT uuid, household_id FROM ban_evasion_household_members WHERE uuid IN (" + placeholders(uuids.size()) + ")";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
for(UUID uuid : uuids) statement.setString(index++, uuid.toString());
Map<UUID, Long> households = new HashMap<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) households.put(UUID.fromString(row.getString("uuid")), row.getLong("household_id"));
}
return households;
}
}
Map<Long, Set<UUID>> householdMembers(Connection connection, Collection<Long> householdIds) throws SQLException {
if(householdIds.isEmpty()) return Map.of();
String sql = "SELECT household_id, uuid FROM ban_evasion_household_members WHERE household_id IN (" + placeholders(householdIds.size()) + ")";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
for(long id : householdIds) statement.setLong(index++, id);
Map<Long, Set<UUID>> members = new HashMap<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next())
members.computeIfAbsent(row.getLong("household_id"), id -> new HashSet<>()).add(UUID.fromString(row.getString("uuid")));
}
return members;
}
}
@Nullable CaseDecision.ExistingCase findCase(Connection connection, UUID first, UUID second) throws SQLException {
long id;
CaseStatus status;
String outcome;
try(PreparedStatement statement = connection.prepareStatement("SELECT id, status, outcome FROM ban_evasion_cases WHERE uuid_a = ? AND uuid_b = ?")) {
statement.setString(1, first.toString());
statement.setString(2, second.toString());
try(ResultSet row = statement.executeQuery()) {
if(!row.next()) return null;
id = row.getLong("id");
status = CaseStatus.valueOf(row.getString("status"));
outcome = row.getString("outcome");
}
}
Set<Reason> reasons = EnumSet.noneOf(Reason.class);
this.reasonsOf(connection, id).forEach(reason -> reasons.add(reason.reason()));
return new CaseDecision.ExistingCase(id, status, outcome == null ? null : Outcome.valueOf(outcome), reasons);
}
/**
* Creates the case, or returns the id of the existing case of the pair.
*/
long createCase(Connection connection, PairFinding finding, Instant at) throws SQLException {
String sql = """
INSERT INTO ban_evasion_cases (uuid_a, uuid_b, status, severity, ban_status_unknown, created_at, last_seen_at, hits)
VALUES (?, ?, 'OPEN', ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
""";
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, finding.first().toString());
statement.setString(2, finding.second().toString());
statement.setString(3, finding.severity().name());
statement.setBoolean(4, finding.banStatusUnknown());
statement.setObject(5, toDb(at));
statement.setObject(6, toDb(at));
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys()) {
if(!keys.next()) throw new SQLException("no id returned for ban evasion case");
return keys.getLong(1);
}
}
}
void updateCase(Connection connection, long caseId, PairFinding finding, Instant at, boolean reopen) throws SQLException {
String sql = """
UPDATE ban_evasion_cases SET
last_seen_at = GREATEST(last_seen_at, ?),
hits = hits + 1,
severity = IF(severity = 'HIGH' OR ? = 'HIGH', 'HIGH', 'NORMAL'),
ban_status_unknown = ?,
status = IF(?, 'OPEN', status),
outcome = IF(?, NULL, outcome)
WHERE id = ?
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setObject(1, toDb(at));
statement.setString(2, finding.severity().name());
statement.setBoolean(3, finding.banStatusUnknown());
statement.setBoolean(4, reopen);
statement.setBoolean(5, reopen);
statement.setLong(6, caseId);
statement.executeUpdate();
}
}
void addReasons(Connection connection, long caseId, Map<Reason, Severity> reasons, Set<Reason> which, long attemptId, Instant at) throws SQLException {
if(which.isEmpty()) return;
String sql = "INSERT IGNORE INTO ban_evasion_case_reasons (case_id, reason, severity, first_seen_at, attempt_id) VALUES (?, ?, ?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
for(Reason reason : which) {
statement.setLong(1, caseId);
statement.setString(2, reason.name());
statement.setString(3, reasons.get(reason).name());
statement.setObject(4, toDb(at));
statement.setLong(5, attemptId);
statement.addBatch();
}
statement.executeBatch();
}
}
List<CaseSummary> listCases(Connection connection, CaseStatus status, int limit) throws SQLException {
String sql = """
SELECT c.id, c.uuid_a, c.uuid_b, c.status, c.outcome, c.severity, c.last_seen_at, c.hits,
%s AS name_a, %s AS name_b
FROM ban_evasion_cases c
WHERE c.status = ?
ORDER BY c.severity = 'HIGH' DESC, c.last_seen_at DESC
LIMIT ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"));
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, status.name());
statement.setInt(2, limit);
List<CaseSummary> cases = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) cases.add(summaryOf(row));
}
return cases;
}
}
OpenCount countOpen(Connection connection) throws SQLException {
String sql = "SELECT COUNT(*) AS total, COALESCE(SUM(severity = 'HIGH'), 0) AS high FROM ban_evasion_cases WHERE status = 'OPEN'";
try(PreparedStatement statement = connection.prepareStatement(sql); ResultSet row = statement.executeQuery()) {
row.next();
return new OpenCount(row.getInt("total"), row.getInt("high"));
}
}
@Nullable CaseDetails loadCase(Connection connection, long caseId, int timelineLimit) throws SQLException {
String sql = """
SELECT c.*, %s AS name_a, %s AS name_b, %s AS closed_by_name
FROM ban_evasion_cases c
WHERE c.id = ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"), NAME_OF.formatted("c.closed_by"));
CaseSummary summary;
boolean banStatusUnknown;
Instant createdAt;
Instant closedAt;
String closedByName;
String note;
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, caseId);
try(ResultSet row = statement.executeQuery()) {
if(!row.next()) return null;
summary = summaryOf(row);
banStatusUnknown = row.getBoolean("ban_status_unknown");
createdAt = fromDb(row, "created_at");
closedAt = nullableFromDb(row, "closed_at");
closedByName = row.getString("closed_by_name");
note = row.getString("note");
}
}
return new CaseDetails(
summary, banStatusUnknown, createdAt, closedAt, closedByName, note,
this.reasonsOf(connection, caseId),
this.sharedNetworks(connection, summary.first(), summary.second()),
this.timeline(connection, summary.first(), summary.second(), timelineLimit)
);
}
void closeCase(Connection connection, long caseId, Outcome outcome, UUID admin, @Nullable String note, Instant at) throws SQLException {
String sql = "UPDATE ban_evasion_cases SET status = 'CLOSED', outcome = ?, closed_at = ?, closed_by = ?, note = ? WHERE id = ?";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, outcome.name());
statement.setObject(2, toDb(at));
statement.setString(3, admin.toString());
statement.setString(4, note);
statement.setLong(5, caseId);
statement.executeUpdate();
}
}
/**
* All accounts that ever used one of the connections of the given account, most recent first.
*/
List<Link> linksOf(Connection connection, UUID uuid, int limit) throws SQLException {
String sql = """
SELECT o.uuid, %s AS name, COUNT(*) AS shared, MIN(o.at) AS first_at, MAX(o.at) AS last_at,
(SELECT c.id FROM ban_evasion_cases c
WHERE (c.uuid_a = ? AND c.uuid_b = o.uuid) OR (c.uuid_a = o.uuid AND c.uuid_b = ?)) AS case_id
FROM login_attempts o
WHERE o.uuid <> ? AND o.network IN (
SELECT s.network FROM login_attempts s WHERE s.uuid = ? AND s.network IS NOT NULL
)
GROUP BY o.uuid
ORDER BY last_at DESC
LIMIT ?
""".formatted(NAME_OF.formatted("o.uuid"));
try(PreparedStatement statement = connection.prepareStatement(sql)) {
for(int index = 1; index <= 4; index++) statement.setString(index, uuid.toString());
statement.setInt(5, limit);
List<Link> links = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) {
UUID other = UUID.fromString(row.getString("uuid"));
String name = nameOr(row.getString("name"), other);
int shared = row.getInt("shared");
Instant firstAt = fromDb(row, "first_at");
Instant lastAt = fromDb(row, "last_at");
long caseId = row.getLong("case_id");
boolean noCase = row.wasNull();
links.add(new Link(other, name, shared, firstAt, lastAt, noCase ? null : caseId));
}
}
return links;
}
}
@Nullable UUID uuidByName(Connection connection, String name) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("SELECT uuid FROM login_attempts WHERE name = ? ORDER BY at DESC LIMIT 1")) {
statement.setString(1, name);
try(ResultSet row = statement.executeQuery()) {
return row.next() ? UUID.fromString(row.getString("uuid")) : null;
}
}
}
String nameOf(Connection connection, UUID uuid) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("SELECT name FROM login_attempts WHERE uuid = ? ORDER BY at DESC LIMIT 1")) {
statement.setString(1, uuid.toString());
try(ResultSet row = statement.executeQuery()) {
return row.next() ? row.getString("name") : uuid.toString();
}
}
}
List<String> recentNetworks(Connection connection, UUID uuid, int limit) throws SQLException {
String sql = """
SELECT network, MAX(at) AS last_at FROM login_attempts
WHERE uuid = ? AND network IS NOT NULL
GROUP BY network ORDER BY last_at DESC LIMIT ?
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setInt(2, limit);
List<String> networks = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) networks.add(row.getString("network"));
}
return networks;
}
}
void addExemption(Connection connection, String network, Instant from, Instant until, UUID admin, @Nullable String note) throws SQLException {
String sql = "INSERT INTO ban_evasion_ip_exemptions (network, valid_from, valid_until, created_by, note) VALUES (?, ?, ?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, network);
statement.setObject(2, toDb(from));
statement.setObject(3, toDb(until));
statement.setString(4, admin.toString());
statement.setString(5, note);
statement.executeUpdate();
}
}
List<Exemption> activeExemptions(Connection connection, Instant now) throws SQLException {
String sql = """
SELECT e.id, e.network, e.valid_from, e.valid_until, e.note, %s AS created_by_name
FROM ban_evasion_ip_exemptions e
WHERE e.valid_until >= ?
ORDER BY e.valid_until
""".formatted(NAME_OF.formatted("e.created_by"));
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setObject(1, toDb(now));
List<Exemption> exemptions = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) exemptions.add(new Exemption(
row.getLong("id"),
row.getString("network"),
fromDb(row, "valid_from"),
fromDb(row, "valid_until"),
row.getString("created_by_name"),
row.getString("note")
));
}
return exemptions;
}
}
void endExemption(Connection connection, long id, Instant now) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("UPDATE ban_evasion_ip_exemptions SET valid_until = ? WHERE id = ? AND valid_until > ?")) {
statement.setObject(1, toDb(now));
statement.setLong(2, id);
statement.setObject(3, toDb(now));
statement.executeUpdate();
}
}
/**
* Puts the accounts into one household; existing households of these accounts are merged.
*/
void linkHousehold(Connection connection, Collection<UUID> uuids, UUID admin, @Nullable String note, Instant at) throws SQLException {
boolean autoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
try {
List<Long> existing = this.householdsOf(connection, uuids).values().stream().distinct().sorted().toList();
long target = existing.isEmpty() ? this.createHousehold(connection, admin, note, at) : existing.getFirst();
for(long other : existing.subList(Math.min(1, existing.size()), existing.size())) {
try(PreparedStatement move = connection.prepareStatement("UPDATE ban_evasion_household_members SET household_id = ? WHERE household_id = ?")) {
move.setLong(1, target);
move.setLong(2, other);
move.executeUpdate();
}
try(PreparedStatement delete = connection.prepareStatement("DELETE FROM ban_evasion_households WHERE id = ?")) {
delete.setLong(1, other);
delete.executeUpdate();
}
}
try(PreparedStatement insert = connection.prepareStatement("INSERT IGNORE INTO ban_evasion_household_members (uuid, household_id) VALUES (?, ?)")) {
for(UUID uuid : uuids) {
insert.setString(1, uuid.toString());
insert.setLong(2, target);
insert.addBatch();
}
insert.executeBatch();
}
connection.commit();
} catch(SQLException e) {
connection.rollback();
throw e;
} finally {
connection.setAutoCommit(autoCommit);
}
}
/**
* All households, newest first.
*/
List<Household> listHouseholds(Connection connection) throws SQLException {
return this.households(connection, "", null);
}
@Nullable Household household(Connection connection, long id) throws SQLException {
List<Household> households = this.households(connection, "WHERE h.id = ?", id);
return households.isEmpty() ? null : households.getFirst();
}
private List<Household> households(Connection connection, String where, @Nullable Long id) throws SQLException {
String sql = """
SELECT h.id, h.created_at, h.note, %s AS created_by_name, m.uuid, %s AS name
FROM ban_evasion_households h
JOIN ban_evasion_household_members m ON m.household_id = h.id
%s
ORDER BY h.id DESC, name
""".formatted(NAME_OF.formatted("h.created_by"), NAME_OF.formatted("m.uuid"), where);
Map<Long, Household> households = new LinkedHashMap<>();
try(PreparedStatement statement = connection.prepareStatement(sql)) {
if(id != null) statement.setLong(1, id);
try(ResultSet row = statement.executeQuery()) {
while(row.next()) {
Instant createdAt = fromDb(row, "created_at");
String createdByName = row.getString("created_by_name");
String householdNote = row.getString("note");
UUID uuid = UUID.fromString(row.getString("uuid"));
households.computeIfAbsent(row.getLong("id"), key -> new Household(key, createdAt, createdByName, householdNote, new ArrayList<>()))
.members().add(new Member(uuid, nameOr(row.getString("name"), uuid)));
}
}
}
return List.copyOf(households.values());
}
/**
* Removes the account from its household; a household with fewer than two members is dissolved.
*/
void removeFromHousehold(Connection connection, UUID uuid) throws SQLException {
Long household = this.householdsOf(connection, List.of(uuid)).get(uuid);
if(household == null) return;
try(PreparedStatement delete = connection.prepareStatement("DELETE FROM ban_evasion_household_members WHERE uuid = ?")) {
delete.setString(1, uuid.toString());
delete.executeUpdate();
}
String sql = "DELETE FROM ban_evasion_households WHERE id = ? AND (SELECT COUNT(*) FROM ban_evasion_household_members WHERE household_id = ?) < 2";
try(PreparedStatement dissolve = connection.prepareStatement(sql)) {
dissolve.setLong(1, household);
dissolve.setLong(2, household);
dissolve.executeUpdate();
}
}
int deleteExpired(Connection connection, int retentionMonths) throws SQLException {
int deleted = 0;
for(String sql : List.of(
"DELETE FROM ban_evasion_cases WHERE last_seen_at < UTC_TIMESTAMP() - INTERVAL ? MONTH",
"DELETE FROM ban_evasion_ip_exemptions WHERE valid_until < UTC_TIMESTAMP() - INTERVAL ? MONTH"
)) {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, retentionMonths);
deleted += statement.executeUpdate();
}
}
return deleted;
}
private long createHousehold(Connection connection, UUID admin, @Nullable String note, Instant at) throws SQLException {
String sql = "INSERT INTO ban_evasion_households (created_at, created_by, note) VALUES (?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setObject(1, toDb(at));
statement.setString(2, admin.toString());
statement.setString(3, note);
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys()) {
keys.next();
return keys.getLong(1);
}
}
}
private List<CaseReason> reasonsOf(Connection connection, long caseId) throws SQLException {
String sql = "SELECT reason, severity, first_seen_at FROM ban_evasion_case_reasons WHERE case_id = ? ORDER BY first_seen_at, reason";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, caseId);
List<CaseReason> reasons = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) reasons.add(new CaseReason(
Reason.valueOf(row.getString("reason")),
Severity.valueOf(row.getString("severity")),
fromDb(row, "first_seen_at")
));
}
return reasons;
}
}
private List<String> sharedNetworks(Connection connection, UUID first, UUID second) throws SQLException {
String sql = """
SELECT DISTINCT a.network FROM login_attempts a
WHERE a.uuid = ? AND a.network IS NOT NULL
AND EXISTS (SELECT 1 FROM login_attempts b WHERE b.uuid = ? AND b.network = a.network)
ORDER BY a.network
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, first.toString());
statement.setString(2, second.toString());
List<String> networks = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) networks.add(row.getString("network"));
}
return networks;
}
}
private List<TimelineEntry> timeline(Connection connection, UUID first, UUID second, int limit) throws SQLException {
String sql = "SELECT at, name, network, stage, result FROM login_attempts WHERE uuid IN (?, ?) ORDER BY at DESC LIMIT ?";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, first.toString());
statement.setString(2, second.toString());
statement.setInt(3, limit);
List<TimelineEntry> entries = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) entries.add(new TimelineEntry(
fromDb(row, "at"), row.getString("name"), row.getString("network"), row.getString("stage"), row.getString("result")
));
}
return entries;
}
}
private static CaseSummary summaryOf(ResultSet row) throws SQLException {
UUID first = UUID.fromString(row.getString("uuid_a"));
UUID second = UUID.fromString(row.getString("uuid_b"));
String outcome = row.getString("outcome");
return new CaseSummary(
row.getLong("id"),
first,
nameOr(row.getString("name_a"), first),
second,
nameOr(row.getString("name_b"), second),
CaseStatus.valueOf(row.getString("status")),
outcome == null ? null : Outcome.valueOf(outcome),
Severity.valueOf(row.getString("severity")),
fromDb(row, "last_seen_at"),
row.getInt("hits")
);
}
private static String nameOr(@Nullable String name, UUID uuid) {
return name != null ? name : uuid.toString();
}
private static String placeholders(int count) {
return String.join(", ", Collections.nCopies(count, "?"));
}
private static LocalDateTime toDb(Instant instant) {
return LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
}
private static Instant fromDb(ResultSet row, String column) throws SQLException {
return row.getObject(column, LocalDateTime.class).toInstant(ZoneOffset.UTC);
}
private static @Nullable Instant nullableFromDb(ResultSet row, String column) throws SQLException {
LocalDateTime value = row.getObject(column, LocalDateTime.class);
return value == null ? null : value.toInstant(ZoneOffset.UTC);
}
}
@@ -0,0 +1,10 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum BanState {
BANNED,
NOT_BANNED,
/**
* The website API could not be asked.
*/
UNKNOWN
}
@@ -0,0 +1,58 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
* Ban states from the website, kept for a short time. UNKNOWN (API unreachable) is never cached,
* but after a failure the website is not asked again for the backoff period, so an unreachable website
* does not slow down every evaluation. The lookup blocks (HTTP), so never call this on the main or database thread.
*/
final class BanStatusCache {
private record Entry(BanState state, Instant fetchedAt) {
}
private final Map<UUID, Entry> entries = new ConcurrentHashMap<>();
private final Function<UUID, BanState> lookup;
private final Duration ttl;
private final Duration backoff;
private final Clock clock;
private volatile Instant failingUntil = Instant.MIN;
BanStatusCache(Function<UUID, BanState> lookup, Duration ttl, Duration backoff, Clock clock) {
this.lookup = lookup;
this.ttl = ttl;
this.backoff = backoff;
this.clock = clock;
}
BanState get(UUID uuid) {
Instant now = this.clock.instant();
Entry entry = this.entries.get(uuid);
if(entry != null && entry.fetchedAt().plus(this.ttl).isAfter(now)) return entry.state();
if(now.isBefore(this.failingUntil)) return BanState.UNKNOWN;
BanState state = this.lookup.apply(uuid);
if(state == BanState.UNKNOWN) {
this.entries.remove(uuid);
this.failingUntil = this.clock.instant().plus(this.backoff);
} else {
this.entries.put(uuid, new Entry(state, now));
}
return state;
}
Map<UUID, BanState> getAll(Collection<UUID> uuids) {
Map<UUID, BanState> states = new LinkedHashMap<>();
uuids.forEach(uuid -> states.put(uuid, this.get(uuid)));
return states;
}
}
@@ -0,0 +1,42 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.util.EnumSet;
import java.util.Set;
/**
* Deduplication: one case per pair; admins are only notified about new information.
* A closed case reopens only for a new reason of severity HIGH, a case closed as confirmed evasion never:
* further attempts of the banned person are expected there and only recorded.
*/
final class CaseDecision {
record ExistingCase(long id, CaseStatus status, @Nullable Outcome outcome, Set<Reason> reasons) {
}
/**
* @param newReasons reasons not yet recorded for the case (all reasons for a new case)
*/
record Result(boolean reopen, Set<Reason> newReasons, boolean announce) {
}
private CaseDecision() {
}
static Result decide(@Nullable ExistingCase existing, PairFinding finding) {
if(existing == null) return new Result(false, Set.copyOf(finding.reasons().keySet()), true);
Set<Reason> newReasons = EnumSet.noneOf(Reason.class);
finding.reasons().keySet().stream()
.filter(reason -> !existing.reasons().contains(reason))
.forEach(newReasons::add);
boolean newRelevant = newReasons.stream().anyMatch(reason -> !reason.informational);
boolean newHigh = newReasons.stream()
.anyMatch(reason -> !reason.informational && finding.reasons().get(reason) == Severity.HIGH);
if(existing.status() == CaseStatus.OPEN) return new Result(false, newReasons, newRelevant);
if(existing.outcome() == Outcome.CONFIRMED) return new Result(false, newReasons, false);
return new Result(newHigh, newReasons, newHigh);
}
}
@@ -0,0 +1,102 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Rules of the ban evasion detection, free of Bukkit and database access.
* Judges one new login attempt against all attempts of other accounts from the same connection.
*/
final class CaseEvaluator {
/**
* @param partnerAttempts attempts of other accounts from the same connection within the time window, not exempted
* @param banStates ban state of the current account, its partners and all members of their households
* @param households household id per account, only for accounts in a household
* @param householdMembers members per household id
* @param onlineOnSameNetwork accounts currently online from the same connection
*/
record Input(
Attempt current,
List<Attempt> partnerAttempts,
Map<UUID, BanState> banStates,
Map<UUID, Long> households,
Map<Long, Set<UUID>> householdMembers,
Set<UUID> onlineOnSameNetwork,
Duration rejectionWindow
) {
}
private CaseEvaluator() {
}
static List<PairFinding> evaluate(Input input) {
UUID self = input.current().uuid();
Map<UUID, List<Attempt>> byPartner = new LinkedHashMap<>();
input.partnerAttempts().stream()
.filter(attempt -> !attempt.uuid().equals(self))
.forEach(attempt -> byPartner.computeIfAbsent(attempt.uuid(), uuid -> new ArrayList<>()).add(attempt));
List<PairFinding> findings = new ArrayList<>();
byPartner.forEach((partner, attempts) -> {
PairFinding finding = evaluatePair(input, partner, attempts);
if(finding != null) findings.add(finding);
});
return findings;
}
private static @Nullable PairFinding evaluatePair(Input input, UUID partner, List<Attempt> partnerAttempts) {
Attempt current = input.current();
BanState selfState = banState(input, current.uuid());
BanState partnerState = banState(input, partner);
Long household = input.households().get(current.uuid());
boolean sameHousehold = household != null && household.equals(input.households().get(partner));
if(sameHousehold && householdState(input, household) != BanState.BANNED) return null;
Map<Reason, Severity> reasons = new EnumMap<>(Reason.class);
reasons.put(Reason.SHARED_CONNECTION, Severity.NORMAL);
if(selfState == BanState.BANNED || partnerState == BanState.BANNED)
reasons.put(Reason.BANNED, Severity.HIGH);
Instant rejectionStart = current.at().minus(input.rejectionWindow());
boolean partnerRejectedRecently = partnerAttempts.stream()
.anyMatch(attempt -> attempt.isRejection()
&& !attempt.at().isBefore(rejectionStart)
&& !attempt.at().isAfter(current.at()));
if(partnerState == BanState.BANNED && partnerRejectedRecently)
reasons.put(Reason.AFTER_REJECTION, Severity.HIGH);
if(current.isWebsiteSignup())
reasons.put(Reason.WEBSITE_SIGNUP, partnerState == BanState.BANNED ? Severity.HIGH : Severity.NORMAL);
if(sameHousehold)
reasons.put(Reason.HOUSEHOLD_MEMBER_BANNED, Severity.HIGH);
if(input.onlineOnSameNetwork().contains(partner))
reasons.put(Reason.ONLINE_SIMULTANEOUSLY, Severity.NORMAL);
boolean unknown = selfState == BanState.UNKNOWN || partnerState == BanState.UNKNOWN;
return PairFinding.of(current.uuid(), partner, reasons, unknown);
}
private static BanState banState(Input input, UUID uuid) {
return input.banStates().getOrDefault(uuid, BanState.UNKNOWN);
}
private static BanState householdState(Input input, long household) {
Set<BanState> states = input.householdMembers().getOrDefault(household, Set.of()).stream()
.map(member -> banState(input, member))
.collect(Collectors.toSet());
if(states.contains(BanState.BANNED)) return BanState.BANNED;
if(states.contains(BanState.UNKNOWN)) return BanState.UNKNOWN;
return BanState.NOT_BANNED;
}
}
@@ -0,0 +1,6 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum CaseStatus {
OPEN,
CLOSED
}
@@ -0,0 +1,32 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* Connection key of every online player, readable from any thread.
*/
final class OnlineNetworks {
private final Map<UUID, String> networks = new ConcurrentHashMap<>();
void joined(UUID uuid, @Nullable String network) {
if(network == null) this.networks.remove(uuid);
else this.networks.put(uuid, network);
}
void left(UUID uuid) {
this.networks.remove(uuid);
}
Set<UUID> onlineOn(String network) {
return this.networks.entrySet().stream()
.filter(entry -> entry.getValue().equals(network))
.map(Map.Entry::getKey)
.collect(Collectors.toUnmodifiableSet());
}
}
@@ -0,0 +1,13 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum Outcome {
CONFIRMED("Umgehung bestätigt"),
UNFOUNDED("unbegründet"),
HOUSEHOLD("Haushalt");
final String label;
Outcome(String label) {
this.label = label;
}
}
@@ -0,0 +1,26 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import java.util.Collections;
import java.util.EnumMap;
import java.util.Map;
import java.util.UUID;
/**
* Reasons why two accounts may belong to the same person. The pair is ordered by the textual UUID,
* so (a, b) and (b, a) always map to the same case.
*/
record PairFinding(UUID first, UUID second, Map<Reason, Severity> reasons, boolean banStatusUnknown) {
static PairFinding of(UUID x, UUID y, Map<Reason, Severity> reasons, boolean banStatusUnknown) {
boolean ordered = x.toString().compareTo(y.toString()) < 0;
EnumMap<Reason, Severity> copy = new EnumMap<>(Reason.class);
copy.putAll(reasons);
return new PairFinding(ordered ? x : y, ordered ? y : x, Collections.unmodifiableMap(copy), banStatusUnknown);
}
Severity severity() {
return this.reasons.entrySet().stream()
.filter(entry -> !entry.getKey().informational)
.map(Map.Entry::getValue)
.reduce(Severity.NORMAL, Severity::max);
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum Reason {
SHARED_CONNECTION("gemeinsamer Anschluss", false),
BANNED("gebannt", false),
AFTER_REJECTION("nach Abweisung", false),
WEBSITE_SIGNUP("Webseiten-Anmeldung", false),
HOUSEHOLD_MEMBER_BANNED("Haushalt, Mitglied gebannt", false),
ONLINE_SIMULTANEOUSLY("gleichzeitig online", true);
final String label;
/**
* Informational reasons are shown in the case, but never trigger a notification.
*/
final boolean informational;
Reason(String label, boolean informational) {
this.label = label;
this.informational = informational;
}
}
@@ -0,0 +1,16 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum Severity {
NORMAL("normal"),
HIGH("hoch");
final String label;
Severity(String label) {
this.label = label;
}
static Severity max(Severity a, Severity b) {
return a.compareTo(b) >= 0 ? a : b;
}
}
@@ -26,10 +26,8 @@ import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static java.util.Map.entry;
import static org.bukkit.Sound.MUSIC_DISC_LAVA_CHICKEN;
public class ProjectStart extends Appliance {
@@ -57,17 +55,23 @@ public class ProjectStart extends Appliance {
)
);
private final Map<GameRule<Boolean>, Boolean> gameRulesAfterStart = Map.ofEntries(
entry(GameRule.DO_DAYLIGHT_CYCLE, true),
entry(GameRule.DO_INSOMNIA, true),
entry(GameRule.DISABLE_RAIDS, false),
entry(GameRule.DO_FIRE_TICK, true),
entry(GameRule.DO_ENTITY_DROPS, true),
entry(GameRule.DO_PATROL_SPAWNING, true),
entry(GameRule.DO_TRADER_SPAWNING, true),
entry(GameRule.DO_WEATHER_CYCLE, true),
entry(GameRule.FALL_DAMAGE, true),
entry(GameRule.FIRE_DAMAGE, true)
private record GameRuleSetting<T>(GameRule<T> rule, T before, T after) {
void apply(boolean afterStart) {
IteratorUtil.worlds(world -> world.setGameRule(this.rule, afterStart ? this.after : this.before));
}
}
private final List<GameRuleSetting<?>> gameRules = List.of(
new GameRuleSetting<>(GameRules.ADVANCE_TIME, false, true),
new GameRuleSetting<>(GameRules.SPAWN_PHANTOMS, false, true),
new GameRuleSetting<>(GameRules.RAIDS, false, true),
new GameRuleSetting<>(GameRules.ENTITY_DROPS, false, true),
new GameRuleSetting<>(GameRules.SPAWN_PATROLS, false, true),
new GameRuleSetting<>(GameRules.SPAWN_WANDERING_TRADERS, false, true),
new GameRuleSetting<>(GameRules.ADVANCE_WEATHER, false, true),
new GameRuleSetting<>(GameRules.FALL_DAMAGE, false, true),
new GameRuleSetting<>(GameRules.FIRE_DAMAGE, false, true),
new GameRuleSetting<>(GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER, 0, -1)
);
public ProjectStart() {
@@ -76,7 +80,7 @@ public class ProjectStart extends Appliance {
this.countdown.addCustomAnnouncement(
new Countdown.CustomAnnouncements(
counter -> counter == this.startMusicAt,
counter -> this.glassLocation
_ -> this.glassLocation
.getWorld()
.playSound(this.glassLocation, MUSIC_DISC_LAVA_CHICKEN, SoundCategory.RECORDS, 500f, 1f)
)
@@ -121,7 +125,7 @@ public class ProjectStart extends Appliance {
this.setEnabled(false);
IteratorUtil.worlds(World::getWorldBorder, worldBorder -> worldBorder.setSize(worldBorder.getMaxSize()));
IteratorUtil.worlds(world -> IteratorUtil.setGameRules(this.gameRulesAfterStart, false));
this.gameRules.forEach(setting -> setting.apply(true));
Bukkit.getWorlds().getFirst().setFullTime(0);
this.netherFireLocations.forEach(location -> Objects.requireNonNull(this.startWorld).getBlockAt(location).setType(Material.FIRE));
@@ -168,7 +172,7 @@ public class ProjectStart extends Appliance {
worldBorder.setDamageAmount(0);
});
IteratorUtil.worlds(world -> world, world -> IteratorUtil.setGameRules(this.gameRulesAfterStart, true));
this.gameRules.forEach(setting -> setting.apply(false));
this.resetAdvancements();
this.blockCycle.reset();
@@ -0,0 +1,55 @@
-- Suspected ban evasion: one case per pair of accounts that shared an internet connection.
CREATE TABLE ban_evasion_cases (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
uuid_a CHAR(36) NOT NULL COMMENT 'textually smaller UUID of the pair',
uuid_b CHAR(36) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'OPEN or CLOSED',
outcome VARCHAR(16) NULL COMMENT 'CONFIRMED, UNFOUNDED or HOUSEHOLD once closed',
severity VARCHAR(16) NOT NULL COMMENT 'NORMAL or HIGH',
ban_status_unknown BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'website API unreachable at the last evaluation',
created_at DATETIME(3) NOT NULL COMMENT 'UTC',
last_seen_at DATETIME(3) NOT NULL COMMENT 'UTC',
hits INT UNSIGNED NOT NULL DEFAULT 1,
closed_at DATETIME(3) NULL COMMENT 'UTC',
closed_by CHAR(36) NULL,
note VARCHAR(255) NULL,
UNIQUE KEY uq_pair (uuid_a, uuid_b),
KEY idx_uuid_b (uuid_b),
KEY idx_status (status, last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE ban_evasion_case_reasons (
case_id BIGINT UNSIGNED NOT NULL,
reason VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL,
first_seen_at DATETIME(3) NOT NULL COMMENT 'UTC',
attempt_id BIGINT UNSIGNED NOT NULL COMMENT 'login_attempts.id that first produced the reason',
PRIMARY KEY (case_id, reason),
CONSTRAINT fk_reason_case FOREIGN KEY (case_id) REFERENCES ban_evasion_cases (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Accounts marked by admins as one household (siblings, ...): no reports among them unless a member is banned.
CREATE TABLE ban_evasion_households (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME(3) NOT NULL COMMENT 'UTC',
created_by CHAR(36) NOT NULL,
note VARCHAR(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE ban_evasion_household_members (
uuid CHAR(36) NOT NULL PRIMARY KEY COMMENT 'an account belongs to at most one household',
household_id BIGINT UNSIGNED NOT NULL,
KEY idx_household (household_id),
CONSTRAINT fk_member_household FOREIGN KEY (household_id) REFERENCES ban_evasion_households (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Temporary exemptions of a connection (LAN party, ...): attempts within the period are not evaluated.
CREATE TABLE ban_evasion_ip_exemptions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
network VARCHAR(45) NOT NULL COMMENT 'same format as login_attempts.network',
valid_from DATETIME(3) NOT NULL COMMENT 'UTC',
valid_until DATETIME(3) NOT NULL COMMENT 'UTC',
created_by CHAR(36) NOT NULL,
note VARCHAR(255) NULL,
KEY idx_network (network, valid_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+1 -1
View File
@@ -2,7 +2,7 @@ dependencies {
implementation project(':common')
implementation project(':core')
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+4 -4
View File
@@ -1,6 +1,6 @@
tasks.register('deployVaroPlugin', Copy) {
dependsOn ":varo:shadowJar"
from { project(":varo").shadowJar.archivePath }
from { project(":varo").shadowJar.archiveFile }
into file('path') // path to plugins folder
rename { fileName -> "varo.jar" }
}
@@ -10,15 +10,15 @@ tasks.register("uploadVaroPlugin") {
doLast {
def jarFile = project(":varo").tasks.named("shadowJar").get().outputs.files.singleFile
exec {
providers.exec {
commandLine "scp", "-4", "-P", "22", jarFile.absolutePath, "user@host:path/varo.jar"
}
}.result.get()
}
}
tasks.register('deployCraftAttackPlugin', Copy) {
dependsOn ":craftattack:shadowJar"
from { project(":craftattack").shadowJar.archivePath }
from { project(":craftattack").shadowJar.archiveFile }
into file('path') // path to plugins folder
rename { fileName -> "craftattack.jar" }
}
+1 -1
View File
@@ -2,7 +2,7 @@ dependencies {
implementation project(':core')
implementation project(':common')
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
}
@@ -11,7 +11,7 @@ import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.inventory.meta.PotionMeta;
import org.bukkit.potion.PotionEffect;
import javax.annotation.Nullable;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
class ForbiddenPotionsListener extends ApplianceListener<ForbiddenItems> {