diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bac7ea..06d84b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,65 @@ All notable changes to AuthLimbo are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-05-07 + +Data-loss bug fix release. Triggered by the YOU500 incident on +`racked.ru` at 2026-05-07 17:13:39 UTC — full inventory void-death during +AuthMe's post-login teleport. See `AUDIT-2026-05-07.md` for the full +forensic trace and `ROADMAP.md` for tracking. + +### Added +- **F1 — VOID-damage guard during post-login restore.** New + `EntityDamageEvent` listener at `EventPriority.HIGHEST, + ignoreCancelled=true`. While a player UUID sits in `pendingTransit` + (post-`LoginEvent`, pre-restore-success), `DamageCause.VOID` events are + cancelled, the player is healed to full, and sync-teleported back to + limbo spawn. Console gets a WARN with the player name + intended TP + target. This single guard would have saved YOU500's inventory. +- **F2 — Recovery on `teleportAsync` future == false.** The previous + log-and-abandon branch in `LoginListener.doTeleport` is replaced with a + retry loop. On any failure (false future, exceptional future, or + chunk-load throw): synchronously snap the player back to limbo spawn, + increment a per-UUID retry counter, schedule another `doTeleport` after + 30 ticks (~1.5 s). After 3 failures: leave the player at limbo spawn + in `GameMode.SPECTATOR`, log SEVERE with full saved coords + retry + count, and alert console with manual-intervention instructions + (`/authlimbo tp `). Players stay in `pendingTransit` across + retries so F1 keeps protecting them. +- **F4 — Pre-empt AuthMe's broken teleport.** New `LoginEvent` listener + at `EventPriority.LOWEST` (runs BEFORE AuthMe-ReReloaded's own internal + post-login teleport). Action: synchronously TP the player back to + limbo spawn and add their UUID to `pendingTransit`. AuthMe's + subsequent teleport then operates against an irrelevant location, and + our existing MONITOR handler still wins last with the authoritative + restore. Net effect: closes the void-death window even when the saved + chunk is far out and slow to load. + +### Internal +- New `Set pendingTransit` (ConcurrentHashMap.newKeySet) and + `Map retryCounts` on `LoginListener`. Both are + watchdog-timed-out after 5 s so we never leak entries on edge cases. +- Constants: `MAX_RETRIES=3`, `RETRY_DELAY_TICKS=30`, + `PENDING_TIMEOUT_TICKS=100`. +- No new Maven dependencies. No new public API. + +### Privacy +- Limbo-on-join invariant unchanged. F4 actually *strengthens* it by + guaranteeing the limbo position is reasserted at LOGIN-LOWEST. + +### Test plan (reproduces YOU500 in dev Paper 1.21.x + AuthMe-ReReloaded fork b49) +- Set saved coord far out (e.g. X=10000, Z=10000) in `authme.db` for a + test account so the chunk is unloaded at login. Restart server. Login. + Expect: F4 sync-TPs to limbo spawn first; F2 retries on false future; + F1 catches any VOID damage during transit; player ends up at saved + coords with full inventory. +- Set saved Y above world build limit (e.g. 5000). Login. Expect: F2 + recovery branch retries up to 3 times, then drops the player into + spectator at limbo spawn with admin alert. +- Trigger a synthetic VOID damage during transit (debug command). + Expect: F1 cancels the damage, snaps player back to limbo spawn at + full health, restore continues. + ## [1.0.0] - 2026-04-30 Initial public release. diff --git a/pom.xml b/pom.xml index c92f8c0..ffba6c8 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ ru.authlimbo AuthLimbo - 1.0.0 + 1.1.0 jar AuthLimbo diff --git a/src/main/java/ru/authlimbo/LoginListener.java b/src/main/java/ru/authlimbo/LoginListener.java index a9b7b6d..5c8edc4 100644 --- a/src/main/java/ru/authlimbo/LoginListener.java +++ b/src/main/java/ru/authlimbo/LoginListener.java @@ -20,29 +20,45 @@ import fr.xephi.authme.events.AuthMeAsyncPreLoginEvent; import fr.xephi.authme.events.LoginEvent; import org.bukkit.Bukkit; import org.bukkit.Chunk; +import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityDamageEvent.DamageCause; import org.bukkit.event.player.PlayerTeleportEvent; import java.util.HashSet; +import java.util.Map; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; /** - * Listens for AuthMe's two relevant events: + * Listens for AuthMe's two relevant events plus a damage guard: * * 1. {@link AuthMeAsyncPreLoginEvent} — fired before AuthMe authenticates * the password. We pin the destination chunk via a plugin chunk-ticket * so it's fully loaded by the time the actual teleport runs. * - * 2. {@link LoginEvent} — fired AFTER AuthMe successfully authenticates - * and runs its own (often broken) post-login teleport. We listen at - * MONITOR priority so we are LAST in the chain, then fire an - * authoritative teleport that overrides whatever AuthMe / Paper safety - * checks did to the player's location. + * 2. {@link LoginEvent} at {@link EventPriority#LOWEST} (F4) — fired BEFORE + * AuthMe-ReReloaded's own internal post-login teleport. We immediately + * sync-TP the player back to limbo spawn so AuthMe's broken teleport + * operates against an irrelevant location. The MONITOR handler then + * runs the authoritative restore. + * + * 3. {@link LoginEvent} at {@link EventPriority#MONITOR} — fires LAST in + * the chain, schedules the authoritative teleport to the saved + * quit-location after a configurable tick delay. + * + * 4. {@link EntityDamageEvent} at {@link EventPriority#HIGHEST} (F1) — + * while a player is in {@code pendingTransit}, cancels {@code VOID} + * damage and snaps them back to limbo spawn at full health. This is + * the YOU500-incident fix: even if AuthMe's broken teleport drops the + * player into an unloaded section, the void death is intercepted. * * Threading: * - AuthMeAsyncPreLoginEvent fires async (AuthMe worker thread). @@ -62,12 +78,32 @@ import java.util.Set; */ public final class LoginListener implements Listener { + /** Hard cap on teleportAsync retries before falling back to spectator at limbo spawn (F2). */ + private static final int MAX_RETRIES = 3; + + /** Tick delay between a failed teleport attempt and the next retry (F2). 1500 ms. */ + private static final long RETRY_DELAY_TICKS = 30L; + + /** Safety timeout before pendingTransit entries expire even if no callback fires (F1). 5 s. */ + private static final long PENDING_TIMEOUT_TICKS = 20L * 5L; + private final AuthLimbo plugin; private final AuthMeDatabase db; /** Tracks active plugin-chunk-tickets so we don't double-add or fail to release. */ private final Set activeTickets = new HashSet<>(); + /** + * F1 — UUIDs of players currently in the post-login restore window. + * While present, the player is protected from VOID damage by + * {@link #onEntityDamage}. Removed on successful TP, on final retry + * give-up, or by the {@link #PENDING_TIMEOUT_TICKS} watchdog. + */ + private final Set pendingTransit = ConcurrentHashMap.newKeySet(); + + /** F2 — per-UUID retry counter for failed teleportAsync attempts. */ + private final Map retryCounts = new ConcurrentHashMap<>(); + public LoginListener(AuthLimbo plugin, AuthMeDatabase db) { this.plugin = plugin; this.db = db; @@ -108,6 +144,50 @@ public final class LoginListener implements Listener { }); } + /* ---------------- F4: pre-empt AuthMe's broken teleport ---------------- */ + + /** + * Runs at LOWEST priority — BEFORE AuthMe-ReReloaded's own LoginEvent + * handler runs its broken teleport. We immediately sync-TP the player + * back to limbo spawn so AuthMe's subsequent teleport runs against an + * irrelevant location. Combined with F1, this closes the void-death + * window even when the saved chunk is far out and slow to load. + * + * Adds the player to {@code pendingTransit} here too — F1 must protect + * the player across the entire LOWEST→MONITOR window. + */ + @EventHandler(priority = EventPriority.LOWEST) + public void onLoginPreEmpt(LoginEvent event) { + Player player = event.getPlayer(); + if (player == null) return; + final UUID id = player.getUniqueId(); + + // Mark in-transit BEFORE AuthMe's teleport runs so F1 catches any + // void damage from that teleport. + pendingTransit.add(id); + scheduleTransitTimeout(id); + + Location limbo = plugin.limbo().spawn(); + if (limbo == null) { + plugin.getLogger().warning("[AuthLimbo] limbo spawn is null at LOWEST pre-empt for " + + player.getName() + " — cannot pre-empt AuthMe teleport."); + return; + } + + // Synchronous teleport so we land BEFORE AuthMe's own MONITOR/HIGH + // handler fires its own teleport. teleportAsync would be racy here. + try { + player.teleport(limbo, PlayerTeleportEvent.TeleportCause.PLUGIN); + if (plugin.debug()) { + plugin.getLogger().info("[AuthLimbo][debug] Pre-empted AuthMe TP for " + + player.getName() + " — pinned at limbo spawn."); + } + } catch (Throwable t) { + plugin.getLogger().warning("[AuthLimbo] pre-empt teleport failed for " + + player.getName() + ": " + t.getMessage()); + } + } + /* ---------------- Post-login: authoritative teleport ---------------- */ @EventHandler(priority = EventPriority.MONITOR) @@ -115,31 +195,93 @@ public final class LoginListener implements Listener { Player player = event.getPlayer(); if (player == null) return; final String name = player.getName(); + final UUID id = player.getUniqueId(); final Location saved = db.getQuitLocation(name); if (saved == null) { plugin.getLogger().info("[AuthLimbo] No saved location for " + name + " — leaving where AuthMe put them."); + // No restore needed — clear transit guard. + pendingTransit.remove(id); + retryCounts.remove(id); return; } + // Defensive: pre-empt handler should have added this already, but ensure. + pendingTransit.add(id); + retryCounts.putIfAbsent(id, 0); + long delay = Math.max(0, plugin.getConfig().getLong("authme.teleport-delay-ticks", 10L)); Bukkit.getScheduler().runTaskLater(plugin, () -> doTeleport(player, name, saved), delay); } + /* ---------------- F1: void-damage guard during transit ---------------- */ + + /** + * F1 — while a player is mid-restore, intercept VOID damage and snap + * them back to limbo spawn at full health. This is the primary fix for + * the YOU500 incident: even if AuthMe's own broken teleport drops the + * player into an unloaded section that triggers "left the confines of + * this world", we cancel the damage and recover the player. + * + * HIGHEST + ignoreCancelled=true so we run last among damage handlers + * and don't double-process events already cancelled by other plugins. + */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onEntityDamage(EntityDamageEvent event) { + if (!(event.getEntity() instanceof Player player)) return; + final UUID id = player.getUniqueId(); + if (!pendingTransit.contains(id)) return; + if (event.getCause() != DamageCause.VOID) return; + + event.setCancelled(true); + + Location limbo = plugin.limbo().spawn(); + plugin.getLogger().warning(String.format( + "[AuthLimbo] VOID damage intercepted for %s during post-login restore" + + " (intended TP target: %s) — relocating to limbo spawn at %s.", + player.getName(), + describeIntendedTarget(player), + limbo == null ? "" : describe(limbo))); + + Bukkit.getConsoleSender().sendMessage( + "[AuthLimbo] WARN: void-damage guard saved " + player.getName() + + " from inventory loss during AuthMe restore."); + + // Heal first so the next-tick snap doesn't carry residual damage state. + try { + player.setHealth(20.0); + player.setFireTicks(0); + player.setFallDistance(0f); + } catch (Throwable t) { + // best-effort + } + + if (limbo != null) { + try { + player.teleport(limbo, PlayerTeleportEvent.TeleportCause.PLUGIN); + } catch (Throwable t) { + plugin.getLogger().warning("[AuthLimbo] limbo recovery teleport failed for " + + player.getName() + ": " + t.getMessage()); + } + } + } + /* ---------------- Core teleport with chunk-prep ---------------- */ private void doTeleport(Player player, String name, Location saved) { if (!player.isOnline()) { plugin.getLogger().info("[AuthLimbo] " + name + " went offline before restore — skipping."); + clearTransit(player.getUniqueId()); return; } World world = saved.getWorld(); if (world == null) { plugin.getLogger().warning("[AuthLimbo] Saved world for " + name + " is no longer loaded."); + clearTransit(player.getUniqueId()); return; } @@ -169,28 +311,118 @@ public final class LoginListener implements Listener { if (plugin.debug()) { plugin.getLogger().info("[AuthLimbo][debug] Teleport ok for " + name); } + // F1/F2: restore complete — clear transit guard. + clearTransit(player.getUniqueId()); } else { - plugin.getLogger().warning("[AuthLimbo] teleportAsync returned false for " - + name + " — Paper may have rejected the location."); + handleTeleportFailure(player, name, saved, + "teleportAsync returned false"); } // Release the ticket 5s later — gives the client time to // download the chunk before we let it unload. scheduleTicketRelease(world, cx, cz, key); }) .exceptionally(ex -> { - plugin.getLogger().warning("[AuthLimbo] teleportAsync threw for " - + name + ": " + ex.getMessage()); + handleTeleportFailure(player, name, saved, + "teleportAsync threw: " + ex.getMessage()); scheduleTicketRelease(world, cx, cz, key); return null; }); }).exceptionally(ex -> { - plugin.getLogger().warning("[AuthLimbo] getChunkAtAsyncUrgently threw for " - + name + ": " + ex.getMessage()); + handleTeleportFailure(player, name, saved, + "getChunkAtAsyncUrgently threw: " + ex.getMessage()); scheduleTicketRelease(world, cx, cz, key); return null; }); } + /* ---------------- F2: failure recovery + retry ---------------- */ + + /** + * F2 — on any failed teleport (false future, exceptional future, or + * chunk-load throw), do not abandon the player. + * + * Steps: + * 1. Synchronously TP back to limbo spawn so they aren't sitting in + * an unloaded chunk that may void-kill them next tick. + * 2. Increment retry counter. Up to {@link #MAX_RETRIES} retries + * schedule another doTeleport after {@link #RETRY_DELAY_TICKS}. + * 3. After MAX_RETRIES failures: leave player at limbo spawn in + * spectator gamemode, log ERROR with full coords + retry count, + * alert console for manual `/authlimbo tp` intervention. + * + * The player remains in {@code pendingTransit} across all retries so + * F1 still protects them from any void damage during the retry window. + */ + private void handleTeleportFailure(Player player, String name, Location saved, String reason) { + final UUID id = player.getUniqueId(); + final int attempt = retryCounts.merge(id, 1, Integer::sum); + + plugin.getLogger().warning(String.format( + "[AuthLimbo] Restore attempt %d/%d failed for %s — %s. Recovering to limbo spawn.", + attempt, MAX_RETRIES, name, reason)); + + // Step 1: snap back to limbo spawn synchronously so they don't + // continue free-falling in an unloaded section. + Location limbo = plugin.limbo().spawn(); + if (limbo != null && player.isOnline()) { + // Run on main thread — teleportAsync future callbacks may not be there. + Bukkit.getScheduler().runTask(plugin, () -> { + try { + player.teleport(limbo, PlayerTeleportEvent.TeleportCause.PLUGIN); + player.setHealth(20.0); + player.setFireTicks(0); + player.setFallDistance(0f); + } catch (Throwable t) { + plugin.getLogger().warning("[AuthLimbo] sync recovery TP failed for " + + name + ": " + t.getMessage()); + } + }); + } + + if (attempt >= MAX_RETRIES) { + // Step 3: give up gracefully. + plugin.getLogger().severe(String.format( + "[AuthLimbo] Failed to restore %s after %d retries — manual intervention needed." + + " Saved coords: %s(%.1f, %.1f, %.1f). Last reason: %s.", + name, MAX_RETRIES, + saved.getWorld() == null ? "" : saved.getWorld().getName(), + saved.getX(), saved.getY(), saved.getZ(), reason)); + + Bukkit.getConsoleSender().sendMessage( + "[AuthLimbo] ERROR: failed to restore " + name + " after " + + MAX_RETRIES + " retries — manual intervention needed." + + " Run `/authlimbo tp " + name + "` after investigating."); + + Bukkit.getScheduler().runTask(plugin, () -> { + if (player.isOnline()) { + try { + player.setGameMode(GameMode.SPECTATOR); + player.sendMessage("§c[AuthLimbo] Could not restore your saved location." + + " Staff have been notified — please ping an admin for" + + " a manual /authlimbo tp."); + } catch (Throwable t) { + // best-effort + } + } + }); + + clearTransit(id); + return; + } + + // Step 2: schedule a retry. Player stays in pendingTransit so F1 + // continues to protect them. + Bukkit.getScheduler().runTaskLater(plugin, () -> { + if (!player.isOnline()) { + clearTransit(id); + return; + } + doTeleport(player, name, saved); + }, RETRY_DELAY_TICKS); + } + + /* ---------------- Helpers ---------------- */ + private void scheduleTicketRelease(World world, int cx, int cz, String key) { if (!activeTickets.contains(key)) return; Bukkit.getScheduler().runTaskLater(plugin, () -> { @@ -206,4 +438,39 @@ public final class LoginListener implements Listener { } }, 20L * 5L); } + + /** + * Removes a player from the transit set + retry counter. After this, + * F1's void-damage guard no longer protects them. + */ + private void clearTransit(UUID id) { + pendingTransit.remove(id); + retryCounts.remove(id); + } + + /** + * Watchdog: if no callback fires within {@link #PENDING_TIMEOUT_TICKS}, + * remove the UUID from pendingTransit so we don't leak entries. + */ + private void scheduleTransitTimeout(UUID id) { + Bukkit.getScheduler().runTaskLater(plugin, () -> { + if (pendingTransit.remove(id)) { + retryCounts.remove(id); + if (plugin.debug()) { + plugin.getLogger().info("[AuthLimbo][debug] pendingTransit timeout for " + id); + } + } + }, PENDING_TIMEOUT_TICKS); + } + + private static String describeIntendedTarget(Player player) { + Location loc = player.getLocation(); + return describe(loc); + } + + private static String describe(Location loc) { + if (loc == null || loc.getWorld() == null) return ""; + return String.format("%s(%.1f, %.1f, %.1f)", + loc.getWorld().getName(), loc.getX(), loc.getY(), loc.getZ()); + } }