mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-26 19:03:49 +00:00
Authenticate backend player sessions before Join
This commit is contained in:
1 parent
043123e168
commit
66762ac8bb
8 files changed
+530
-41
No files matched your search
@@ -8,6 +8,20 @@ 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;
|
||||
|
||||
+173
-27
@@ -1,41 +1,55 @@
|
||||
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)
|
||||
@@ -47,45 +61,141 @@ 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.
|
||||
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) {
|
||||
AuthenticationResult result = authenticateInternal(player, true);
|
||||
if (result.propertyRemovalFailed) {
|
||||
// Fail closed: do not bind a session when the forwarding credential
|
||||
// could not be removed from the profile before it is broadcast.
|
||||
if (stripped.foundProperty) player.kickPlayer("Unable to validate forwarded session");
|
||||
return;
|
||||
if (result.foundProperty) player.kickPlayer("Unable to validate forwarded session");
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
BackendPlayerSession session = new BackendPlayerSession(claims.playerId(), claims.proxyEpoch(),
|
||||
claims.connectionId(), claims.backendName(), claims.backendEpoch(), UUID.randomUUID());
|
||||
sessions.put(player.getUniqueId(), new BoundSession(player, session));
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
BoundSession current = sessions.get(player.getUniqueId());
|
||||
if (current != null && current.player == player) sessions.remove(player.getUniqueId(), current);
|
||||
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();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BackendPlayerSession> find(Player player) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized boolean consumeNonce(UUID nonce, long expiresAt, long now) {
|
||||
private 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;
|
||||
@@ -160,9 +270,12 @@ final class OwnedBukkitSessionService implements BukkitSessionService, Listener,
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
sessions.clear();
|
||||
synchronized (this) { consumedNonces.clear(); }
|
||||
synchronized (lifecycleLock) {
|
||||
closed = true;
|
||||
sessions.clear();
|
||||
provisionalSessions.clear();
|
||||
consumedNonces.clear();
|
||||
}
|
||||
java.util.Arrays.fill(secret, (byte) 0);
|
||||
}
|
||||
|
||||
@@ -175,4 +288,37 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
-3
@@ -4,8 +4,11 @@ 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;
|
||||
@@ -14,6 +17,8 @@ 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.*;
|
||||
|
||||
@@ -109,12 +114,112 @@ 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);
|
||||
}
|
||||
@@ -125,11 +230,24 @@ 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 true;
|
||||
if (method.getName().equals("isOnline")) return online.get();
|
||||
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";
|
||||
@@ -137,7 +255,7 @@ class OwnedBukkitSessionServiceTest {
|
||||
if (method.getName().equals("equals")) return proxy == arguments[0];
|
||||
return null;
|
||||
});
|
||||
return new TestPlayer(player, profile, kicked);
|
||||
return new TestPlayer(player, profile, kicked, online);
|
||||
}
|
||||
|
||||
public interface ProfileHolder {
|
||||
@@ -206,10 +324,12 @@ class OwnedBukkitSessionServiceTest {
|
||||
final Player player;
|
||||
final Object profile;
|
||||
final AtomicBoolean kicked;
|
||||
TestPlayer(Player player, Object profile, AtomicBoolean kicked) {
|
||||
final AtomicBoolean online;
|
||||
TestPlayer(Player player, Object profile, AtomicBoolean kicked, AtomicBoolean online) {
|
||||
this.player = player;
|
||||
this.profile = profile;
|
||||
this.kicked = kicked;
|
||||
this.online = online;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,8 @@ 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 连接会明确拒绝。关闭时在有限等待内尝试发送注销帧,代理收到后立即注销目录;若链路阻塞或意外断线,则保留到租约过期,不强制关闭已经存在的玩家游戏连接。
|
||||
|
||||
## 验证范围
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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()); }
|
||||
}
|
||||
@@ -23,10 +23,13 @@ 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")) {
|
||||
@@ -56,10 +59,26 @@ public final class UraniumTransferPlugin implements Plugin {
|
||||
}));
|
||||
});
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public Optional<InitialPlacementHandler> initialPlacementHandler() {
|
||||
@@ -68,7 +87,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")
|
||||
&& "uranium-smoke-plugin".equals(server.metadata().get("source")))) {
|
||||
&& (sharedBackendHost || "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()));
|
||||
@@ -82,6 +101,7 @@ 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) {
|
||||
@@ -116,6 +136,7 @@ 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();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ param(
|
||||
[string]$BackendJavaAgent,
|
||||
[switch]$InstalledPlugin,
|
||||
[switch]$ReleaseSource,
|
||||
[switch]$AuthenticateEarly,
|
||||
[string]$BukkitApiJar,
|
||||
[switch]$DebugSession,
|
||||
[switch]$TraceBackend,
|
||||
[switch]$PrismClient,
|
||||
@@ -16,6 +18,9 @@ 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 ($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' }
|
||||
@@ -86,8 +91,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) {
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(5)
|
||||
function WaitForProxyLog([string]$path, [string]$pattern, [int]$timeoutSeconds = 5) {
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($timeoutSeconds)
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
if ((Test-Path -LiteralPath $path) -and
|
||||
(Select-String -LiteralPath $path -Pattern $pattern -Quiet)) { return }
|
||||
@@ -98,6 +103,25 @@ function WaitForProxyLog([string]$path, [string]$pattern) {
|
||||
|
||||
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
|
||||
"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" }
|
||||
@"
|
||||
@@ -160,6 +184,25 @@ $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' }
|
||||
}
|
||||
$old = StartUranium (Join-Path $runDir 'old') $oldPort
|
||||
$servers.Add($old)
|
||||
WaitForReady $oldPort $old.Process $old.Directory
|
||||
@@ -215,6 +258,34 @@ 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
|
||||
@@ -228,9 +299,8 @@ plugins:
|
||||
newPort: "$newRoutePort"
|
||||
returnToOld: "$returnToOldSetting"
|
||||
releaseSource: "$releaseSourceSetting"
|
||||
backends:
|
||||
- name: old
|
||||
address: "127.0.0.1:$oldRoutePort"
|
||||
sharedBackendHost: "$sharedHostSetting"
|
||||
$backendSettings
|
||||
"@ | Set-Content -LiteralPath $config -Encoding utf8
|
||||
$java = (Get-Command java.exe -ErrorAction Stop).Source
|
||||
$proxyLog = Join-Path $runDir 'proxy.stdout.log'
|
||||
@@ -255,6 +325,7 @@ backends:
|
||||
) -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'" |
|
||||
@@ -334,7 +405,7 @@ backends:
|
||||
Write-Output 'REAL_URANIUM_PLUGIN_TRANSFER_PASS dynamicRegistration=true status=NETWORK_READY'
|
||||
}
|
||||
}
|
||||
WaitForProxyLog $proxyLog 'SMOKE_PLUGIN_REGISTER_PASS name=new'
|
||||
if (-not $AuthenticateEarly) { 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) {
|
||||
@@ -395,3 +466,18 @@ backends:
|
||||
}
|
||||
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'
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user