From e3de258124dc2bd42a24f6d00f07e15004d4a361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 14:38:54 +0200 Subject: [PATCH 01/17] added shared database access and file based migrations to core --- .../eu/mhsl/craftattack/spawn/core/Main.java | 10 ++ .../spawn/core/appliance/Appliance.java | 10 ++ .../spawn/core/database/Database.java | 131 +++++++++++++++++ .../spawn/core/database/Migrations.java | 138 ++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java create mode 100644 core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java index a0ace2f..06d2470 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java @@ -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 appliances; private RepositoryLoader repositoryLoader; + private Database database; private Reflections reflections; @Override @@ -42,6 +44,8 @@ public final class Main extends JavaPlugin { Configuration.readConfig(); List 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 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; } diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java index f59454b..1cc14fd 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java @@ -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//} in the resources. + */ + private String migrationsName() { + String name = this.getClass().getSimpleName(); + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } + protected T queryAppliance(Class clazz) { return Main.instance().getAppliance(clazz); } diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java new file mode 100644 index 0000000..c03aafc --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java @@ -0,0 +1,131 @@ +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 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 CompletableFuture query(SqlQuery query) { + CompletableFuture 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); + } + }); + 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(); + } +} diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java new file mode 100644 index 0000000..ac01ed3 --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java @@ -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//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> 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 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 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> findMigrations() { + if(migrationsByAppliance != null) return migrationsByAppliance; + Map> 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 appliedVersions(Connection connection, String appliance) throws SQLException { + Set 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); + } + } +} From 6bfb14db2b3b593e7cca9c0f8f8f57981e06469d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 14:38:54 +0200 Subject: [PATCH 02/17] added LoginLog appliance recording login attempts with IP --- .../tooling/loginLog/LoginAttempt.java | 31 ++++ .../appliances/tooling/loginLog/LoginLog.java | 147 ++++++++++++++++++ .../tooling/loginLog/LoginLogListener.java | 34 ++++ .../loginLog/001_create_login_attempts.sql | 15 ++ core/src/main/resources/config.yml | 10 ++ 5 files changed, 237 insertions(+) create mode 100644 common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java create mode 100644 common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java create mode 100644 common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java create mode 100644 common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java new file mode 100644 index 0000000..65ac0f1 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java @@ -0,0 +1,31 @@ +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 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); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java new file mode 100644 index 0000000..ed39f5f --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java @@ -0,0 +1,147 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog; + +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import eu.mhsl.craftattack.spawn.core.database.Database; +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.geysermc.floodgate.api.FloodgateApi; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.InetAddress; +import java.sql.PreparedStatement; +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.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. + * 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 pendingAttempts = new ConcurrentHashMap<>(); + private final List 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), this.isBedrock(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), this.isBedrock(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 sql = "INSERT INTO login_attempts (at, uuid, name, ip, bedrock, stage, result, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + this.database.execute("recording login attempt of " + attempt.name(), connection -> { + try(PreparedStatement statement = connection.prepareStatement(sql)) { + 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.setBoolean(5, attempt.bedrock()); + statement.setString(6, attempt.stage()); + statement.setString(7, attempt.result()); + statement.setString(8, attempt.reason()); + statement.executeUpdate(); + } + }); + } + + 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 boolean isBedrock(UUID uuid) { + try { + return FloodgateApi.getInstance().isFloodgatePlayer(uuid); + } catch(Throwable e) { + return false; + } + } + + 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 listeners() { + return List.of(new LoginLogListener()); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java new file mode 100644 index 0000000..bf13108 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java @@ -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 { + @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() + ); + } +} diff --git a/common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql b/common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql new file mode 100644 index 0000000..9440545 --- /dev/null +++ b/common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql @@ -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; diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index f254c42..59ca5b2 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -7,6 +7,16 @@ api: secret: secret baseurl: https://mhsl.eu/craftattack/api/ +database: + host: + port: 3306 + name: craftattack + user: + password: + +loginLog: + retentionMonths: 16 + worldMuseum: uuid: connect-server-name: worldmuseum From 097222a56567656d013e1710faaa0033f4e9f423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 14:47:59 +0200 Subject: [PATCH 03/17] fixed api error responses failing to serialize exceptions --- .../eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java index 1b86556..02be1c5 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java @@ -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); } From 490d8b989142091e26ca78628bbcf255bf3eced7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 14:47:59 +0200 Subject: [PATCH 04/17] added floodgate uuid check to bedrock utils --- .../spawn/core/util/server/Floodgate.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/server/Floodgate.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/server/Floodgate.java index b71cc09..22bc3e9 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/server/Floodgate.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/server/Floodgate.java @@ -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 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 callback) { From 095db7e796b3037893852894355d1ce589cc38ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 14:47:59 +0200 Subject: [PATCH 05/17] added website endpoint to LoginLog for reporting signup and login IPs --- .../tooling/loginLog/LoginAttempt.java | 1 + .../appliances/tooling/loginLog/LoginLog.java | 47 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java index 65ac0f1..d8e098b 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java @@ -19,6 +19,7 @@ record LoginAttempt( ) { 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) { diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java index ed39f5f..0535378 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java @@ -1,14 +1,16 @@ 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.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.geysermc.floodgate.api.FloodgateApi; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -20,6 +22,7 @@ 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; @@ -27,6 +30,7 @@ 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. + * 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 { @@ -68,7 +72,7 @@ public class LoginLog extends Appliance { 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), this.isBedrock(uuid), + Instant.now(), uuid, name, LoginAttempt.ipOf(address), Floodgate.isFloodgateUuid(uuid), LoginAttempt.STAGE_PRE_LOGIN, result, plainText(kickMessage) ); @@ -82,7 +86,7 @@ public class LoginLog extends Appliance { LoginAttempt pending = this.pendingAttempts.remove(uuid); LoginAttempt attempt = pending != null ? pending - : new LoginAttempt(Instant.now(), uuid, name, LoginAttempt.ipOf(address), this.isBedrock(uuid), "", "", null); + : new LoginAttempt(Instant.now(), uuid, name, LoginAttempt.ipOf(address), Floodgate.isFloodgateUuid(uuid), "", "", null); this.write(attempt.withOutcome(LoginAttempt.STAGE_LOGIN, result, plainText(kickMessage))); } @@ -124,12 +128,41 @@ public class LoginLog extends Appliance { }); } - private boolean isBedrock(UUID uuid) { + 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 { - return FloodgateApi.getInstance().isFloodgatePlayer(uuid); - } catch(Throwable e) { - return false; + 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) { From 86ae261c60dd6e3568de7afbb5f3f161bec54a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 17:00:11 +0200 Subject: [PATCH 06/17] added connection key and recorded event to LoginLog --- .../appliances/tooling/loginLog/LoginLog.java | 27 +++++-- .../migrations/loginLog/002_add_network.sql | 6 ++ .../core/event/LoginAttemptRecordedEvent.java | 49 +++++++++++++ .../spawn/core/util/network/Networks.java | 72 +++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 common/src/main/resources/migrations/loginLog/002_add_network.sql create mode 100644 core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java create mode 100644 core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java index 0535378..e1f05b6 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java @@ -5,6 +5,8 @@ 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; @@ -16,6 +18,8 @@ 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; @@ -30,6 +34,7 @@ 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. */ @@ -101,19 +106,29 @@ public class LoginLog extends Appliance { } private void write(LoginAttempt attempt) { - String sql = "INSERT INTO login_attempts (at, uuid, name, ip, bedrock, stage, result, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + 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 -> { - try(PreparedStatement statement = connection.prepareStatement(sql)) { + 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.setBoolean(5, attempt.bedrock()); - statement.setString(6, attempt.stage()); - statement.setString(7, attempt.result()); - statement.setString(8, attempt.reason()); + 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() + ))); }); } diff --git a/common/src/main/resources/migrations/loginLog/002_add_network.sql b/common/src/main/resources/migrations/loginLog/002_add_network.sql new file mode 100644 index 0000000..fa8c100 --- /dev/null +++ b/common/src/main/resources/migrations/loginLog/002_add_network.sql @@ -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 network VARCHAR(45) NULL COMMENT 'IPv4 address or IPv6 /64 prefix, NULL if unknown' AFTER ip, + ADD KEY idx_network_at (network, at); + +UPDATE login_attempts SET network = ip WHERE ip REGEXP '^[0-9]{1,3}([.][0-9]{1,3}){3}$'; diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java new file mode 100644 index 0000000..3646f30 --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java @@ -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; + } +} diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java new file mode 100644 index 0000000..af47cb3 --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java @@ -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); + } +} From 675d87f6e92159e0c8f53ad2e621ff9d038ce37f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 17:04:06 +0200 Subject: [PATCH 07/17] added optional permission for appliance commands --- .../mhsl/craftattack/spawn/core/appliance/Appliance.java | 1 + .../spawn/core/appliance/ApplianceCommand.java | 8 ++++++++ core/src/main/resources/config.yml | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java index 1cc14fd..e6abb5b 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java @@ -139,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); diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/ApplianceCommand.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/ApplianceCommand.java index 0de6055..810b547 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/ApplianceCommand.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/ApplianceCommand.java @@ -30,6 +30,14 @@ public abstract class ApplianceCommand 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 { diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 59ca5b2..ae6a280 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -17,6 +17,12 @@ database: loginLog: retentionMonths: 16 +banEvasion: + windowDays: 14 + rejectionWindowHours: 24 + banCacheMinutes: 5 + retentionMonths: 16 + worldMuseum: uuid: connect-server-name: worldmuseum From 84c3e7df136acbeaa81a4f6ff4f123a1db2b48f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 17:04:06 +0200 Subject: [PATCH 08/17] added BanEvasion appliance --- .../banEvasion/AccountLinksCommand.java | 35 + .../tooling/banEvasion/AdminNotifier.java | 77 +++ .../tooling/banEvasion/Attempt.java | 22 + .../tooling/banEvasion/BanEvasion.java | 272 ++++++++ .../tooling/banEvasion/BanEvasionDialogs.java | 367 +++++++++++ .../banEvasion/BanEvasionListener.java | 24 + .../banEvasion/BanEvasionRepository.java | 615 ++++++++++++++++++ .../tooling/banEvasion/BanState.java | 10 + .../tooling/banEvasion/BanStatusCache.java | 48 ++ .../tooling/banEvasion/CaseDecision.java | 40 ++ .../tooling/banEvasion/CaseEvaluator.java | 102 +++ .../tooling/banEvasion/CaseStatus.java | 6 + .../tooling/banEvasion/OnlineNetworks.java | 32 + .../tooling/banEvasion/Outcome.java | 13 + .../tooling/banEvasion/PairFinding.java | 26 + .../appliances/tooling/banEvasion/Reason.java | 21 + .../tooling/banEvasion/Severity.java | 16 + .../banEvasion/001_create_tables.sql | 55 ++ 18 files changed, 1781 insertions(+) create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionListener.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanState.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseStatus.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/OnlineNetworks.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Outcome.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/PairFinding.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Reason.java create mode 100644 craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Severity.java create mode 100644 craftattack/src/main/resources/migrations/banEvasion/001_create_tables.sql diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java new file mode 100644 index 0000000..43fb7b6 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java @@ -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 – accounts sharing a connection with the player. + * Only visible to admins. + */ +class AccountLinksCommand extends ApplianceCommand.PlayerChecked { + 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]); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java new file mode 100644 index 0000000..440b265 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java @@ -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 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; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java new file mode 100644 index 0000000..0f9b9df --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java @@ -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_"); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java new file mode 100644 index 0000000..d1058d1 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java @@ -0,0 +1,272 @@ +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.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +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; + + @FunctionalInterface + interface Loader { + T load() throws Exception; + } + + private record Households(Map ofAccount, Map> members) { + } + + private final BanEvasionRepository repository = new BanEvasionRepository(); + private final OnlineNetworks onlineNetworks = new OnlineNetworks(); + private Database database; + private ExecutorService worker; + 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)), Clock.systemUTC()); + 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(); + } + + 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 query(Database.SqlQuery query) { + return this.database.query(query).join(); + } + + /** + * Blocks for HTTP requests. Only for the worker thread. + */ + Map banStates(Collection 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. + */ + void loadFor(Player admin, Loader loader, Consumer 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 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 online) { + try { + if(this.query(connection -> this.repository.isExempt(connection, attempt.network(), attempt.at()))) return; + List partners = this.query(connection -> this.repository.partnerAttempts(connection, attempt, this.window)); + if(partners.isEmpty()) return; + + Set involved = new HashSet<>(); + involved.add(attempt.uuid()); + partners.forEach(partner -> involved.add(partner.uuid())); + Households households = this.query(connection -> { + Map ofAccount = this.repository.householdsOf(connection, involved); + return new Households(ofAccount, this.repository.householdMembers(connection, new HashSet<>(ofAccount.values()))); + }); + + Set banStateNeeded = new HashSet<>(involved); + households.members().values().forEach(banStateNeeded::addAll); + Map banStates = this.banStatus.getAll(banStateNeeded); + + List 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 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() + ); + } + + private BanState lookupBanState(UUID uuid) { + try { + ReqResp 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 strikes = Objects.requireNonNullElse(response.data().strikes(), List.of()); + return this.queryAppliance(Strikes.class).isBanned(strikes).isBanned() ? BanState.BANNED : BanState.NOT_BANNED; + } catch(RuntimeException e) { + Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: " + e.getMessage()); + return BanState.UNKNOWN; + } + } + + 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 listeners() { + return List.of(new BanEvasionListener()); + } + + @Override + protected @NotNull List> commands() { + return List.of(new AccountLinksCommand()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java new file mode 100644 index 0000000..8b22ce7 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java @@ -0,0 +1,367 @@ +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 banStates, Map households) { + } + + private record LinksView(String name, List links, List networks) { + } + + private final BanEvasion appliance; + + BanEvasionDialogs(BanEvasion appliance) { + this.appliance = appliance; + } + + /** + * Click event for chat messages that only works for admins. + */ + static ClickEvent adminClick(Consumer 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 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 accounts = List.of(details.summary().first(), details.summary().second()); + Map 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) + )), view -> { + List lines = new ArrayList<>(); + 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 buttons = new ArrayList<>(); + 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 networks) { + List 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 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 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 -> { + List lines = new ArrayList<>(); + if(exemptions.isEmpty()) lines.add(Component.text("Keine aktiven IP-Ausnahmen.")); + exemptions.forEach(exemption -> lines.add(Component.text(String.format("%s · %s bis %s · %s%s", + exemption.network(), TIME.format(exemption.validFrom()), TIME.format(exemption.validUntil()), + exemption.createdByName() == null ? "?" : exemption.createdByName(), + exemption.note() == null ? "" : " · " + exemption.note())))); + + List buttons = new ArrayList<>(); + exemptions.forEach(exemption -> buttons.add(button("Beenden: " + exemption.network(), player -> this.endExemption(player, exemption)))); + buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN))); + show(admin, "IP-Ausnahmen", lines(lines), List.of(), buttons); + }); + } + + void openHouseholds(Player admin) { + this.appliance.loadFor(admin, () -> this.appliance.query(this.appliance.repository()::listHouseholds), households -> { + List lines = new ArrayList<>(); + if(households.isEmpty()) lines.add(Component.text("Keine Haushalte.")); + households.forEach(household -> lines.add(Component.text(String.format("#%d: %s%s", + household.id(), + household.members().stream().map(BanEvasionRepository.Member::name).collect(Collectors.joining(", ")), + household.note() == null ? "" : " · " + household.note())))); + + List buttons = new ArrayList<>(); + households.forEach(household -> household.members().forEach(member -> + buttons.add(button("Aus #" + household.id() + " entfernen: " + member.name(), player -> this.removeMember(player, member))))); + buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN))); + show(admin, "Haushalte", 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 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 inputs = new ArrayList<>(); + List 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 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, 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 by %s", member.name(), admin.getName())); + this.openHouseholds(admin); + }); + } + + 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 networks, boolean full) { + if(networks.isEmpty()) return "keine"; + return networks.stream().map(network -> network(network, full)).collect(Collectors.joining(", ")); + } + + private static Component lines(List lines) { + return Component.join(JoinConfiguration.newlines(), lines); + } + + private static DialogAction adminAction(BiConsumer 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 action) { + return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction(action)).build(); + } + + private static ActionButton button(String label, Consumer action) { + return button(Component.text(label), (player, response) -> action.accept(player)); + } + + private static void show(Player admin, String title, Component body, List inputs, List 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); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionListener.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionListener.java new file mode 100644 index 0000000..f66d7e4 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionListener.java @@ -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 { + @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()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java new file mode 100644 index 0000000..353825b --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java @@ -0,0 +1,615 @@ +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 reasons, + List sharedNetworks, + List 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, @Nullable String note, List 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 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 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 householdsOf(Connection connection, Collection 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 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> householdMembers(Connection connection, Collection 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> 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; + try(PreparedStatement statement = connection.prepareStatement("SELECT id, status 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")); + } + } + Set reasons = EnumSet.noneOf(Reason.class); + this.reasonsOf(connection, id).forEach(reason -> reasons.add(reason.reason())); + return new CaseDecision.ExistingCase(id, status, 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 reasons, Set 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 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 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 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 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 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 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 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 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 uuids, UUID admin, @Nullable String note, Instant at) throws SQLException { + boolean autoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + List 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); + } + } + + List listHouseholds(Connection connection) throws SQLException { + String sql = """ + SELECT h.id, h.note, m.uuid, %s AS name + FROM ban_evasion_households h + JOIN ban_evasion_household_members m ON m.household_id = h.id + ORDER BY h.id, name + """.formatted(NAME_OF.formatted("m.uuid")); + Map households = new LinkedHashMap<>(); + try(PreparedStatement statement = connection.prepareStatement(sql); ResultSet row = statement.executeQuery()) { + while(row.next()) { + long id = row.getLong("id"); + String householdNote = row.getString("note"); + UUID uuid = UUID.fromString(row.getString("uuid")); + households.computeIfAbsent(id, key -> new Household(key, 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 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 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 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 networks = new ArrayList<>(); + try(ResultSet row = statement.executeQuery()) { + while(row.next()) networks.add(row.getString("network")); + } + return networks; + } + } + + private List 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 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); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanState.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanState.java new file mode 100644 index 0000000..c28ccc4 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanState.java @@ -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 +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java new file mode 100644 index 0000000..31c10b6 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java @@ -0,0 +1,48 @@ +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. + * 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 entries = new ConcurrentHashMap<>(); + private final Function lookup; + private final Duration ttl; + private final Clock clock; + + BanStatusCache(Function lookup, Duration ttl, Clock clock) { + this.lookup = lookup; + this.ttl = ttl; + 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(); + + BanState state = this.lookup.apply(uuid); + if(state == BanState.UNKNOWN) this.entries.remove(uuid); + else this.entries.put(uuid, new Entry(state, now)); + return state; + } + + Map getAll(Collection uuids) { + Map states = new LinkedHashMap<>(); + uuids.forEach(uuid -> states.put(uuid, this.get(uuid))); + return states; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java new file mode 100644 index 0000000..a204734 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java @@ -0,0 +1,40 @@ +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. + */ +final class CaseDecision { + record ExistingCase(long id, CaseStatus status, Set reasons) { + } + + /** + * @param newReasons reasons not yet recorded for the case (all reasons for a new case) + */ + record Result(boolean reopen, Set 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 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); + return new Result(newHigh, newReasons, newHigh); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java new file mode 100644 index 0000000..268691c --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java @@ -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 partnerAttempts, + Map banStates, + Map households, + Map> householdMembers, + Set onlineOnSameNetwork, + Duration rejectionWindow + ) { + } + + private CaseEvaluator() { + } + + static List evaluate(Input input) { + UUID self = input.current().uuid(); + Map> byPartner = new LinkedHashMap<>(); + input.partnerAttempts().stream() + .filter(attempt -> !attempt.uuid().equals(self)) + .forEach(attempt -> byPartner.computeIfAbsent(attempt.uuid(), uuid -> new ArrayList<>()).add(attempt)); + + List 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 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 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 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; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseStatus.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseStatus.java new file mode 100644 index 0000000..3b969ab --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseStatus.java @@ -0,0 +1,6 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion; + +enum CaseStatus { + OPEN, + CLOSED +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/OnlineNetworks.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/OnlineNetworks.java new file mode 100644 index 0000000..b9ec6ba --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/OnlineNetworks.java @@ -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 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 onlineOn(String network) { + return this.networks.entrySet().stream() + .filter(entry -> entry.getValue().equals(network)) + .map(Map.Entry::getKey) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Outcome.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Outcome.java new file mode 100644 index 0000000..4fa0be0 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Outcome.java @@ -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; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/PairFinding.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/PairFinding.java new file mode 100644 index 0000000..5740aca --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/PairFinding.java @@ -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 reasons, boolean banStatusUnknown) { + static PairFinding of(UUID x, UUID y, Map reasons, boolean banStatusUnknown) { + boolean ordered = x.toString().compareTo(y.toString()) < 0; + EnumMap 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); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Reason.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Reason.java new file mode 100644 index 0000000..c0d038d --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Reason.java @@ -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; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Severity.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Severity.java new file mode 100644 index 0000000..55a27d4 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Severity.java @@ -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; + } +} diff --git a/craftattack/src/main/resources/migrations/banEvasion/001_create_tables.sql b/craftattack/src/main/resources/migrations/banEvasion/001_create_tables.sql new file mode 100644 index 0000000..3e2f606 --- /dev/null +++ b/craftattack/src/main/resources/migrations/banEvasion/001_create_tables.sql @@ -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; From ee265a858cd9f77438e5984c2de06ba6dd7bfd84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 17:06:18 +0200 Subject: [PATCH 09/17] made network migration repeatable --- .../main/resources/migrations/loginLog/002_add_network.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/main/resources/migrations/loginLog/002_add_network.sql b/common/src/main/resources/migrations/loginLog/002_add_network.sql index fa8c100..64f61c6 100644 --- a/common/src/main/resources/migrations/loginLog/002_add_network.sql +++ b/common/src/main/resources/migrations/loginLog/002_add_network.sql @@ -1,6 +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 network VARCHAR(45) NULL COMMENT 'IPv4 address or IPv6 /64 prefix, NULL if unknown' AFTER ip, - ADD KEY idx_network_at (network, at); + 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}$'; From f4faf763e3279bf306704d089b60ecd9523e8f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 17:12:24 +0200 Subject: [PATCH 10/17] fixed database queries never completing on runtime errors --- .../java/eu/mhsl/craftattack/spawn/core/database/Database.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java index c03aafc..153ec94 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java @@ -93,6 +93,8 @@ public class Database { } catch(SQLException e) { this.closeConnection(); future.completeExceptionally(e); + } catch(RuntimeException e) { + future.completeExceptionally(e); } }); return future; From 66dace7f2ae645d2305a2f1eae17cdbbf369cac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 17:12:24 +0200 Subject: [PATCH 11/17] added timeouts to ban evasion lookups --- .../tooling/banEvasion/BanEvasion.java | 48 +++++++++++++++---- .../tooling/banEvasion/BanStatusCache.java | 20 ++++++-- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java index d1058d1..4a4cc26 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java @@ -32,9 +32,13 @@ 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; @@ -49,6 +53,9 @@ 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 { @@ -62,6 +69,7 @@ public class BanEvasion extends Appliance { private final OnlineNetworks onlineNetworks = new OnlineNetworks(); private Database database; private ExecutorService worker; + private ExecutorService httpExecutor; private BukkitTask retentionTask; private BanStatusCache banStatus; private AdminNotifier notifier; @@ -86,7 +94,12 @@ public class BanEvasion extends Appliance { 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)), Clock.systemUTC()); + 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); @@ -103,6 +116,7 @@ public class BanEvasion extends Appliance { 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() { @@ -121,7 +135,8 @@ public class BanEvasion extends Appliance { * Runs the query on the database thread and waits for it. Only for the worker thread. */ T query(Database.SqlQuery query) { - return this.database.query(query).join(); + // 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(); } /** @@ -240,19 +255,34 @@ public class BanEvasion extends Appliance { ); } + /** + * 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 { - ReqResp 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 strikes = Objects.requireNonNullElse(response.data().strikes(), List.of()); - return this.queryAppliance(Strikes.class).isBanned(strikes).isBanned() ? BanState.BANNED : BanState.NOT_BANNED; - } catch(RuntimeException e) { - Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: " + e.getMessage()); + 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 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 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); diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java index 31c10b6..10c9bd9 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java @@ -11,8 +11,9 @@ 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. - * The lookup blocks (HTTP), so never call this on the main or database thread. + * 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) { @@ -21,11 +22,14 @@ final class BanStatusCache { private final Map entries = new ConcurrentHashMap<>(); private final Function lookup; private final Duration ttl; + private final Duration backoff; private final Clock clock; + private volatile Instant failingUntil = Instant.MIN; - BanStatusCache(Function lookup, Duration ttl, Clock clock) { + BanStatusCache(Function lookup, Duration ttl, Duration backoff, Clock clock) { this.lookup = lookup; this.ttl = ttl; + this.backoff = backoff; this.clock = clock; } @@ -34,9 +38,15 @@ final class BanStatusCache { 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); - else this.entries.put(uuid, new Entry(state, now)); + 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; } From 974c5c5200364eea401b7c607a329664256d7b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 17:16:13 +0200 Subject: [PATCH 12/17] confirmed ban evasion cases stay closed --- .../banEvasion/BanEvasionRepository.java | 45 +++++++++++++------ .../tooling/banEvasion/CaseDecision.java | 6 ++- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java index 353825b..fabd57a 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java @@ -70,7 +70,7 @@ final class BanEvasionRepository { record Member(UUID uuid, String name) { } - record Household(long id, @Nullable String note, List members) { + record Household(long id, Instant createdAt, @Nullable String createdByName, @Nullable String note, List members) { } record Exemption(long id, String network, Instant validFrom, Instant validUntil, @Nullable String createdByName, @Nullable String note) { @@ -156,18 +156,20 @@ final class BanEvasionRepository { @Nullable CaseDecision.ExistingCase findCase(Connection connection, UUID first, UUID second) throws SQLException { long id; CaseStatus status; - try(PreparedStatement statement = connection.prepareStatement("SELECT id, status FROM ban_evasion_cases WHERE uuid_a = ? AND uuid_b = ?")) { + 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 reasons = EnumSet.noneOf(Reason.class); this.reasonsOf(connection, id).forEach(reason -> reasons.add(reason.reason())); - return new CaseDecision.ExistingCase(id, status, reasons); + return new CaseDecision.ExistingCase(id, status, outcome == null ? null : Outcome.valueOf(outcome), reasons); } /** @@ -458,21 +460,38 @@ final class BanEvasionRepository { } } + /** + * All households, newest first. + */ List listHouseholds(Connection connection) throws SQLException { + return this.households(connection, "", null); + } + + @Nullable Household household(Connection connection, long id) throws SQLException { + List households = this.households(connection, "WHERE h.id = ?", id); + return households.isEmpty() ? null : households.getFirst(); + } + + private List households(Connection connection, String where, @Nullable Long id) throws SQLException { String sql = """ - SELECT h.id, h.note, m.uuid, %s AS name + 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 - ORDER BY h.id, name - """.formatted(NAME_OF.formatted("m.uuid")); + %s + ORDER BY h.id DESC, name + """.formatted(NAME_OF.formatted("h.created_by"), NAME_OF.formatted("m.uuid"), where); Map households = new LinkedHashMap<>(); - try(PreparedStatement statement = connection.prepareStatement(sql); ResultSet row = statement.executeQuery()) { - while(row.next()) { - long id = row.getLong("id"); - String householdNote = row.getString("note"); - UUID uuid = UUID.fromString(row.getString("uuid")); - households.computeIfAbsent(id, key -> new Household(key, householdNote, new ArrayList<>())) - .members().add(new Member(uuid, nameOr(row.getString("name"), uuid))); + 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()); diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java index a204734..44f53ea 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java @@ -7,10 +7,11 @@ 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 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, Set reasons) { + record ExistingCase(long id, CaseStatus status, @Nullable Outcome outcome, Set reasons) { } /** @@ -35,6 +36,7 @@ final class CaseDecision { .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); } } From 7bd1e13660103234084a2a891f4d5698d92814c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 17:16:13 +0200 Subject: [PATCH 13/17] reworked household and exemption dialogs --- .../tooling/banEvasion/BanEvasionDialogs.java | 88 ++++++++++++++----- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java index 8b22ce7..ce80a87 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java @@ -51,7 +51,7 @@ final class BanEvasionDialogs { private record CaseView(BanEvasionRepository.CaseDetails details, Map banStates, Map households) { } - private record LinksView(String name, List links, List networks) { + private record LinksView(String name, List links, List networks, @Nullable Long household) { } private final BanEvasion appliance; @@ -120,15 +120,19 @@ final class BanEvasionDialogs { 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().recentNetworks(connection, uuid, RECENT_NETWORKS), + this.appliance.repository().householdsOf(connection, List.of(uuid)).get(uuid) )), view -> { List 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 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)))); @@ -160,34 +164,72 @@ final class BanEvasionDialogs { void openExemptions(Player admin) { Instant now = Instant.now(); this.appliance.loadFor(admin, () -> this.appliance.query(connection -> this.appliance.repository().activeExemptions(connection, now)), exemptions -> { - List lines = new ArrayList<>(); - if(exemptions.isEmpty()) lines.add(Component.text("Keine aktiven IP-Ausnahmen.")); - exemptions.forEach(exemption -> lines.add(Component.text(String.format("%s · %s bis %s · %s%s", - exemption.network(), TIME.format(exemption.validFrom()), TIME.format(exemption.validUntil()), - exemption.createdByName() == null ? "?" : exemption.createdByName(), - exemption.note() == null ? "" : " · " + exemption.note())))); - + Component body = Component.text(exemptions.isEmpty() + ? "Keine aktiven IP-Ausnahmen." + : exemptions.size() + " aktive IP-Ausnahmen, zuerst endende oben."); List buttons = new ArrayList<>(); - exemptions.forEach(exemption -> buttons.add(button("Beenden: " + exemption.network(), player -> this.endExemption(player, exemption)))); + 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", lines(lines), List.of(), buttons); + 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 finden."; + List 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 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 lines = new ArrayList<>(); - if(households.isEmpty()) lines.add(Component.text("Keine Haushalte.")); - households.forEach(household -> lines.add(Component.text(String.format("#%d: %s%s", - household.id(), - household.members().stream().map(BanEvasionRepository.Member::name).collect(Collectors.joining(", ")), - household.note() == null ? "" : " · " + household.note())))); + 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 buttons = new ArrayList<>(); - households.forEach(household -> household.members().forEach(member -> - buttons.add(button("Aus #" + household.id() + " entfernen: " + member.name(), player -> this.removeMember(player, member))))); - buttons.add(button("Zur Fallliste", player -> this.openList(player, CaseStatus.OPEN))); - show(admin, "Haushalte", lines(lines), List.of(), buttons); + 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); }); } @@ -290,13 +332,13 @@ final class BanEvasionDialogs { }); } - private void removeMember(Player admin, BanEvasionRepository.Member member) { + 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 by %s", member.name(), admin.getName())); - this.openHouseholds(admin); + Main.logger().info(String.format("BanEvasion: %s removed from household #%d by %s", member.name(), householdId, admin.getName())); + this.openHousehold(admin, householdId); }); } From ddcbdbaec5d210d685ea1263179df6838737bee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 21:07:27 +0200 Subject: [PATCH 14/17] fixed dropped database statements never completing their query --- .../spawn/core/database/Database.java | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java index 153ec94..c5ef7fb 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java @@ -9,6 +9,7 @@ import java.sql.DriverManager; import java.sql.SQLException; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -36,7 +37,7 @@ public class Database { 1, 1, 0, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1000), runnable -> new Thread(runnable, "Spawn-Database"), - (runnable, executor) -> Main.logger().warning("Database queue full, dropping statement") + new ThreadPoolExecutor.AbortPolicy() ); private @Nullable Connection connection; @@ -69,13 +70,19 @@ public class Database { */ 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); - } - }); + try { + this.executor.execute(() -> { + try { + action.run(this.getConnection()); + } catch(SQLException e) { + this.handleError(description, e); + } catch(RuntimeException e) { + Main.logger().log(Level.WARNING, String.format("Database: %s failed", description), e); + } + }); + } catch(RejectedExecutionException e) { + Main.logger().warning("Database queue full, dropping statement"); + } } /** @@ -87,16 +94,21 @@ public class Database { 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); - } - }); + try { + this.executor.execute(() -> { + try { + future.complete(query.run(this.getConnection())); + } catch(SQLException e) { + this.closeConnection(); + future.completeExceptionally(e); + } catch(RuntimeException e) { + future.completeExceptionally(e); + } + }); + } catch(RejectedExecutionException e) { + Main.logger().warning("Database queue full, dropping statement"); + future.completeExceptionally(e); + } return future; } From 882bacf57bca52f61ec9e817a3314ab01c254ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 21:07:27 +0200 Subject: [PATCH 15/17] added optional request timeout to http repositories --- .../spawn/core/api/client/HttpRepository.java | 14 ++++++++++++++ .../api/repositories/WhitelistRepository.java | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java index ee9eebf..74ced9f 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java @@ -10,6 +10,7 @@ import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; import java.util.List; import java.util.Objects; import java.util.function.Consumer; @@ -74,6 +75,19 @@ public abstract class HttpRepository extends Repository { return this.execute(request, outputType, null); } + /** + * Like {@link #get(String, Class)}, but the request fails with an exception once the timeout has passed. + */ + protected ReqResp get(String command, Duration timeout, Class outputType) { + HttpRequest request = this.getRequestBuilder(this.getUri(command, parameters -> { + })) + .timeout(timeout) + .GET() + .build(); + + return this.execute(request, outputType, null); + } + private URI getUri(String command, Consumer parameters) { try { URIBuilder builder = new URIBuilder(this.basePath + "/" + command); diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/api/repositories/WhitelistRepository.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/api/repositories/WhitelistRepository.java index cad137e..3e63032 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/api/repositories/WhitelistRepository.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/api/repositories/WhitelistRepository.java @@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.api.client.HttpRepository; import eu.mhsl.craftattack.spawn.core.api.client.ReqResp; import eu.mhsl.craftattack.spawn.common.api.CraftAttackApi; +import java.time.Duration; import java.util.List; import java.util.UUID; @@ -29,4 +30,12 @@ public class WhitelistRepository extends HttpRepository { UserData.class ); } + + public ReqResp getUserData(UUID userId, Duration timeout) { + return this.get( + "users/%s".formatted(userId.toString()), + timeout, + UserData.class + ); + } } From 0800793e64cdc7092aca7284badd1219dda2ca15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 21:07:27 +0200 Subject: [PATCH 16/17] added lookup indexes to login attempts --- .../resources/migrations/loginLog/003_add_lookup_indexes.sql | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 common/src/main/resources/migrations/loginLog/003_add_lookup_indexes.sql diff --git a/common/src/main/resources/migrations/loginLog/003_add_lookup_indexes.sql b/common/src/main/resources/migrations/loginLog/003_add_lookup_indexes.sql new file mode 100644 index 0000000..3486e41 --- /dev/null +++ b/common/src/main/resources/migrations/loginLog/003_add_lookup_indexes.sql @@ -0,0 +1,5 @@ +-- Latest name per account and account per name, used for every name shown in the ban evasion dialogs. +ALTER TABLE login_attempts + ADD KEY IF NOT EXISTS idx_uuid_at (uuid, at), + ADD KEY IF NOT EXISTS idx_name_at (name, at), + DROP KEY IF EXISTS idx_uuid; From 6590513d5628236eff655250044aeb6e2dbf2a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 14 Oct 2026 21:07:27 +0200 Subject: [PATCH 17/17] simplified BanEvasion --- .../appliances/tooling/loginLog/LoginLog.java | 5 +- .../core/event/LoginAttemptRecordedEvent.java | 1 - .../banEvasion/AccountLinksCommand.java | 1 - .../tooling/banEvasion/AdminNotifier.java | 3 +- .../tooling/banEvasion/Attempt.java | 11 +- .../tooling/banEvasion/BanEvasion.java | 142 ++--- .../tooling/banEvasion/BanEvasionDialogs.java | 304 ++++++----- .../banEvasion/BanEvasionRepository.java | 498 +++++++----------- .../tooling/banEvasion/BanStatusCache.java | 8 +- .../tooling/banEvasion/CaseEvaluator.java | 13 +- 10 files changed, 455 insertions(+), 531 deletions(-) diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java index e1f05b6..af4c1ca 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java @@ -6,6 +6,7 @@ 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.event.SpawnEvent; import eu.mhsl.craftattack.spawn.core.util.network.Networks; import eu.mhsl.craftattack.spawn.core.util.server.Floodgate; import net.kyori.adventure.text.Component; @@ -126,8 +127,8 @@ public class LoginLog extends Appliance { 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() + SpawnEvent.call(new LoginAttemptRecordedEvent(new LoginAttemptRecordedEvent.RecordedAttempt( + id, attempt.at(), attempt.uuid(), attempt.name(), network, attempt.stage(), attempt.result() ))); }); } diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java index 3646f30..9fbf2e2 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java @@ -29,7 +29,6 @@ public class LoginAttemptRecordedEvent extends Event { Instant at, UUID uuid, String name, - String ip, @Nullable String network, String stage, String result diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java index 43fb7b6..c485524 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java @@ -25,7 +25,6 @@ class AccountLinksCommand extends ApplianceCommand.PlayerChecked { @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"); diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java index 440b265..f579817 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java @@ -53,6 +53,7 @@ final class AdminNotifier { } private Component format(Notice notice) { + long caseId = notice.caseId(); NamedTextColor color = notice.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.GRAY; String reasons = notice.reasons().stream() .filter(reason -> !reason.informational) @@ -66,7 +67,7 @@ final class AdminNotifier { .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)))) + .clickEvent(BanEvasionDialogs.adminClick(player -> this.appliance.dialogs().openCase(player, caseId, false)))) .build(); } diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java index 0f9b9df..ed7b871 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java @@ -1,5 +1,7 @@ package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion; +import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent; + import java.time.Instant; import java.util.UUID; @@ -7,7 +9,14 @@ 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"; + private static final String STAGE_WEBSITE = "WEBSITE"; + + /** + * Only for attempts with a known connection key. + */ + static Attempt of(LoginAttemptRecordedEvent.RecordedAttempt recorded) { + return new Attempt(recorded.id(), recorded.at(), recorded.uuid(), recorded.name(), recorded.network(), recorded.stage(), recorded.result()); + } boolean isWebsiteSignup() { return STAGE_WEBSITE.equals(this.stage) && "SIGNUP".equals(this.result); diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java index 4a4cc26..9585491 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java @@ -25,20 +25,18 @@ import java.sql.Connection; import java.sql.SQLException; import java.time.Clock; import java.time.Duration; +import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; 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; @@ -53,7 +51,6 @@ 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); @@ -62,14 +59,16 @@ public class BanEvasion extends Appliance { T load() throws Exception; } - private record Households(Map ofAccount, Map> members) { + /** + * Everything an evaluation needs from the database, loaded in one go. + */ + private record Context(List partners, Map households, Map> householdMembers) { } 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; @@ -95,11 +94,6 @@ public class BanEvasion extends Appliance { 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); @@ -116,7 +110,6 @@ public class BanEvasion extends Appliance { 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() { @@ -135,8 +128,7 @@ public class BanEvasion extends Appliance { * Runs the query on the database thread and waits for it. Only for the worker thread. */ T query(Database.SqlQuery 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(); + return this.database.query(query).join(); } /** @@ -166,13 +158,30 @@ public class BanEvasion extends Appliance { }); } + /** + * Shortcut of {@link #loadFor} for a single database query. + */ + void queryFor(Player admin, Database.SqlQuery query, Consumer onMainThread) { + this.loadFor(admin, () -> this.query(query), onMainThread); + } + + /** + * Runs a database change for the admin, then continues on the main thread. + */ + void runFor(Player admin, Database.SqlAction action, Runnable onMainThread) { + this.queryFor(admin, connection -> { + action.run(connection); + return true; + }, done -> onMainThread.run()); + } + /** * 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 online = this.onlineNetworks.onlineOn(recorded.network()); + Attempt attempt = Attempt.of(recorded); + Set online = this.onlineNetworks.onlineOn(attempt.network()); this.submit(() -> this.evaluate(attempt, online)); } @@ -181,7 +190,7 @@ public class BanEvasion extends Appliance { 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)); + this.queryFor(player, this.repository::countOpen, count -> this.notifier.announceOpenCases(player, count)); } void playerLeft(Player player) { @@ -198,35 +207,61 @@ public class BanEvasion extends Appliance { private void evaluate(Attempt attempt, Set online) { try { - if(this.query(connection -> this.repository.isExempt(connection, attempt.network(), attempt.at()))) return; - List partners = this.query(connection -> this.repository.partnerAttempts(connection, attempt, this.window)); - if(partners.isEmpty()) return; + Context context = this.query(connection -> this.loadContext(connection, attempt)); + if(context == null) return; - Set involved = new HashSet<>(); - involved.add(attempt.uuid()); - partners.forEach(partner -> involved.add(partner.uuid())); - Households households = this.query(connection -> { - Map ofAccount = this.repository.householdsOf(connection, involved); - return new Households(ofAccount, this.repository.householdMembers(connection, new HashSet<>(ofAccount.values()))); - }); - - Set banStateNeeded = new HashSet<>(involved); - households.members().values().forEach(banStateNeeded::addAll); + Set banStateNeeded = new HashSet<>(); + banStateNeeded.add(attempt.uuid()); + context.partners().forEach(partner -> banStateNeeded.add(partner.uuid())); + context.householdMembers().values().forEach(banStateNeeded::addAll); Map banStates = this.banStatus.getAll(banStateNeeded); List findings = CaseEvaluator.evaluate(new CaseEvaluator.Input( - attempt, partners, banStates, households.ofAccount(), households.members(), online, this.rejectionWindow + attempt, context.partners(), banStates, context.households(), context.householdMembers(), 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); - } + if(findings.isEmpty()) return; + + // partner attempts are ordered by time, so the last one carries the latest name + Map names = new HashMap<>(); + context.partners().forEach(partner -> names.put(partner.uuid(), partner.name())); + names.put(attempt.uuid(), attempt.name()); + + List notices = this.query(connection -> { + List announced = new ArrayList<>(); + for(PairFinding finding : findings) { + AdminNotifier.Notice notice = this.applyFinding(connection, finding, attempt, banStates, names); + if(notice != null) announced.add(notice); + } + return announced; + }); + notices.forEach(this.notifier::announce); } 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 banStates) throws SQLException { + /** + * @return null if the attempt is exempted or nobody else used the connection within the window + */ + private @Nullable Context loadContext(Connection connection, Attempt attempt) throws SQLException { + if(this.repository.isExempt(connection, attempt.network(), attempt.at())) return null; + List partners = this.repository.partnerAttempts(connection, attempt, this.window); + if(partners.isEmpty()) return null; + + Set involved = new HashSet<>(); + involved.add(attempt.uuid()); + partners.forEach(partner -> involved.add(partner.uuid())); + Map households = this.repository.householdsOf(connection, involved); + return new Context(partners, households, this.repository.householdMembers(connection, new HashSet<>(households.values()))); + } + + private @Nullable AdminNotifier.Notice applyFinding( + Connection connection, + PairFinding finding, + Attempt attempt, + Map banStates, + Map names + ) throws SQLException { CaseDecision.ExistingCase existing = this.repository.findCase(connection, finding.first(), finding.second()); CaseDecision.Result decision = CaseDecision.decide(existing, finding); long caseId; @@ -245,9 +280,9 @@ public class BanEvasion extends Appliance { if(!decision.announce()) return null; return new AdminNotifier.Notice( caseId, - this.repository.nameOf(connection, finding.first()), + names.get(finding.first()), banStates.get(finding.first()) == BanState.BANNED, - this.repository.nameOf(connection, finding.second()), + names.get(finding.second()), banStates.get(finding.second()) == BanState.BANNED, finding.severity(), finding.reasons().keySet(), @@ -255,34 +290,19 @@ public class BanEvasion extends Appliance { ); } - /** - * 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(); + ReqResp response = this.queryRepository(WhitelistRepository.class).getUserData(uuid, BAN_LOOKUP_TIMEOUT); + if(response.status() == HttpStatus.NOT_FOUND) return BanState.NOT_BANNED; + if(response.status() != HttpStatus.OK || response.data() == null) return BanState.UNKNOWN; + List strikes = Objects.requireNonNullElse(response.data().strikes(), List.of()); + return this.queryAppliance(Strikes.class).isBanned(strikes).isBanned() ? BanState.BANNED : BanState.NOT_BANNED; + } catch(RuntimeException e) { + Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: " + e.getMessage()); return BanState.UNKNOWN; } } - private BanState fetchBanState(UUID uuid) { - ReqResp 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 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); diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java index ce80a87..2457b01 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java @@ -20,6 +20,8 @@ import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.jetbrains.annotations.Nullable; +import java.sql.Connection; +import java.sql.SQLException; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; @@ -33,7 +35,8 @@ 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. + * Admin dialogs of the ban evasion detection. Every button checks the admin permission again on the server + * and captures only the ids it needs, so the registered callbacks stay small. */ final class BanEvasionDialogs { private static final int LIST_LIMIT = 30; @@ -42,7 +45,10 @@ final class BanEvasionDialogs { 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 ClickCallback.Options CHAT_CALLBACK = ClickCallback.Options.builder() + .uses(ClickCallback.UNLIMITED_USES).lifetime(Duration.ofHours(12)).build(); + private static final ClickCallback.Options DIALOG_CALLBACK = ClickCallback.Options.builder() + .uses(ClickCallback.UNLIMITED_USES).lifetime(Duration.ofMinutes(30)).build(); 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"; @@ -54,49 +60,57 @@ final class BanEvasionDialogs { private record LinksView(String name, List links, List networks, @Nullable Long household) { } + private record HouseholdView(BanEvasionRepository.Household household, Map banStates) { + } + private final BanEvasion appliance; + private final BanEvasionRepository repository; BanEvasionDialogs(BanEvasion appliance) { this.appliance = appliance; + this.repository = appliance.repository(); } /** * Click event for chat messages that only works for admins. */ static ClickEvent adminClick(Consumer 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() - ); + return ClickEvent.callback(audience -> { + if(audience instanceof Player player && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player); + }, CHAT_CALLBACK); } void openList(Player admin, CaseStatus status) { - this.appliance.loadFor(admin, - () -> this.appliance.query(connection -> this.appliance.repository().listCases(connection, status, LIST_LIMIT)), - cases -> { - List 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); + this.appliance.queryFor(admin, connection -> this.repository.listCases(connection, status, LIST_LIMIT), cases -> { + List buttons = new ArrayList<>(); + cases.forEach(summary -> { + long caseId = summary.id(); + buttons.add(button(caseLabel(summary), player -> this.openCase(player, caseId, 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 accounts = List.of(details.summary().first(), details.summary().second()); - Map households = this.appliance.query(connection -> this.appliance.repository().householdsOf(connection, accounts)); - return new CaseView(details, this.appliance.banStates(accounts), households); + record Loaded(BanEvasionRepository.CaseDetails details, Map households) { + } + Loaded loaded = this.appliance.query(connection -> { + BanEvasionRepository.CaseDetails details = this.repository.loadCase(connection, caseId, TIMELINE_LIMIT); + if(details == null) return null; + return new Loaded(details, this.repository.householdsOf(connection, List.of(details.summary().first(), details.summary().second()))); + }); + if(loaded == null) return null; + List accounts = List.of(loaded.details().summary().first(), loaded.details().summary().second()); + return new CaseView(loaded.details(), this.appliance.banStates(accounts), loaded.households()); }, view -> { if(view == null) { admin.sendMessage(Component.text("Fall #" + caseId + " gibt es nicht (mehr).", NamedTextColor.RED)); @@ -107,40 +121,18 @@ final class BanEvasionDialogs { } 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); + this.appliance.queryFor(admin, connection -> { + BanEvasionRepository.Member account = this.repository.accountByName(connection, name); + return account == null ? null : this.loadLinks(connection, account.uuid(), account.name()); + }, view -> { + if(view == null) admin.sendMessage(Component.text("Keine Einträge zu " + name + ".", NamedTextColor.RED)); + else this.showLinks(admin, view); }); } 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 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 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); - }); + this.appliance.queryFor(admin, connection -> this.loadLinks(connection, uuid, this.repository.nameOf(connection, uuid)), + view -> this.showLinks(admin, view)); } void openNewExemption(Player admin, List networks) { @@ -150,11 +142,11 @@ final class BanEvasionDialogs { List 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() + noteInput("Notiz, z. B. LAN-Party bei …") ); List buttons = List.of( - button(Component.text("Ausnahme anlegen"), (player, response) -> this.createExemption(player, networks, response)), - button("Abbrechen", player -> this.openList(player, CaseStatus.OPEN)) + inputButton("Ausnahme anlegen", (player, response) -> this.createExemption(player, networks, response)), + this.backToList("Abbrechen") ); 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."), @@ -163,7 +155,7 @@ final class BanEvasionDialogs { void openExemptions(Player admin) { Instant now = Instant.now(); - this.appliance.loadFor(admin, () -> this.appliance.query(connection -> this.appliance.repository().activeExemptions(connection, now)), exemptions -> { + this.appliance.queryFor(admin, connection -> this.repository.activeExemptions(connection, now), exemptions -> { Component body = Component.text(exemptions.isEmpty() ? "Keine aktiven IP-Ausnahmen." : exemptions.size() + " aktive IP-Ausnahmen, zuerst endende oben."); @@ -171,45 +163,32 @@ final class BanEvasionDialogs { 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))); + player -> this.showExemption(player, exemption)))); + buttons.add(this.backToList("Zur Fallliste")); 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 -> { + this.appliance.queryFor(admin, this.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 finden."; List 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))); + households.stream().limit(LIST_LIMIT).forEach(household -> { + long householdId = household.id(); + buttons.add(button( + "#" + householdId + ": " + household.members().stream().map(BanEvasionRepository.Member::name).collect(Collectors.joining(", ")), + player -> this.openHousehold(player, householdId))); + }); + buttons.add(this.backToList("Zur Fallliste")); show(admin, "Haushalte", Component.text(text), List.of(), buttons); }); } - private record HouseholdView(BanEvasionRepository.Household household, Map banStates) { - } - void openHousehold(Player admin, long householdId) { this.appliance.loadFor(admin, () -> { - BanEvasionRepository.Household household = this.appliance.query(connection -> this.appliance.repository().household(connection, householdId)); + BanEvasionRepository.Household household = this.appliance.query(connection -> this.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 -> { @@ -220,8 +199,7 @@ final class BanEvasionDialogs { } BanEvasionRepository.Household household = view.household(); List lines = new ArrayList<>(); - lines.add(Component.text("Angelegt " + TIME.format(household.createdAt()) + " von " - + (household.createdByName() == null ? "?" : household.createdByName()))); + lines.add(Component.text("Angelegt " + TIME.format(household.createdAt()) + " von " + orUnknown(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))); @@ -233,6 +211,51 @@ final class BanEvasionDialogs { }); } + private LinksView loadLinks(Connection connection, UUID uuid, String name) throws SQLException { + return new LinksView( + name, + this.repository.linksOf(connection, uuid, LINK_LIMIT), + this.repository.recentNetworks(connection, uuid, RECENT_NETWORKS), + this.repository.householdsOf(connection, List.of(uuid)).get(uuid) + ); + } + + private void showLinks(Player admin, LinksView view) { + String name = view.name(); + Long household = view.household(); + List networks = view.networks(); + List lines = new ArrayList<>(); + if(household != null) lines.add(Component.text(name + " gehört zu Haushalt #" + household + ".")); + if(view.links().isEmpty()) lines.add(Component.text("Kein anderer Account hat je einen Anschluss mit " + 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 buttons = new ArrayList<>(); + if(household != null) buttons.add(button("Haushalt #" + household, player -> this.openHousehold(player, household))); + view.links().forEach(link -> { + Long caseId = link.caseId(); + if(caseId != null) buttons.add(button("Fall #" + caseId + ": " + link.name(), player -> this.openCase(player, caseId, false))); + }); + if(!networks.isEmpty()) buttons.add(button("IP-Ausnahme für Anschluss von " + name, player -> this.openNewExemption(player, networks))); + buttons.add(this.backToList("Zur Fallliste")); + show(admin, "Accounts zu " + name, lines(lines), List.of(), buttons); + } + + private void showExemption(Player admin, BanEvasionRepository.Exemption exemption) { + long exemptionId = exemption.id(); + 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: " + orUnknown(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, exemptionId)), + button("Zurück", this::openExemptions) + )); + } + private void showCase(Player admin, CaseView view, boolean fullNetworks) { BanEvasionRepository.CaseDetails details = view.details(); BanEvasionRepository.CaseSummary summary = details.summary(); @@ -262,40 +285,41 @@ final class BanEvasionDialogs { 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(), + TIME.format(details.closedAt()), orUnknown(details.closedByName()), details.note() == null ? "" : ": " + details.note()), NamedTextColor.GRAY)); } long caseId = summary.id(); + UUID first = summary.first(); + UUID second = summary.second(); + CaseStatus status = summary.status(); + List shared = details.sharedNetworks(); List inputs = new ArrayList<>(); List buttons = new ArrayList<>(); - if(summary.status() == CaseStatus.OPEN) { - inputs.add(DialogInput.text(NOTE_KEY, Component.text("Notiz (optional)")).width(BODY_WIDTH).maxLength(255).build()); + if(status == CaseStatus.OPEN) { + inputs.add(noteInput("Notiz (optional)")); 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(inputButton("Schließen: " + outcome.label, + (player, response) -> this.close(player, caseId, first, second, 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()))); + buttons.add(button("Accounts zu " + summary.firstName(), player -> this.openLinks(player, first))); + buttons.add(button("Accounts zu " + summary.secondName(), player -> this.openLinks(player, second))); + if(!shared.isEmpty()) buttons.add(button("IP-Ausnahme anlegen", player -> this.openNewExemption(player, shared))); + buttons.add(button("Zurück zur Liste", player -> this.openList(player, 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(); + private void close(Player admin, long caseId, UUID first, UUID second, Outcome outcome, @Nullable String rawNote) { + String note = note(rawNote); 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.appliance.runFor(admin, connection -> { + this.repository.closeCase(connection, caseId, outcome, adminUuid, note, now); + if(outcome == Outcome.HOUSEHOLD) this.repository.linkHousehold(connection, List.of(first, second), adminUuid, note, now); + }, () -> { + Main.logger().info(String.format("BanEvasion: case #%d closed as %s by %s", caseId, outcome, admin.getName())); + admin.sendMessage(Component.text(String.format("Fall #%d geschlossen: %s", caseId, outcome.label), NamedTextColor.GRAY)); this.openList(admin, CaseStatus.OPEN); }); } @@ -307,41 +331,35 @@ final class BanEvasionDialogs { 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(); + String note = note(response.getText(NOTE_KEY)); 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 -> { + this.appliance.runFor(admin, connection -> this.repository.addExemption(connection, network, from, until, adminUuid, note), () -> { 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) { + private void endExemption(Player admin, long exemptionId) { 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.appliance.runFor(admin, connection -> this.repository.endExemption(connection, exemptionId, now), () -> { + Main.logger().info(String.format("BanEvasion: exemption #%d ended by %s", exemptionId, 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 -> { + this.appliance.runFor(admin, connection -> this.repository.removeFromHousehold(connection, member.uuid()), () -> { Main.logger().info(String.format("BanEvasion: %s removed from household #%d by %s", member.name(), householdId, admin.getName())); this.openHousehold(admin, householdId); }); } + private ActionButton backToList(String label) { + return button(label, player -> this.openList(player, CaseStatus.OPEN)); + } + 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; @@ -368,28 +386,44 @@ final class BanEvasionDialogs { return networks.stream().map(network -> network(network, full)).collect(Collectors.joining(", ")); } + private static String orUnknown(@Nullable String name) { + return name == null ? "?" : name; + } + + private static @Nullable String note(@Nullable String raw) { + return raw == null || raw.isBlank() ? null : raw.strip(); + } + + private static DialogInput noteInput(String label) { + return DialogInput.text(NOTE_KEY, Component.text(label)).width(BODY_WIDTH).maxLength(255).build(); + } + private static Component lines(List lines) { return Component.join(JoinConfiguration.newlines(), lines); } private static DialogAction adminAction(BiConsumer 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() - ); + 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); + }); + }, DIALOG_CALLBACK); } - private static ActionButton button(Component label, BiConsumer action) { - return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction(action)).build(); + private static ActionButton button(Component label, Consumer action) { + return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction((player, response) -> action.accept(player))).build(); } private static ActionButton button(String label, Consumer action) { - return button(Component.text(label), (player, response) -> action.accept(player)); + return button(Component.text(label), action); + } + + /** + * Button that reads the inputs of the dialog. + */ + private static ActionButton inputButton(String label, BiConsumer action) { + return ActionButton.builder(Component.text(label)).width(BUTTON_WIDTH).action(adminAction(action)).build(); } private static void show(Player admin, String title, Component body, List inputs, List buttons) { diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java index fabd57a..38d263e 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java @@ -20,6 +20,7 @@ import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -76,6 +77,11 @@ final class BanEvasionRepository { record Exemption(long id, String network, Instant validFrom, Instant validUntil, @Nullable String createdByName, @Nullable String note) { } + @FunctionalInterface + private interface RowMapper { + T map(ResultSet row) throws SQLException; + } + /** * Latest known name of the account in the given column. */ @@ -83,13 +89,7 @@ final class BanEvasionRepository { 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(); - } - } + return first(connection, sql, row -> true, network, at) != null; } List partnerAttempts(Connection connection, Attempt current, Duration window) throws SQLException { @@ -103,73 +103,43 @@ final class BanEvasionRepository { ) 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 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; - } + return list(connection, sql, row -> new Attempt( + row.getLong("id"), + fromDb(row, "at"), + uuid(row, "uuid"), + row.getString("name"), + row.getString("network"), + row.getString("stage"), + row.getString("result") + ), current.network(), current.uuid(), current.at().minus(window), current.at()); } Map householdsOf(Connection connection, Collection 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 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 households = new HashMap<>(); + list(connection, sql, row -> Map.entry(uuid(row, "uuid"), row.getLong("household_id")), uuids.toArray()) + .forEach(entry -> households.put(entry.getKey(), entry.getValue())); + return households; } Map> householdMembers(Connection connection, Collection 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> 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; - } + Map> members = new HashMap<>(); + list(connection, sql, row -> Map.entry(row.getLong("household_id"), uuid(row, "uuid")), householdIds.toArray()) + .forEach(entry -> members.computeIfAbsent(entry.getKey(), id -> new HashSet<>()).add(entry.getValue())); + 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 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); + String sql = "SELECT id, status, outcome FROM ban_evasion_cases WHERE uuid_a = ? AND uuid_b = ?"; + CaseDecision.ExistingCase found = first(connection, sql, row -> new CaseDecision.ExistingCase( + row.getLong("id"), CaseStatus.valueOf(row.getString("status")), outcomeOf(row), EnumSet.noneOf(Reason.class) + ), first, second); + if(found == null) return null; + this.reasonsOf(connection, found.id()).forEach(reason -> found.reasons().add(reason.reason())); + return found; } /** @@ -181,19 +151,7 @@ final class BanEvasionRepository { 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); - } - } + return insert(connection, sql, finding.first(), finding.second(), finding.severity(), finding.banStatusUnknown(), at, at); } void updateCase(Connection connection, long caseId, PairFinding finding, Instant at, boolean reopen) throws SQLException { @@ -207,31 +165,12 @@ final class BanEvasionRepository { 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(); - } + update(connection, sql, at, finding.severity(), finding.banStatusUnknown(), reopen, reopen, caseId); } void addReasons(Connection connection, long caseId, Map reasons, Set 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(); - } + for(Reason reason : which) update(connection, sql, caseId, reason, reasons.get(reason), at, attemptId); } List listCases(Connection connection, CaseStatus status, int limit) throws SQLException { @@ -243,23 +182,12 @@ final class BanEvasionRepository { 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 cases = new ArrayList<>(); - try(ResultSet row = statement.executeQuery()) { - while(row.next()) cases.add(summaryOf(row)); - } - return cases; - } + return list(connection, sql, BanEvasionRepository::summaryOf, status, limit); } 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")); - } + return Objects.requireNonNull(first(connection, sql, row -> new OpenCount(row.getInt("total"), row.getInt("high")))); } @Nullable CaseDetails loadCase(Connection connection, long caseId, int timelineLimit) throws SQLException { @@ -268,42 +196,29 @@ final class BanEvasionRepository { 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"); - } - } + CaseDetails found = first(connection, sql, row -> new CaseDetails( + summaryOf(row), + row.getBoolean("ban_status_unknown"), + fromDb(row, "created_at"), + nullableFromDb(row, "closed_at"), + row.getString("closed_by_name"), + row.getString("note"), + List.of(), List.of(), List.of() + ), caseId); + if(found == null) return null; + UUID first = found.summary().first(); + UUID second = found.summary().second(); return new CaseDetails( - summary, banStatusUnknown, createdAt, closedAt, closedByName, note, + found.summary(), found.banStatusUnknown(), found.createdAt(), found.closedAt(), found.closedByName(), found.note(), this.reasonsOf(connection, caseId), - this.sharedNetworks(connection, summary.first(), summary.second()), - this.timeline(connection, summary.first(), summary.second(), timelineLimit) + this.sharedNetworks(connection, first, second), + this.timeline(connection, first, 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(); - } + update(connection, sql, outcome, at, admin, note, caseId); } /** @@ -322,43 +237,30 @@ final class BanEvasionRepository { 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 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; - } + return list(connection, sql, row -> { + UUID other = uuid(row, "uuid"); + return new Link( + other, + nameOr(row.getString("name"), other), + row.getInt("shared"), + fromDb(row, "first_at"), + fromDb(row, "last_at"), + row.getObject("case_id", Long.class) + ); + }, uuid, uuid, uuid, uuid, limit); } - - @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; - } - } + /** + * Latest account that used the name (case-insensitive), with its name as stored. + */ + @Nullable Member accountByName(Connection connection, String name) throws SQLException { + String sql = "SELECT uuid, name FROM login_attempts WHERE name = ? ORDER BY at DESC LIMIT 1"; + return first(connection, sql, row -> new Member(uuid(row, "uuid"), row.getString("name")), name); } 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(); - } - } + String name = first(connection, "SELECT name FROM login_attempts WHERE uuid = ? ORDER BY at DESC LIMIT 1", row -> row.getString("name"), uuid); + return nameOr(name, uuid); } List recentNetworks(Connection connection, UUID uuid, int limit) throws SQLException { @@ -367,27 +269,12 @@ final class BanEvasionRepository { 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 networks = new ArrayList<>(); - try(ResultSet row = statement.executeQuery()) { - while(row.next()) networks.add(row.getString("network")); - } - return networks; - } + return list(connection, sql, row -> row.getString("network"), uuid, limit); } 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(); - } + update(connection, sql, network, from, until, admin, note); } List activeExemptions(Connection connection, Instant now) throws SQLException { @@ -397,30 +284,18 @@ final class BanEvasionRepository { 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 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; - } + return list(connection, sql, row -> new Exemption( + row.getLong("id"), + row.getString("network"), + fromDb(row, "valid_from"), + fromDb(row, "valid_until"), + row.getString("created_by_name"), + row.getString("note") + ), now); } 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(); - } + update(connection, "UPDATE ban_evasion_ip_exemptions SET valid_until = ? WHERE id = ? AND valid_until > ?", now, id, now); } /** @@ -431,28 +306,22 @@ final class BanEvasionRepository { connection.setAutoCommit(false); try { List 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(); + long target = existing.isEmpty() + ? insert(connection, "INSERT INTO ban_evasion_households (created_at, created_by, note) VALUES (?, ?, ?)", at, admin, note) + : existing.getFirst(); + if(existing.size() > 1) { + Object[] merged = existing.subList(1, existing.size()).toArray(); + String in = placeholders(merged.length); + Object[] moveParams = new Object[merged.length + 1]; + moveParams[0] = target; + System.arraycopy(merged, 0, moveParams, 1, merged.length); + update(connection, "UPDATE ban_evasion_household_members SET household_id = ? WHERE household_id IN (" + in + ")", moveParams); + update(connection, "DELETE FROM ban_evasion_households WHERE id IN (" + in + ")", merged); } + for(UUID uuid : uuids) + update(connection, "INSERT IGNORE INTO ban_evasion_household_members (uuid, household_id) VALUES (?, ?)", uuid, target); connection.commit(); - } catch(SQLException e) { + } catch(SQLException | RuntimeException e) { connection.rollback(); throw e; } finally { @@ -464,7 +333,7 @@ final class BanEvasionRepository { * All households, newest first. */ List listHouseholds(Connection connection) throws SQLException { - return this.households(connection, "", null); + return this.households(connection, ""); } @Nullable Household household(Connection connection, long id) throws SQLException { @@ -472,7 +341,7 @@ final class BanEvasionRepository { return households.isEmpty() ? null : households.getFirst(); } - private List households(Connection connection, String where, @Nullable Long id) throws SQLException { + private List households(Connection connection, String where, Object... params) 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 @@ -481,19 +350,15 @@ final class BanEvasionRepository { ORDER BY h.id DESC, name """.formatted(NAME_OF.formatted("h.created_by"), NAME_OF.formatted("m.uuid"), where); Map 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))); - } + list(connection, sql, row -> { + UUID uuid = uuid(row, "uuid"); + Household household = households.get(row.getLong("id")); + if(household == null) { + household = new Household(row.getLong("id"), fromDb(row, "created_at"), row.getString("created_by_name"), row.getString("note"), new ArrayList<>()); + households.put(household.id(), household); } - } + return household.members().add(new Member(uuid, nameOr(row.getString("name"), uuid))); + }, params); return List.copyOf(households.values()); } @@ -503,60 +368,24 @@ final class BanEvasionRepository { 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(); - } + update(connection, "DELETE FROM ban_evasion_household_members WHERE uuid = ?", uuid); + update(connection, + "DELETE FROM ban_evasion_households WHERE id = ? AND (SELECT COUNT(*) FROM ban_evasion_household_members WHERE household_id = ?) < 2", + household, household); } 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); - } - } + return update(connection, "DELETE FROM ban_evasion_cases WHERE last_seen_at < UTC_TIMESTAMP() - INTERVAL ? MONTH", retentionMonths) + + update(connection, "DELETE FROM ban_evasion_ip_exemptions WHERE valid_until < UTC_TIMESTAMP() - INTERVAL ? MONTH", retentionMonths); } private List 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 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; - } + return list(connection, sql, row -> new CaseReason( + Reason.valueOf(row.getString("reason")), + Severity.valueOf(row.getString("severity")), + fromDb(row, "first_seen_at") + ), caseId); } private List sharedNetworks(Connection connection, UUID first, UUID second) throws SQLException { @@ -566,37 +395,19 @@ final class BanEvasionRepository { 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 networks = new ArrayList<>(); - try(ResultSet row = statement.executeQuery()) { - while(row.next()) networks.add(row.getString("network")); - } - return networks; - } + return list(connection, sql, row -> row.getString("network"), first, second); } private List 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 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; - } + return list(connection, sql, row -> new TimelineEntry( + fromDb(row, "at"), row.getString("name"), row.getString("network"), row.getString("stage"), row.getString("result") + ), first, second, limit); } 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"); + UUID first = uuid(row, "uuid_a"); + UUID second = uuid(row, "uuid_b"); return new CaseSummary( row.getLong("id"), first, @@ -604,13 +415,72 @@ final class BanEvasionRepository { second, nameOr(row.getString("name_b"), second), CaseStatus.valueOf(row.getString("status")), - outcome == null ? null : Outcome.valueOf(outcome), + outcomeOf(row), Severity.valueOf(row.getString("severity")), fromDb(row, "last_seen_at"), row.getInt("hits") ); } + private static List list(Connection connection, String sql, RowMapper mapper, Object... params) throws SQLException { + try(PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, params); + List rows = new ArrayList<>(); + try(ResultSet row = statement.executeQuery()) { + while(row.next()) rows.add(mapper.map(row)); + } + return rows; + } + } + + private static @Nullable T first(Connection connection, String sql, RowMapper mapper, Object... params) throws SQLException { + List rows = list(connection, sql, mapper, params); + return rows.isEmpty() ? null : rows.getFirst(); + } + + private static int update(Connection connection, String sql, Object... params) throws SQLException { + try(PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, params); + return statement.executeUpdate(); + } + } + + /** + * Executes an insert and returns the generated (or, with LAST_INSERT_ID(id), the existing) id. + */ + private static long insert(Connection connection, String sql, Object... params) throws SQLException { + try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + bind(statement, params); + statement.executeUpdate(); + try(ResultSet keys = statement.getGeneratedKeys()) { + if(!keys.next()) throw new SQLException("no id returned by insert"); + return keys.getLong(1); + } + } + } + + private static void bind(PreparedStatement statement, Object... params) throws SQLException { + for(int i = 0; i < params.length; i++) { + Object value = switch(params[i]) { + case UUID uuid -> uuid.toString(); + case Instant instant -> toDb(instant); + case Enum constant -> constant.name(); + case null -> null; + default -> params[i]; + }; + statement.setObject(i + 1, value); + } + } + + private static UUID uuid(ResultSet row, String column) throws SQLException { + return UUID.fromString(row.getString(column)); + } + + private static @Nullable Outcome outcomeOf(ResultSet row) throws SQLException { + String outcome = row.getString("outcome"); + return outcome == null ? null : Outcome.valueOf(outcome); + } + private static String nameOr(@Nullable String name, UUID uuid) { return name != null ? name : uuid.toString(); } @@ -624,7 +494,7 @@ final class BanEvasionRepository { } private static Instant fromDb(ResultSet row, String column) throws SQLException { - return row.getObject(column, LocalDateTime.class).toInstant(ZoneOffset.UTC); + return Objects.requireNonNull(nullableFromDb(row, column), column); } private static @Nullable Instant nullableFromDb(ResultSet row, String column) throws SQLException { diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java index 10c9bd9..796d683 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java @@ -41,12 +41,8 @@ final class BanStatusCache { 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)); - } + if(state == BanState.UNKNOWN) this.failingUntil = this.clock.instant().plus(this.backoff); + else this.entries.put(uuid, new Entry(state, now)); return state; } diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java index 268691c..a7e9246 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java @@ -11,7 +11,6 @@ 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. @@ -61,7 +60,7 @@ final class CaseEvaluator { 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; + if(sameHousehold && !householdBanned(input, household)) return null; Map reasons = new EnumMap<>(Reason.class); reasons.put(Reason.SHARED_CONNECTION, Severity.NORMAL); @@ -91,12 +90,8 @@ final class CaseEvaluator { return input.banStates().getOrDefault(uuid, BanState.UNKNOWN); } - private static BanState householdState(Input input, long household) { - Set 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; + private static boolean householdBanned(Input input, long household) { + return input.householdMembers().getOrDefault(household, Set.of()).stream() + .anyMatch(member -> banState(input, member) == BanState.BANNED); } }