Merge branch 'feature/permissions-luckperms' of https://git.nest.potatolab.uk:8443/layue13/MoonBridge into codex/backend-session-binding

This commit is contained in:
layue13 committed 2026-09-28 02:45:48 +08:00
commit 80a753abc7
11 files changed
+1498 -2

No files matched your search

+32
View File
@@ -84,6 +84,38 @@ plugins:
同步使用 LuckPerms 原生实现。`messaging-service: auto` 根据共享 SQL 或启用的 Redis 等配置选择消息同步;默认本地 H2 不启用 messenger,`sync-minutes: -1` 不执行周期重载。上游将旧值 `none` 按 `auto` 处理,不应把它当作强制关闭。Velocity 的 `pluginmsg` 不适用于 MoonBridge。
### 使用 MySQL 与跨代理同步
在 LuckPerms 数据目录的 `config.yml` 中设置以下配置,然后重启代理。数据库需预先创建,账号需能创建、查询和修改 LuckPerms 的表:
```yaml
server: proxy-1
storage-method: mysql
data:
address: "mysql.example.net:3306"
database: moonbridge_permissions
username: moonbridge
password: "替换为数据库密码"
table-prefix: "luckperms_"
messaging-service: auto
```
多台代理使用相同的数据库和表前缀,分别设置各自的 `server`。MySQL 本身即可提供 LuckPerms 的 SQL 消息同步,无需额外安装 Redis。身份模式与 UUID 必须一致;共享存储不会自动转换离线 UUID 与正版 UUID。
若已有 Redis,可继续以 MySQL 存储权限,将消息同步改为:
```yaml
messaging-service: redis
redis:
enabled: true
address: "redis.example.net:6379"
password: "替换为Redis密码"
```
Redis 负责通知其他实例重载权限,权限数据仍保存在 MySQL。Redis 中断期间不会保证其他实例即时看到修改;恢复连接后的新通知可触发刷新,也可以在需要时使用 `lp sync` 主动同步。数据库中断时,已加载玩家可继续使用现有缓存,新玩家加载失败会被拒绝登录;缓存不是数据库最新状态的保证。
MySQL 8.4 与 Redis 7.4 已用实际分发包完成双实例同步、重启持久化和故障恢复验收,详见 [验收记录](../smoke/results/2026-09-28-permissions.md)。其他数据库实现由上游提供,本次没有据此宣称 PostgreSQL 或 MariaDB 已完成联调。
backend 可安装与其 Minecraft / Java 版本兼容的原生 Bukkit LuckPerms,使用相同的身份模式、共享数据库与 LuckPerms 支持的同步方式;MoonBridge 平台插件 JAR 只供代理使用。共享数据库不意味着所有场景的检查结果相同:代理知道后端名称,但不知道玩家在 Bukkit 世界中的位置或其他后端私有上下文。玩家首次登录的权限加载不依赖某个 backend 在线。
接口分层参考 Velocity 的 [PermissionSubject](https://github.com/PaperMC/Velocity/blob/dev/3.0.0/api/src/main/java/com/velocitypowered/api/permission/PermissionSubject.java)、[PermissionProvider](https://github.com/PaperMC/Velocity/blob/dev/3.0.0/api/src/main/java/com/velocitypowered/api/permission/PermissionProvider.java) 和 [PermissionsSetupEvent](https://github.com/PaperMC/Velocity/blob/dev/3.0.0/api/src/main/java/com/velocitypowered/api/event/permission/PermissionsSetupEvent.java):业务侧查询、提供者计算、登录等待初始化。MoonBridge 使用显式唯一提供者和连接所有权来管理生命周期,并把服务不可用与三态权限值分开。
@@ -25,6 +25,7 @@ import me.lucko.luckperms.common.config.generic.adapter.ConfigurationAdapter;
import me.lucko.luckperms.common.dependencies.Dependency;
import me.lucko.luckperms.common.event.AbstractEventBus;
import me.lucko.luckperms.common.messaging.MessagingFactory;
import me.lucko.luckperms.common.locale.TranslationManager;
import me.lucko.luckperms.common.model.User;
import me.lucko.luckperms.common.model.manager.group.StandardGroupManager;
import me.lucko.luckperms.common.model.manager.track.StandardTrackManager;
@@ -229,7 +230,9 @@ final class MoonBridgePlatform extends AbstractLuckPermsPlugin {
@Override protected String getName(CommandSource source) { return source.player().map(PlayerView::username).orElse(Sender.CONSOLE_NAME); }
@Override protected UUID getUniqueId(CommandSource source) { return source.player().map(player -> player.identity().playerId()).orElse(Sender.CONSOLE_UUID); }
@Override protected void sendMessage(CommandSource source, net.kyori.adventure.text.Component message) {
MoonBridgeChat.send(source, GsonComponentSerializer.gson().serialize(message));
// LuckPerms translation keys are registered in its private Adventure classloader.
// Resolve them before crossing the JSON boundary; Minecraft cannot translate these keys.
MoonBridgeChat.send(source, GsonComponentSerializer.gson().serialize(TranslationManager.render(message)));
}
@Override protected Tristate getPermissionValue(CommandSource source, String node) {
if (source.isConsole()) return Tristate.TRUE;
+4
View File
@@ -87,7 +87,11 @@ public final class PermissionAcceptance {
expect(probe, transferred, "luckperms.user.permission.set", PermissionResult.ALLOW);
require(!host.completeCommand(transferred, "/lp ").orElseThrow().toCompletableFuture()
.get(5, TimeUnit.SECONDS).isEmpty(), "LuckPerms native command completion");
int infoStart = output.size();
command(probe, output, "lp info");
require(output.size() > infoStart, "LuckPerms info must produce readable output");
require(output.subList(infoStart, output.size()).stream().noneMatch(line -> line.contains("luckperms.command.")),
"LuckPerms translations must be rendered before crossing the host JSON bridge");
command(probe, output, USER + "permission info");
PlayerView reconnect = new PlayerView(new PlayerIdentity(UUID_VALUE, 2), first.username(), "lobby");
+286
View File
@@ -0,0 +1,286 @@
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);
}
}
+297
View File
@@ -0,0 +1,297 @@
import dev.moonbridge.api.*;
import dev.moonbridge.api.permission.PermissionResult;
import dev.moonbridge.core.backend.InMemoryBackendCatalog;
import dev.moonbridge.core.plugin.PluginHost;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.io.BufferedReader;
import java.io.InputStreamReader;
/** Exercises the packaged LuckPerms plugin against external SQL and/or Redis services. */
public final class PermissionNetworkAcceptance {
private static final String PROVIDER = "dev.moonbridge.luckperms.LuckPermsMoonBridgePlugin";
private static final long TIMEOUT_SECONDS = 30;
private static final BufferedReader CONTROL = new BufferedReader(new InputStreamReader(System.in));
public static void main(String[] args) throws Exception {
var backgroundFailures = new CopyOnWriteArrayList<String>();
Thread.setDefaultUncaughtExceptionHandler((thread, failure) -> backgroundFailures.add(
thread.getName() + ": " + failure.getClass().getName()));
if (args.length != 4 && args.length != 5) {
throw new IllegalArgumentException("Usage: PermissionNetworkAcceptance <distribution> <config-path> <evidence-parent> <sql|redis> [outage]");
}
Path distribution = Path.of(args[0]).toAbsolutePath();
String mode = args[3].toLowerCase(Locale.ROOT);
Path evidenceRoot = Files.createDirectories(Path.of(args[2]).toAbsolutePath());
boolean outage = args.length == 5 && args[4].equalsIgnoreCase("outage");
require(args.length < 5 || outage, "optional fifth argument must be outage");
if (!mode.equals("sql") && !mode.equals("redis")) {
throw new IllegalArgumentException("Mode must be sql or redis");
}
String configTemplate = Files.readString(Path.of(args[1]).toAbsolutePath());
if (mode.equals("sql")) {
require(configTemplate.contains("storage-method: mysql"), "SQL mode requires MySQL storage");
require(configTemplate.contains("messaging-service: auto"), "SQL mode must exercise automatic SQL messenger selection");
} else {
require(configTemplate.contains("storage-method: mysql"), "Redis mode still requires shared MySQL persistence");
require(configTemplate.contains("messaging-service: redis"), "Redis mode requires the Redis messenger");
}
Path evidence = Files.createTempDirectory(evidenceRoot, "permission-network-" + mode + "-");
UUID playerId = UUID.randomUUID();
String user = "lp user " + playerId + " ";
String uniqueGroup = "acceptance_network_" + playerId.toString().replace("-", "").substring(0, 10);
Path pluginA = preparePlugin(distribution, evidence.resolve("instance-a"),
configForServer(configTemplate, "acceptance-a"));
Path pluginB = preparePlugin(distribution, evidence.resolve("instance-b"),
configForServer(configTemplate, "acceptance-b"));
var transcript = new CopyOnWriteArrayList<String>();
var playersA = new TestPlayers();
var playersB = new TestPlayers();
PlayerView a = new PlayerView(new PlayerIdentity(playerId, 1), "NetworkPermProbe", "lobby");
PlayerView b = new PlayerView(new PlayerIdentity(playerId, 1), "NetworkPermProbe", "lobby");
playersA.current = a;
playersB.current = b;
try (Running first = start(pluginA, "acceptance-proxy-a", playersA, transcript);
Running second = start(pluginB, "acceptance-proxy-b", playersB, transcript)) {
first.host.permissionService().prepare(a).toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
second.host.permissionService().prepare(b).toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
// Both users are loaded before writes so a successful result requires the configured messenger
// to invalidate the remote instance's already-populated user/cache.
expect(first, a, "acceptance.network.grant", PermissionResult.UNDEFINED);
expect(second, b, "acceptance.network.grant", PermissionResult.UNDEFINED);
command(first, "lp user " + playerId + " permission set acceptance.network.grant true");
expect(second, b, "acceptance.network.grant", PermissionResult.ALLOW);
command(first, "lp user " + playerId + " permission set acceptance.network.grant false");
expect(second, b, "acceptance.network.grant", PermissionResult.DENY);
command(first, "lp user " + playerId + " permission unset acceptance.network.grant");
expect(second, b, "acceptance.network.grant", PermissionResult.UNDEFINED);
command(first, "lp creategroup " + uniqueGroup);
command(first, "lp group " + uniqueGroup + " permission set acceptance.network.inherited true");
command(first, user + "parent add " + uniqueGroup);
expect(first, a, "acceptance.network.inherited", PermissionResult.ALLOW);
expect(second, b, "acceptance.network.inherited", PermissionResult.ALLOW);
command(first, "lp user " + playerId + " permission set acceptance.network.inherited false");
expect(second, b, "acceptance.network.inherited", PermissionResult.DENY);
command(first, user + "permission unset acceptance.network.inherited");
expect(second, b, "acceptance.network.inherited", PermissionResult.ALLOW);
command(first, user + "permission set acceptance.network.context true server=acceptance-a");
expect(first, a, "acceptance.network.context", PermissionResult.ALLOW);
expect(second, b, "acceptance.network.context", PermissionResult.UNDEFINED);
command(first, user + "permission settemp acceptance.network.temporary true 2s");
expect(first, a, "acceptance.network.temporary", PermissionResult.ALLOW);
await(() -> first.probe.context.permissions().check(a.identity(), "acceptance.network.temporary")
== PermissionResult.UNDEFINED, "temporary permission expiry");
if (outage && mode.equals("sql")) {
runMysqlOutage(first, second, a, b, playersA, playerId);
} else if (outage) {
runRedisOutage(first, second, a, b, playerId, user);
}
// Restart the second isolated plugin/classloader and prove shared SQL data survives a fresh load.
second.host.permissionService().release(b.identity());
playersB.current = null;
}
try (Running restarted = start(pluginB, "acceptance-proxy-b", playersB, transcript)) {
PlayerView returning = new PlayerView(new PlayerIdentity(playerId, 2), "NetworkPermProbe", "lobby");
playersB.current = returning;
restarted.host.permissionService().prepare(returning).toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
expect(restarted, returning, "acceptance.network.inherited", PermissionResult.ALLOW);
expect(restarted, returning, "acceptance.network.context", PermissionResult.UNDEFINED);
if (outage && mode.equals("redis")) {
expect(restarted, returning, "acceptance.network.redis-during-outage", PermissionResult.ALLOW);
expect(restarted, returning, "acceptance.network.redis-after-recovery", PermissionResult.ALLOW);
}
restarted.host.permissionService().release(returning.identity());
playersB.current = null;
}
Thread.sleep(500);
require(backgroundFailures.isEmpty(), "uncaught background failures: " + backgroundFailures);
// Capture command output only; never log config contents, connection URLs, usernames, or passwords.
Files.write(evidence.resolve("command-output.txt"), transcript);
Files.writeString(evidence.resolve("result.txt"), "PERMISSION_NETWORK_ACCEPTANCE_PASS mode=" + mode
+ " outage=" + outage + " player=" + playerId + " group=" + uniqueGroup + "\n");
System.out.println("PERMISSION_NETWORK_ACCEPTANCE_PASS mode=" + mode + " evidence=" + evidence);
}
private static Path preparePlugin(Path distribution, Path instance, String config) throws Exception {
Path plugins = Files.createDirectories(instance.resolve("plugins"));
try (var files = Files.list(distribution.resolve("plugins"))) {
Path jar = files.filter(file -> file.getFileName().toString().startsWith("luckperms-moonbridge-")
&& file.toString().endsWith(".jar") && !file.toString().endsWith("-engine.jar"))
.findFirst().orElseThrow(() -> new IllegalStateException("Packaged LuckPerms plugin JAR not found"));
Files.copy(jar, plugins.resolve(jar.getFileName()));
}
Path data = Files.createDirectories(plugins.resolve("data").resolve(PROVIDER));
Files.writeString(data.resolve("config.yml"), config);
return plugins;
}
private static String configForServer(String template, String server) {
if (template.contains("@SERVER@")) return template.replace("@SERVER@", server);
String configured = template.replaceFirst("(?m)^server:.*$", "server: " + server);
require(!configured.equals(template), "config must contain a top-level server setting or @SERVER@");
return configured;
}
private static void runMysqlOutage(Running first, Running second, PlayerView a, PlayerView b,
TestPlayers playersA, UUID playerId) throws Exception {
command(first, "lp user " + playerId + " permission set acceptance.network.mysql-cache true");
expect(first, a, "acceptance.network.mysql-cache", PermissionResult.ALLOW);
expect(second, b, "acceptance.network.mysql-cache", PermissionResult.ALLOW);
handshake("REQUEST_STOP_MYSQL", "MYSQL_STOPPED");
expect(first, a, "acceptance.network.mysql-cache", PermissionResult.ALLOW);
expect(second, b, "acceptance.network.mysql-cache", PermissionResult.ALLOW);
UUID freshId = UUID.randomUUID();
PlayerView fresh = new PlayerView(new PlayerIdentity(freshId, 40), "FreshMysqlProbe", "lobby");
playersA.current = fresh;
try {
first.host.permissionService().prepare(fresh).toCompletableFuture().get(20, TimeUnit.SECONDS);
throw new AssertionError("prepare unexpectedly succeeded while MySQL was stopped");
} catch (ExecutionException | CompletionException | TimeoutException expected) {
first.transcript.add("MYSQL_OUTAGE_FRESH_PREPARE_FAILED " + expected.getClass().getSimpleName());
}
require(first.probe.context.permissions().check(fresh.identity(), "acceptance.network.mysql-cache")
== PermissionResult.UNAVAILABLE,
"failed new-user preparation must remain UNAVAILABLE during MySQL outage");
handshake("REQUEST_START_MYSQL", "MYSQL_STARTED");
playersA.current = a;
PlayerView recovered = null;
Throwable lastFailure = null;
for (int attempt = 0; attempt < 6 && recovered == null; attempt++) {
PlayerView candidate = new PlayerView(new PlayerIdentity(freshId, 50 + attempt), "FreshMysqlProbe", "lobby");
try {
first.host.permissionService().prepare(candidate).toCompletableFuture().get(15, TimeUnit.SECONDS);
recovered = candidate;
first.transcript.add("MYSQL_OUTAGE_RECOVERED_PREPARE attempt=" + (attempt + 1));
} catch (ExecutionException | CompletionException | TimeoutException failure) {
lastFailure = failure;
first.transcript.add("MYSQL_OUTAGE_RETRY_FAILED attempt=" + (attempt + 1));
Thread.sleep(1000);
}
}
if (recovered == null) throw new AssertionError("prepare did not recover after MySQL restart", lastFailure);
expect(first, recovered, "acceptance.network.no-such-recovery-node", PermissionResult.UNDEFINED);
command(first, "lp user " + freshId + " permission set acceptance.network.mysql-recovered true");
expect(first, recovered, "acceptance.network.mysql-recovered", PermissionResult.ALLOW);
first.host.permissionService().release(recovered.identity());
}
private static void runRedisOutage(Running first, Running second, PlayerView a, PlayerView b,
UUID playerId, String user) throws Exception {
handshake("REQUEST_STOP_REDIS", "REDIS_STOPPED");
command(first, user + "permission set acceptance.network.redis-during-outage true");
expect(first, a, "acceptance.network.redis-during-outage", PermissionResult.ALLOW);
expect(second, b, "acceptance.network.redis-during-outage", PermissionResult.UNDEFINED);
handshake("REQUEST_START_REDIS", "REDIS_STARTED");
command(first, user + "permission set acceptance.network.redis-after-recovery true");
expect(second, b, "acceptance.network.redis-after-recovery", PermissionResult.ALLOW);
first.transcript.add("REDIS_OUTAGE_DATABASE_WRITE_PLAYER " + playerId);
}
private static void handshake(String request, String acknowledgement) throws Exception {
System.out.println(request);
System.out.flush();
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(120);
while (System.nanoTime() < deadline) {
if (!CONTROL.ready()) {
Thread.sleep(25);
continue;
}
String line = CONTROL.readLine();
if (line == null) throw new IllegalStateException("control stdin closed while awaiting " + acknowledgement);
if (line.equals(acknowledgement)) return;
throw new IllegalStateException("unexpected control acknowledgement");
}
throw new TimeoutException("timed out waiting for " + acknowledgement);
}
private static Running start(Path plugins, String proxyId, Players players,
List<String> transcript) throws Exception {
PluginHost host = new PluginHost(new InMemoryBackendCatalog(), players, Duration.ofSeconds(5));
Probe probe = new Probe();
try {
host.load(List.of(probe));
host.loadPlugins(plugins, Map.of(PROVIDER, Map.of("proxy-id", proxyId)));
host.enable();
return new Running(host, probe, transcript);
} catch (Throwable failure) {
host.close();
throw failure;
}
}
private static void command(Running running, String value) throws Exception {
running.transcript.add("COMMAND " + value);
require(running.probe.context.commands().execute(
CommandSource.console(component -> running.transcript.add(
PlainTextComponentSerializer.plainText().serialize(component))), value)
.toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS), "registered command: " + value);
}
private static void expect(Running running, PlayerView player, String node,
PermissionResult expected) throws Exception {
await(() -> running.probe.context.permissions().check(player.identity(), node) == expected,
node + " = " + expected);
}
private static void await(java.util.function.BooleanSupplier condition, String description) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
while (!condition.getAsBoolean()) {
if (System.nanoTime() > deadline) throw new AssertionError("Timed out waiting for " + description);
Thread.sleep(50);
}
}
private static void require(boolean passed, String description) {
if (!passed) throw new AssertionError(description);
}
private record Running(PluginHost host, Probe probe, List<String> transcript) implements AutoCloseable {
@Override public void close() { host.close(); }
}
private static final class Probe implements Plugin {
PluginContext context;
@Override public void onLoad(PluginContext value) { context = value; }
}
private static final class TestPlayers implements Players {
volatile PlayerView current;
@Override public Optional<PlayerView> find(PlayerIdentity identity) {
PlayerView player = current;
return player != null && player.identity().equals(identity) ? Optional.of(player) : Optional.empty();
}
@Override public List<PlayerView> online() {
PlayerView player = current;
return player == null ? List.of() : List.of(player);
}
@Override public CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
return CompletableFuture.completedFuture(TransferResult.failed("acceptance probe does not transfer"));
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
return CompletableFuture.completedFuture(find(identity).isPresent()
? MessageResult.SENT : MessageResult.NOT_CONNECTED);
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
}
}
+257
View File
@@ -0,0 +1,257 @@
import dev.moonbridge.api.CommandSource;
import dev.moonbridge.api.DisconnectResult;
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.Players;
import dev.moonbridge.api.TransferResult;
import dev.moonbridge.api.permission.PermissionResult;
import dev.moonbridge.core.backend.InMemoryBackendCatalog;
import dev.moonbridge.core.plugin.PluginHost;
import dev.moonbridge.core.protocol.MinecraftText;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Interactive native-LuckPerms Web Editor acceptance against an isolated assembled distribution. */
public final class PermissionWebEditorAcceptance {
private static final String PROVIDER = "dev.moonbridge.luckperms.LuckPermsMoonBridgePlugin";
private static final UUID PLAYER_ID = UUID.fromString("1f20f54a-62d2-4ef5-8e49-2a0d0b6c9174");
private static final String USERNAME = "WebEditorProbe";
private static final String NODE = "acceptance.webeditor";
private static final Pattern URL = Pattern.compile("https?://[^\\s<>\\\"']+");
private static final long DEADLINE_NANOS = TimeUnit.MINUTES.toNanos(10);
private PermissionWebEditorAcceptance() { }
public static void main(String[] args) throws Exception {
if (args.length != 2) {
throw new IllegalArgumentException("Usage: PermissionWebEditorAcceptance <distribution> <evidence parent directory>");
}
Path distribution = Path.of(args[0]).toAbsolutePath();
Path evidenceRoot = Files.createDirectories(Path.of(args[1]).toAbsolutePath());
Path evidence = Files.createTempDirectory(evidenceRoot, "web-editor-");
Path plugins = Files.createDirectories(evidence.resolve("plugins"));
Path data = Files.createDirectories(plugins.resolve("data").resolve(PROVIDER));
try (var files = Files.list(distribution.resolve("plugins"))) {
Path jar = files.filter(file -> file.getFileName().toString().startsWith("luckperms-moonbridge-")
&& file.toString().endsWith(".jar") && !file.toString().endsWith("-engine.jar"))
.findFirst().orElseThrow(() -> new IllegalStateException("Native LuckPerms plugin JAR not found in distribution"));
Files.copy(jar, plugins.resolve(jar.getFileName()));
}
Files.writeString(data.resolve("config.yml"),
"server: acceptance-proxy\nstorage-method: h2\nmessaging-service: none\n");
var transcript = new CopyOnWriteArrayList<String>();
var backgroundFailures = new CopyOnWriteArrayList<Throwable>();
Thread.setDefaultUncaughtExceptionHandler((thread, failure) -> {
backgroundFailures.add(failure);
failure.printStackTrace();
});
var stdin = new ArrayBlockingQueue<String>(8);
var inputEnded = new AtomicBoolean();
Thread.ofPlatform().daemon().name("web-editor-acceptance-stdin").start(() -> {
try (var reader = new BufferedReader(new InputStreamReader(System.in))) {
String line;
while ((line = reader.readLine()) != null) {
if (!stdin.offer(line)) {
stdin.poll();
stdin.offer(line);
}
}
} catch (Throwable failure) {
backgroundFailures.add(failure);
} finally {
inputEnded.set(true);
}
});
TestPlayers players = new TestPlayers(transcript);
PlayerView player = new PlayerView(new PlayerIdentity(PLAYER_ID, 1), USERNAME, "lobby");
long deadline = System.nanoTime() + DEADLINE_NANOS;
boolean applied = false;
String applyMode = "";
writeEvidence(evidence, transcript, "WEB_EDITOR_ACCEPTANCE_WAITING\n");
try (PluginHost host = new PluginHost(new InMemoryBackendCatalog(), players, Duration.ofSeconds(5))) {
Probe probe = start(host, plugins);
players.current = player;
host.permissionService().prepare(player).toCompletableFuture().get(30, TimeUnit.SECONDS);
check(probe, player, PermissionResult.UNDEFINED, "before opening editor");
execute(probe, transcript, "lp user " + PLAYER_ID + " editor");
String url = awaitUrl(transcript, deadline, backgroundFailures);
Files.writeString(evidence.resolve("web-editor.txt"),
"player-uuid=" + PLAYER_ID + "\nusername=" + USERNAME + "\npermission=" + NODE + "\nurl=" + url + "\n");
System.out.println("WEB_EDITOR_URL=" + url);
System.out.println("WEB_EDITOR_PLAYER=" + USERNAME + " (" + PLAYER_ID + ")");
System.out.println("WEB_EDITOR_PERMISSION=" + NODE);
System.out.println("Apply in the browser, then enter `lp applyedits <code>` here if WebEditor does not auto-apply.");
System.out.println("Enter `quit` to stop. Deadline: 10 minutes.");
boolean stdinClosedNotified = false;
while (System.nanoTime() < deadline) {
checkBackground(backgroundFailures);
if (probe.context.permissions().check(player.identity(), NODE) == PermissionResult.ALLOW) {
applied = true;
applyMode = "websocket-auto";
break;
}
String line = stdin.poll(250, TimeUnit.MILLISECONDS);
if (line == null) {
if (inputEnded.get() && !stdinClosedNotified) {
System.out.println("stdin closed; continuing to watch for WebEditor websocket updates until timeout.");
stdinClosedNotified = true;
}
continue;
}
String command = line.trim();
if (command.equalsIgnoreCase("quit")) {
System.out.println("WEB_EDITOR_ACCEPTANCE_STOPPED");
return;
}
if (command.isEmpty()) continue;
if (!command.matches("(?i)^/?lp\\s+applyedits\\s+\\S+\\s*$")) {
System.out.println("Input ignored; expected `lp applyedits <code>` or `quit`.");
continue;
}
execute(probe, transcript, command.startsWith("/") ? command.substring(1) : command);
// A successful command can either apply through the editor socket or explicitly through applyedits.
long applyDeadline = Math.min(deadline, System.nanoTime() + TimeUnit.SECONDS.toNanos(20));
while (System.nanoTime() < applyDeadline
&& probe.context.permissions().check(player.identity(), NODE) != PermissionResult.ALLOW) {
checkBackground(backgroundFailures);
Thread.sleep(100);
}
if (probe.context.permissions().check(player.identity(), NODE) == PermissionResult.ALLOW) {
applied = true;
applyMode = "applyedits-command";
break;
}
System.out.println("The permission is not ALLOW yet; you can enter another applyedits code or wait for WebEditor.");
}
if (!applied) throw new IllegalStateException("Web Editor did not grant " + NODE + " before the 10-minute deadline");
check(probe, player, PermissionResult.ALLOW, "after editor apply");
host.permissionService().release(player.identity());
players.current = null;
}
// A fresh PluginHost classloader and fresh H2 storage connection must read the saved grant.
try (PluginHost restarted = new PluginHost(new InMemoryBackendCatalog(), players, Duration.ofSeconds(5))) {
Probe probe = start(restarted, plugins);
PlayerView returning = new PlayerView(new PlayerIdentity(PLAYER_ID, 2), USERNAME, "lobby");
players.current = returning;
restarted.permissionService().prepare(returning).toCompletableFuture().get(30, TimeUnit.SECONDS);
check(probe, returning, PermissionResult.ALLOW, "after host restart");
restarted.permissionService().release(returning.identity());
players.current = null;
}
Thread.sleep(300);
checkBackground(backgroundFailures);
writeEvidence(evidence, transcript, "WEB_EDITOR_ACCEPTANCE_PASS\nmode=" + applyMode + "\n");
System.out.println("WEB_EDITOR_ACCEPTANCE_PASS mode=" + applyMode + " evidence=" + evidence);
}
private static Probe start(PluginHost host, Path pluginDirectory) throws Exception {
Probe probe = new Probe();
host.load(List.of(probe));
host.loadPlugins(pluginDirectory, Map.of(PROVIDER, Map.of("proxy-id", "acceptance-proxy")));
host.enable();
return probe;
}
private static void execute(Probe probe, List<String> transcript, String command) throws Exception {
boolean handled = probe.context.commands().execute(
CommandSource.console(message -> capture(transcript, message)), command)
.toCompletableFuture().get(30, TimeUnit.SECONDS);
if (!handled) throw new IllegalStateException("LuckPerms did not consume command root: " + root(command));
}
private static String root(String command) {
String trimmed = command.startsWith("/") ? command.substring(1) : command;
int space = trimmed.indexOf(' ');
return space < 0 ? trimmed : trimmed.substring(0, space);
}
private static String awaitUrl(List<String> transcript, long deadline, List<Throwable> backgroundFailures) throws Exception {
while (System.nanoTime() < deadline) {
checkBackground(backgroundFailures);
for (String line : transcript) {
Matcher matcher = URL.matcher(line);
if (matcher.find()) return matcher.group().replaceAll("[),.;]+$", "");
}
Thread.sleep(100);
}
throw new IllegalStateException("LuckPerms editor command did not emit a URL before the deadline");
}
private static void check(Probe probe, PlayerView player, PermissionResult expected, String phase) {
PermissionResult actual = probe.context.permissions().check(player.identity(), NODE);
if (actual != expected) throw new AssertionError(phase + ": expected " + expected + " for " + NODE + ", got " + actual);
}
private static void checkBackground(List<Throwable> failures) {
if (!failures.isEmpty()) throw new AssertionError("background plugin failure: " + failures.getFirst(), failures.getFirst());
}
private static void writeEvidence(Path evidence, List<String> transcript, String result) throws Exception {
Files.write(evidence.resolve("console-output.txt"), transcript);
Files.writeString(evidence.resolve("result.txt"), result);
}
private static void capture(List<String> transcript, Component component) {
MinecraftText.encode(component);
String line = PlainTextComponentSerializer.plainText().serialize(component);
transcript.add(line);
if (URL.matcher(line).find()) System.out.println("WEB_EDITOR_OUTPUT=" + line);
}
private static final class Probe implements Plugin {
private PluginContext context;
@Override public void onLoad(PluginContext loaded) { context = loaded; }
}
private static final class TestPlayers implements Players {
private final List<String> transcript;
private volatile PlayerView current;
private TestPlayers(List<String> transcript) { this.transcript = transcript; }
@Override public Optional<PlayerView> find(PlayerIdentity identity) {
PlayerView player = current;
return player != null && player.identity().equals(identity) ? Optional.of(player) : Optional.empty();
}
@Override public List<PlayerView> online() {
PlayerView player = current;
return player == null ? List.of() : List.of(player);
}
@Override public CompletionStage<TransferResult> transfer(PlayerIdentity identity, String backendName) {
return CompletableFuture.completedFuture(TransferResult.failed("Web Editor acceptance does not transfer players"));
}
@Override public CompletionStage<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
capture(transcript, message);
return CompletableFuture.completedFuture(find(identity).isPresent() ? MessageResult.SENT : MessageResult.NOT_CONNECTED);
}
@Override public CompletionStage<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
}
}
+41
View File
@@ -0,0 +1,41 @@
// Launch the real cached Forge 1.7.10 client without reading launcher account data.
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
const {spawn,spawnSync}=require('node:child_process');
const profile=path.resolve(process.argv[2]||'');
const port=Number(process.argv[3]);
const cache=process.argv[4]||path.join(process.env.APPDATA,'PrismLauncher');
const java=process.argv[5]||'C:/Program Files/Zulu/zulu-8/bin/javaw.exe';
if (!path.basename(profile).startsWith('MoonBridgePermission-') || !fs.existsSync(path.join(profile,'mmc-pack.json')) || !Number.isInteger(port) || port<1 || port>65535)
throw Error('Usage: node smoke/permission-client-direct.cjs <temporary-profile> <proxy-port> [Prism-cache-root] [javaw8]');
const game=path.join(profile,'minecraft'),natives=path.join(profile,'natives');
fs.mkdirSync(game,{recursive:true});fs.mkdirSync(natives,{recursive:true});
const metas=['net.minecraft/1.7.10.json','org.lwjgl/2.9.4-nightly-20150209.json','net.minecraftforge/10.13.4.1614.json'].map(f=>JSON.parse(fs.readFileSync(path.join(cache,'meta',f),'utf8')));
function artifact(name,classifier,expectedSha) {
const [group,id,version,originalClassifier]=name.split(':');
const suffix=classifier||originalClassifier;
const file=path.join(cache,'libraries',group.replaceAll('.','/'),id,version,`${id}-${version}${suffix?'-'+suffix:''}.jar`);
if (!fs.existsSync(file)) throw Error(`Cached library missing: ${name}${classifier?':'+classifier:''}`);
if (expectedSha && crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex')!==expectedSha) throw Error(`Cached library checksum mismatch: ${name}`);
return file;
}
const libraries=new Map();
for(const meta of metas) for(const lib of meta.libraries) {
if(lib.rules){let allowed=false;for(const rule of lib.rules)if(!rule.os||rule.os.name==='windows')allowed=rule.action==='allow';if(!allowed)continue;}
if(lib.natives){
const classifier=lib.natives.windows?.replace('${arch}','64'); if(!classifier)continue;
const file=artifact(lib.name,classifier,lib.downloads?.classifiers?.[classifier]?.sha1);
const result=spawnSync('jar',['-xf',file],{cwd:natives,encoding:'utf8',windowsHide:true});
if(result.status!==0)throw Error(`Could not extract native library ${lib.name}: ${result.stderr}`);
}else libraries.set(lib.name.split(':').slice(0,2).join(':'),lib);
}
for(const [key,lib] of libraries) libraries.set(key,artifact(lib.name,null,lib.downloads?.artifact?.sha1));
libraries.set('minecraft',artifact(metas[0].mainJar.name,null,metas[0].mainJar.downloads.artifact.sha1));
const identity=crypto.createHash('md5').update('OfflinePlayer:PrismSmoke').digest();identity[6]=(identity[6]&15)|48;identity[8]=(identity[8]&63)|128;
const args=['-Xms512m','-Xmx1024m',`-Djava.library.path=${natives}`,'-cp',[...libraries.values()].join(path.delimiter),metas[2].mainClass,
'--tweakClass',metas[2]['+tweakers'][0],'--username','PrismSmoke','--version','1.7.10','--gameDir',game,'--assetsDir',path.join(cache,'assets'),
'--assetIndex',metas[0].assetIndex.id,'--uuid',identity.toString('hex'),'--accessToken','0','--userProperties','{}','--userType','legacy',
'--server','127.0.0.1','--port',String(port),'--width','854','--height','480'];
const out=fs.openSync(path.join(profile,'direct-client.stdout.log'),'w'),err=fs.openSync(path.join(profile,'direct-client.stderr.log'),'w');
const child=spawn(java,args,{cwd:game,detached:true,windowsHide:true,stdio:['ignore',out,err]});
child.on('error',error=>{console.error(error.message);process.exitCode=1;});
child.on('spawn',()=>{fs.closeSync(out);fs.closeSync(err);console.log(JSON.stringify({pid:child.pid,profile,libraryCount:libraries.size,accountDataRead:false}));child.unref();});
+335
View File
@@ -0,0 +1,335 @@
#Requires -Version 7.0
param(
[string]$BundlePath = 'C:\Users\layue\Documents\ChatGPT\tdlm 2\Uranium\rfg\build\rfg-netty-compat-bundle',
[string]$DistributionPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'proxy-core\build\install\moonbridge'),
[string]$Java8Path = 'C:\Program Files\Zulu\zulu-8\bin\java.exe',
[string]$Java8wPath = 'C:\Program Files\Zulu\zulu-8\bin\javaw.exe',
[string]$PrismTemplateInstance = '1.7.10',
[string]$PrismCacheRoot = (Join-Path $env:APPDATA 'PrismLauncher')
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$bundle = (Resolve-Path -LiteralPath $BundlePath).Path
$distribution = (Resolve-Path -LiteralPath $DistributionPath).Path
$template = Join-Path (Join-Path $PrismCacheRoot 'instances') $PrismTemplateInstance
$directHelper = Join-Path $PSScriptRoot 'permission-client-direct.cjs'
$distributionLib = Join-Path $distribution 'lib'
$proxyLib = Join-Path $distribution 'lib\*'
$packagedPlugins = Join-Path $distribution 'plugins'
$java25 = (Get-Command java.exe -ErrorAction Stop).Source
$javac25 = (Get-Command javac.exe -ErrorAction Stop).Source
$jar25 = (Get-Command jar.exe -ErrorAction Stop).Source
$node = (Get-Command node.exe -ErrorAction Stop).Source
foreach ($required in @($Java8Path, $Java8wPath, $template, $PrismCacheRoot, $distributionLib,
$packagedPlugins, $directHelper)) {
if (-not (Test-Path -LiteralPath $required)) { throw "Required client acceptance input not found: $required" }
}
if ((Get-Content -LiteralPath (Join-Path $bundle 'eula.txt') -Raw).Trim() -ne 'eula=true') {
throw 'The supplied Uranium bundle must already contain eula=true'
}
$serverJars = @(Get-ChildItem -LiteralPath $bundle -Filter '*-server.jar')
if ($serverJars.Count -ne 1) { throw "Expected exactly one Uranium server JAR in $bundle" }
$luckPermsJars = @(Get-ChildItem -LiteralPath $packagedPlugins -Filter 'luckperms-moonbridge-*.jar' |
Where-Object { $_.Name -notlike '*-engine.jar' })
if ($luckPermsJars.Count -ne 1) { throw "Expected one packaged LuckPerms MoonBridge plugin in $packagedPlugins" }
$templateCfg = Join-Path $template 'instance.cfg'
$templatePack = Join-Path $template 'mmc-pack.json'
foreach ($required in @($templateCfg, $templatePack)) {
if (-not (Test-Path -LiteralPath $required)) { throw "Prism template metadata missing: $required" }
}
$packJson = Get-Content -LiteralPath $templatePack -Raw | ConvertFrom-Json
$components = @($packJson.components | ForEach-Object { "$($_.uid)@$($_.version)" })
if (-not ($components -contains 'net.minecraft@1.7.10') -or
-not ($components -contains 'net.minecraftforge@10.13.4.1614')) {
throw "Prism template must be Minecraft 1.7.10 with Forge 10.13.4.1614; found $($components -join ', ')"
}
function FreePort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$listener.Start()
try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port }
finally { $listener.Stop() }
}
function WaitForPort([int]$port, [System.Diagnostics.Process]$process, [int]$timeoutSeconds) {
$deadline = [DateTime]::UtcNow.AddSeconds($timeoutSeconds)
while ([DateTime]::UtcNow -lt $deadline) {
if ($process.HasExited) { throw "Process exited before port $port opened: $($process.ExitCode)" }
$client = [System.Net.Sockets.TcpClient]::new()
try { $client.Connect([System.Net.IPAddress]::Loopback, $port); return }
catch [System.Net.Sockets.SocketException] { Start-Sleep -Milliseconds 200 }
finally { $client.Dispose() }
}
throw "Port $port did not open within $timeoutSeconds seconds"
}
function WaitForRealClient([int]$processId, [string]$instanceFolder, [int]$timeoutSeconds) {
$deadline = [DateTime]::UtcNow.AddSeconds($timeoutSeconds)
while ([DateTime]::UtcNow -lt $deadline) {
$pathPart = "\instances\$instanceFolder\"
$process = Get-CimInstance Win32_Process -Filter "ProcessId = '$processId'" -ErrorAction SilentlyContinue
if ($process) {
if ($process.Name -ne 'javaw.exe') { throw "Expected javaw.exe for real client PID $processId, found $($process.Name)" }
if (-not $process.CommandLine -or
$process.CommandLine.Replace('/', '\').IndexOf($pathPart, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
throw "Real client PID $processId does not reference the isolated profile $instanceFolder"
}
return $process
}
Start-Sleep -Milliseconds 500
}
throw "Direct Java 8 client PID $processId was not running within $timeoutSeconds seconds"
}
function SaveClientLogs([string]$profile, [string]$destination) {
$source = Join-Path $profile 'minecraft\logs'
$saved = $false
New-Item -ItemType Directory -Path $destination -Force | Out-Null
if (Test-Path -LiteralPath $source) {
Get-ChildItem -LiteralPath $source -Force | Copy-Item -Destination $destination -Recurse -Force
$saved = $true
}
Get-ChildItem -LiteralPath $profile -File -Filter 'direct-client.*' -ErrorAction SilentlyContinue |
Copy-Item -Destination $destination -Force
if (Test-Path -LiteralPath (Join-Path $destination 'direct-client.stdout.log')) { $saved = $true }
if (Test-Path -LiteralPath (Join-Path $destination 'direct-client.stderr.log')) { $saved = $true }
return $saved
}
$runDir = Join-Path $repoRoot ('build\permission-client-' + [guid]::NewGuid().ToString('N'))
$instancesRoot = Join-Path $runDir 'instances'
$backendDir = Join-Path $runDir 'backend'
$pluginDir = Join-Path $runDir 'proxy-plugins'
$pluginClasses = Join-Path $runDir 'plugin-classes'
$evidenceDir = Join-Path $runDir 'evidence'
$profileName = 'MoonBridgePermission-' + [guid]::NewGuid().ToString('N')
$temporaryProfile = Join-Path $instancesRoot $profileName
$clientPid = $null
$clientLogsCopied = $false
$server = $null
$serverOutput = $null
$serverErrors = $null
$proxy = $null
$helperProcess = $null
try {
New-Item -ItemType Directory -Path $runDir, $backendDir, $pluginDir, $pluginClasses, $evidenceDir `
-Force | Out-Null
Get-ChildItem -LiteralPath $bundle | Copy-Item -Destination $backendDir -Recurse
$serverJar = @(Get-ChildItem -LiteralPath $backendDir -Filter '*-server.jar')
if ($serverJar.Count -ne 1) { throw "Expected one copied Uranium server JAR in $backendDir" }
$bundleHash = (Get-FileHash -LiteralPath $serverJar[0].FullName -Algorithm SHA256).Hash
$serverPort = FreePort
$proxyPort = FreePort
while ($proxyPort -eq $serverPort) { $proxyPort = FreePort }
@"
server-port=$serverPort
server-ip=127.0.0.1
online-mode=false
level-name=world
motd=MoonBridge LuckPerms client acceptance
"@ | Set-Content -LiteralPath (Join-Path $backendDir 'server.properties') -Encoding utf8
$info = [System.Diagnostics.ProcessStartInfo]::new()
$info.FileName = $Java8Path
$info.WorkingDirectory = $backendDir
$info.UseShellExecute = $false
$info.CreateNoWindow = $true
$info.RedirectStandardInput = $true
$info.RedirectStandardOutput = $true
$info.RedirectStandardError = $true
foreach ($argument in @('-Xms512m', '-Xmx1024m', '-jar', $serverJar[0].Name, 'nogui')) {
$info.ArgumentList.Add($argument)
}
$server = [System.Diagnostics.Process]::Start($info)
$serverOutput = $server.StandardOutput.ReadToEndAsync()
$serverErrors = $server.StandardError.ReadToEndAsync()
WaitForPort $serverPort $server 120
$serverLog = Join-Path $backendDir 'logs\latest.log'
$readyDeadline = [DateTime]::UtcNow.AddSeconds(120)
while ([DateTime]::UtcNow -lt $readyDeadline) {
if ($server.HasExited) { throw "Uranium exited during startup: $($server.ExitCode)" }
if ((Test-Path -LiteralPath $serverLog) -and
(Select-String -LiteralPath $serverLog -Pattern 'Done \(' -Quiet)) { break }
Start-Sleep -Milliseconds 250
}
if (-not (Test-Path -LiteralPath $serverLog) -or
-not (Select-String -LiteralPath $serverLog -Pattern 'Done \(' -Quiet)) {
throw 'Uranium did not report ready within 120 seconds'
}
& $javac25 -cp $proxyLib -d $pluginClasses `
(Join-Path $PSScriptRoot 'PermissionClientAcceptancePlugin.java')
if ($LASTEXITCODE -ne 0) { throw 'Could not compile PermissionClientAcceptancePlugin against the installed distribution' }
$serviceDir = Join-Path $pluginClasses 'META-INF\services'
New-Item -ItemType Directory -Path $serviceDir -Force | Out-Null
'dev.moonbridge.smoke.PermissionClientAcceptancePlugin' |
Set-Content -LiteralPath (Join-Path $serviceDir 'dev.moonbridge.api.Plugin') -Encoding utf8
& $jar25 -cf (Join-Path $pluginDir 'permission-client-acceptance.jar') -C $pluginClasses .
if ($LASTEXITCODE -ne 0) { throw 'Could not package the permission client acceptance plugin' }
Copy-Item -LiteralPath $luckPermsJars[0].FullName -Destination $pluginDir
$luckPermsData = Join-Path $pluginDir 'data\dev.moonbridge.luckperms.LuckPermsMoonBridgePlugin'
New-Item -ItemType Directory -Path $luckPermsData -Force | Out-Null
@'
server: acceptance-proxy
storage-method: h2
messaging-service: none
'@ | Set-Content -LiteralPath (Join-Path $luckPermsData 'config.yml') -Encoding utf8
$proxyConfig = Join-Path $runDir 'moonbridge.yml'
$pluginDirYaml = $pluginDir.Replace('\', '/')
$evidenceDirYaml = $evidenceDir.Replace('\', '/')
@"
listen: "127.0.0.1:$proxyPort"
authentication: OFFLINE
initialRouting:
servers: [uranium]
timeoutSeconds: 15
plugins:
directory: "$pluginDirYaml"
enabled:
dev.moonbridge.luckperms.LuckPermsMoonBridgePlugin:
proxy-id: acceptance-proxy
dev.moonbridge.smoke.PermissionClientAcceptancePlugin:
evidenceDir: "$evidenceDirYaml"
backends:
- name: uranium
address: "127.0.0.1:$serverPort"
"@ | Set-Content -LiteralPath $proxyConfig -Encoding utf8
$proxy = Start-Process -FilePath $java25 -ArgumentList @(
'-cp', ('"{0}"' -f $proxyLib), 'dev.moonbridge.app.ProxyMain',
'--config', ('"{0}"' -f $proxyConfig)
) -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $runDir 'proxy.stdout.log') `
-RedirectStandardError (Join-Path $runDir 'proxy.stderr.log')
WaitForPort $proxyPort $proxy 20
New-Item -ItemType Directory -Path $instancesRoot -Force | Out-Null
$templateCfgText = Get-Content -LiteralPath $templateCfg -Raw
$templateCfgText = $templateCfgText -replace '(?m)^name=.*$', "name=$profileName"
New-Item -ItemType Directory -Path $temporaryProfile -Force | Out-Null
Set-Content -LiteralPath (Join-Path $temporaryProfile 'instance.cfg') -Value $templateCfgText -Encoding utf8
Copy-Item -LiteralPath $templatePack -Destination $temporaryProfile
New-Item -ItemType Directory -Path (Join-Path $temporaryProfile 'minecraft') -Force | Out-Null
$profileMods = @(Get-ChildItem (Join-Path $temporaryProfile 'minecraft') -Filter '*.jar' -Recurse -File)
if ($profileMods.Count -ne 0) { throw 'Temporary Prism profile unexpectedly contains client mods' }
$helperStdout = Join-Path $runDir 'direct-helper.stdout.log'
$helperStderr = Join-Path $runDir 'direct-helper.stderr.log'
$helperProcess = Start-Process -FilePath $node -ArgumentList @(
('"{0}"' -f $directHelper), ('"{0}"' -f $temporaryProfile), [string]$proxyPort,
('"{0}"' -f $PrismCacheRoot), ('"{0}"' -f $Java8wPath)
) -WorkingDirectory $repoRoot -WindowStyle Hidden -PassThru `
-RedirectStandardOutput $helperStdout -RedirectStandardError $helperStderr
if (-not $helperProcess.WaitForExit(60000)) {
Stop-Process -Id $helperProcess.Id -Force -ErrorAction SilentlyContinue
throw 'Direct Minecraft launcher helper did not finish within 60 seconds'
}
if ($helperProcess.ExitCode -ne 0) { throw "Direct Minecraft launcher failed with exit code $($helperProcess.ExitCode); inspect $helperStderr" }
$helperResult = Get-Content -LiteralPath $helperStdout -Raw | ConvertFrom-Json
if ([IO.Path]::GetFullPath([string]$helperResult.profile) -ne [IO.Path]::GetFullPath($temporaryProfile)) {
throw 'Direct Minecraft launcher returned a profile other than this run''s isolated profile'
}
if ($helperResult.accountDataRead -ne $false) { throw 'Direct Minecraft launcher must not read account data' }
if ([int]$helperResult.libraryCount -le 0) { throw 'Direct Minecraft launcher resolved no cached libraries' }
$clientPid = [int]$helperResult.pid
$null = WaitForRealClient $clientPid $profileName 30
$resultLog = Join-Path $evidenceDir 'result.log'
$deadline = [DateTime]::UtcNow.AddSeconds(360)
while ([DateTime]::UtcNow -lt $deadline) {
if ($proxy.HasExited) { throw "MoonBridge exited during client acceptance: $($proxy.ExitCode)" }
if ($server.HasExited) { throw "Uranium exited during client acceptance: $($server.ExitCode)" }
if (-not (Get-Process -Id $clientPid -ErrorAction SilentlyContinue)) {
throw 'Real Forge Minecraft client exited before permission acceptance completed'
}
if (Test-Path -LiteralPath $resultLog) {
if (Select-String -LiteralPath $resultLog -Pattern '^PERMISSION_CLIENT_ACCEPTANCE_FAIL ' -Quiet) {
throw "Permission client plugin reported failure; inspect $resultLog"
}
if (Select-String -LiteralPath $resultLog -Pattern '^PERMISSION_CLIENT_ACCEPTANCE_PASS ' -Quiet) { break }
}
Start-Sleep -Milliseconds 500
}
if (-not (Test-Path -LiteralPath $resultLog) -or
-not (Select-String -LiteralPath $resultLog -Pattern '^PERMISSION_CLIENT_ACCEPTANCE_PASS ' -Quiet)) {
throw "Permission client acceptance did not finish within 360 seconds; inspect $resultLog"
}
if (-not (Select-String -LiteralPath $serverLog -Pattern 'PrismSmoke.*logged in' -Quiet)) {
throw 'Uranium did not record the real Prism client login'
}
$forgeServerLog = Join-Path $backendDir 'logs\fml-server-latest.log'
if (-not (Test-Path -LiteralPath $forgeServerLog) -or
-not (Select-String -LiteralPath $forgeServerLog -Pattern 'Server side modded connection established' -Quiet)) {
throw 'Uranium did not record completion of the Forge modded handshake'
}
if (Select-String -LiteralPath $serverLog -Pattern 'PrismSmoke lost connection' -Quiet) {
throw 'Uranium recorded a PrismSmoke disconnect during acceptance'
}
$clientLogCopy = Join-Path $runDir 'client-logs'
$clientLogsCopied = SaveClientLogs $temporaryProfile $clientLogCopy
if (-not $clientLogsCopied) { throw 'Temporary Forge profile has no Minecraft client logs' }
$clientLog = Join-Path $clientLogCopy 'latest.log'
$fmlLog = Join-Path $clientLogCopy 'fml-client-latest.log'
$parserPattern = 'JsonParseException|Unable to parse|Failed to parse.{0,40}(chat|component)|ChatComponent.{0,40}(Exception|error)'
foreach ($log in @($clientLog, $fmlLog)) {
if ((Test-Path -LiteralPath $log) -and (Select-String -LiteralPath $log -Pattern $parserPattern -Quiet)) {
throw "Client log contains a chat/component parsing error: $log"
}
}
if (-not (Select-String -LiteralPath $clientLog -Pattern '\[CHAT\].*PERMISSION_CLIENT_COMMAND_PASS' -Quiet)) {
throw 'Client log did not record the permission-gated command marker'
}
if (-not (Select-String -LiteralPath $clientLog -Pattern '\[CHAT\].*PERMISSION_CLIENT_RICH_TEXT_PASS' -Quiet)) {
throw 'Client log did not record the rich-text marker'
}
if (Select-String -LiteralPath $clientLog -Pattern 'luckperms\.command\.' -Quiet) {
throw 'Client log contains an untranslated LuckPerms command key'
}
$null = WaitForRealClient $clientPid $profileName 5
Write-Output "REAL_FORGE_LUCKPERMS_CLIENT_PASS instance=$profileName proxyPort=$proxyPort serverPort=$serverPort"
Write-Output "Uranium server JAR SHA-256: $bundleHash"
} finally {
if ($clientPid) {
$clientProcess = Get-CimInstance Win32_Process -Filter "ProcessId = '$clientPid'" -ErrorAction SilentlyContinue
if ($clientProcess -and $clientProcess.Name -eq 'javaw.exe' -and $clientProcess.CommandLine -and
$clientProcess.CommandLine.Replace('/', '\').IndexOf("\instances\$profileName\", [StringComparison]::OrdinalIgnoreCase) -ge 0) {
Stop-Process -Id $clientPid -Force -ErrorAction SilentlyContinue
Wait-Process -Id $clientPid -ErrorAction SilentlyContinue
}
}
if ($proxy -and -not $proxy.HasExited) {
Stop-Process -Id $proxy.Id -Force
Wait-Process -Id $proxy.Id -ErrorAction SilentlyContinue
}
if ($server -and -not $server.HasExited) {
$server.StandardInput.WriteLine('stop')
$server.StandardInput.Flush()
if (-not $server.WaitForExit(30000)) {
$server.Kill()
$server.WaitForExit()
}
}
if ($serverOutput) {
$serverOutput.GetAwaiter().GetResult() | Set-Content -LiteralPath (Join-Path $runDir 'server.stdout.log')
$serverErrors.GetAwaiter().GetResult() | Set-Content -LiteralPath (Join-Path $runDir 'server.stderr.log')
}
if (-not $clientLogsCopied -and $temporaryProfile -and (Test-Path -LiteralPath $temporaryProfile)) {
$clientLogsCopied = SaveClientLogs $temporaryProfile (Join-Path $runDir 'client-logs')
}
if ($temporaryProfile -and (Test-Path -LiteralPath $temporaryProfile)) {
if (-not (Test-Path -LiteralPath $instancesRoot -PathType Container)) {
throw "Refusing to remove temporary profile because this run's instances root is missing: $instancesRoot"
}
$resolvedRoot = [IO.Path]::GetFullPath($instancesRoot).TrimEnd('\') + '\'
$resolvedTarget = [IO.Path]::GetFullPath($temporaryProfile)
if (-not $resolvedTarget.StartsWith($resolvedRoot, [StringComparison]::OrdinalIgnoreCase) -or
(Split-Path -Leaf $resolvedTarget) -notlike 'MoonBridgePermission-*') {
throw "Refusing to remove a Prism path outside this smoke's temporary profile: $resolvedTarget"
}
Remove-Item -LiteralPath $resolvedTarget -Recurse -Force
}
Write-Output "Runtime evidence: $runDir"
}
+39
View File
@@ -0,0 +1,39 @@
// Run after permission-services.cjs start and compiling PermissionNetworkAcceptance.
const fs = require('node:fs');
const path = require('node:path');
const {spawn,spawnSync} = require('node:child_process');
const root = path.resolve(__dirname,'..');
const mode = process.argv[2];
if (!['sql','redis'].includes(mode)) throw new Error('Usage: node smoke/permission-network.cjs sql|redis [outage]');
const outage = process.argv[3] === 'outage';
const log = fs.createWriteStream(path.join(root,'build',`permission-network-${mode}${outage?'-outage':''}.log`));
const args = ['-cp',`${path.join(root,'proxy-core/build/install/moonbridge/lib/*')}${path.delimiter}${path.join(root,'build/permission-network-classes')}`,
'PermissionNetworkAcceptance','proxy-core/build/install/moonbridge',`build/permission-services/${mode==='sql'?'mysql':'redis'}-base.yml`,'build/permission-network',mode];
if (outage) args.push('outage');
const child = spawn('java',args,{cwd:root,windowsHide:true,stdio:['pipe','pipe','pipe']});
let pending = '';
child.stderr.on('data',chunk=>log.write(chunk));
child.stdout.on('data',chunk=>{
log.write(chunk); pending+=chunk.toString();
let end;
while ((end=pending.indexOf('\n'))>=0) {
const line=pending.slice(0,end).trim(); pending=pending.slice(end+1);
const request=/^REQUEST_(STOP|START)_(MYSQL|REDIS)$/.exec(line);
if (request) {
const [,verb,kind]=request;
console.log(line);
const action=`${verb==='STOP'?'pause':'resume'}-${kind.toLowerCase()}`;
const result=spawnSync(process.execPath,[path.join(__dirname,'permission-services.cjs'),action],{encoding:'utf8',windowsHide:true});
if (result.status!==0) { console.error(result.stderr); child.kill(); process.exitCode=1; return; }
if (verb==='START' && kind==='REDIS') {
const subscribers=spawnSync(process.execPath,[path.join(__dirname,'permission-services.cjs'),'wait-redis-subscribers'],{encoding:'utf8',windowsHide:true});
if (subscribers.status!==0) { console.error(subscribers.stderr);child.kill();process.exitCode=1;return; }
console.log(subscribers.stdout.trim());
}
child.stdin.write(`${kind}_${verb==='STOP'?'STOPPED':'STARTED'}\n`);
} else if (line.includes('ACCEPTANCE_PASS')) console.log(line);
}
});
const deadline=setTimeout(()=>{console.error('Network acceptance exceeded 10 minutes');child.kill();process.exitCode=1;},600000);
child.on('error',error=>{console.error(error.message);process.exitCode=1;});
child.on('close',(code,signal)=>{clearTimeout(deadline);log.end();console.log(`Network acceptance exit=${code} signal=${signal || 'none'}`);process.exitCode=process.exitCode||(code===0?0:1);});
+117
View File
@@ -0,0 +1,117 @@
// Isolated, loopback-only services for PermissionNetworkAcceptance. Secrets stay in ignored build/.
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const net = require('node:net');
const {spawnSync} = require('node:child_process');
const root = path.resolve(__dirname, '..');
const dir = path.join(root, 'build', 'permission-services');
const stateFile = path.join(dir, 'state.json');
const label = 'moonbridge.permission-acceptance';
let createdThisRun = false;
function docker(args) {
const r = spawnSync('docker', args, {encoding:'utf8', windowsHide:true});
if (r.status !== 0) throw new Error(`docker ${args[0]} failed: ${r.stderr}`);
return r.stdout.trim();
}
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
async function freePort() {
const server = net.createServer();
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
const port = server.address().port;
await new Promise(resolve=>server.close(resolve));
return port;
}
function readState() { return JSON.parse(fs.readFileSync(stateFile, 'utf8')); }
function owned(id, run) {
if (docker(['inspect','--format',`{{index .Config.Labels "${label}"}}`,id]) !== run)
throw new Error('Container ownership mismatch');
}
function clearTestSecrets() {
for (const file of ['mysql.env','redis.conf']) {
const target=path.join(dir,file); if(fs.existsSync(target)) fs.unlinkSync(target);
}
const redact=file=>{if(fs.existsSync(file))fs.writeFileSync(file,fs.readFileSync(file,'utf8').replace(/^(\s*password:).*$/gm,'$1 "<removed after acceptance>"'));};
for (const file of ['mysql-base.yml','redis-base.yml']) redact(path.join(dir,file));
const evidence=path.join(root,'build','permission-network');
if (fs.existsSync(evidence)) for (const entry of fs.readdirSync(evidence,{withFileTypes:true})) {
if (!entry.isDirectory() || !/^permission-network-(sql|redis)-\d+$/.test(entry.name)) continue;
for (const instance of ['instance-a','instance-b'])
redact(path.join(evidence,entry.name,instance,'plugins','data','dev.moonbridge.luckperms.LuckPermsMoonBridgePlugin','config.yml'));
}
}
function cleanup(state) {
for (const kind of ['redis','mysql']) if (state[kind]) {owned(state[kind],state.run);docker(['rm','-f','-v',state[kind]]);}
clearTestSecrets();
fs.renameSync(stateFile,path.join(dir,`completed-${state.run}.json`));
}
async function ready(id, kind) {
const command = kind === 'mysql'
? 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -u"$MYSQL_USER" "$MYSQL_DATABASE" -N -e "SELECT 1"'
: 'REDISCLI_AUTH=$(sed -n "s/^requirepass //p" /usr/local/etc/redis/redis.conf) redis-cli ping';
for (let i=0; i<90; i++) {
const r = spawnSync('docker',['exec',id,'sh','-c',command],{encoding:'utf8',windowsHide:true});
if (r.status===0 && /^(1|PONG)$/.test(r.stdout.trim())) return;
await delay(1000);
}
throw new Error(`${kind} readiness timeout`);
}
async function main() {
const action = process.argv[2];
fs.mkdirSync(dir,{recursive:true});
if (action === 'start') {
if (fs.existsSync(stateFile)) throw new Error('Existing fixture state; clean it before starting another run');
const run = crypto.randomBytes(6).toString('hex');
const mysqlPassword = crypto.randomBytes(24).toString('hex');
const redisPassword = crypto.randomBytes(24).toString('hex');
const env = path.join(dir,'mysql.env');
const redisConfig = path.join(dir,'redis.conf');
fs.writeFileSync(env,`MYSQL_ROOT_PASSWORD=${crypto.randomBytes(24).toString('hex')}\nMYSQL_DATABASE=moonbridge_permissions\nMYSQL_USER=moonbridge\nMYSQL_PASSWORD=${mysqlPassword}\n`);
fs.writeFileSync(redisConfig,`bind 0.0.0.0\nprotected-mode yes\nrequirepass ${redisPassword}\nappendonly no\n`);
const state = {run};
const save = () => fs.writeFileSync(stateFile,JSON.stringify(state,null,2));
save(); createdThisRun = true;
// Bind an explicit free port: Docker can change an ephemeral mapping across stop/start.
state.mysqlPort = await freePort();
state.mysql = docker(['run','-d','--name',`moonbridge-permissions-mysql-${run}`,'--label',`${label}=${run}`,'-p',`127.0.0.1:${state.mysqlPort}:3306`,'--env-file',env,'mysql:8.4']); save();
state.redisPort = await freePort();
state.redis = docker(['run','-d','--name',`moonbridge-permissions-redis-${run}`,'--label',`${label}=${run}`,'-p',`127.0.0.1:${state.redisPort}:6379`,'--mount',`type=bind,source=${redisConfig},target=/usr/local/etc/redis/redis.conf,readonly`,'redis:7.4','redis-server','/usr/local/etc/redis/redis.conf']); save();
await Promise.all([ready(state.mysql,'mysql'),ready(state.redis,'redis')]);
state.mysqlPort = Number(docker(['port',state.mysql,'3306/tcp']).split(':').pop());
state.redisPort = Number(docker(['port',state.redis,'6379/tcp']).split(':').pop());
state.mysqlImage = docker(['image','inspect','mysql:8.4','--format','{{index .RepoDigests 0}}']);
state.redisImage = docker(['image','inspect','redis:7.4','--format','{{index .RepoDigests 0}}']);
for (const mode of ['mysql','redis']) {
const config = `server: "@SERVER@"\nstorage-method: mysql\ndata:\n address: "127.0.0.1:${state.mysqlPort}"\n database: moonbridge_permissions\n username: moonbridge\n password: "${mysqlPassword}"\n pool-settings:\n maximum-pool-size: 4\n minimum-idle: 1\n connection-timeout: 5000\n properties:\n useSSL: false\n allowPublicKeyRetrieval: true\n table-prefix: "${mode}_lp_"\nmessaging-service: ${mode === 'mysql' ? 'auto' : 'redis'}\nsync-minutes: -1\nredis:\n enabled: ${mode === 'redis'}\n address: "127.0.0.1:${state.redisPort}"\n password: "${redisPassword}"\n`;
fs.writeFileSync(path.join(dir,`${mode}-base.yml`),config);
}
save();
console.log(JSON.stringify({ready:true,mysqlPort:state.mysqlPort,redisPort:state.redisPort,mysqlImage:state.mysqlImage,redisImage:state.redisImage}));
} else if (action === 'stop') {
const state = readState();
cleanup(state);
console.log('Fixture containers removed; test credentials removed from retained configs');
} else if (action === 'query') {
const state = readState(); owned(state.mysql,state.run);
const sql = "SELECT VERSION(); SHOW TABLES; SELECT COUNT(*) FROM mysql_lp_user_permissions; SELECT COUNT(*) FROM redis_lp_user_permissions; SELECT uuid,permission,value FROM mysql_lp_user_permissions WHERE permission='acceptance.network.mysql-recovered'; SELECT uuid,permission,value FROM redis_lp_user_permissions WHERE permission IN ('acceptance.network.redis-during-outage','acceptance.network.redis-after-recovery');";
console.log(docker(['exec',state.mysql,'sh','-c',`MYSQL_PWD="$MYSQL_PASSWORD" mysql -u"$MYSQL_USER" "$MYSQL_DATABASE" -N -e "${sql}"`]));
console.log(docker(['exec',state.redis,'redis-server','--version']));
} else if (action === 'wait-redis-subscribers') {
const state = readState(); owned(state.redis,state.run);
for (let attempt=0; attempt<30; attempt++) {
const output=docker(['exec',state.redis,'sh','-c','REDISCLI_AUTH=$(sed -n "s/^requirepass //p" /usr/local/etc/redis/redis.conf) redis-cli --raw PUBSUB NUMSUB luckperms:update']);
if (Number(output.split(/\r?\n/).at(-1))===2) {console.log('Both LuckPerms Redis subscriptions restored');return;}
await delay(1000);
}
throw new Error('The two LuckPerms Redis subscriptions did not reconnect within 30 seconds');
} else if (['pause-mysql','resume-mysql','pause-redis','resume-redis'].includes(action)) {
const state = readState(); const [verb,kind] = action.split('-'); owned(state[kind],state.run);
docker([verb === 'pause' ? 'stop' : 'start',state[kind]]);
if (verb === 'resume') await ready(state[kind],kind);
console.log(`${action} completed`);
} else throw new Error('Usage: node smoke/permission-services.cjs start|stop|query|pause-mysql|resume-mysql|pause-redis|resume-redis|wait-redis-subscribers');
}
main().catch(error => {
console.error(error.message);process.exitCode=1;
if (createdThisRun) try {cleanup(readState());} catch(cleanupError) {console.error(`Fixture cleanup failed: ${cleanupError.message}`);}
});
+86 -1
View File
@@ -18,4 +18,89 @@ PERMISSION_ACCEPTANCE_PASS
## 证据边界
玩家探针使用正式 API 的连接身份和快照,没有在这次验收中启动真实 Minecraft 客户端。H2 持久化经过验证;共享 MySQL/PostgreSQL、Redis 等同步方式使用上游实现,但未进行外部服务联调。Web Editor 的网络提交没有进行实测。构建和本地验收不代表 Maven 已发布;远端结果以 PR/CI 为准。
完整 `gradlew.bat check :proxy-core:distZip` 已通过:283 个 JUnit 测试,0 失败、0 错误、0 跳过,并运行实际分发包的 H2 验收。实现提交 `7f1980269a041514cf903262c7ebeb5abd2b39f6` 的 [CI 10086](https://git.nest.potatolab.uk:8443/layue13/MoonBridge/actions/runs/10086/jobs/26091) 成功,用时 7 分 44 秒。
翻译修复后的全项目检查与分发包构建再次通过(40 秒,未改动的测试任务复用结果)。最终 H2 证据为 `proxy-core/build/permission-acceptance/run-3128309359661808602/`。本机验收插件 JAR 为 3,814,168 字节,SHA-256:`6ed65c58a3f013725aec08d79c5ba0d70e8eb70c044d8e5a4634802594c47aef`。
## MySQL 与 Redis 外部服务验收
使用 Docker 中隔离的 MySQL 8.4.11、Redis 7.4.11,仅绑定本机回环地址,随机测试凭据保存在忽略的 `build/` 中。没有连接生产数据库。镜像固定证据:
- MySQL:`mysql@sha256:0744ee5ef89ce6ccfa13de3e579fe6b9e27f93dd70da9c06d2c908b1b193fb8d`
- Redis:`redis@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f`
`PermissionNetworkAcceptance.java` 在同一 JVM 内启动两个独立 PluginHost、插件 JAR 副本、类加载器和数据目录,连接真正的 MySQL/Redis。两个实例先加载相同 UUID,然后由 A 修改权限,等待 B 已加载的缓存更新;没有调用手工同步命令。每次使用独立 UUID 和组名。
| 场景 | 结果 |
| --- | --- |
| MySQL + `messaging-service: auto` 选择 SQL messenger | PASS |
| MySQL + Redis messenger | PASS |
| 跨实例授权、显式拒绝、撤销、组继承与个人覆盖 | 两种 messenger 均 PASS |
| 不同 `server` 上下文、临时节点到期 | PASS |
| 关闭插件后以新类加载器重新读取 MySQL | PASS |
| MySQL 停机:已有缓存仍可查,新 UUID 准备失败且结果为 UNAVAILABLE | PASS |
| MySQL 恢复:新连接重新加载并写入权限 | PASS |
| Redis 停机:A 写入 MySQL,B 缓存没有即时收到该修改 | PASS |
| Redis 两个订阅重新建立后:新变更同步到 B;重启 B 后仍可读取停机期间及恢复后的两项权限 | PASS |
最终带故障注入的证据目录为:
- `build/permission-network/permission-network-sql-4436507219108060839/`
- `build/permission-network/permission-network-redis-13789818503751059472/`
- 主进程日志:`build/permission-network-sql-outage.log`、`build/permission-network-redis-outage.log`
- 数据库版本、表、行数及故障期间/恢复后写入的具体权限行:`build/permission-services/database-evidence.txt`
最终两轮在插件关闭后等待 500ms,并确认没有未捕获后台异常。预期停机期间的连接失败日志不等同于未捕获异常。测试结束已删除本轮容器和临时卷,移除凭据文件,并对保留的测试配置中的密码进行脱敏。
故障测试暴露并修正了测试工具的两个前提:Docker 随机映射端口可能在 stop/start 后改变,现改为显式绑定一个空闲端口;Redis 可响应 PING 不代表 LuckPerms 已恢复订阅,现以 `PUBSUB NUMSUB luckperms:update` 返回两个订阅者作为恢复后的发消息前置条件。Redis pub/sub 不补发断线窗口的通知,不能据此宣称所有实例在故障期间保持一致。
PowerShell 复现(先完成 `:proxy-core:installDist`,需要 JDK 25、Node.js、Docker):
```powershell
javac -cp 'proxy-core/build/install/moonbridge/lib/*' -d build/permission-network-classes smoke/PermissionNetworkAcceptance.java
node smoke/permission-services.cjs start
try {
node smoke/permission-network.cjs sql outage
if ($LASTEXITCODE -ne 0) { throw 'SQL acceptance failed' }
node smoke/permission-network.cjs redis outage
if ($LASTEXITCODE -ne 0) { throw 'Redis acceptance failed' }
node smoke/permission-services.cjs query
} finally {
node smoke/permission-services.cjs stop
}
```
测试配置中的无 TLS 连接及 `allowPublicKeyRetrieval` 仅用于隔离的回环数据库,不是部署示例。通用部署配置见 [权限文档](../../docs/permissions.md)。
## Web Editor 完整往返
实际加载分发包的 LuckPerms JAR,用隔离的 H2 测试玩家生成官方 Web Editor 页面,在浏览器中增加 `acceptance.webeditor=true` 并保存,执行页面返回的 `lp applyedits`。断言 MoonBridge 通用权限 API 从 UNDEFINED 变为 ALLOW,并在关闭、重新加载插件后仍为 ALLOW。
结果:`WEB_EDITOR_ACCEPTANCE_PASS mode=applyedits-command`。翻译修复后重新验收的证据目录为 `build/permission-webeditor/web-editor-7249685645443168252/`;浏览器保存成功截图为 `build/permission-webeditor/editor-saved.png`。本次验证显式 applyedits 流程,没有验证信任浏览器后的 WebSocket 自动应用。
手动交互复现:
```powershell
javac -cp 'proxy-core/build/install/moonbridge/lib/*' -d build/permission-network-classes smoke/PermissionWebEditorAcceptance.java
java -cp 'proxy-core/build/install/moonbridge/lib/*;build/permission-network-classes' PermissionWebEditorAcceptance proxy-core/build/install/moonbridge build/permission-webeditor
```
打开输出的编辑器 URL,为 `WebEditorProbe` 添加 `acceptance.webeditor=true`;保存后把 `lp applyedits <code>` 输入该进程。验收有 10 分钟期限。
## 真实 Forge 1.7.10 客户端
`pwsh -NoProfile -File smoke/permission-client.ps1` 返回 `REAL_FORGE_LUCKPERMS_CLIENT_PASS`,证据目录为 `build/permission-client-d521ac97ca82434699d97f77e9d95023/`。
客户端为实际 Minecraft 1.7.10 / Forge 10.13.4.1614 / Java 8 游戏进程。脚本从本机 Prism 已缓存的版本元数据解析并校验可用校验值的依赖,合并 Forge 的依赖覆盖,用独立游戏目录直接启动;不读取账号文件,不加载原实例的模组或世界。代理与 Uranium 也均为本轮独立进程及数据目录,仅监听本机。Uranium JAR SHA-256 为 `B16747D08BAD4B7C67DB8F1F41C9C31066FEAAAC22B527775F604BDFA885BAD2`。
- 服务端 `fml-server-latest.log` 与客户端 `fml-client-latest.log` 都记录了 modded connection established,服务端记录 PrismSmoke 登录。
- 以实际在线连接的 PlayerIdentity 验证 UNDEFINED、ALLOW、DENY;受限业务命令的执行计数为 0 → 1 → 1。
- 作为该玩家调用 `/lp info`、权限列表,共收到 19 个带样式的组件;通过正式 `Players.sendMessage` 发往客户端。
- 客户端 `[CHAT]` 日志包含 `PERMISSION_CLIENT_COMMAND_PASS`、`PERMISSION_CLIENT_RICH_TEXT_PASS`;没有聊天组件解析错误或残留 `luckperms.command.*` 翻译键。
- 相同连接、相同后端保持 10 秒,验收期间没有断线;随后脚本清理本轮进程及临时游戏目录,保留日志。
这次客户端验收发现并修复了适配器发送 LuckPerms 组件前未调用 `TranslationManager.render` 的问题;翻译必须在 LuckPerms 私有 Adventure 类加载器内完成,再通过 JSON 转入宿主。`PermissionAcceptance` 新增命令文案断言。修复后重新运行原生 H2 验收、MySQL/Redis 故障恢复、Web Editor 和客户端测试,本文上方的目录指向修复后的结果。
测试命令通过代理 API 使用真实在线玩家来源发起,并非键盘输入。验证了真实客户端接收、解析聊天和连接存活;没有宣称人工视觉检查、鼠标点击或悬停交互已验收。脚本遵守 LuckPerms 原生 500ms 玩家命令限流,在两条管理命令之间等待 600ms,没有关闭生产限流。
PostgreSQL、MariaDB、Redis Cluster/Sentinel 及 TLS 部署没有在本次测试矩阵中。构建和本地验收不代表 Maven 已发布;远端结果以 PR/CI 为准。