Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
build/
.kotlin/

# CloudNet extension jars dropped in for local testing (see bridge/README.md)
extensions/

# LuckPerms standalone runtime data (generated on boot when running locally)
data/

# IntelliJ IDEA
.idea/
*.iws
Expand Down
30 changes: 30 additions & 0 deletions bridge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Bounce Bridge

Minestom extension that teaches CloudNet's bridge module how Bounce resolves permissions —
it registers a `MinestomPermissionChecker` backed by Adventure's `PermissionChecker.POINTER`,
the same pointer `PermissionAwarePlayer` installs and LuckPerms answers through.

## Why this exists

CloudNet's bridge ships a default permission checker that only inspects
`player.getPermissionLevel()`, which is always `0` on a LuckPerms-managed server. Without this
extension, CloudNet's maintenance-mode bypass and any task-level `requiredPermission` check
reject every player — staff included.

## Deployment

This module is never bundled into the game or setup fat jars. Build it and drop the resulting
jar into the CloudNet service's `extensions/` folder, next to the `CloudNet_Bridge` extension
it depends on (declared via `dependencies = "CloudNet_Bridge"` on
`BounceBridgePermissionExtension`, so it will refuse to load before the bridge is present):

```
./gradlew :bridge:build
cp bridge/build/libs/bridge-*.jar <cloudnet-service>/extensions/
```

If this jar is missing from a service's `extensions/` folder, the server boots and runs
normally — CloudNet's bridge silently falls back to its `getPermissionLevel()`-based checker,
reproducing the exact maintenance-mode/permission-gate bug this extension exists to fix. There
is currently no log line anywhere that flags a missing extension; treat this file as the
authoritative reminder until that gap is closed.
30 changes: 30 additions & 0 deletions bridge/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
plugins {
id("bounce.java-conventions")
}

// Minestom extension that bridges CloudNet permission checks to LuckPerms. It is packaged as a
// standalone extension jar (dropped into a CloudNet service's extensions/ folder next to the
// CloudNet bridge) and never bundled into a fat jar. Everything it compiles against is provided at
// runtime: the CloudNet driver by the CloudNet wrapper, the bridge by the CloudNet_Bridge
// extension, Minestom and Adventure by the application classloader.
dependencies {
compileOnly(platform(libs.aonyx.bom))
compileOnly(libs.minestom)
compileOnly(libs.adventure)
compileOnly(platform(libs.minestom.extensions.bom))
compileOnly(libs.minestom.extensions)
compileOnly(libs.minestom.extensions.processor)
annotationProcessor(platform(libs.minestom.extensions.bom))
annotationProcessor(libs.minestom.extensions.processor)

compileOnly(platform(libs.cloudnet.bom))
compileOnly(libs.cloudnet.driver.api)
compileOnly(libs.cloudnet.bridge)
compileOnly(libs.cloudnet.bridge.impl)
}

// The annotation processor generates extension.json but cannot know the project version. Subprojects
// do not inherit the root version, so read it from the root project explicitly.
tasks.compileJava {
options.compilerArgs.add("-Aminestom.extension.version=${rootProject.version}")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package net.theevilreaper.bounce.bridge;

import eu.cloudnetservice.driver.registry.ServiceRegistry;
import eu.cloudnetservice.modules.bridge.impl.platform.minestom.MinestomPermissionChecker;
import net.kyori.adventure.permission.PermissionChecker;
import net.kyori.adventure.util.TriState;
import net.minestom.server.extensions.Extension;
import net.onelitefeather.minestom.extensions.processor.ExtensionInfo;

/**
* Minestom extension that teaches the CloudNet bridge how Bounce resolves permissions.
* <p>
* The bridge ships a default checker that only inspects {@code player.getPermissionLevel()}, which
* is always {@code 0} on a LuckPerms-managed server — maintenance bypass and task-level
* {@code requiredPermission} checks would therefore reject every player, staff included. This
* extension registers a checker that reads Adventure's {@link PermissionChecker#POINTER} instead,
* the same pointer LuckPerms and the {@code /stop} command read, and marks it the registry default.
* <p>
* {@link MinestomPermissionChecker} only exists inside the CloudNet bridge's extension classloader,
* so this glue cannot live in the application. The {@code CloudNet_Bridge} dependency declared below
* makes this extension load after the bridge and share its classloader hierarchy. Minestom and
* Adventure come from the application classloader above, so the pointer read here is the very one
* the player carries.
*/
@ExtensionInfo(
name = "BounceCloudNetPermissions",
authors = "theEvilReaper",
dependencies = "CloudNet_Bridge"
)
public final class BounceBridgePermissionExtension extends Extension {

@Override
public void initialize() {
MinestomPermissionChecker checker = (player, permission) ->
player.getOrDefault(PermissionChecker.POINTER, PermissionChecker.always(TriState.FALSE))
.test(permission);
ServiceRegistry.registry()
.registerProvider(MinestomPermissionChecker.class, "bounce-luckperms", checker)
.markAsDefaultService();
}

@Override
public void terminate() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@NotNullByDefault
package net.theevilreaper.bounce.bridge;

import org.jetbrains.annotations.NotNullByDefault;
20 changes: 18 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,28 @@ description = "Bounce"
dependencies {
implementation(project(":common"))
implementation(platform(libs.aonyx.bom))
implementation(platform(libs.cloudnet.bom))
implementation(platform(libs.falco.bom))
implementation(project(":block"))
implementation(libs.adventure)
implementation(libs.falco.anvil)
implementation(libs.pvp)
implementation(libs.minestom)
implementation(libs.aves)
implementation(libs.bundles.cloudnet)
implementation(libs.slf4j.api)
// SLF4J needs a binding at runtime; without one it falls back to NOP and the
// server logs nothing at all.
runtimeOnly(libs.slf4j.simple)
implementation(libs.xerus)

implementation(platform(libs.minestom.extensions.bom))
implementation(libs.minestom.extensions)

// Guava used to arrive transitively through CloudNet; bundle it explicitly now that CloudNet
// is no longer a direct dependency of this module (it loads as an extension at runtime instead).
implementation(libs.guava)
compileOnly(libs.luckperms.api)
runtimeOnly(libs.luckperms.minestom.loader)

testImplementation(libs.minestom)
testImplementation(libs.aves)
testImplementation(libs.cyano)
Expand All @@ -33,6 +43,12 @@ dependencies {
testRuntimeOnly(libs.junit.engine)
}

// Keeps the loader off the test class path, which is what makes LuckPermsSupport report absent and
// every permission check answer TRUE during tests.
configurations.testRuntimeClasspath {
exclude(group = "net.luckperms", module = "minestom-loader")
}

application {
mainClass.set("net.theevilreaper.bounce.BounceServer")
}
Expand Down
6 changes: 6 additions & 0 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ dependencies {
compileOnly(libs.minestom)
compileOnly(libs.aves)
compileOnly(libs.xerus)
compileOnly(libs.luckperms.api)
// Only to compile LuckPermsSupport.bootstrap(). The artifact is shipped by the game and setup
// modules as runtimeOnly - common must not put it on any runtime class path, because its
// absence is exactly what LuckPermsSupport detects.
compileOnly(libs.luckperms.minestom.loader)

testImplementation(libs.minestom)
testImplementation(libs.luckperms.api)
testImplementation(libs.aves)
testImplementation(libs.cyano)
testImplementation(libs.junit.api)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package net.theevilreaper.bounce.common.bootstrap;

import net.minestom.server.MinecraftServer;
import net.minestom.server.command.CommandManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;

/**
* Wires the parts a CloudNet-managed service process needs: reading the bind address CloudNet
* assigns per-service, and reacting to CloudNet's stdin-based stop signal instead of being killed
* after a timeout.
*/
public final class ServiceBootstrap {

private static final Logger LOGGER = LoggerFactory.getLogger(ServiceBootstrap.class);
private static final String DEFAULT_BIND_HOST = "localhost";
private static final int DEFAULT_BIND_PORT = 25565;
private static final String DEFAULT_WORKING_DIR = "";

private ServiceBootstrap() {
}

/**
* Resolves the host to bind the server to.
*
* @return the value of the {@code service.bind.host} system property CloudNet assigns per
* service, or {@value #DEFAULT_BIND_HOST} for standalone (non-CloudNet) runs
*/
public static String resolveBindHost() {
return System.getProperty("service.bind.host", DEFAULT_BIND_HOST);
}

/**
* Resolves the port to bind the server to.
*
* @return the value of the {@code service.bind.port} system property CloudNet assigns per
* service, or {@value #DEFAULT_BIND_PORT} for standalone (non-CloudNet) runs
*/
public static int resolveBindPort() {
return Integer.getInteger("service.bind.port", DEFAULT_BIND_PORT);
}

/**
* Resolves the working directory root used to locate config, map, and other data files.
*
* @return the value of the {@code service.working.dir} system property CloudNet assigns per
* service, or the JVM's current working directory for standalone (non-CloudNet) runs
*/
public static Path resolveWorkingDirectory() {
return Paths.get(System.getProperty("service.working.dir", DEFAULT_WORKING_DIR));
}

/**
* Registers the {@code StopCommand} and starts a daemon thread reading commands from stdin,
* so CloudNet can stop the service cleanly instead of killing it after a timeout. Should be
* called once during startup, before the server starts accepting connections.
*/
public static void installShutdownHandling() {
MinecraftServer.getCommandManager().register(new StopCommand());
Thread.ofPlatform().name("bounce-console-input").daemon().start(ServiceBootstrap::listenForConsoleInput);
}

/**
* Reads lines from {@link System#in} until the stream closes and executes each one as a
* console command. This is what lets CloudNet's stdin-based {@code stop} signal reach the
* {@code StopCommand}.
*/
private static void listenForConsoleInput() {
CommandManager commandManager = MinecraftServer.getCommandManager();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) continue;
String command = line;
MinecraftServer.getSchedulerManager().scheduleNextTick(() -> commandManager.execute(commandManager.getConsoleSender(), command));
}
} catch (IOException e) {
LOGGER.error("Failed to read command from stdin", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package net.theevilreaper.bounce.common.bootstrap;

import net.kyori.adventure.permission.PermissionChecker;
import net.kyori.adventure.util.TriState;
import net.minestom.server.MinecraftServer;
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.entity.Player;
import net.theevilreaper.bounce.common.permission.LuckPermsSupport;

/**
* Shuts the service down cleanly. Reserved for non-player senders (the console/CloudNet) and
* players holding {@value #PERMISSION}, since a service should not be stoppable by regular players.
* <p>
* Unlike every other permission check in this codebase, this command does NOT fall back to
* granting access when LuckPerms is absent from the class path — a joining player being able to
* stop a live service is a materially larger risk than the general "grant everything locally"
* fallback other permissions rely on for local/test runs. In that mode, only non-player senders
* (console, CloudNet's stdin stop signal) can run this command.
*/
public final class StopCommand extends Command {

private static final String PERMISSION = "bounce.command.stop";

/**
* Creates a new instance of the {@link StopCommand} and wires its condition and executor.
*/
public StopCommand() {
super("stop");
setCondition((sender, commandString) -> !(sender instanceof Player) || (LuckPermsSupport.isPresent() && hasStopPermission(sender)));
setDefaultExecutor((sender, context) -> Thread.ofPlatform().name("bounce-shutdown").start(() -> {
MinecraftServer.stopCleanly();
System.exit(0);
}));
}

/**
* Checks whether the given sender is allowed to run this command.
* <p>
* Reads Adventure's {@link PermissionChecker#POINTER}, which the player implementation backs
* with LuckPerms (see {@code PermissionAwarePlayer}). A sender without that pointer is denied.
*
* @param sender the sender to check
* @return {@code true} if the sender holds {@value #PERMISSION}, {@code false} otherwise
*/
private static boolean hasStopPermission(CommandSender sender) {
return sender.getOrDefault(PermissionChecker.POINTER, PermissionChecker.always(TriState.FALSE))
.test(PERMISSION);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@NotNullByDefault
package net.theevilreaper.bounce.common.bootstrap;

import org.jetbrains.annotations.NotNullByDefault;
Loading
Loading