mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-26 19:03:49 +00:00
287 lines
16 KiB
Java
287 lines
16 KiB
Java
package dev.moonbridge.smoke;
|
|
|
|
import dev.moonbridge.api.CommandSource;
|
|
import dev.moonbridge.api.MessageResult;
|
|
import dev.moonbridge.api.PlayerIdentity;
|
|
import dev.moonbridge.api.PlayerView;
|
|
import dev.moonbridge.api.Plugin;
|
|
import dev.moonbridge.api.PluginContext;
|
|
import dev.moonbridge.api.event.EventSubscription;
|
|
import dev.moonbridge.api.event.PlayerDisconnectedEvent;
|
|
import dev.moonbridge.api.permission.PermissionResult;
|
|
import dev.moonbridge.core.protocol.MinecraftText;
|
|
import net.kyori.adventure.text.Component;
|
|
import net.kyori.adventure.text.event.ClickEvent;
|
|
import net.kyori.adventure.text.event.HoverEvent;
|
|
import net.kyori.adventure.text.format.NamedTextColor;
|
|
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.List;
|
|
import java.util.Optional;
|
|
import java.util.concurrent.CopyOnWriteArrayList;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
import java.util.concurrent.atomic.AtomicReference;
|
|
|
|
/** Server-driven permission checks against an actual Forge client session. */
|
|
public final class PermissionClientAcceptancePlugin implements Plugin {
|
|
private static final String PLAYER_NAME = "PrismSmoke";
|
|
private static final String BACKEND_NAME = "uranium";
|
|
private static final String COMMAND_PERMISSION = "acceptance.client.execute";
|
|
private static final String COMMAND_ROOT = "permclient";
|
|
private static final String USER = "lp user ";
|
|
private static final String DENIED_TEXT = "You do not have permission to use this command.";
|
|
|
|
private final AtomicInteger handlerExecutions = new AtomicInteger();
|
|
private final AtomicReference<PlayerIdentity> targetIdentity = new AtomicReference<>();
|
|
private final AtomicReference<PlayerIdentity> disconnectedIdentity = new AtomicReference<>();
|
|
private volatile PluginContext context;
|
|
private volatile Path evidenceDirectory;
|
|
private volatile boolean stopping;
|
|
private volatile Thread worker;
|
|
private volatile EventSubscription disconnectSubscription;
|
|
|
|
@Override public void onLoad(PluginContext loadedContext) {
|
|
context = loadedContext;
|
|
String configured = loadedContext.settings().get("evidenceDir");
|
|
if (configured == null || configured.isBlank()) {
|
|
throw new IllegalArgumentException("evidenceDir setting is required");
|
|
}
|
|
evidenceDirectory = Path.of(configured).toAbsolutePath().normalize();
|
|
}
|
|
|
|
@Override public void onEnable() {
|
|
context.commands().register(COMMAND_ROOT, COMMAND_PERMISSION, invocation -> {
|
|
PlayerIdentity current = targetIdentity.get();
|
|
if (current == null || !current.equals(invocation.player().identity())) {
|
|
throw new IllegalStateException("command invocation did not use the connected acceptance player");
|
|
}
|
|
handlerExecutions.incrementAndGet();
|
|
if (!"run".equals(invocation.arguments())) {
|
|
invocation.reply("usage: /permclient run");
|
|
return;
|
|
}
|
|
invocation.reply(richCommandReply());
|
|
});
|
|
disconnectSubscription = context.events().subscribe(PlayerDisconnectedEvent.class, event -> {
|
|
PlayerIdentity expected = targetIdentity.get();
|
|
if (expected != null && expected.equals(event.player().identity())) {
|
|
disconnectedIdentity.set(expected);
|
|
record("CLIENT_DISCONNECTED connectionId=" + expected.connectionId());
|
|
}
|
|
return java.util.concurrent.CompletableFuture.completedFuture(null);
|
|
});
|
|
worker = Thread.ofVirtual().name("permission-client-acceptance").start(this::runAcceptance);
|
|
record("PLUGIN_READY backend=" + BACKEND_NAME + " player=" + PLAYER_NAME);
|
|
}
|
|
|
|
@Override public void onDisable() {
|
|
stopping = true;
|
|
Thread active = worker;
|
|
if (active != null) active.interrupt();
|
|
EventSubscription subscription = disconnectSubscription;
|
|
if (subscription != null) subscription.close();
|
|
}
|
|
|
|
private void runAcceptance() {
|
|
try {
|
|
PlayerView player = waitForPlayer(360);
|
|
targetIdentity.set(player.identity());
|
|
record("PLAYER_CONNECTED name=" + player.username()
|
|
+ " uuid=" + player.identity().playerId()
|
|
+ " connectionId=" + player.identity().connectionId()
|
|
+ " backend=" + player.currentServer().orElse("<none>"));
|
|
|
|
awaitResult(player.identity(), COMMAND_PERMISSION, PermissionResult.UNDEFINED, 30);
|
|
runProxyCommandAsPlayer(player, "/" + COMMAND_ROOT + " run", "undefined permission denial", 0, false);
|
|
require(handlerExecutions.get() == 0, "undefined permission must not call the command handler");
|
|
|
|
consoleCommand(USER + player.identity().playerId() + " permission set "
|
|
+ COMMAND_PERMISSION + " true");
|
|
awaitResult(player.identity(), COMMAND_PERMISSION, PermissionResult.ALLOW, 30);
|
|
runProxyCommandAsPlayer(player, "/" + COMMAND_ROOT + " run", "granted command", 1, true);
|
|
require(handlerExecutions.get() == 1, "grant must run the business handler exactly once");
|
|
|
|
consoleCommand(USER + player.identity().playerId() + " permission set "
|
|
+ COMMAND_PERMISSION + " false");
|
|
awaitResult(player.identity(), COMMAND_PERMISSION, PermissionResult.DENY, 30);
|
|
runProxyCommandAsPlayer(player, "/" + COMMAND_ROOT + " run", "explicit deny", 1, false);
|
|
require(handlerExecutions.get() == 1, "explicit deny must leave the business handler count unchanged");
|
|
|
|
consoleCommand(USER + player.identity().playerId() + " permission set luckperms.* true");
|
|
awaitResult(player.identity(), "luckperms.user.permission.set", PermissionResult.ALLOW, 30);
|
|
runLuckPermsPlayerCommand(player, "/lp info", "LuckPerms info");
|
|
// LuckPerms limits player commands to one per 500 ms; keep the
|
|
// smoke from testing its rate limiter instead of permission output.
|
|
Thread.sleep(600);
|
|
runLuckPermsPlayerCommand(player, USER + player.identity().playerId() + " permission info",
|
|
"LuckPerms permission info");
|
|
|
|
Component rich = Component.text("PERMISSION_CLIENT_RICH_TEXT_PASS", NamedTextColor.GREEN)
|
|
.clickEvent(ClickEvent.suggestCommand("/help"))
|
|
.hoverEvent(HoverEvent.showText(Component.text("PERMISSION_CLIENT_HOVER_PASS")));
|
|
String encoded = MinecraftText.encode(rich);
|
|
require(encoded.contains("clickEvent") && encoded.contains("hoverEvent") && encoded.contains("value"),
|
|
"proxy MinecraftText encoder must preserve supported click and hover fields");
|
|
MessageResult sent = context.players().sendMessage(player.identity(), rich)
|
|
.toCompletableFuture().get(15, TimeUnit.SECONDS);
|
|
require(sent == MessageResult.SENT, "rich-text marker send returned " + sent);
|
|
record("RICH_TEXT_SENT status=" + sent + " proxyEncoder=PASS click=true hover=true jsonChars="
|
|
+ encoded.length());
|
|
|
|
for (int second = 0; second < 10; second++) {
|
|
ensureSameSession(player);
|
|
Thread.sleep(1_000);
|
|
}
|
|
ensureSameSession(player);
|
|
require(disconnectedIdentity.get() == null, "target client disconnected during the hold");
|
|
record("PERMISSION_CLIENT_ACCEPTANCE_PASS handlerExecutions=1 heldSeconds=10"
|
|
+ " clientCommandOrigin=proxy-api playerKeyboardInput=false");
|
|
} catch (InterruptedException interrupted) {
|
|
if (!stopping) {
|
|
Thread.currentThread().interrupt();
|
|
fail("acceptance interrupted unexpectedly", interrupted);
|
|
}
|
|
} catch (Throwable failure) {
|
|
fail("acceptance failed", failure);
|
|
}
|
|
}
|
|
|
|
private PlayerView waitForPlayer(int timeoutSeconds) throws Exception {
|
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
|
|
while (System.nanoTime() < deadline && !stopping) {
|
|
Optional<PlayerView> match = context.players().online().stream()
|
|
.filter(player -> PLAYER_NAME.equals(player.username()))
|
|
.filter(player -> BACKEND_NAME.equals(player.currentServer().orElse(null)))
|
|
.findFirst();
|
|
if (match.isPresent()) return match.get();
|
|
Thread.sleep(250);
|
|
}
|
|
throw new IllegalStateException("no online " + PLAYER_NAME + " session on " + BACKEND_NAME
|
|
+ " within " + timeoutSeconds + " seconds");
|
|
}
|
|
|
|
private void runProxyCommandAsPlayer(PlayerView player, String command, String phase,
|
|
int expectedHandlerCount, boolean expectedAllowed) throws Exception {
|
|
List<Component> replies = new CopyOnWriteArrayList<>();
|
|
boolean consumed = context.commands().execute(CommandSource.player(player, replies::add), command)
|
|
.toCompletableFuture().get(30, TimeUnit.SECONDS);
|
|
require(consumed, phase + " command was not consumed by the proxy");
|
|
if (!expectedAllowed) {
|
|
require(replies.stream().map(PlainTextComponentSerializer.plainText()::serialize)
|
|
.anyMatch(DENIED_TEXT::equals), phase + " did not return the expected permission denial");
|
|
} else {
|
|
require(replies.stream().anyMatch(component ->
|
|
PlainTextComponentSerializer.plainText().serialize(component)
|
|
.contains("PERMISSION_CLIENT_COMMAND_PASS")),
|
|
phase + " handler did not generate its marker reply");
|
|
}
|
|
if (!replies.isEmpty()) sendComponents(player.identity(), replies, phase + " command reply");
|
|
record("COMMAND_PHASE=" + phase.replace(' ', '_') + " consumed=true handlerExecutions="
|
|
+ handlerExecutions.get() + " replyComponents=" + replies.size());
|
|
}
|
|
|
|
private void runLuckPermsPlayerCommand(PlayerView player, String command, String phase) throws Exception {
|
|
List<Component> replies = new CopyOnWriteArrayList<>();
|
|
boolean consumed = context.commands().execute(CommandSource.player(player, replies::add), command)
|
|
.toCompletableFuture().get(30, TimeUnit.SECONDS);
|
|
require(consumed, phase + " command was not consumed by the proxy");
|
|
require(!replies.isEmpty(), phase + " returned no player-facing LuckPerms components");
|
|
int styled = 0;
|
|
for (Component component : replies) {
|
|
String plain = PlainTextComponentSerializer.plainText().serialize(component);
|
|
require(!plain.contains("luckperms.command."),
|
|
phase + " contains an untranslated LuckPerms key: " + plain);
|
|
String json = MinecraftText.encode(component);
|
|
if (isStyled(component)) styled++;
|
|
require(!json.isBlank(), phase + " component encoded to empty JSON");
|
|
}
|
|
require(styled > 0, phase + " returned no styled Adventure component");
|
|
sendComponents(player.identity(), replies, phase);
|
|
record("LUCKPERMS_PLAYER_OUTPUT phase=" + phase.replace(' ', '_') + " components=" + replies.size()
|
|
+ " styled=" + styled + " proxyEncoder=PASS deliveredToClient=true");
|
|
}
|
|
|
|
private void consoleCommand(String command) throws Exception {
|
|
List<Component> output = new CopyOnWriteArrayList<>();
|
|
boolean consumed = context.commands().execute(CommandSource.console(output::add), command)
|
|
.toCompletableFuture().get(30, TimeUnit.SECONDS);
|
|
require(consumed, "LuckPerms console command was not consumed: " + command);
|
|
record("LUCKPERMS_CONSOLE_COMMAND consumed=true outputComponents=" + output.size());
|
|
}
|
|
|
|
private void sendComponents(PlayerIdentity identity, List<Component> components, String description)
|
|
throws Exception {
|
|
int sent = 0;
|
|
for (Component component : components) {
|
|
// Exercise the exact proxy serializer used by the actual Players.sendMessage packet path.
|
|
MinecraftText.encode(component);
|
|
MessageResult result = context.players().sendMessage(identity, component)
|
|
.toCompletableFuture().get(15, TimeUnit.SECONDS);
|
|
require(result == MessageResult.SENT, description + " send returned " + result);
|
|
sent++;
|
|
}
|
|
record("CLIENT_MESSAGE_DELIVERY phase=" + description.replace(' ', '_') + " sent=" + sent);
|
|
}
|
|
|
|
private void awaitResult(PlayerIdentity identity, String node, PermissionResult expected,
|
|
int timeoutSeconds) throws Exception {
|
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
|
|
PermissionResult observed = PermissionResult.UNAVAILABLE;
|
|
while (System.nanoTime() < deadline && !stopping) {
|
|
observed = context.permissions().check(identity, node);
|
|
if (observed == expected) {
|
|
record("PERMISSION_CHECK node=" + node + " result=" + observed);
|
|
return;
|
|
}
|
|
Thread.sleep(100);
|
|
}
|
|
throw new IllegalStateException("permission " + node + " expected " + expected + " but observed " + observed);
|
|
}
|
|
|
|
private void ensureSameSession(PlayerView expected) {
|
|
PlayerView current = context.players().find(expected.identity())
|
|
.orElseThrow(() -> new IllegalStateException("target connection is no longer online"));
|
|
require(current.username().equals(expected.username()), "target username changed during the test");
|
|
require(current.currentServer().orElse("").equals(BACKEND_NAME), "target backend changed during the test");
|
|
}
|
|
|
|
private static Component richCommandReply() {
|
|
return Component.text("PERMISSION_CLIENT_COMMAND_PASS", NamedTextColor.GOLD)
|
|
.decorate(net.kyori.adventure.text.format.TextDecoration.BOLD)
|
|
.clickEvent(ClickEvent.suggestCommand("/help"))
|
|
.hoverEvent(HoverEvent.showText(Component.text("permission-gated command reply")));
|
|
}
|
|
|
|
private static boolean isStyled(Component component) {
|
|
if (component.style().color() != null || component.style().clickEvent() != null
|
|
|| component.style().hoverEvent() != null) return true;
|
|
return component.children().stream().anyMatch(PermissionClientAcceptancePlugin::isStyled);
|
|
}
|
|
|
|
private void record(String line) {
|
|
try {
|
|
Files.createDirectories(evidenceDirectory);
|
|
Files.writeString(evidenceDirectory.resolve("result.log"), line + System.lineSeparator(),
|
|
StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.CREATE,
|
|
java.nio.file.StandardOpenOption.APPEND);
|
|
} catch (IOException failure) {
|
|
context.logger().error("Could not write permission client acceptance evidence", failure);
|
|
}
|
|
}
|
|
|
|
private void fail(String description, Throwable failure) {
|
|
context.logger().error("Permission client acceptance " + description, failure);
|
|
record("PERMISSION_CLIENT_ACCEPTANCE_FAIL phase=" + description.replace(' ', '_')
|
|
+ " error=" + failure.getClass().getName() + ": " + String.valueOf(failure.getMessage()));
|
|
}
|
|
|
|
private static void require(boolean condition, String message) {
|
|
if (!condition) throw new IllegalStateException(message);
|
|
}
|
|
}
|