added BanEvasion appliance

This commit is contained in:
MineTec committed 2026-10-14 17:04:33 +02:00
1 parent 675d87f6e9
commit 84c3e7df13
18 files changed
+1781

No files matched your search

@@ -0,0 +1,35 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.util.server.Floodgate;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* /accountLinks – list of suspected cases; /accountLinks <player> – accounts sharing a connection with the player.
* Only visible to admins.
*/
class AccountLinksCommand extends ApplianceCommand.PlayerChecked<BanEvasion> {
AccountLinksCommand() {
super("accountLinks");
}
@Override
public @Nullable String permission() {
return BanEvasion.ADMIN_PERMISSION;
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
Player player = this.getPlayer();
if(!player.hasPermission(BanEvasion.ADMIN_PERMISSION)) return;
if(Floodgate.isBedrock(player)) throw new Error("Nur für Java-Clients");
if(!this.getAppliance().isActive()) throw new Error("Keine Datenbank konfiguriert");
if(args.length == 0) this.getAppliance().dialogs().openList(player, CaseStatus.OPEN);
else this.getAppliance().dialogs().openLinks(player, args[0]);
}
}
@@ -0,0 +1,77 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.Main;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.Comparator;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Chat messages to online admins. Never contains IP addresses.
*/
final class AdminNotifier {
record Notice(
long caseId,
String firstName,
boolean firstBanned,
String secondName,
boolean secondBanned,
Severity severity,
Set<Reason> reasons,
boolean reopened
) {
}
private final BanEvasion appliance;
AdminNotifier(BanEvasion appliance) {
this.appliance = appliance;
}
void announce(Notice notice) {
Bukkit.getScheduler().runTask(Main.instance(), () -> {
Component message = this.format(notice);
Bukkit.getOnlinePlayers().stream()
.filter(player -> player.hasPermission(BanEvasion.ADMIN_PERMISSION))
.forEach(player -> player.sendMessage(message));
});
}
void announceOpenCases(Player admin, BanEvasionRepository.OpenCount count) {
if(count.total() == 0) return;
String text = count.total() == 1 ? "1 offener Verdachtsfall" : count.total() + " offene Verdachtsfälle";
admin.sendMessage(Component.text()
.append(Component.text("⚑ " + text, count.high() > 0 ? NamedTextColor.RED : NamedTextColor.GRAY))
.append(Component.text(count.high() > 0 ? " (" + count.high() + " hoch)" : "", NamedTextColor.RED))
.append(Component.text(" [Liste]", NamedTextColor.GOLD)
.clickEvent(BanEvasionDialogs.adminClick(player -> this.appliance.dialogs().openList(player, CaseStatus.OPEN))))
.build());
}
private Component format(Notice notice) {
NamedTextColor color = notice.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.GRAY;
String reasons = notice.reasons().stream()
.filter(reason -> !reason.informational)
.sorted(Comparator.reverseOrder())
.map(reason -> reason.label)
.collect(Collectors.joining(", "));
return Component.text()
.append(Component.text(String.format("⚑ %s [%s]: ", notice.reopened() ? "Wieder offen" : "Verdacht", notice.severity().label), color))
.append(account(notice.firstName(), notice.firstBanned()))
.append(Component.text(" ↔ ", color))
.append(account(notice.secondName(), notice.secondBanned()))
.append(Component.text(" – " + reasons + " ", color))
.append(Component.text("[Öffnen]", NamedTextColor.GOLD)
.clickEvent(BanEvasionDialogs.adminClick(player -> this.appliance.dialogs().openCase(player, notice.caseId(), false))))
.build();
}
private static Component account(String name, boolean banned) {
Component component = Component.text(name, NamedTextColor.WHITE);
return banned ? component.append(Component.text(" (gebannt)", NamedTextColor.RED)) : component;
}
}
@@ -0,0 +1,22 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import java.time.Instant;
import java.util.UUID;
/**
* One row of login_attempts with a known connection key.
*/
record Attempt(long id, Instant at, UUID uuid, String name, String network, String stage, String result) {
static final String STAGE_WEBSITE = "WEBSITE";
boolean isWebsiteSignup() {
return STAGE_WEBSITE.equals(this.stage) && "SIGNUP".equals(this.result);
}
/**
* Rejected by the server (whitelist, ban, maintenance, ...), not merely aborted by the client.
*/
boolean isRejection() {
return !STAGE_WEBSITE.equals(this.stage) && this.result.startsWith("KICK_");
}
}
@@ -0,0 +1,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> {
T load() throws Exception;
}
private record Households(Map<UUID, Long> ofAccount, Map<Long, Set<UUID>> members) {
}
private final BanEvasionRepository repository = new BanEvasionRepository();
private final OnlineNetworks onlineNetworks = new OnlineNetworks();
private Database database;
private ExecutorService worker;
private 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> T query(Database.SqlQuery<T> query) {
return this.database.query(query).join();
}
/**
* Blocks for HTTP requests. Only for the worker thread.
*/
Map<UUID, BanState> banStates(Collection<UUID> uuids) {
return this.banStatus.getAll(uuids);
}
/**
* Loads data on the worker thread and hands it to the main thread, if the admin is still online.
*/
<T> void loadFor(Player admin, Loader<T> loader, Consumer<T> onMainThread) {
if(!this.active) return;
this.submit(() -> {
T result;
try {
result = loader.load();
} catch(Exception e) {
Main.logger().log(Level.WARNING, "BanEvasion: loading data for " + admin.getName() + " failed", e);
Bukkit.getScheduler().runTask(Main.instance(), () -> admin.sendMessage(Component.text("Fehler beim Laden der Daten.", NamedTextColor.RED)));
return;
}
Bukkit.getScheduler().runTask(Main.instance(), () -> {
if(admin.isOnline()) onMainThread.accept(result);
});
});
}
/**
* Called on the database thread: hands the attempt to the worker without waiting.
*/
void onAttemptRecorded(LoginAttemptRecordedEvent.RecordedAttempt recorded) {
if(!this.active || recorded.network() == null) return;
Attempt attempt = new Attempt(recorded.id(), recorded.at(), recorded.uuid(), recorded.name(), recorded.network(), recorded.stage(), recorded.result());
Set<UUID> online = this.onlineNetworks.onlineOn(recorded.network());
this.submit(() -> this.evaluate(attempt, online));
}
void playerJoined(Player player) {
InetSocketAddress address = player.getAddress();
String network = address == null || address.getAddress() == null ? null : Networks.networkOf(address.getAddress().getHostAddress());
this.onlineNetworks.joined(player.getUniqueId(), network);
if(!this.active || !player.hasPermission(ADMIN_PERMISSION)) return;
this.loadFor(player, () -> this.query(this.repository::countOpen), count -> this.notifier.announceOpenCases(player, count));
}
void playerLeft(Player player) {
this.onlineNetworks.left(player.getUniqueId());
}
private void submit(Runnable task) {
try {
this.worker.execute(task);
} catch(RejectedExecutionException e) {
Main.logger().warning("BanEvasion: worker stopped, task dropped");
}
}
private void evaluate(Attempt attempt, Set<UUID> online) {
try {
if(this.query(connection -> this.repository.isExempt(connection, attempt.network(), attempt.at()))) return;
List<Attempt> partners = this.query(connection -> this.repository.partnerAttempts(connection, attempt, this.window));
if(partners.isEmpty()) return;
Set<UUID> involved = new HashSet<>();
involved.add(attempt.uuid());
partners.forEach(partner -> involved.add(partner.uuid()));
Households households = this.query(connection -> {
Map<UUID, Long> ofAccount = this.repository.householdsOf(connection, involved);
return new Households(ofAccount, this.repository.householdMembers(connection, new HashSet<>(ofAccount.values())));
});
Set<UUID> banStateNeeded = new HashSet<>(involved);
households.members().values().forEach(banStateNeeded::addAll);
Map<UUID, BanState> banStates = this.banStatus.getAll(banStateNeeded);
List<PairFinding> findings = CaseEvaluator.evaluate(new CaseEvaluator.Input(
attempt, partners, banStates, households.ofAccount(), households.members(), online, this.rejectionWindow
));
for(PairFinding finding : findings) {
AdminNotifier.Notice notice = this.query(connection -> this.applyFinding(connection, finding, attempt, banStates));
if(notice != null) this.notifier.announce(notice);
}
} catch(Exception e) {
Main.logger().log(Level.WARNING, String.format("BanEvasion: evaluating login attempt #%d failed", attempt.id()), e);
}
}
private @Nullable AdminNotifier.Notice applyFinding(Connection connection, PairFinding finding, Attempt attempt, Map<UUID, BanState> banStates) throws SQLException {
CaseDecision.ExistingCase existing = this.repository.findCase(connection, finding.first(), finding.second());
CaseDecision.Result decision = CaseDecision.decide(existing, finding);
long caseId;
if(existing == null) {
caseId = this.repository.createCase(connection, finding, attempt.at());
} else {
caseId = existing.id();
this.repository.updateCase(connection, caseId, finding, attempt.at(), decision.reopen());
}
this.repository.addReasons(connection, caseId, finding.reasons(), decision.newReasons(), attempt.id(), attempt.at());
if(existing == null || !decision.newReasons().isEmpty()) {
String change = existing == null ? "created" : decision.reopen() ? "reopened" : "updated";
Main.logger().info(String.format("BanEvasion: case #%d %s %s", caseId, change, decision.newReasons()));
}
if(!decision.announce()) return null;
return new AdminNotifier.Notice(
caseId,
this.repository.nameOf(connection, finding.first()),
banStates.get(finding.first()) == BanState.BANNED,
this.repository.nameOf(connection, finding.second()),
banStates.get(finding.second()) == BanState.BANNED,
finding.severity(),
finding.reasons().keySet(),
decision.reopen()
);
}
private BanState lookupBanState(UUID uuid) {
try {
ReqResp<WhitelistRepository.UserData> response = this.queryRepository(WhitelistRepository.class).getUserData(uuid);
if(response.status() == HttpStatus.NOT_FOUND) return BanState.NOT_BANNED;
if(response.status() != HttpStatus.OK || response.data() == null) return BanState.UNKNOWN;
List<WhitelistRepository.UserData.Strike> strikes = Objects.requireNonNullElse(response.data().strikes(), List.of());
return this.queryAppliance(Strikes.class).isBanned(strikes).isBanned() ? BanState.BANNED : BanState.NOT_BANNED;
} 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<Listener> listeners() {
return List.of(new BanEvasionListener());
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new AccountLinksCommand());
}
}
@@ -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<UUID, BanState> banStates, Map<UUID, Long> households) {
}
private record LinksView(String name, List<BanEvasionRepository.Link> links, List<String> 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<Player> action) {
return ClickEvent.callback(
audience -> {
if(audience instanceof Player player && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player);
},
ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).lifetime(CALLBACK_LIFETIME).build()
);
}
void openList(Player admin, CaseStatus status) {
this.appliance.loadFor(admin,
() -> this.appliance.query(connection -> this.appliance.repository().listCases(connection, status, LIST_LIMIT)),
cases -> {
List<ActionButton> buttons = new ArrayList<>();
cases.forEach(summary -> buttons.add(button(caseLabel(summary), (player, response) -> this.openCase(player, summary.id(), false))));
CaseStatus other = status == CaseStatus.OPEN ? CaseStatus.CLOSED : CaseStatus.OPEN;
buttons.add(button(other == CaseStatus.OPEN ? "Offene Fälle" : "Geschlossene Fälle", player -> this.openList(player, other)));
buttons.add(button("Haushalte", this::openHouseholds));
buttons.add(button("IP-Ausnahmen", this::openExemptions));
Component body = cases.isEmpty()
? Component.text("Keine Fälle.")
: Component.text(String.format("%d Fälle (höchstens %d), hohe zuerst, dann neueste.", cases.size(), LIST_LIMIT));
show(admin, status == CaseStatus.OPEN ? "Verdachtsfälle – offen" : "Verdachtsfälle – geschlossen", body, List.of(), buttons);
});
}
void openCase(Player admin, long caseId, boolean fullNetworks) {
this.appliance.loadFor(admin, () -> {
BanEvasionRepository.CaseDetails details = this.appliance.query(connection -> this.appliance.repository().loadCase(connection, caseId, TIMELINE_LIMIT));
if(details == null) return null;
List<UUID> accounts = List.of(details.summary().first(), details.summary().second());
Map<UUID, Long> households = this.appliance.query(connection -> this.appliance.repository().householdsOf(connection, accounts));
return new CaseView(details, this.appliance.banStates(accounts), households);
}, view -> {
if(view == null) {
admin.sendMessage(Component.text("Fall #" + caseId + " gibt es nicht (mehr).", NamedTextColor.RED));
return;
}
this.showCase(admin, view, fullNetworks);
});
}
void openLinks(Player admin, String name) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> this.appliance.repository().uuidByName(connection, name)), uuid -> {
if(uuid == null) {
admin.sendMessage(Component.text("Keine Einträge zu " + name + ".", NamedTextColor.RED));
return;
}
this.openLinks(admin, uuid);
});
}
void openLinks(Player admin, UUID uuid) {
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> new LinksView(
this.appliance.repository().nameOf(connection, uuid),
this.appliance.repository().linksOf(connection, uuid, LINK_LIMIT),
this.appliance.repository().recentNetworks(connection, uuid, RECENT_NETWORKS)
)), view -> {
List<Component> 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<ActionButton> 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<String> networks) {
List<SingleOptionDialogInput.OptionEntry> options = new ArrayList<>();
for(int i = 0; i < networks.size(); i++)
options.add(SingleOptionDialogInput.OptionEntry.create(networks.get(i), Component.text(networks.get(i)), i == 0));
List<DialogInput> inputs = List.of(
DialogInput.singleOption(NETWORK_KEY, Component.text("Anschluss"), options).width(BODY_WIDTH).build(),
DialogInput.numberRange(HOURS_KEY, Component.text("Dauer in Stunden"), 1, 168).step(1f).initial(12f).width(BODY_WIDTH).build(),
DialogInput.text(NOTE_KEY, Component.text("Notiz, z. B. LAN-Party bei …")).width(BODY_WIDTH).maxLength(255).build()
);
List<ActionButton> buttons = List.of(
button(Component.text("Ausnahme anlegen"), (player, response) -> this.createExemption(player, networks, response)),
button("Abbrechen", player -> this.openList(player, CaseStatus.OPEN))
);
show(admin, "IP-Ausnahme anlegen",
Component.text("Einträge von diesem Anschluss lösen in der gewählten Zeit keine Meldung aus. Sie werden weiter protokolliert."),
inputs, buttons);
}
void openExemptions(Player admin) {
Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> this.appliance.repository().activeExemptions(connection, now)), exemptions -> {
List<Component> 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<ActionButton> 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<Component> 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<ActionButton> 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<Component> lines = new ArrayList<>();
lines.add(Component.text(String.format("Status: %s%s · Stärke: %s · %d Treffer",
summary.status() == CaseStatus.OPEN ? "offen" : "geschlossen",
summary.outcome() == null ? "" : " (" + summary.outcome().label + ")",
summary.severity().label, summary.hits())));
lines.add(Component.text("Erstellt " + TIME.format(details.createdAt()) + " · zuletzt " + TIME.format(summary.lastSeenAt())));
if(details.banStatusUnknown())
lines.add(Component.text("Bann-Status war bei der letzten Prüfung nicht abrufbar.", NamedTextColor.GOLD));
lines.add(Component.empty());
lines.add(accountLine(summary.firstName(), view.banStates().get(summary.first()), view.households().get(summary.first())));
lines.add(accountLine(summary.secondName(), view.banStates().get(summary.second()), view.households().get(summary.second())));
lines.add(Component.empty());
lines.add(Component.text("Gründe:"));
details.reasons().forEach(reason -> lines.add(Component.text(
String.format("• %s (%s) seit %s", reason.reason().label, reason.severity().label, TIME.format(reason.firstSeenAt())),
reason.severity() == Severity.HIGH && !reason.reason().informational ? NamedTextColor.RED : NamedTextColor.GRAY)));
lines.add(Component.empty());
lines.add(Component.text("Gemeinsame Anschlüsse: " + networks(details.sharedNetworks(), fullNetworks)));
lines.add(Component.empty());
lines.add(Component.text("Letzte Einträge:"));
details.timeline().forEach(entry -> lines.add(Component.text(String.format("%s %s %s %s %s",
TIME.format(entry.at()), entry.name(), entry.stage(), entry.result(),
entry.network() == null ? "?" : network(entry.network(), fullNetworks)), NamedTextColor.GRAY)));
if(details.closedAt() != null) {
lines.add(Component.empty());
lines.add(Component.text(String.format("Zuletzt geschlossen %s von %s%s",
TIME.format(details.closedAt()),
details.closedByName() == null ? "?" : details.closedByName(),
details.note() == null ? "" : ": " + details.note()), NamedTextColor.GRAY));
}
long caseId = summary.id();
List<DialogInput> inputs = new ArrayList<>();
List<ActionButton> buttons = new ArrayList<>();
if(summary.status() == CaseStatus.OPEN) {
inputs.add(DialogInput.text(NOTE_KEY, Component.text("Notiz (optional)")).width(BODY_WIDTH).maxLength(255).build());
for(Outcome outcome : Outcome.values())
buttons.add(button(Component.text("Schließen: " + outcome.label), (player, response) -> this.close(player, summary, outcome, response.getText(NOTE_KEY))));
}
buttons.add(button(fullNetworks ? "Anschlüsse gekürzt" : "Volle Anschlüsse zeigen", player -> this.openCase(player, caseId, !fullNetworks)));
buttons.add(button("Accounts zu " + summary.firstName(), player -> this.openLinks(player, summary.first())));
buttons.add(button("Accounts zu " + summary.secondName(), player -> this.openLinks(player, summary.second())));
if(!details.sharedNetworks().isEmpty())
buttons.add(button("IP-Ausnahme anlegen", player -> this.openNewExemption(player, details.sharedNetworks())));
buttons.add(button("Zurück zur Liste", player -> this.openList(player, summary.status())));
show(admin, "Verdachtsfall #" + caseId, lines(lines), inputs, buttons);
}
private void close(Player admin, BanEvasionRepository.CaseSummary summary, Outcome outcome, @Nullable String rawNote) {
String note = rawNote == null || rawNote.isBlank() ? null : rawNote.strip();
UUID adminUuid = admin.getUniqueId();
Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().closeCase(connection, summary.id(), outcome, adminUuid, note, now);
if(outcome == Outcome.HOUSEHOLD)
this.appliance.repository().linkHousehold(connection, List.of(summary.first(), summary.second()), adminUuid, note, now);
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: case #%d closed as %s by %s", summary.id(), outcome, admin.getName()));
admin.sendMessage(Component.text(String.format("Fall #%d geschlossen: %s", summary.id(), outcome.label), NamedTextColor.GRAY));
this.openList(admin, CaseStatus.OPEN);
});
}
private void createExemption(Player admin, List<String> allowed, DialogResponseView response) {
String network = response.getText(NETWORK_KEY);
Float hours = response.getFloat(HOURS_KEY);
if(network == null || !allowed.contains(network) || hours == null || hours < 1) {
admin.sendMessage(Component.text("Ungültige Eingabe.", NamedTextColor.RED));
return;
}
String rawNote = response.getText(NOTE_KEY);
String note = rawNote == null || rawNote.isBlank() ? null : rawNote.strip();
Instant from = Instant.now();
Instant until = from.plus(Duration.ofHours(Math.round(hours)));
UUID adminUuid = admin.getUniqueId();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().addExemption(connection, network, from, until, adminUuid, note);
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: exemption until %s added by %s", until, admin.getName()));
admin.sendMessage(Component.text(String.format("IP-Ausnahme für %s bis %s angelegt.", Networks.mask(network), TIME.format(until)), NamedTextColor.GRAY));
});
}
private void endExemption(Player admin, BanEvasionRepository.Exemption exemption) {
Instant now = Instant.now();
this.appliance.loadFor(admin, () -> this.appliance.query(connection -> {
this.appliance.repository().endExemption(connection, exemption.id(), now);
return true;
}), done -> {
Main.logger().info(String.format("BanEvasion: exemption #%d ended by %s", exemption.id(), admin.getName()));
this.openExemptions(admin);
});
}
private void removeMember(Player admin, 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<String> networks, boolean full) {
if(networks.isEmpty()) return "keine";
return networks.stream().map(network -> network(network, full)).collect(Collectors.joining(", "));
}
private static Component lines(List<Component> lines) {
return Component.join(JoinConfiguration.newlines(), lines);
}
private static DialogAction adminAction(BiConsumer<Player, DialogResponseView> action) {
return DialogAction.customClick(
(response, audience) -> {
if(!(audience instanceof Player player)) return;
Bukkit.getScheduler().runTask(Main.instance(), () -> {
if(player.isOnline() && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player, response);
});
},
ClickCallback.Options.builder().uses(ClickCallback.UNLIMITED_USES).lifetime(CALLBACK_LIFETIME).build()
);
}
private static ActionButton button(Component label, BiConsumer<Player, DialogResponseView> action) {
return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction(action)).build();
}
private static ActionButton button(String label, Consumer<Player> action) {
return button(Component.text(label), (player, response) -> action.accept(player));
}
private static void show(Player admin, String title, Component body, List<DialogInput> inputs, List<ActionButton> buttons) {
Dialog dialog = Dialog.create(factory -> factory.empty()
.base(DialogBase.builder(Component.text(title))
.canCloseWithEscape(true)
.afterAction(DialogBase.DialogAfterAction.CLOSE)
.body(List.of(DialogBody.plainMessage(body, BODY_WIDTH)))
.inputs(inputs)
.build())
.type(DialogType.multiAction(buttons)
.columns(1)
.exitAction(ActionButton.builder(Component.text("Schließen")).width(BUTTON_WIDTH).build())
.build()));
admin.showDialog(dialog);
}
}
@@ -0,0 +1,24 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
class BanEvasionListener extends ApplianceListener<BanEvasion> {
@EventHandler
public void onAttemptRecorded(LoginAttemptRecordedEvent event) {
this.getAppliance().onAttemptRecorded(event.getAttempt());
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
this.getAppliance().playerJoined(event.getPlayer());
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
this.getAppliance().playerLeft(event.getPlayer());
}
}
@@ -0,0 +1,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<CaseReason> reasons,
List<String> sharedNetworks,
List<TimelineEntry> timeline
) {
}
record OpenCount(int total, int high) {
}
record Link(UUID uuid, String name, int sharedAttempts, Instant firstAt, Instant lastAt, @Nullable Long caseId) {
}
record Member(UUID uuid, String name) {
}
record Household(long id, @Nullable String note, List<Member> members) {
}
record Exemption(long id, String network, Instant validFrom, Instant validUntil, @Nullable String createdByName, @Nullable String note) {
}
/**
* Latest known name of the account in the given column.
*/
private static final String NAME_OF = "(SELECT n.name FROM login_attempts n WHERE n.uuid = %s ORDER BY n.at DESC LIMIT 1)";
boolean isExempt(Connection connection, String network, Instant at) throws SQLException {
String sql = "SELECT 1 FROM ban_evasion_ip_exemptions WHERE network = ? AND ? BETWEEN valid_from AND valid_until LIMIT 1";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, network);
statement.setObject(2, toDb(at));
try(ResultSet result = statement.executeQuery()) {
return result.next();
}
}
}
List<Attempt> partnerAttempts(Connection connection, Attempt current, Duration window) throws SQLException {
String sql = """
SELECT a.id, a.at, a.uuid, a.name, a.network, a.stage, a.result
FROM login_attempts a
WHERE a.network = ? AND a.uuid <> ? AND a.at BETWEEN ? AND ?
AND NOT EXISTS (
SELECT 1 FROM ban_evasion_ip_exemptions e
WHERE e.network = a.network AND a.at BETWEEN e.valid_from AND e.valid_until
)
ORDER BY a.at
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, current.network());
statement.setString(2, current.uuid().toString());
statement.setObject(3, toDb(current.at().minus(window)));
statement.setObject(4, toDb(current.at()));
List<Attempt> attempts = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) attempts.add(new Attempt(
row.getLong("id"),
fromDb(row, "at"),
UUID.fromString(row.getString("uuid")),
row.getString("name"),
row.getString("network"),
row.getString("stage"),
row.getString("result")
));
}
return attempts;
}
}
Map<UUID, Long> householdsOf(Connection connection, Collection<UUID> uuids) throws SQLException {
if(uuids.isEmpty()) return Map.of();
String sql = "SELECT uuid, household_id FROM ban_evasion_household_members WHERE uuid IN (" + placeholders(uuids.size()) + ")";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
for(UUID uuid : uuids) statement.setString(index++, uuid.toString());
Map<UUID, Long> households = new HashMap<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) households.put(UUID.fromString(row.getString("uuid")), row.getLong("household_id"));
}
return households;
}
}
Map<Long, Set<UUID>> householdMembers(Connection connection, Collection<Long> householdIds) throws SQLException {
if(householdIds.isEmpty()) return Map.of();
String sql = "SELECT household_id, uuid FROM ban_evasion_household_members WHERE household_id IN (" + placeholders(householdIds.size()) + ")";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
int index = 1;
for(long id : householdIds) statement.setLong(index++, id);
Map<Long, Set<UUID>> members = new HashMap<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next())
members.computeIfAbsent(row.getLong("household_id"), id -> new HashSet<>()).add(UUID.fromString(row.getString("uuid")));
}
return members;
}
}
@Nullable CaseDecision.ExistingCase findCase(Connection connection, UUID first, UUID second) throws SQLException {
long id;
CaseStatus status;
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<Reason> 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<Reason, Severity> reasons, Set<Reason> which, long attemptId, Instant at) throws SQLException {
if(which.isEmpty()) return;
String sql = "INSERT IGNORE INTO ban_evasion_case_reasons (case_id, reason, severity, first_seen_at, attempt_id) VALUES (?, ?, ?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
for(Reason reason : which) {
statement.setLong(1, caseId);
statement.setString(2, reason.name());
statement.setString(3, reasons.get(reason).name());
statement.setObject(4, toDb(at));
statement.setLong(5, attemptId);
statement.addBatch();
}
statement.executeBatch();
}
}
List<CaseSummary> listCases(Connection connection, CaseStatus status, int limit) throws SQLException {
String sql = """
SELECT c.id, c.uuid_a, c.uuid_b, c.status, c.outcome, c.severity, c.last_seen_at, c.hits,
%s AS name_a, %s AS name_b
FROM ban_evasion_cases c
WHERE c.status = ?
ORDER BY c.severity = 'HIGH' DESC, c.last_seen_at DESC
LIMIT ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"));
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, status.name());
statement.setInt(2, limit);
List<CaseSummary> cases = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) cases.add(summaryOf(row));
}
return cases;
}
}
OpenCount countOpen(Connection connection) throws SQLException {
String sql = "SELECT COUNT(*) AS total, COALESCE(SUM(severity = 'HIGH'), 0) AS high FROM ban_evasion_cases WHERE status = 'OPEN'";
try(PreparedStatement statement = connection.prepareStatement(sql); ResultSet row = statement.executeQuery()) {
row.next();
return new OpenCount(row.getInt("total"), row.getInt("high"));
}
}
@Nullable CaseDetails loadCase(Connection connection, long caseId, int timelineLimit) throws SQLException {
String sql = """
SELECT c.*, %s AS name_a, %s AS name_b, %s AS closed_by_name
FROM ban_evasion_cases c
WHERE c.id = ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"), NAME_OF.formatted("c.closed_by"));
CaseSummary summary;
boolean banStatusUnknown;
Instant createdAt;
Instant closedAt;
String closedByName;
String note;
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, caseId);
try(ResultSet row = statement.executeQuery()) {
if(!row.next()) return null;
summary = summaryOf(row);
banStatusUnknown = row.getBoolean("ban_status_unknown");
createdAt = fromDb(row, "created_at");
closedAt = nullableFromDb(row, "closed_at");
closedByName = row.getString("closed_by_name");
note = row.getString("note");
}
}
return new CaseDetails(
summary, banStatusUnknown, createdAt, closedAt, closedByName, note,
this.reasonsOf(connection, caseId),
this.sharedNetworks(connection, summary.first(), summary.second()),
this.timeline(connection, summary.first(), summary.second(), timelineLimit)
);
}
void closeCase(Connection connection, long caseId, Outcome outcome, UUID admin, @Nullable String note, Instant at) throws SQLException {
String sql = "UPDATE ban_evasion_cases SET status = 'CLOSED', outcome = ?, closed_at = ?, closed_by = ?, note = ? WHERE id = ?";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, outcome.name());
statement.setObject(2, toDb(at));
statement.setString(3, admin.toString());
statement.setString(4, note);
statement.setLong(5, caseId);
statement.executeUpdate();
}
}
/**
* All accounts that ever used one of the connections of the given account, most recent first.
*/
List<Link> linksOf(Connection connection, UUID uuid, int limit) throws SQLException {
String sql = """
SELECT o.uuid, %s AS name, COUNT(*) AS shared, MIN(o.at) AS first_at, MAX(o.at) AS last_at,
(SELECT c.id FROM ban_evasion_cases c
WHERE (c.uuid_a = ? AND c.uuid_b = o.uuid) OR (c.uuid_a = o.uuid AND c.uuid_b = ?)) AS case_id
FROM login_attempts o
WHERE o.uuid <> ? AND o.network IN (
SELECT s.network FROM login_attempts s WHERE s.uuid = ? AND s.network IS NOT NULL
)
GROUP BY o.uuid
ORDER BY last_at DESC
LIMIT ?
""".formatted(NAME_OF.formatted("o.uuid"));
try(PreparedStatement statement = connection.prepareStatement(sql)) {
for(int index = 1; index <= 4; index++) statement.setString(index, uuid.toString());
statement.setInt(5, limit);
List<Link> links = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) {
UUID other = UUID.fromString(row.getString("uuid"));
String name = nameOr(row.getString("name"), other);
int shared = row.getInt("shared");
Instant firstAt = fromDb(row, "first_at");
Instant lastAt = fromDb(row, "last_at");
long caseId = row.getLong("case_id");
boolean noCase = row.wasNull();
links.add(new Link(other, name, shared, firstAt, lastAt, noCase ? null : caseId));
}
}
return links;
}
}
@Nullable UUID uuidByName(Connection connection, String name) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("SELECT uuid FROM login_attempts WHERE name = ? ORDER BY at DESC LIMIT 1")) {
statement.setString(1, name);
try(ResultSet row = statement.executeQuery()) {
return row.next() ? UUID.fromString(row.getString("uuid")) : null;
}
}
}
String nameOf(Connection connection, UUID uuid) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("SELECT name FROM login_attempts WHERE uuid = ? ORDER BY at DESC LIMIT 1")) {
statement.setString(1, uuid.toString());
try(ResultSet row = statement.executeQuery()) {
return row.next() ? row.getString("name") : uuid.toString();
}
}
}
List<String> recentNetworks(Connection connection, UUID uuid, int limit) throws SQLException {
String sql = """
SELECT network, MAX(at) AS last_at FROM login_attempts
WHERE uuid = ? AND network IS NOT NULL
GROUP BY network ORDER BY last_at DESC LIMIT ?
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString());
statement.setInt(2, limit);
List<String> networks = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) networks.add(row.getString("network"));
}
return networks;
}
}
void addExemption(Connection connection, String network, Instant from, Instant until, UUID admin, @Nullable String note) throws SQLException {
String sql = "INSERT INTO ban_evasion_ip_exemptions (network, valid_from, valid_until, created_by, note) VALUES (?, ?, ?, ?, ?)";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, network);
statement.setObject(2, toDb(from));
statement.setObject(3, toDb(until));
statement.setString(4, admin.toString());
statement.setString(5, note);
statement.executeUpdate();
}
}
List<Exemption> activeExemptions(Connection connection, Instant now) throws SQLException {
String sql = """
SELECT e.id, e.network, e.valid_from, e.valid_until, e.note, %s AS created_by_name
FROM ban_evasion_ip_exemptions e
WHERE e.valid_until >= ?
ORDER BY e.valid_until
""".formatted(NAME_OF.formatted("e.created_by"));
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setObject(1, toDb(now));
List<Exemption> exemptions = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) exemptions.add(new Exemption(
row.getLong("id"),
row.getString("network"),
fromDb(row, "valid_from"),
fromDb(row, "valid_until"),
row.getString("created_by_name"),
row.getString("note")
));
}
return exemptions;
}
}
void endExemption(Connection connection, long id, Instant now) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("UPDATE ban_evasion_ip_exemptions SET valid_until = ? WHERE id = ? AND valid_until > ?")) {
statement.setObject(1, toDb(now));
statement.setLong(2, id);
statement.setObject(3, toDb(now));
statement.executeUpdate();
}
}
/**
* Puts the accounts into one household; existing households of these accounts are merged.
*/
void linkHousehold(Connection connection, Collection<UUID> uuids, UUID admin, @Nullable String note, Instant at) throws SQLException {
boolean autoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
try {
List<Long> existing = this.householdsOf(connection, uuids).values().stream().distinct().sorted().toList();
long target = existing.isEmpty() ? this.createHousehold(connection, admin, note, at) : existing.getFirst();
for(long other : existing.subList(Math.min(1, existing.size()), existing.size())) {
try(PreparedStatement move = connection.prepareStatement("UPDATE ban_evasion_household_members SET household_id = ? WHERE household_id = ?")) {
move.setLong(1, target);
move.setLong(2, other);
move.executeUpdate();
}
try(PreparedStatement delete = connection.prepareStatement("DELETE FROM ban_evasion_households WHERE id = ?")) {
delete.setLong(1, other);
delete.executeUpdate();
}
}
try(PreparedStatement insert = connection.prepareStatement("INSERT IGNORE INTO ban_evasion_household_members (uuid, household_id) VALUES (?, ?)")) {
for(UUID uuid : uuids) {
insert.setString(1, uuid.toString());
insert.setLong(2, target);
insert.addBatch();
}
insert.executeBatch();
}
connection.commit();
} catch(SQLException e) {
connection.rollback();
throw e;
} finally {
connection.setAutoCommit(autoCommit);
}
}
List<Household> 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<Long, Household> 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<CaseReason> reasonsOf(Connection connection, long caseId) throws SQLException {
String sql = "SELECT reason, severity, first_seen_at FROM ban_evasion_case_reasons WHERE case_id = ? ORDER BY first_seen_at, reason";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, caseId);
List<CaseReason> reasons = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) reasons.add(new CaseReason(
Reason.valueOf(row.getString("reason")),
Severity.valueOf(row.getString("severity")),
fromDb(row, "first_seen_at")
));
}
return reasons;
}
}
private List<String> sharedNetworks(Connection connection, UUID first, UUID second) throws SQLException {
String sql = """
SELECT DISTINCT a.network FROM login_attempts a
WHERE a.uuid = ? AND a.network IS NOT NULL
AND EXISTS (SELECT 1 FROM login_attempts b WHERE b.uuid = ? AND b.network = a.network)
ORDER BY a.network
""";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, first.toString());
statement.setString(2, second.toString());
List<String> networks = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) networks.add(row.getString("network"));
}
return networks;
}
}
private List<TimelineEntry> timeline(Connection connection, UUID first, UUID second, int limit) throws SQLException {
String sql = "SELECT at, name, network, stage, result FROM login_attempts WHERE uuid IN (?, ?) ORDER BY at DESC LIMIT ?";
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, first.toString());
statement.setString(2, second.toString());
statement.setInt(3, limit);
List<TimelineEntry> entries = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) entries.add(new TimelineEntry(
fromDb(row, "at"), row.getString("name"), row.getString("network"), row.getString("stage"), row.getString("result")
));
}
return entries;
}
}
private static CaseSummary summaryOf(ResultSet row) throws SQLException {
UUID first = UUID.fromString(row.getString("uuid_a"));
UUID second = UUID.fromString(row.getString("uuid_b"));
String outcome = row.getString("outcome");
return new CaseSummary(
row.getLong("id"),
first,
nameOr(row.getString("name_a"), first),
second,
nameOr(row.getString("name_b"), second),
CaseStatus.valueOf(row.getString("status")),
outcome == null ? null : Outcome.valueOf(outcome),
Severity.valueOf(row.getString("severity")),
fromDb(row, "last_seen_at"),
row.getInt("hits")
);
}
private static String nameOr(@Nullable String name, UUID uuid) {
return name != null ? name : uuid.toString();
}
private static String placeholders(int count) {
return String.join(", ", Collections.nCopies(count, "?"));
}
private static LocalDateTime toDb(Instant instant) {
return LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
}
private static Instant fromDb(ResultSet row, String column) throws SQLException {
return row.getObject(column, LocalDateTime.class).toInstant(ZoneOffset.UTC);
}
private static @Nullable Instant nullableFromDb(ResultSet row, String column) throws SQLException {
LocalDateTime value = row.getObject(column, LocalDateTime.class);
return value == null ? null : value.toInstant(ZoneOffset.UTC);
}
}
@@ -0,0 +1,10 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum BanState {
BANNED,
NOT_BANNED,
/**
* The website API could not be asked.
*/
UNKNOWN
}
@@ -0,0 +1,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<UUID, Entry> entries = new ConcurrentHashMap<>();
private final Function<UUID, BanState> lookup;
private final Duration ttl;
private final Clock clock;
BanStatusCache(Function<UUID, BanState> 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<UUID, BanState> getAll(Collection<UUID> uuids) {
Map<UUID, BanState> states = new LinkedHashMap<>();
uuids.forEach(uuid -> states.put(uuid, this.get(uuid)));
return states;
}
}
@@ -0,0 +1,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<Reason> reasons) {
}
/**
* @param newReasons reasons not yet recorded for the case (all reasons for a new case)
*/
record Result(boolean reopen, Set<Reason> newReasons, boolean announce) {
}
private CaseDecision() {
}
static Result decide(@Nullable ExistingCase existing, PairFinding finding) {
if(existing == null) return new Result(false, Set.copyOf(finding.reasons().keySet()), true);
Set<Reason> newReasons = EnumSet.noneOf(Reason.class);
finding.reasons().keySet().stream()
.filter(reason -> !existing.reasons().contains(reason))
.forEach(newReasons::add);
boolean newRelevant = newReasons.stream().anyMatch(reason -> !reason.informational);
boolean newHigh = newReasons.stream()
.anyMatch(reason -> !reason.informational && finding.reasons().get(reason) == Severity.HIGH);
if(existing.status() == CaseStatus.OPEN) return new Result(false, newReasons, newRelevant);
return new Result(newHigh, newReasons, newHigh);
}
}
@@ -0,0 +1,102 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Rules of the ban evasion detection, free of Bukkit and database access.
* Judges one new login attempt against all attempts of other accounts from the same connection.
*/
final class CaseEvaluator {
/**
* @param partnerAttempts attempts of other accounts from the same connection within the time window, not exempted
* @param banStates ban state of the current account, its partners and all members of their households
* @param households household id per account, only for accounts in a household
* @param householdMembers members per household id
* @param onlineOnSameNetwork accounts currently online from the same connection
*/
record Input(
Attempt current,
List<Attempt> partnerAttempts,
Map<UUID, BanState> banStates,
Map<UUID, Long> households,
Map<Long, Set<UUID>> householdMembers,
Set<UUID> onlineOnSameNetwork,
Duration rejectionWindow
) {
}
private CaseEvaluator() {
}
static List<PairFinding> evaluate(Input input) {
UUID self = input.current().uuid();
Map<UUID, List<Attempt>> byPartner = new LinkedHashMap<>();
input.partnerAttempts().stream()
.filter(attempt -> !attempt.uuid().equals(self))
.forEach(attempt -> byPartner.computeIfAbsent(attempt.uuid(), uuid -> new ArrayList<>()).add(attempt));
List<PairFinding> findings = new ArrayList<>();
byPartner.forEach((partner, attempts) -> {
PairFinding finding = evaluatePair(input, partner, attempts);
if(finding != null) findings.add(finding);
});
return findings;
}
private static @Nullable PairFinding evaluatePair(Input input, UUID partner, List<Attempt> partnerAttempts) {
Attempt current = input.current();
BanState selfState = banState(input, current.uuid());
BanState partnerState = banState(input, partner);
Long household = input.households().get(current.uuid());
boolean sameHousehold = household != null && household.equals(input.households().get(partner));
if(sameHousehold && householdState(input, household) != BanState.BANNED) return null;
Map<Reason, Severity> reasons = new EnumMap<>(Reason.class);
reasons.put(Reason.SHARED_CONNECTION, Severity.NORMAL);
if(selfState == BanState.BANNED || partnerState == BanState.BANNED)
reasons.put(Reason.BANNED, Severity.HIGH);
Instant rejectionStart = current.at().minus(input.rejectionWindow());
boolean partnerRejectedRecently = partnerAttempts.stream()
.anyMatch(attempt -> attempt.isRejection()
&& !attempt.at().isBefore(rejectionStart)
&& !attempt.at().isAfter(current.at()));
if(partnerState == BanState.BANNED && partnerRejectedRecently)
reasons.put(Reason.AFTER_REJECTION, Severity.HIGH);
if(current.isWebsiteSignup())
reasons.put(Reason.WEBSITE_SIGNUP, partnerState == BanState.BANNED ? Severity.HIGH : Severity.NORMAL);
if(sameHousehold)
reasons.put(Reason.HOUSEHOLD_MEMBER_BANNED, Severity.HIGH);
if(input.onlineOnSameNetwork().contains(partner))
reasons.put(Reason.ONLINE_SIMULTANEOUSLY, Severity.NORMAL);
boolean unknown = selfState == BanState.UNKNOWN || partnerState == BanState.UNKNOWN;
return PairFinding.of(current.uuid(), partner, reasons, unknown);
}
private static BanState banState(Input input, UUID uuid) {
return input.banStates().getOrDefault(uuid, BanState.UNKNOWN);
}
private static BanState householdState(Input input, long household) {
Set<BanState> states = input.householdMembers().getOrDefault(household, Set.of()).stream()
.map(member -> banState(input, member))
.collect(Collectors.toSet());
if(states.contains(BanState.BANNED)) return BanState.BANNED;
if(states.contains(BanState.UNKNOWN)) return BanState.UNKNOWN;
return BanState.NOT_BANNED;
}
}
@@ -0,0 +1,6 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum CaseStatus {
OPEN,
CLOSED
}
@@ -0,0 +1,32 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* Connection key of every online player, readable from any thread.
*/
final class OnlineNetworks {
private final Map<UUID, String> networks = new ConcurrentHashMap<>();
void joined(UUID uuid, @Nullable String network) {
if(network == null) this.networks.remove(uuid);
else this.networks.put(uuid, network);
}
void left(UUID uuid) {
this.networks.remove(uuid);
}
Set<UUID> onlineOn(String network) {
return this.networks.entrySet().stream()
.filter(entry -> entry.getValue().equals(network))
.map(Map.Entry::getKey)
.collect(Collectors.toUnmodifiableSet());
}
}
@@ -0,0 +1,13 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum Outcome {
CONFIRMED("Umgehung bestätigt"),
UNFOUNDED("unbegründet"),
HOUSEHOLD("Haushalt");
final String label;
Outcome(String label) {
this.label = label;
}
}
@@ -0,0 +1,26 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import java.util.Collections;
import java.util.EnumMap;
import java.util.Map;
import java.util.UUID;
/**
* Reasons why two accounts may belong to the same person. The pair is ordered by the textual UUID,
* so (a, b) and (b, a) always map to the same case.
*/
record PairFinding(UUID first, UUID second, Map<Reason, Severity> reasons, boolean banStatusUnknown) {
static PairFinding of(UUID x, UUID y, Map<Reason, Severity> reasons, boolean banStatusUnknown) {
boolean ordered = x.toString().compareTo(y.toString()) < 0;
EnumMap<Reason, Severity> copy = new EnumMap<>(Reason.class);
copy.putAll(reasons);
return new PairFinding(ordered ? x : y, ordered ? y : x, Collections.unmodifiableMap(copy), banStatusUnknown);
}
Severity severity() {
return this.reasons.entrySet().stream()
.filter(entry -> !entry.getKey().informational)
.map(Map.Entry::getValue)
.reduce(Severity.NORMAL, Severity::max);
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum Reason {
SHARED_CONNECTION("gemeinsamer Anschluss", false),
BANNED("gebannt", false),
AFTER_REJECTION("nach Abweisung", false),
WEBSITE_SIGNUP("Webseiten-Anmeldung", false),
HOUSEHOLD_MEMBER_BANNED("Haushalt, Mitglied gebannt", false),
ONLINE_SIMULTANEOUSLY("gleichzeitig online", true);
final String label;
/**
* Informational reasons are shown in the case, but never trigger a notification.
*/
final boolean informational;
Reason(String label, boolean informational) {
this.label = label;
this.informational = informational;
}
}
@@ -0,0 +1,16 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum Severity {
NORMAL("normal"),
HIGH("hoch");
final String label;
Severity(String label) {
this.label = label;
}
static Severity max(Severity a, Severity b) {
return a.compareTo(b) >= 0 ? a : b;
}
}
@@ -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;