mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-25 18:46:18 +00:00
Merge branch 'feature/permissions-luckperms' of https://git.nest.potatolab.uk:8443/layue13/MoonBridge into codex/backend-session-binding
This commit is contained in:
commit
80a753abc7
11 files changed
+1498
-2
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);});
|
||||
@@ -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}`);}
|
||||
});
|
||||
@@ -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 为准。
|
||||
Reference in new issue
Block a user