2 Commits
9 changed files with 526 additions and 0 deletions

No files matched your search

@@ -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);
}
}
@@ -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<UUID, LoginAttempt> pendingAttempts = new ConcurrentHashMap<>();
private final List<BukkitTask> tasks = new ArrayList<>();
private Database database;
private int retentionMonths;
public LoginLog() {
super("loginLog");
}
@Override
public void onEnable() {
this.database = Main.instance().getDatabase();
if(!this.database.isAvailable()) {
Main.logger().warning("LoginLog: no database configured, login attempts are not recorded");
return;
}
this.retentionMonths = this.localConfig().getInt("retentionMonths", 16);
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::flushStaleAttempts, TICKS_PER_MINUTE, TICKS_PER_MINUTE
));
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::deleteExpired, TICKS_PER_MINUTE, TICKS_PER_MINUTE * 60 * 24
));
}
@Override
public void onDisable() {
this.tasks.forEach(BukkitTask::cancel);
}
void onPreLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) {
if(!this.database.isAvailable()) return;
LoginAttempt attempt = new LoginAttempt(
Instant.now(), uuid, name, LoginAttempt.ipOf(address), 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<Listener> listeners() {
return List.of(new LoginLogListener());
}
}
@@ -0,0 +1,34 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.event.player.PlayerLoginEvent;
/**
* Listens on MONITOR priority, so the final result of all other checks (whitelist, bans, maintenance, ...) is recorded.
*/
class LoginLogListener extends ApplianceListener<LoginLog> {
@EventHandler(priority = EventPriority.MONITOR)
public void onPreLogin(AsyncPlayerPreLoginEvent event) {
this.getAppliance().onPreLogin(
event.getUniqueId(),
event.getName(),
event.getAddress(),
event.getLoginResult().name(),
event.getLoginResult() == AsyncPlayerPreLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onLogin(PlayerLoginEvent event) {
this.getAppliance().onLogin(
event.getPlayer().getUniqueId(),
event.getPlayer().getName(),
event.getAddress(),
event.getResult().name(),
event.getResult() == PlayerLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
}
@@ -0,0 +1,15 @@
-- Every login attempt (allowed or not) with the player's IP, basis for detecting ban evasion.
CREATE TABLE login_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
at DATETIME(3) NOT NULL COMMENT 'UTC',
uuid CHAR(36) NOT NULL,
name VARCHAR(64) NOT NULL,
ip VARCHAR(45) NOT NULL,
bedrock BOOLEAN NOT NULL,
stage VARCHAR(16) NOT NULL COMMENT 'PRE_LOGIN or LOGIN',
result VARCHAR(32) NOT NULL COMMENT 'Bukkit login result, or ABORTED',
reason VARCHAR(255) NULL COMMENT 'kick message as plain text',
KEY idx_at (at),
KEY idx_uuid (uuid),
KEY idx_ip (ip)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.api.client.RepositoryLoader;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.database.Database;
import org.bukkit.Bukkit;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.java.JavaPlugin;
@@ -23,6 +24,7 @@ public final class Main extends JavaPlugin {
private List<Appliance> appliances;
private RepositoryLoader repositoryLoader;
private Database database;
private Reflections reflections;
@Override
@@ -42,6 +44,8 @@ public final class Main extends JavaPlugin {
Configuration.readConfig();
List<String> disabledAppliances = Configuration.pluginConfig.getStringList("disabledAppliances");
this.database = new Database(Configuration.cfg.getConfigurationSection("database"));
Main.logger().info("Initializing reflections...");
this.reflections = new Reflections(projectPackage);
@@ -94,6 +98,8 @@ public final class Main extends JavaPlugin {
HandlerList.unregisterAll(this);
Bukkit.getScheduler().cancelTasks(this);
Main.logger().info("Disabled " + this.appliances.size() + " appliances!");
if(this.database != null) this.database.close();
}
public Appliance restartAppliance(Class<? extends Appliance> applianceClass) {
@@ -138,6 +144,10 @@ public final class Main extends JavaPlugin {
return this.appliances;
}
public Database getDatabase() {
return this.database;
}
public RepositoryLoader getRepositoryLoader() {
return this.repositoryLoader;
}
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.client.Repository;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.database.Migrations;
import org.bukkit.Bukkit;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.ConfigurationSection;
@@ -107,6 +108,7 @@ public abstract class Appliance {
}
public void enableSequence(JavaPlugin plugin) {
Migrations.apply(Main.instance().getDatabase(), this.migrationsName());
this.onEnable();
this.initialize(plugin);
}
@@ -116,6 +118,14 @@ public abstract class Appliance {
this.destruct(plugin);
}
/**
* Name of the folder containing this appliance's SQL migrations: {@code migrations/<name>/} in the resources.
*/
private String migrationsName() {
String name = this.getClass().getSimpleName();
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
}
protected <T extends Appliance> T queryAppliance(Class<T> clazz) {
return Main.instance().getAppliance(clazz);
}
@@ -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> {
T run(Connection connection) throws SQLException;
}
private final @Nullable String jdbcUrl;
private final String user;
private final String password;
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1000),
runnable -> new Thread(runnable, "Spawn-Database"),
(runnable, executor) -> Main.logger().warning("Database queue full, dropping statement")
);
private @Nullable Connection connection;
public Database(@Nullable ConfigurationSection config) {
String host = config == null ? "" : config.getString("host", "");
if(host.isBlank()) {
this.jdbcUrl = null;
this.user = "";
this.password = "";
Main.logger().info("No database configured, database features are disabled");
return;
}
this.jdbcUrl = String.format(
"jdbc:mysql://%s:%d/%s?connectTimeout=3000&socketTimeout=10000&allowMultiQueries=true",
host,
config.getInt("port", 3306),
config.getString("name", "craftattack")
);
this.user = config.getString("user", "");
this.password = config.getString("password", "");
}
public boolean isAvailable() {
return this.jdbcUrl != null;
}
/**
* Runs the action in the background without waiting for it. Errors are logged.
*/
public void execute(String description, SqlAction action) {
if(!this.isAvailable()) return;
this.executor.execute(() -> {
try {
action.run(this.getConnection());
} catch(SQLException e) {
this.handleError(description, e);
}
});
}
/**
* Runs the query in the background. The future fails if the database is unavailable or the query fails.
*/
public <T> CompletableFuture<T> query(SqlQuery<T> query) {
CompletableFuture<T> future = new CompletableFuture<>();
if(!this.isAvailable()) {
future.completeExceptionally(new IllegalStateException("No database configured"));
return future;
}
this.executor.execute(() -> {
try {
future.complete(query.run(this.getConnection()));
} catch(SQLException e) {
this.closeConnection();
future.completeExceptionally(e);
}
});
return future;
}
public void close() {
this.executor.shutdown();
try {
if(!this.executor.awaitTermination(5, TimeUnit.SECONDS))
Main.logger().warning("Database: pending statements were not finished before shutdown");
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
}
this.closeConnection();
}
private Connection getConnection() throws SQLException {
if(this.connection == null || !this.connection.isValid(2))
this.connection = DriverManager.getConnection(this.jdbcUrl, this.user, this.password);
return this.connection;
}
private void closeConnection() {
if(this.connection == null) return;
try {
this.connection.close();
} catch(SQLException ignored) {
}
this.connection = null;
}
private void handleError(String description, SQLException e) {
Main.logger().log(Level.WARNING, String.format("Database: %s failed: %s", description, e.getMessage()));
this.closeConnection();
}
}
@@ -0,0 +1,138 @@
package eu.mhsl.craftattack.spawn.core.database;
import eu.mhsl.craftattack.spawn.core.Main;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Applies SQL migration files shipped in the plugin jar.
* Files live in {@code migrations/<appliance>/NNN_description.sql} and run in ascending order of NNN.
* Applied versions are tracked per appliance in the table {@code schema_migrations}.
*/
public class Migrations {
private static final String DIRECTORY = "migrations/";
private static final Pattern FILE_PATTERN = Pattern.compile("^([^/]+)/(\\d+)_[^/]+\\.sql$");
private static Map<String, List<Migration>> migrationsByAppliance;
private record Migration(int version, String fileName, String path) {
}
/**
* Queues all pending migrations of the appliance on the database thread,
* so they run before any statement the appliance submits afterwards.
*/
public static void apply(Database database, String appliance) {
List<Migration> migrations = findMigrations().getOrDefault(appliance, List.of());
if(migrations.isEmpty()) return;
if(!database.isAvailable()) {
Main.logger().warning(String.format("Appliance '%s' has migrations, but no database is configured", appliance));
return;
}
database.execute("migrations of " + appliance, connection -> {
createMigrationTable(connection);
Set<Integer> applied = appliedVersions(connection, appliance);
for(Migration migration : migrations) {
if(applied.contains(migration.version())) continue;
Main.logger().info(String.format("Applying migration %s/%s", appliance, migration.fileName()));
try(Statement statement = connection.createStatement()) {
statement.execute(readResource(migration.path()));
}
markApplied(connection, appliance, migration);
}
});
}
private static synchronized Map<String, List<Migration>> findMigrations() {
if(migrationsByAppliance != null) return migrationsByAppliance;
Map<String, List<Migration>> migrations = new HashMap<>();
try(JarFile jar = new JarFile(pluginJar())) {
jar.stream()
.map(JarEntry::getName)
.filter(name -> name.startsWith(DIRECTORY))
.forEach(path -> {
Matcher matcher = FILE_PATTERN.matcher(path.substring(DIRECTORY.length()));
if(!matcher.matches()) return;
String fileName = path.substring(path.lastIndexOf('/') + 1);
migrations.computeIfAbsent(matcher.group(1), appliance -> new ArrayList<>())
.add(new Migration(Integer.parseInt(matcher.group(2)), fileName, path));
});
} catch(IOException e) {
throw new IllegalStateException("Could not read migrations from plugin jar", e);
}
migrations.values().forEach(list -> list.sort(Comparator.comparingInt(Migration::version)));
migrationsByAppliance = migrations;
return migrations;
}
private static void createMigrationTable(Connection connection) throws SQLException {
try(Statement statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
appliance VARCHAR(64) NOT NULL,
version INT NOT NULL,
file_name VARCHAR(255) NOT NULL,
applied_at DATETIME NOT NULL DEFAULT UTC_TIMESTAMP(),
PRIMARY KEY (appliance, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
}
}
private static Set<Integer> appliedVersions(Connection connection, String appliance) throws SQLException {
Set<Integer> versions = new HashSet<>();
try(PreparedStatement statement = connection.prepareStatement("SELECT version FROM schema_migrations WHERE appliance = ?")) {
statement.setString(1, appliance);
try(ResultSet result = statement.executeQuery()) {
while(result.next()) versions.add(result.getInt(1));
}
}
return versions;
}
private static void markApplied(Connection connection, String appliance, Migration migration) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("INSERT INTO schema_migrations (appliance, version, file_name) VALUES (?, ?, ?)")) {
statement.setString(1, appliance);
statement.setInt(2, migration.version());
statement.setString(3, migration.fileName());
statement.executeUpdate();
}
}
private static String readResource(String path) throws SQLException {
try(InputStream stream = Main.class.getClassLoader().getResourceAsStream(path)) {
if(stream == null) throw new SQLException("Migration file not found: " + path);
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch(IOException e) {
throw new SQLException("Could not read migration file " + path, e);
}
}
private static File pluginJar() {
try {
return new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI());
} catch(Exception e) {
throw new IllegalStateException("Could not locate plugin jar", e);
}
}
}
+10
View File
@@ -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