Merge pull request 'Restore MoonBridge to approved transfer-event scope' (#22) from revert/authority-only-scope into main

This commit is contained in:
layue13 committed 2026-09-30 22:31:42 +00:00
commit 52ffadcd26
10 files changed
+41 -850

No files matched your search

@@ -8,20 +8,6 @@ public interface BukkitSessionService {
/** Returns empty when the forwarding proof is missing, invalid, expired, replayed, or stale. */
Optional<BackendPlayerSession> find(Player player);
/**
* Verifies and binds the forwarded session proof for this exact player object before it is
* online. Repeated calls for the same login incarnation return the same binding. A successful
* result does not make {@link #find(Player)} succeed until Bukkit's join event promotes the
* binding. Call from the server thread while the login profile is available; callers should
* reject the login when authentication is empty.
*
* @return the authenticated binding, or empty when the proof is missing, invalid, expired,
* replayed, stale, or the bounded provisional table cannot admit another login
*/
default Optional<BackendPlayerSession> authenticate(Player player) {
return Optional.empty();
}
/** Matches all connection identity fields, rather than UUID alone. */
default boolean matches(Player player, java.util.UUID proxyEpoch, long connectionId) {
if (player == null || proxyEpoch == null) return false;
@@ -1,55 +1,41 @@
package dev.moonbridge.bukkit;
import dev.moonbridge.messaging.session.ForwardedSessionProof;
import java.lang.ref.WeakReference;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerLoginEvent;
import org.bukkit.event.player.PlayerQuitEvent;
/** Validates legacy-forwarded profile proofs and binds them to real Bukkit player instances. */
final class OwnedBukkitSessionService implements BukkitSessionService, Listener, AutoCloseable {
private static final int MAX_NONCES = 8192;
private static final int MAX_PROVISIONAL_SESSIONS = 1024;
private final String backendName;
private final byte[] secret;
private final LongSupplier backendEpoch;
private final Supplier<UUID> proxyEpoch;
private final LongSupplier clockMillis;
private final Map<UUID, BoundSession> sessions = new ConcurrentHashMap<>();
// Provisional bindings must not keep an aborted login's Player object alive.
// All access is guarded by lifecycleLock and each entry has the proof expiry.
private final Map<UUID, ProvisionalSession> provisionalSessions = new HashMap<>();
private final Map<UUID, Long> consumedNonces = new HashMap<>();
private final Object lifecycleLock = new Object();
private volatile boolean closed;
OwnedBukkitSessionService(String backendName, byte[] secret, LongSupplier backendEpoch,
Supplier<UUID> proxyEpoch) {
this(backendName, secret, backendEpoch, proxyEpoch, System::currentTimeMillis);
}
OwnedBukkitSessionService(String backendName, byte[] secret, LongSupplier backendEpoch,
Supplier<UUID> proxyEpoch, LongSupplier clockMillis) {
if (backendName == null || backendName.trim().isEmpty()) throw new IllegalArgumentException("backendName is required");
if (secret == null || secret.length < 32) throw new IllegalArgumentException("session secret is too short");
this.backendName = backendName;
this.secret = secret.clone();
this.backendEpoch = java.util.Objects.requireNonNull(backendEpoch, "backendEpoch");
this.proxyEpoch = java.util.Objects.requireNonNull(proxyEpoch, "proxyEpoch");
this.clockMillis = java.util.Objects.requireNonNull(clockMillis, "clockMillis");
}
@EventHandler(priority = EventPriority.LOWEST)
@@ -61,141 +47,45 @@ final class OwnedBukkitSessionService implements BukkitSessionService, Listener,
// legacy packet writer expects every property signature to be non-null.
// Remove the private property before any normal-priority join handler
// or the playerLoggedIn spawn broadcast can observe the profile.
AuthenticationResult result = authenticateInternal(player, true);
if (result.propertyRemovalFailed) {
BoundSession previous = sessions.get(player.getUniqueId());
if (previous != null && previous.player == player) sessions.remove(player.getUniqueId(), previous);
if (closed) return;
ProofProperty stripped = stripForwardedProof(player);
if (!stripped.removed) {
// Fail closed: do not bind a session when the forwarding credential
// could not be removed from the profile before it is broadcast.
if (result.foundProperty) player.kickPlayer("Unable to validate forwarded session");
if (stripped.foundProperty) player.kickPlayer("Unable to validate forwarded session");
return;
}
}
long now = System.currentTimeMillis();
ForwardedSessionProof.Claims claims = ForwardedSessionProof.verify(
stripped.proof, secret, backendName, proxyEpoch.get(), backendEpoch.getAsLong(),
player.getUniqueId(), now).orElse(null);
if (claims == null || !consumeNonce(claims.nonce(), claims.expiresAtEpochMillis(), now)) return;
/** Removes only the provisional binding belonging to the denied login object. */
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerLogin(PlayerLoginEvent event) {
if (event == null || event.getResult() == PlayerLoginEvent.Result.ALLOWED) return;
Player player = event.getPlayer();
if (player == null) return;
synchronized (lifecycleLock) {
UUID playerId = player.getUniqueId();
ProvisionalSession pending = provisionalSessions.get(playerId);
if (pending != null && pending.player.get() == player)
provisionalSessions.remove(playerId, pending);
}
BackendPlayerSession session = new BackendPlayerSession(claims.playerId(), claims.proxyEpoch(),
claims.connectionId(), claims.backendName(), claims.backendEpoch(), UUID.randomUUID());
sessions.put(player.getUniqueId(), new BoundSession(player, session));
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
Player player = event.getPlayer();
synchronized (lifecycleLock) {
UUID playerId = player.getUniqueId();
ProvisionalSession pending = provisionalSessions.get(playerId);
if (pending != null && pending.player.get() == player)
provisionalSessions.remove(playerId, pending);
BoundSession current = sessions.get(playerId);
if (current != null && current.player == player) sessions.remove(playerId, current);
}
}
@Override
public Optional<BackendPlayerSession> authenticate(Player player) {
return Optional.ofNullable(authenticateInternal(player, false).session);
}
/** All proof stripping, nonce consumption and provisional promotion is serialized. */
private AuthenticationResult authenticateInternal(Player player, boolean promote) {
if (player == null) return AuthenticationResult.EMPTY;
synchronized (lifecycleLock) {
if (closed) return AuthenticationResult.EMPTY;
UUID playerId = player.getUniqueId();
if (playerId == null) return AuthenticationResult.EMPTY;
long now = clockMillis.getAsLong();
pruneProvisional(now);
BoundSession active = sessions.get(playerId);
if (active != null && !hasCurrentBackendIdentity(active.session)) {
sessions.remove(playerId, active);
if (active.player == player) return AuthenticationResult.EMPTY;
active = null;
}
if (active != null && active.player == player) return AuthenticationResult.bound(active.session);
ProvisionalSession pending = provisionalSessions.get(playerId);
if (pending != null && !hasCurrentBackendIdentity(pending.session)) {
provisionalSessions.remove(playerId, pending);
if (pending.player.get() == player) return AuthenticationResult.EMPTY;
pending = null;
}
if (pending != null && pending.player.get() == player && pending.expiresAt > now) {
if (promote) {
provisionalSessions.remove(playerId, pending);
sessions.put(playerId, new BoundSession(player, pending.session));
}
return AuthenticationResult.bound(pending.session);
}
ProofProperty stripped = stripForwardedProof(player);
if (!stripped.removed) return AuthenticationResult.removalFailure(stripped.foundProperty);
// A UUID collision from a different live login must never replace the current owner.
if (active != null && active.player != player) {
if (active.player.isOnline()) return AuthenticationResult.EMPTY;
sessions.remove(playerId, active);
}
if (pending != null) return AuthenticationResult.EMPTY;
ForwardedSessionProof.Claims claims = ForwardedSessionProof.verify(
stripped.proof, secret, backendName, proxyEpoch.get(), backendEpoch.getAsLong(),
playerId, now).orElse(null);
if (claims == null) return AuthenticationResult.EMPTY;
if (provisionalSessions.size() >= MAX_PROVISIONAL_SESSIONS) return AuthenticationResult.EMPTY;
if (!consumeNonce(claims.nonce(), claims.expiresAtEpochMillis(), now)) return AuthenticationResult.EMPTY;
BackendPlayerSession session = new BackendPlayerSession(claims.playerId(), claims.proxyEpoch(),
claims.connectionId(), claims.backendName(), claims.backendEpoch(), UUID.randomUUID());
ProvisionalSession created = new ProvisionalSession(player, session, claims.expiresAtEpochMillis());
provisionalSessions.put(playerId, created);
if (promote) {
provisionalSessions.remove(playerId, created);
sessions.put(playerId, new BoundSession(player, session));
}
return AuthenticationResult.bound(session);
}
}
private void pruneProvisional(long now) {
provisionalSessions.entrySet().removeIf(entry -> {
ProvisionalSession pending = entry.getValue();
return pending.expiresAt <= now || pending.player.get() == null;
});
consumedNonces.entrySet().removeIf(entry -> entry.getValue() <= now);
}
private boolean hasCurrentBackendIdentity(BackendPlayerSession session) {
UUID currentProxyEpoch = proxyEpoch.get();
return currentProxyEpoch != null
&& currentProxyEpoch.equals(session.getProxyEpoch())
&& backendName.equals(session.getBackendName())
&& backendEpoch.getAsLong() == session.getBackendEpoch();
BoundSession current = sessions.get(player.getUniqueId());
if (current != null && current.player == player) sessions.remove(player.getUniqueId(), current);
}
@Override
public Optional<BackendPlayerSession> find(Player player) {
if (player == null) return Optional.empty();
synchronized (lifecycleLock) {
if (closed || !player.isOnline()) return Optional.empty();
UUID playerId = player.getUniqueId();
BoundSession current = sessions.get(playerId);
if (current == null || current.player != player) return Optional.empty();
if (!hasCurrentBackendIdentity(current.session)) {
sessions.remove(playerId, current);
return Optional.empty();
}
return Optional.of(current.session);
}
if (closed || player == null || !player.isOnline()) return Optional.empty();
BoundSession current = sessions.get(player.getUniqueId());
if (current == null || current.player != player) return Optional.empty();
return Optional.of(current.session);
}
private boolean consumeNonce(UUID nonce, long expiresAt, long now) {
private synchronized boolean consumeNonce(UUID nonce, long expiresAt, long now) {
if (closed) return false;
consumedNonces.entrySet().removeIf(entry -> entry.getValue() <= now);
if (consumedNonces.containsKey(nonce) || consumedNonces.size() >= MAX_NONCES) return false;
consumedNonces.put(nonce, expiresAt);
return true;
@@ -270,12 +160,9 @@ final class OwnedBukkitSessionService implements BukkitSessionService, Listener,
@Override
public void close() {
synchronized (lifecycleLock) {
closed = true;
sessions.clear();
provisionalSessions.clear();
consumedNonces.clear();
}
closed = true;
sessions.clear();
synchronized (this) { consumedNonces.clear(); }
java.util.Arrays.fill(secret, (byte) 0);
}
@@ -288,37 +175,4 @@ final class OwnedBukkitSessionService implements BukkitSessionService, Listener,
this.session = session;
}
}
private static final class ProvisionalSession {
private final WeakReference<Player> player;
private final BackendPlayerSession session;
private final long expiresAt;
private ProvisionalSession(Player player, BackendPlayerSession session, long expiresAt) {
this.player = new WeakReference<>(player);
this.session = session;
this.expiresAt = expiresAt;
}
}
private static final class AuthenticationResult {
private static final AuthenticationResult EMPTY = new AuthenticationResult(null, false, false);
private final BackendPlayerSession session;
private final boolean propertyRemovalFailed;
private final boolean foundProperty;
private AuthenticationResult(BackendPlayerSession session, boolean propertyRemovalFailed,
boolean foundProperty) {
this.session = session;
this.propertyRemovalFailed = propertyRemovalFailed;
this.foundProperty = foundProperty;
}
private static AuthenticationResult bound(BackendPlayerSession session) {
return new AuthenticationResult(session, false, false);
}
private static AuthenticationResult removalFailure(boolean foundProperty) {
return new AuthenticationResult(null, true, foundProperty);
}
}
}
@@ -4,11 +4,8 @@ import dev.moonbridge.messaging.session.ForwardedSessionProof;
import org.bukkit.entity.Player;
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerLoginEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.junit.jupiter.api.Test;
import java.net.InetAddress;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collection;
@@ -17,8 +14,6 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
@@ -114,112 +109,12 @@ class OwnedBukkitSessionServiceTest {
service.onPlayerJoin(new PlayerJoinEvent(current.player, "joined"));
BackendPlayerSession original = service.find(current.player).orElseThrow(AssertionError::new);
service.onPlayerJoin(new PlayerJoinEvent(stale.player, "late stale join"));
service.onPlayerQuit(new PlayerQuitEvent(stale.player, "stale quit"));
assertSame(original, service.find(current.player).orElseThrow(AssertionError::new));
assertFalse(service.find(stale.player).isPresent());
service.close();
}
@Test void earlyAuthenticationIsIdempotentHiddenUntilJoinAndPromotesSameBinding() {
UUID playerId = UUID.randomUUID();
String proof = proof(playerId, UUID.randomUUID(), System.currentTimeMillis() + 20_000L);
TestProfile profile = new TestProfile(playerId, "early");
profile.getProperties().put(ForwardedSessionProof.PROPERTY_NAME,
new TestProperty(ForwardedSessionProof.PROPERTY_NAME, proof, null));
TestPlayer fixture = player(playerId, profile, false);
OwnedBukkitSessionService service = service();
BackendPlayerSession first = service.authenticate(fixture.player).orElseThrow(AssertionError::new);
BackendPlayerSession repeated = service.authenticate(fixture.player).orElseThrow(AssertionError::new);
assertSame(first, repeated);
assertFalse(service.find(fixture.player).isPresent(), "provisional binding must remain hidden from find");
assertTrue(profile.getProperties().get(ForwardedSessionProof.PROPERTY_NAME).isEmpty());
TestProfile replayProfile = new TestProfile(playerId, "replay");
replayProfile.getProperties().put(ForwardedSessionProof.PROPERTY_NAME,
new TestProperty(ForwardedSessionProof.PROPERTY_NAME, proof, null));
TestPlayer replay = player(playerId, replayProfile, false);
assertFalse(service.authenticate(replay.player).isPresent(), "another player object cannot claim pending identity");
fixture.online.set(true);
service.onPlayerJoin(new PlayerJoinEvent(fixture.player, "joined"));
assertSame(first, service.find(fixture.player).orElseThrow(AssertionError::new));
service.close();
}
@Test void deniedLoginClearsOnlyItsProvisionalBinding() throws Exception {
UUID playerId = UUID.randomUUID();
TestPlayer denied = playerWithProof(playerId, System.currentTimeMillis() + 20_000L, false);
OwnedBukkitSessionService service = service();
BackendPlayerSession deniedSession = service.authenticate(denied.player).orElseThrow(AssertionError::new);
PlayerLoginEvent deniedEvent = new PlayerLoginEvent(denied.player, "localhost",
InetAddress.getLoopbackAddress());
deniedEvent.setResult(PlayerLoginEvent.Result.KICK_OTHER);
service.onPlayerLogin(deniedEvent);
TestPlayer retry = playerWithProof(playerId, System.currentTimeMillis() + 20_000L, false);
BackendPlayerSession retrySession = service.authenticate(retry.player).orElseThrow(AssertionError::new);
assertNotSame(deniedSession, retrySession);
assertFalse(service.find(denied.player).isPresent());
service.close();
}
@Test void expiredProvisionalBindingDoesNotBlockFreshLoginForSameUuid() {
UUID playerId = UUID.randomUUID();
long start = 1_000_000L;
AtomicLong clock = new AtomicLong(start);
OwnedBukkitSessionService service = new OwnedBukkitSessionService(BACKEND, SECRET,
() -> BACKEND_EPOCH, () -> PROXY_EPOCH, clock::get);
TestPlayer expired = playerWithProof(playerId, start + 100L, false);
BackendPlayerSession expiredSession = service.authenticate(expired.player).orElseThrow(AssertionError::new);
clock.set(start + 101L);
TestPlayer fresh = playerWithProof(playerId, start + 5_000L, false);
BackendPlayerSession freshSession = service.authenticate(fresh.player).orElseThrow(AssertionError::new);
assertNotSame(expiredSession, freshSession);
assertFalse(service.find(expired.player).isPresent());
service.close();
}
@Test void earlyBindingIsDiscardedWhenBackendEpochChangesBeforeJoin() {
UUID playerId = UUID.randomUUID();
AtomicLong currentBackendEpoch = new AtomicLong(BACKEND_EPOCH);
AtomicReference<UUID> currentProxyEpoch = new AtomicReference<>(PROXY_EPOCH);
OwnedBukkitSessionService service = new OwnedBukkitSessionService(BACKEND, SECRET,
currentBackendEpoch::get, currentProxyEpoch::get);
TestPlayer fixture = playerWithProof(playerId, System.currentTimeMillis() + 20_000L, false);
assertTrue(service.authenticate(fixture.player).isPresent());
currentBackendEpoch.incrementAndGet();
fixture.online.set(true);
service.onPlayerJoin(new PlayerJoinEvent(fixture.player, "joined after backend restart"));
assertFalse(service.find(fixture.player).isPresent());
assertFalse(service.authenticate(fixture.player).isPresent(), "stripped proof must not rebind implicitly");
service.close();
}
@Test void findInvalidatesAnOnlineBindingWhenProxyEpochChanges() {
UUID playerId = UUID.randomUUID();
AtomicLong currentBackendEpoch = new AtomicLong(BACKEND_EPOCH);
AtomicReference<UUID> currentProxyEpoch = new AtomicReference<>(PROXY_EPOCH);
OwnedBukkitSessionService service = new OwnedBukkitSessionService(BACKEND, SECRET,
currentBackendEpoch::get, currentProxyEpoch::get);
TestPlayer fixture = playerWithProof(playerId, System.currentTimeMillis() + 20_000L, false);
BackendPlayerSession authenticated = service.authenticate(fixture.player).orElseThrow(AssertionError::new);
fixture.online.set(true);
service.onPlayerJoin(new PlayerJoinEvent(fixture.player, "joined"));
assertSame(authenticated, service.find(fixture.player).orElseThrow(AssertionError::new));
currentProxyEpoch.set(UUID.randomUUID());
assertFalse(service.find(fixture.player).isPresent());
service.close();
}
private static OwnedBukkitSessionService service() {
return new OwnedBukkitSessionService(BACKEND, SECRET, () -> BACKEND_EPOCH, () -> PROXY_EPOCH);
}
@@ -230,24 +125,11 @@ class OwnedBukkitSessionServiceTest {
}
private static TestPlayer player(UUID playerId, Object profile) {
return player(playerId, profile, true);
}
private static TestPlayer playerWithProof(UUID playerId, long expiry, boolean online) {
TestProfile profile = new TestProfile(playerId, "proof-player");
profile.getProperties().put(ForwardedSessionProof.PROPERTY_NAME,
new TestProperty(ForwardedSessionProof.PROPERTY_NAME,
proof(playerId, UUID.randomUUID(), expiry), null));
return player(playerId, profile, online);
}
private static TestPlayer player(UUID playerId, Object profile, boolean initiallyOnline) {
AtomicBoolean kicked = new AtomicBoolean();
AtomicBoolean online = new AtomicBoolean(initiallyOnline);
Player player = (Player) Proxy.newProxyInstance(Player.class.getClassLoader(),
new Class<?>[]{Player.class, ProfileHolder.class}, (proxy, method, arguments) -> {
if (method.getName().equals("getUniqueId")) return playerId;
if (method.getName().equals("isOnline")) return online.get();
if (method.getName().equals("isOnline")) return true;
if (method.getName().equals("getGameProfile")) return profile;
if (method.getName().equals("kickPlayer")) { kicked.set(true); return null; }
if (method.getName().equals("toString")) return "test-player";
@@ -255,7 +137,7 @@ class OwnedBukkitSessionServiceTest {
if (method.getName().equals("equals")) return proxy == arguments[0];
return null;
});
return new TestPlayer(player, profile, kicked, online);
return new TestPlayer(player, profile, kicked);
}
public interface ProfileHolder {
@@ -324,12 +206,10 @@ class OwnedBukkitSessionServiceTest {
final Player player;
final Object profile;
final AtomicBoolean kicked;
final AtomicBoolean online;
TestPlayer(Player player, Object profile, AtomicBoolean kicked, AtomicBoolean online) {
TestPlayer(Player player, Object profile, AtomicBoolean kicked) {
this.player = player;
this.profile = profile;
this.kicked = kicked;
this.online = online;
}
}
}
-2
View File
@@ -108,8 +108,6 @@ Proxy 为每个插件管理独立的 scope,在停用时自动撤销。项目
HMAC 挑战认证本身不加密链路;跨机器使用私有加密链路或可信 TLS 终止器。每个实例配置独立的实例 ID、后端名称和密钥;重复认证同一实例会接管旧连接,旧连接的迟到回复不能影响新连接。
需要在 Bukkit `PlayerJoinEvent` 前验证转发身份的插件可调用 `BukkitSessionService.authenticate(player)`。该方法会校验并移除玩家 profile 中的一次性证明,返回绑定到当前 `Player` 对象的身份;同一对象重复调用返回相同绑定。该预备绑定不会让 `find(player)` 在玩家上线前返回值;`PlayerJoinEvent` 会将它提升为在线会话,未提前认证的登录仍由 Join 监听器验证。被拒绝的 `PlayerLoginEvent` 会清除对应对象的预备绑定,证明到期后也不能再占用有效容量。调用者必须在服务器线程、profile 属性可用时执行认证,并在结果为空时拒绝登录。业务插件仍需在目标 Uranium 构建上确认自己的认证钩子早于权威玩家数据加载;此 API 本身不改变 Bukkit 加载顺序。
协议已升级为版本 2,proxy 与后端宿主必须一起升级;旧协议和 Java API 已移除,版本 1 连接会明确拒绝。关闭时在有限等待内尝试发送注销帧,代理收到后立即注销目录;若链路阻塞或意外断线,则保留到租约过期,不强制关闭已经存在的玩家游戏连接。
## 验证范围
-64
View File
@@ -1,64 +0,0 @@
package dev.moonbridge.smoke;
import dev.moonbridge.bukkit.BackendPlayerSession;
import dev.moonbridge.bukkit.BukkitSessionService;
import java.util.IdentityHashMap;
import java.util.Map;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerLoginEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.java.JavaPlugin;
/** Real login/Join proof lifecycle acceptance; compiles only against the public Bukkit API. */
public final class EarlySessionAcceptancePlugin extends JavaPlugin implements Listener {
private BukkitSessionService sessions;
private final Map<Player, BackendPlayerSession> pending = new IdentityHashMap<>();
@Override public void onEnable() {
sessions = getServer().getServicesManager().load(BukkitSessionService.class);
if (sessions == null) throw new IllegalStateException("Missing shared session service");
getServer().getPluginManager().registerEvents(this, this);
}
@EventHandler(priority = EventPriority.LOWEST)
public void login(PlayerLoginEvent event) {
Player player = event.getPlayer();
try {
BackendPlayerSession binding = sessions.authenticate(player).orElseThrow(
() -> new AssertionError("No early proof"));
if (sessions.authenticate(player).orElse(null) != binding)
throw new AssertionError("Repeated authentication changed binding");
if (sessions.find(player).isPresent()) throw new AssertionError("Provisional session became online");
if (!binding.getPlayerId().equals(player.getUniqueId()) || binding.getBackendEpoch() <= 0)
throw new AssertionError("Invalid backend identity");
Object profile = player.getClass().getMethod("getProfile").invoke(player);
Object properties = profile.getClass().getMethod("getProperties").invoke(profile);
Object matches = properties.getClass().getMethod("get", Object.class)
.invoke(properties, "moonbridge:session");
if (((Iterable<?>) matches).iterator().hasNext()) throw new AssertionError("Proof leaked into profile");
pending.put(player, binding);
getLogger().info("EARLY_AUTH_PASS player=" + player.getUniqueId() + " join=" + binding.getJoinEpoch());
} catch (Throwable failure) {
getLogger().severe("EARLY_AUTH_FAILED " + failure);
event.disallow(PlayerLoginEvent.Result.KICK_OTHER, "Early session acceptance failed");
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void join(PlayerJoinEvent event) {
final Player player = event.getPlayer();
final BackendPlayerSession expected = pending.remove(player);
getServer().getScheduler().runTask(this, () -> {
if (expected == null || sessions.find(player).orElse(null) != expected) {
getLogger().severe("EARLY_PROMOTION_FAILED");
player.kickPlayer("Early session promotion failed");
} else getLogger().info("EARLY_PROMOTION_PASS join=" + expected.getJoinEpoch());
});
}
@EventHandler public void quit(PlayerQuitEvent event) { pending.remove(event.getPlayer()); }
}
@@ -1,268 +0,0 @@
package dev.moonbridge.smoke;
import cc.uraniummc.api.playerdata.PlayerDataAuthority;
import cc.uraniummc.api.playerdata.PlayerDataAuthorityProvider;
import cc.uraniummc.api.playerdata.PlayerDataLoadContext;
import cc.uraniummc.api.playerdata.Position;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Date;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.init.Items;
import net.minecraft.item.Item;
import net.minecraft.nbt.CompressedStreamTools;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.nbt.NBTTagList;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.event.entity.player.PlayerEvent;
import cpw.mods.fml.common.eventhandler.SubscribeEvent;
import org.bukkit.BanList;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerLoginEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.java.JavaPlugin;
/** Real Uranium managed-login acceptance probe; test-only, no persistence service. */
public final class PlayerDataAuthorityAcceptancePlugin extends JavaPlugin implements Listener {
private static final String PROBE_NAME = "NettyProbe";
private final AtomicInteger logins = new AtomicInteger();
private final AtomicInteger restores = new AtomicInteger();
private final AtomicInteger loginChecks = new AtomicInteger();
private final AtomicInteger postJoinChecks = new AtomicInteger();
private final AtomicInteger suppressedLoads = new AtomicInteger();
private final AtomicInteger quitSaves = new AtomicInteger();
private final Map<UUID, State> states = new ConcurrentHashMap<>();
private final Set<UUID> quitting = ConcurrentHashMap.newKeySet();
private volatile PlayerDataAuthority.Registration registration;
private String node;
private volatile boolean failed;
@Override public void onEnable() {
node = getConfig().getString("backendName", "").trim().toLowerCase();
if (!node.equals("old") && !node.equals("new")) {
throw new IllegalStateException("backendName must be old or new");
}
try {
registration = PlayerDataAuthority.register(new ProbeProvider());
} catch (RuntimeException conflict) {
throw new IllegalStateException("Could not register the exclusive player-data probe", conflict);
}
// Force SCM to construct this login's PlayerLoginEvent with a native denial.
Bukkit.getBanList(BanList.Type.NAME).addBan(PROBE_NAME,
"Player-data authority acceptance probe", (Date) null, getName());
if (!Bukkit.getBanList(BanList.Type.NAME).isBanned(PROBE_NAME)) {
throw new IllegalStateException("Could not install the isolated name ban");
}
getServer().getPluginManager().registerEvents(this, this);
MinecraftForge.EVENT_BUS.register(this);
getLogger().info("PDA_PROBE_READY node=" + node + " nativeNameBan=true");
}
private final class ProbeProvider implements PlayerDataAuthorityProvider {
@Override public PlayerDataLoadContext begin(Player player) throws Exception {
if (!PROBE_NAME.equals(player.getName())) return null;
if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("begin was not on the server thread");
final int ordinal = logins.incrementAndGet();
final int amount = (node.equals("old") ? 10 : 20) + ordinal;
final int staleAmount = 40 + amount;
if (node.equals("old") && ordinal == 2) {
int savedAmount = readLocalDiamondCount(player.getUniqueId());
if (savedAmount != 51) {
fail("returning A did not have first-exit local inventory marker 51; found " + savedAmount);
throw new IOException("Expected an existing stale A player file with 51 diamonds");
}
getLogger().info("PDA_PROBE_OLD_FILE_PASS node=old previousAmount=" + savedAmount);
}
State state = new State(player, player.getUniqueId(), ordinal, amount, staleAmount);
states.put(player.getUniqueId(), state);
Position position = node.equals("old") && ordinal == 2
? new Position(9999, 0.5D, 70.0D, 0.5D, 0.0F, 0.0F)
: new Position(0, 0.5D, 70.0D, 0.5D, 0.0F, 0.0F);
return new PlayerDataLoadContext() {
@Override public Position position() { return position; }
@Override public void restore(Player target) {
if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("restore was not on the server thread");
if (!state.uuid.equals(target.getUniqueId())) throw new IllegalStateException("wrong player context");
if (node.equals("old") && ordinal == 2) {
if (!target.getWorld().getName().equals(Bukkit.getWorlds().get(0).getName())) {
throw new IllegalStateException("invalid dimension did not fall back to overworld");
}
getLogger().info("PDA_PROBE_FALLBACK_PASS node=old login=2 world=" + target.getWorld().getName());
}
target.getInventory().clear();
target.getInventory().setItem(0, new ItemStack(Material.DIAMOND, amount));
target.loadData();
requireDiamondAmount(target, amount, "CraftPlayer.loadData inside restore");
state.restored = true;
restores.incrementAndGet();
getLogger().info("PDA_PROBE_RESTORE_PASS node=" + node + " login=" + ordinal
+ " amount=" + amount + " loadData=retained");
}
@Override public void abort(Player target, Throwable cause) {
states.remove(state.uuid, state);
quitting.remove(state.uuid);
getLogger().warning("PDA_PROBE_ABORT node=" + node + " login=" + ordinal
+ " reason=" + (cause == null ? "login-denied" : cause.getClass().getSimpleName()));
}
};
}
}
@EventHandler(priority = EventPriority.LOWEST)
public void login(PlayerLoginEvent event) {
if (!PROBE_NAME.equals(event.getPlayer().getName())) return;
Player player = event.getPlayer();
State state = states.get(player.getUniqueId());
try {
if (state == null || !state.restored) throw new AssertionError("login event ran before restore");
requireDiamondAmount(player, state.amount, "PlayerLoginEvent");
loginChecks.incrementAndGet();
if (event.getResult() == PlayerLoginEvent.Result.ALLOWED) {
throw new AssertionError("native name ban did not pre-deny PlayerLoginEvent");
}
PlayerLoginEvent.Result initial = event.getResult();
event.setResult(PlayerLoginEvent.Result.ALLOWED);
event.setKickMessage("");
getLogger().info("PDA_PROBE_NATIVE_DENIAL_OVERRIDE_PASS node=" + node + " login=" + state.ordinal
+ " initial=" + initial + " restoredBeforeEvent=true");
} catch (Throwable failure) {
fail("PlayerLoginEvent acceptance failed: " + failure);
event.disallow(PlayerLoginEvent.Result.KICK_OTHER, "Player-data authority probe failed");
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void join(PlayerJoinEvent event) {
Player player = event.getPlayer();
State state = states.get(player.getUniqueId());
if (state == null) return;
Bukkit.getScheduler().runTask(this, () -> {
try {
requireDiamondAmount(player, state.amount, "post-Join next tick");
postJoinChecks.incrementAndGet();
if (state.ordinal == 1 && node.equals("old")) {
// Persist a deliberately different local value before the transfer away from A.
player.getInventory().setItem(0, new ItemStack(Material.DIAMOND, state.staleAmount));
player.saveData();
getLogger().info("PDA_PROBE_STALE_LOCAL_SEED_PASS node=old amount=" + state.staleAmount);
} else {
player.getInventory().setItem(0, new ItemStack(Material.DIAMOND, state.staleAmount));
player.saveData();
}
getLogger().info("PDA_PROBE_POST_JOIN_PASS node=" + node + " login=" + state.ordinal
+ " authoritativeAmount=" + state.amount + " savedLocalMarker=" + state.staleAmount);
} catch (Throwable failure) {
fail("post-Join check failed: " + failure);
player.kickPlayer("Player-data authority probe failed");
}
});
}
@EventHandler(priority = EventPriority.MONITOR)
public void quit(PlayerQuitEvent event) {
State state = states.get(event.getPlayer().getUniqueId());
if (state == null) return;
quitting.add(state.uuid);
try {
requireDiamondAmount(event.getPlayer(), state.staleAmount, "Bukkit Quit before Forge save");
} catch (AssertionError failure) {
fail(failure.getMessage());
}
}
@SubscribeEvent
public void loadFromFile(PlayerEvent.LoadFromFile event) {
State state = states.get(event.entityPlayer.func_110124_au());
if (state != null) {
suppressedLoads.incrementAndGet();
fail("Forge LoadFromFile fired for managed player " + state.uuid);
}
}
@SubscribeEvent
public void saveToFile(PlayerEvent.SaveToFile event) {
UUID uuid = event.entityPlayer.func_110124_au();
State state = states.get(uuid);
if (state == null || !quitting.remove(uuid)) return;
try {
requireDiamondAmount(state.player, state.staleAmount, "Forge SaveToFile");
quitSaves.incrementAndGet();
getLogger().info("PDA_PROBE_QUIT_SAVE_PASS node=" + node + " login=" + state.ordinal
+ " savedLocalMarker=" + state.staleAmount);
} catch (AssertionError failure) {
fail(failure.getMessage());
}
}
private int readLocalDiamondCount(UUID uuid) throws IOException {
File file = new File(new File(Bukkit.getWorlds().get(0).getWorldFolder(), "playerdata"), uuid + ".dat");
if (!file.isFile()) return -1;
NBTTagCompound data;
try (FileInputStream input = new FileInputStream(file)) {
data = CompressedStreamTools.func_74796_a(input);
}
NBTTagList inventory = data.func_150295_c("Inventory", 10);
for (int i = 0; i < inventory.func_74745_c(); i++) {
NBTTagCompound stack = inventory.func_150305_b(i);
if (stack.func_74765_d("id") == (short) Item.func_150891_b(Items.field_151045_i)) {
return stack.func_74771_c("Count") & 255;
}
}
return 0;
}
private void requireDiamondAmount(Player player, int expected, String checkpoint) {
ItemStack stack = player.getInventory().getItem(0);
if (stack == null || stack.getType() != Material.DIAMOND || stack.getAmount() != expected) {
throw new AssertionError(checkpoint + " expected " + expected + " diamonds, found "
+ (stack == null ? "empty" : stack.getType() + "x" + stack.getAmount()));
}
}
private void fail(String reason) {
failed = true;
getLogger().severe("PDA_PROBE_FAIL node=" + node + " reason=" + reason);
}
@Override public void onDisable() {
PlayerDataAuthority.Registration handle = registration;
if (handle != null) handle.close();
MinecraftForge.EVENT_BUS.unregister(this);
getLogger().info("PDA_PROBE_SUMMARY node=" + node + " logins=" + logins.get()
+ " restores=" + restores.get() + " loginChecks=" + loginChecks.get()
+ " postJoinChecks=" + postJoinChecks.get() + " LoadFromFile=" + suppressedLoads.get()
+ " quitSaveEvents=" + quitSaves.get() + " failed=" + failed);
}
private static final class State {
private final Player player;
private final UUID uuid;
private final int ordinal;
private final int amount;
private final int staleAmount;
private volatile boolean restored;
private State(Player player, UUID uuid, int ordinal, int amount, int staleAmount) {
this.player = player;
this.uuid = uuid;
this.ordinal = ordinal;
this.amount = amount;
this.staleAmount = staleAmount;
}
}
}
+5 -26
View File
@@ -23,13 +23,10 @@ public final class UraniumTransferPlugin implements Plugin {
private PluginContext context;
private ServerRegistration targetRegistration;
private boolean returnToOld;
private boolean sharedBackendHost;
private volatile Thread registrationWorker;
private volatile Thread worker;
@Override public void onLoad(PluginContext loadedContext) {
context = loadedContext;
sharedBackendHost = Boolean.parseBoolean(context.settings().getOrDefault("sharedBackendHost", "false"));
int port = Integer.parseInt(context.settings().get("newPort"));
String returnSetting = context.settings().getOrDefault("returnToOld", "false");
if (!returnSetting.equals("true") && !returnSetting.equals("false")) {
@@ -59,26 +56,10 @@ public final class UraniumTransferPlugin implements Plugin {
}));
});
}
if (sharedBackendHost) {
registrationWorker = Thread.ofVirtual().name("uranium-registration-smoke").start(() -> {
try {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
while (System.nanoTime() < deadline) {
if (context.servers().find("old").isPresent() && context.servers().find("new").isPresent()) {
context.logger().info("SMOKE_SHARED_HOSTS_READY");
return;
}
Thread.sleep(20);
}
context.logger().error("SMOKE_SHARED_HOSTS_FAILED registration timeout");
} catch (InterruptedException stopped) { Thread.currentThread().interrupt(); }
});
} else {
targetRegistration = context.servers().register(new ServerDefinition("new",
URI.create("tcp://127.0.0.1:" + port), Map.of(),
Map.of("source", "uranium-smoke-plugin")));
context.logger().info("SMOKE_PLUGIN_REGISTER_PASS name=new port={}", port);
}
targetRegistration = context.servers().register(new ServerDefinition("new",
URI.create("tcp://127.0.0.1:" + port), Map.of(),
Map.of("source", "uranium-smoke-plugin")));
context.logger().info("SMOKE_PLUGIN_REGISTER_PASS name=new port={}", port);
}
@Override public Optional<InitialPlacementHandler> initialPlacementHandler() {
@@ -87,7 +68,7 @@ public final class UraniumTransferPlugin implements Plugin {
servers.stream().map(server -> server.name()).toList());
if (servers.stream().noneMatch(server -> server.name().equals("old"))
|| servers.stream().noneMatch(server -> server.name().equals("new")
&& (sharedBackendHost || "uranium-smoke-plugin".equals(server.metadata().get("source"))))) {
&& "uranium-smoke-plugin".equals(server.metadata().get("source")))) {
return CompletableFuture.completedFuture(PlacementDecision.reject("Missing Uranium smoke backend"));
}
worker = Thread.ofVirtual().name("uranium-transfer-smoke").start(() -> transferWhenConnected(player.identity()));
@@ -101,7 +82,6 @@ public final class UraniumTransferPlugin implements Plugin {
while (System.nanoTime() < deadline) {
PlayerView player = context.players().find(identity).orElse(null);
if (player != null && player.currentServer().filter("old"::equals).isPresent()) {
if (sharedBackendHost) Thread.sleep(500); // Allow the next Bukkit tick to verify promotion.
var result = context.players().transfer(identity, "new").toCompletableFuture()
.get(30, TimeUnit.SECONDS);
if (result.status() != TransferStatus.NETWORK_READY) {
@@ -136,7 +116,6 @@ public final class UraniumTransferPlugin implements Plugin {
@Override public void onDisable() {
Thread current = worker;
if (current != null) current.interrupt();
if (registrationWorker != null) registrationWorker.interrupt();
if (targetRegistration != null) targetRegistration.unregister();
}
}
+6 -131
View File
@@ -5,10 +5,6 @@ param(
[string]$BackendJavaAgent,
[switch]$InstalledPlugin,
[switch]$ReleaseSource,
[switch]$AuthenticateEarly,
[switch]$ManagedPlayerData,
[string]$UraniumApiJar,
[string]$BukkitApiJar,
[switch]$DebugSession,
[switch]$TraceBackend,
[switch]$PrismClient,
@@ -20,12 +16,6 @@ param(
$ErrorActionPreference = 'Stop'
if ($ReleaseSource -and -not $InstalledPlugin) { throw '-ReleaseSource requires -InstalledPlugin' }
if ($AuthenticateEarly -and (-not $InstalledPlugin -or $TraceBackend -or -not $BukkitApiJar)) {
throw '-AuthenticateEarly requires -InstalledPlugin and -BukkitApiJar, and cannot use -TraceBackend'
}
if ($ManagedPlayerData -and (-not $AuthenticateEarly -or -not $ReturnToOld -or -not $UraniumApiJar -or $PrismClient)) {
throw '-ManagedPlayerData requires -AuthenticateEarly, -ReturnToOld, and -UraniumApiJar, with the Netty probe'
}
if ($BackendJavaAgent) { $BackendJavaAgent = (Resolve-Path -LiteralPath $BackendJavaAgent).Path }
if ($PrismClient -and -not $InstalledPlugin) { throw '-PrismClient requires -InstalledPlugin' }
if ($DebugSession -and -not $InstalledPlugin) { throw '-DebugSession requires -InstalledPlugin' }
@@ -96,8 +86,8 @@ function WaitForPort([int]$port, [System.Diagnostics.Process]$process) {
throw "Proxy port $port did not open within 15 seconds"
}
function WaitForProxyLog([string]$path, [string]$pattern, [int]$timeoutSeconds = 5) {
$deadline = [DateTime]::UtcNow.AddSeconds($timeoutSeconds)
function WaitForProxyLog([string]$path, [string]$pattern) {
$deadline = [DateTime]::UtcNow.AddSeconds(5)
while ([DateTime]::UtcNow -lt $deadline) {
if ((Test-Path -LiteralPath $path) -and
(Select-String -LiteralPath $path -Pattern $pattern -Quiet)) { return }
@@ -108,31 +98,6 @@ function WaitForProxyLog([string]$path, [string]$pattern, [int]$timeoutSeconds =
function StartUranium([string]$directory, [int]$port) {
Copy-Item -LiteralPath $bundle -Destination $directory -Recurse
if ($AuthenticateEarly) {
$node = Split-Path -Leaf $directory
$plugins = Join-Path $directory 'plugins'
$hostConfig = Join-Path $plugins 'MoonBridgeBackend'
New-Item -ItemType Directory -Force $plugins, $hostConfig | Out-Null
Copy-Item -LiteralPath $hostJar.FullName -Destination $plugins
Copy-Item -LiteralPath $earlyProbeJar -Destination $plugins
if ($ManagedPlayerData) {
Copy-Item -LiteralPath $authorityProbeJar -Destination $plugins
$authorityConfig = Join-Path $plugins 'PlayerDataAuthorityAcceptance'
New-Item -ItemType Directory -Force $authorityConfig | Out-Null
"backendName: $node" | Set-Content -LiteralPath (Join-Path $authorityConfig 'config.yml') -Encoding utf8
}
"settings:`n bungeecord: true`n" |
Set-Content -LiteralPath (Join-Path $directory 'spigot.yml') -Encoding utf8
@"
proxyHost: 127.0.0.1
proxyPort: $controlPort
instanceId: early-$node
backendName: $node
gameAddress: "tcp://127.0.0.1:$port"
keyId: primary
secret: isolated-early-session-secret-at-least-32-bytes
"@ | Set-Content -LiteralPath (Join-Path $hostConfig 'config.yml') -Encoding utf8
}
$serverJar = @(Get-ChildItem -LiteralPath $directory -Filter '*-server.jar')
if ($serverJar.Count -ne 1) { throw "Expected one Uranium server JAR in $directory" }
@"
@@ -195,37 +160,6 @@ $prismLauncher = $null
$initialJavawIds = @()
$clientLaunchAttempted = $false
try {
if ($AuthenticateEarly) {
$controlPort = FreePort
while ($controlPort -eq $oldPort -or $controlPort -eq $newPort) { $controlPort = FreePort }
$distribution = Split-Path -Parent $proxyLib
$hostJars = @(Get-ChildItem (Join-Path $distribution 'backend-host') -Filter '*.jar')
if ($hostJars.Count -ne 1) { throw 'Expected one shared Bukkit host artifact' }
$hostJar = $hostJars[0]
$earlyClasses = Join-Path $runDir 'early-classes'
New-Item -ItemType Directory -Force $earlyClasses | Out-Null
$apiClasspath = Join-Path $distribution 'backend-api/*'
$javac8 = Join-Path (Split-Path -Parent $Java8Path) 'javac.exe'
& $javac8 -cp "$apiClasspath;$BukkitApiJar" -d $earlyClasses (Join-Path $PSScriptRoot 'EarlySessionAcceptancePlugin.java')
if ($LASTEXITCODE -ne 0) { throw 'Early session probe compilation failed' }
"name: EarlySessionAcceptance`nmain: dev.moonbridge.smoke.EarlySessionAcceptancePlugin`nversion: 1.0`ndepend: [MoonBridgeBackend]`n" |
Set-Content -LiteralPath (Join-Path $earlyClasses 'plugin.yml') -Encoding utf8
$earlyProbeJar = Join-Path $runDir 'early-session-acceptance.jar'
& jar cf $earlyProbeJar -C $earlyClasses .
if ($LASTEXITCODE -ne 0) { throw 'Early session probe packaging failed' }
if ($ManagedPlayerData) {
$authorityClasses = Join-Path $runDir 'authority-classes'
New-Item -ItemType Directory -Force $authorityClasses | Out-Null
$libraryClasspath = Join-Path $bundle 'libraries/*'
& $javac8 -cp "$UraniumApiJar;$BukkitApiJar;$libraryClasspath" -d $authorityClasses (Join-Path $PSScriptRoot 'PlayerDataAuthorityAcceptancePlugin.java')
if ($LASTEXITCODE -ne 0) { throw 'Player data authority probe compilation failed' }
"name: PlayerDataAuthorityAcceptance`nmain: dev.moonbridge.smoke.PlayerDataAuthorityAcceptancePlugin`nversion: 1.0`ndepend: [MoonBridgeBackend, EarlySessionAcceptance]`n" |
Set-Content -LiteralPath (Join-Path $authorityClasses 'plugin.yml') -Encoding utf8
$authorityProbeJar = Join-Path $runDir 'player-data-authority-acceptance.jar'
& jar cf $authorityProbeJar -C $authorityClasses .
if ($LASTEXITCODE -ne 0) { throw 'Player data authority probe packaging failed' }
}
}
$old = StartUranium (Join-Path $runDir 'old') $oldPort
$servers.Add($old)
WaitForReady $oldPort $old.Process $old.Directory
@@ -281,34 +215,6 @@ try {
$pluginDirYaml = $pluginDir.Replace('\', '/')
$returnToOldSetting = if ($ReturnToOld) { 'true' } else { 'false' }
$releaseSourceSetting = if ($ReleaseSource) { 'true' } else { 'false' }
$sharedHostSetting = if ($AuthenticateEarly) { 'true' } else { 'false' }
$backendSettings = @"
backends:
- name: old
address: "127.0.0.1:$oldRoutePort"
"@
if ($AuthenticateEarly) {
$backendSettings = @"
backends: []
backendChannel:
listen: "127.0.0.1:$controlPort"
leaseSeconds: 30
maxConnections: 8
clients:
early-old:
backendName: old
keyId: primary
secret: isolated-early-session-secret-at-least-32-bytes
allowedHosts: [127.0.0.1]
allowedNamespaces: [accept]
early-new:
backendName: new
keyId: primary
secret: isolated-early-session-secret-at-least-32-bytes
allowedHosts: [127.0.0.1]
allowedNamespaces: [accept]
"@
}
@"
listen: "127.0.0.1:$proxyPort"
authentication: OFFLINE
@@ -322,8 +228,9 @@ plugins:
newPort: "$newRoutePort"
returnToOld: "$returnToOldSetting"
releaseSource: "$releaseSourceSetting"
sharedBackendHost: "$sharedHostSetting"
$backendSettings
backends:
- name: old
address: "127.0.0.1:$oldRoutePort"
"@ | Set-Content -LiteralPath $config -Encoding utf8
$java = (Get-Command java.exe -ErrorAction Stop).Source
$proxyLog = Join-Path $runDir 'proxy.stdout.log'
@@ -348,7 +255,6 @@ $backendSettings
) -WindowStyle Hidden -PassThru -RedirectStandardOutput $proxyLog `
-RedirectStandardError (Join-Path $runDir 'proxy.stderr.log')
WaitForPort $proxyPort $proxy
if ($AuthenticateEarly) { WaitForProxyLog $proxyLog 'SMOKE_SHARED_HOSTS_READY' 60 }
if ($PrismClient) {
if (-not (Test-Path -LiteralPath $PrismPath)) { throw "Prism Launcher not found: $PrismPath" }
$existingSmokeLauncher = @(Get-CimInstance Win32_Process -Filter "Name = 'prismlauncher.exe'" |
@@ -428,7 +334,7 @@ $backendSettings
Write-Output 'REAL_URANIUM_PLUGIN_TRANSFER_PASS dynamicRegistration=true status=NETWORK_READY'
}
}
if (-not $AuthenticateEarly) { WaitForProxyLog $proxyLog 'SMOKE_PLUGIN_REGISTER_PASS name=new' }
WaitForProxyLog $proxyLog 'SMOKE_PLUGIN_REGISTER_PASS name=new'
WaitForProxyLog $proxyLog 'SMOKE_PLUGIN_TRANSFER_PASS status=NETWORK_READY'
if ($ReturnToOld) { WaitForProxyLog $proxyLog 'SMOKE_PLUGIN_RETURN_PASS status=NETWORK_READY' }
if ($ReleaseSource) {
@@ -489,34 +395,3 @@ $backendSettings
}
Write-Output "Runtime logs: $runDir"
}
if ($AuthenticateEarly) {
# Bukkit JUL logs reach stdout, while Uranium's latest.log may omit them.
# Drain and stop our servers before reading those redirected streams.
$expectedOld = if ($ReturnToOld) { 2 } else { 1 }
foreach ($item in @(@{ Server = $old; Expected = $expectedOld }, @{ Server = $new; Expected = 1 })) {
$log = Join-Path $item.Server.Directory 'server.stdout.log'
foreach ($marker in @('EARLY_AUTH_PASS', 'EARLY_PROMOTION_PASS')) {
if (@(Select-String -LiteralPath $log -Pattern $marker).Count -ne $item.Expected) {
throw "Expected $($item.Expected) $marker markers in $log"
}
}
if (Select-String -LiteralPath $log -Pattern 'EARLY_.*FAILED' -Quiet) { throw "Early session probe failed: $log" }
}
Write-Output 'REAL_URANIUM_EARLY_SESSION_PASS beforeJoin=true sameBinding=true privateProofRemoved=true'
}
if ($ManagedPlayerData) {
foreach ($item in @(@{ Server = $old; Expected = 2 }, @{ Server = $new; Expected = 1 })) {
$log = Join-Path $item.Server.Directory 'server.stdout.log'
foreach ($marker in @('PDA_PROBE_RESTORE_PASS', 'PDA_PROBE_NATIVE_DENIAL_OVERRIDE_PASS', 'PDA_PROBE_POST_JOIN_PASS')) {
if (@(Select-String -LiteralPath $log -Pattern $marker).Count -ne $item.Expected) {
throw "Expected $($item.Expected) $marker markers in $log"
}
}
if (Select-String -LiteralPath $log -Pattern 'PDA_PROBE_FAIL|failed=true|Exception caught during firing event|NoSuchMethodError|NoClassDefFoundError' -Quiet) { throw "Player authority probe failed: $log" }
}
$oldLog = Join-Path $old.Directory 'server.stdout.log'
foreach ($marker in @('PDA_PROBE_OLD_FILE_PASS', 'PDA_PROBE_FALLBACK_PASS', 'PDA_PROBE_QUIT_SAVE_PASS')) {
if (-not (Select-String -LiteralPath $oldLog -Pattern $marker -Quiet)) { throw "Missing $marker in $oldLog" }
}
Write-Output 'REAL_URANIUM_PLAYER_AUTHORITY_PASS beforeLogin=true staleFileSkipped=true loadDataGuarded=true invalidDimensionFallback=true normalQuitSave=true'
}
@@ -1,36 +0,0 @@
# Early backend session authentication
Verified on 2026-10-01 against the local `feature/early-backend-session` sources. This is an optional public Bukkit API addition; it does not modify transfer commands or require a PlayerDataSQL-specific core integration.
## Source checks
`gradlew --no-daemon check :proxy-core:installDist` succeeded. The seven project test suites contain 314 tests, zero failures, errors or skips; unchanged suites were up-to-date. `verifyApiJar` and `verifyHostJar` confirmed the Java 8 API/host packaging.
Lifecycle tests exercise repeated early authentication, proof replay, same-object Join promotion, denied login cleanup, expiry, stale Quit, epoch changes before Join and after promotion, and close.
## Real Uranium round trip
Command (Java 25 for proxy/tools, Java 8 for the server and Bukkit probe):
```powershell
./smoke/local-uranium-transfer.ps1 -BundlePath build/uranium-event-bundle `
-InstalledPlugin -ReleaseSource -ReturnToOld -DebugSession `
-AuthenticateEarly -BukkitApiJar <spigot-api-1.8-R0.1-SNAPSHOT.jar>
```
The probe compiles against public API artifacts. Each isolated backend installs one shared MoonBridge host, enables legacy forwarding and dynamically registers through the existing channel. It authenticates twice during PlayerLogin, checks identical binding and provisional-only status, verifies the private profile property was removed, then checks the same binding through `find` on the first tick after Join.
Successful run: `build/local-uranium-transfer-df0f9afbb80e471d9c725fe80cb5d712`.
- Two old-backend and one new-backend early authentication markers.
- Matching counts of online promotion markers; zero failure markers.
- Initial transfer and return both `NETWORK_READY`; synthetic Forge client observed round-trip respawns/keepalives.
- Optional asynchronous source-release barrier passed. **No database persistence is certified by this smoke.**
- Both servers stopped through their normal `stop` command; this script terminates only proxy/tap processes it created.
Server SHA-256: `A05ECB7CA50CB00390A690608CC243738711C2EC079C37FBA3B0119BB261373A`.
Shared host SHA-256: `FA7F990AF4C05DD008F6A25956CF175645F6E6B7F20DC253CFC7ACA100177569`.
The first new-harness run omitted `spigot.yml` legacy forwarding and was rejected with no early proof. The harness now explicitly enables it; rejection was not bypassed or weakened.
This is login lifecycle evidence on a minimal Forge server. It does not establish full-pack player-data restoration, module synchronization, or Blood Magic ledger performance. No PLAY throughput superiority claim is made for this change; authentication operates during login, not on every PLAY packet.
@@ -1,13 +0,0 @@
# Uranium player-data authority boundary
Scope: an optional public-API acceptance probe on the new Uranium authority implementation. This does not load PlayerDataSQL or a modpack, and its source-release barrier does not confirm SQL persistence.
Harness: `local-uranium-transfer.ps1 -InstalledPlugin -ReleaseSource -ReturnToOld -DebugSession -AuthenticateEarly -ManagedPlayerData -UraniumApiJar <matching development jar> -BukkitApiJar <Spigot API>`.
Local run: `build/local-uranium-transfer-7237a6cadf8e45378742e866d5039d4f`. Server artifact SHA-256: `EBCC88FE572D4CC448C86D1619C1B01762BE1D69282016EA90EFB494737F703E`. Its source was the dirty Uranium `evo@325e7de5ee` authority implementation later committed as `13855d1`; this was not a published artifact.
Result: exit 0; real protocol A→B→A and both source-release barriers passed. A had two restored/logged-in admissions, B one. All three login events initially denied a native name ban and observed restored inventory before the probe overrode that denial. All three next-tick checks retained the restored inventory.
A's first exit persisted a deliberately stale local marker of 51 diamonds. Returning A read that existing file, restored 12, called `Player.loadData()`, and still observed 12 before login and after Join. The supplied invalid dimension 9999 fell back to the overworld. Managed Forge `LoadFromFile` events: 0. Normal quit `SaveToFile` events: A 2, B 1. Probe failures and event/linkage errors: 0.
An earlier run exposed `NoSuchMethodError` in the probe's direct NMS `getBukkitEntity` return descriptor (development Craft class package differs from runtime). The probe now retains its exact Bukkit player for validation, and the harness rejects event/linkage errors in addition to explicit assertion markers. Compilation by itself did not detect that runtime failure.