diff --git a/.gitignore b/.gitignore index 1b71ca1..3094f55 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ bin/ # OS-spezifisch .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# LuckPerms (runtime-generated: config, H2 database, relocated libs) +data/ \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 2b1ea84..091dfdc 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -10,6 +10,9 @@ dependencies { compileOnly(libs.minestom) compileOnly(libs.aves) compileOnly(libs.xerus) + compileOnly(libs.luckperms.api) { + exclude(group = "net.kyori.adventure") + } testImplementation(libs.minestom) testImplementation(libs.cyano) diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/ServiceBootstrap.java b/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/ServiceBootstrap.java new file mode 100644 index 0000000..a774732 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/ServiceBootstrap.java @@ -0,0 +1,78 @@ +package net.onelitefeather.cygnus.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; + +/** + * 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. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.6.7 + **/ +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 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); + } + + /** + * Registers the {@link 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("cygnus-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 + * {@link 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; + commandManager.execute(commandManager.getConsoleSender(), line); + } + } catch (IOException e) { + LOGGER.error("Failed to read command from stdin", e); + } + } +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/StopCommand.java b/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/StopCommand.java new file mode 100644 index 0000000..bd80f76 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/bootstrap/StopCommand.java @@ -0,0 +1,47 @@ +package net.onelitefeather.cygnus.common.bootstrap; + +import net.luckperms.api.LuckPermsProvider; +import net.luckperms.api.model.user.User; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.builder.Command; +import net.minestom.server.entity.Player; + +/** + * 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. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.6.7 + **/ +public final class StopCommand extends Command { + + private static final String PERMISSION = "cygnus.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 player) || hasStopPermission(player)); + setDefaultExecutor((sender, context) -> Thread.ofPlatform().name("cygnus-shutdown").start(() -> { + MinecraftServer.stopCleanly(); + System.exit(0); + })); + } + + /** + * Checks whether the given player is allowed to run this command via LuckPerms. + *

+ * Assumes LuckPerms has already been bootstrapped (see {@code MinestomLoader}), which is + * guaranteed by the time a player can connect and send commands. + * + * @param player the player to check + * @return {@code true} if the player holds {@value #PERMISSION}, {@code false} otherwise + * (including when LuckPerms has no cached data for the player yet) + */ + private static boolean hasStopPermission(Player player) { + User user = LuckPermsProvider.get().getUserManager().getUser(player.getUuid()); + return user != null && user.getCachedData().getPermissionData().checkPermission(PERMISSION).asBoolean(); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/bootstrap/ServiceBootstrapTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/bootstrap/ServiceBootstrapTest.java new file mode 100644 index 0000000..c530a36 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/bootstrap/ServiceBootstrapTest.java @@ -0,0 +1,40 @@ +package net.onelitefeather.cygnus.common.bootstrap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ServiceBootstrapTest { + + @AfterEach + void clearSystemProperties() { + System.clearProperty("service.bind.host"); + System.clearProperty("service.bind.port"); + } + + @Test + @DisabledIfSystemProperty(named = "service.bind.host", matches = ".+") + void testDefaultBindHost() { + assertEquals("localhost", ServiceBootstrap.resolveBindHost()); + } + + @Test + @DisabledIfSystemProperty(named = "service.bind.port", matches = ".+") + void testDefaultBindPort() { + assertEquals(25565, ServiceBootstrap.resolveBindPort()); + } + + @Test + void testBindHostFromSystemProperty() { + System.setProperty("service.bind.host", "0.0.0.0"); + assertEquals("0.0.0.0", ServiceBootstrap.resolveBindHost()); + } + + @Test + void testBindPortFromSystemProperty() { + System.setProperty("service.bind.port", "30000"); + assertEquals(30000, ServiceBootstrap.resolveBindPort()); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/bootstrap/StopCommandTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/bootstrap/StopCommandTest.java new file mode 100644 index 0000000..3abbe9e --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/bootstrap/StopCommandTest.java @@ -0,0 +1,29 @@ +package net.onelitefeather.cygnus.common.bootstrap; + +import net.minestom.server.command.CommandSender; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MicrotusExtension.class) +class StopCommandTest { + + @Test + void testCommandName() { + StopCommand command = new StopCommand(); + assertEquals("stop", command.getName()); + } + + @Test + void testConsoleSenderIsAlwaysAllowed(@NotNull Env env) { + StopCommand command = new StopCommand(); + CommandSender consoleSender = env.process().command().getConsoleSender(); + + assertTrue(command.getCondition().canUse(consoleSender, "stop")); + } +} diff --git a/game/build.gradle.kts b/game/build.gradle.kts index 3fcaa86..30ab3cd 100644 --- a/game/build.gradle.kts +++ b/game/build.gradle.kts @@ -21,6 +21,18 @@ dependencies { implementation(platform(libs.cloudnet.bom)) implementation(libs.bundles.cloudnet) + //LuckPerms + implementation(libs.guava) + compileOnly(libs.luckperms.api) { + exclude(group = "net.kyori.adventure") + } + compileOnly(libs.luckperms.minestom.loader) { + exclude(group = "net.kyori.adventure") + } + runtimeOnly(libs.luckperms.minestom.loader) { + exclude(group = "net.kyori.adventure") + } + testImplementation(libs.minestom) testImplementation(libs.adventure) testImplementation(libs.cyano) @@ -32,6 +44,10 @@ dependencies { testRuntimeOnly(libs.junit.engine) } +configurations.testRuntimeClasspath { + exclude(group = "net.luckperms", module = "minestom-loader") +} + tasks { jar { archiveClassifier.set("unshaded") diff --git a/game/src/main/java/net/onelitefeather/cygnus/CygnusLoader.java b/game/src/main/java/net/onelitefeather/cygnus/CygnusLoader.java index 09ed857..d41f20c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/CygnusLoader.java +++ b/game/src/main/java/net/onelitefeather/cygnus/CygnusLoader.java @@ -3,13 +3,16 @@ import dev.derklaro.aerogel.Injector; import eu.cloudnetservice.driver.inject.InjectionLayer; import eu.cloudnetservice.modules.bridge.impl.platform.minestom.MinestomBridgeExtension; +import me.lucko.luckperms.minestom.loader.MinestomLoader; import net.minestom.server.MinecraftServer; +import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; import net.onelitefeather.cygnus.common.dimension.DimensionFactory; public final class CygnusLoader { static void main() { MinecraftServer server = MinecraftServer.init(); + MinestomLoader.get().load().registerShutdownHook().start(); String customDimensions = System.getProperty("cygnus.customDimension", "false"); if (Boolean.parseBoolean(customDimensions)) { DimensionFactory.registerAll(); @@ -18,6 +21,7 @@ static void main() { try (InjectionLayer layer = InjectionLayer.ext()) { layer.instance(MinestomBridgeExtension.class).onLoad(); } - server.start("0.0.0.0", 25565); + ServiceBootstrap.installShutdownHandling(); + server.start(ServiceBootstrap.resolveBindHost(), ServiceBootstrap.resolveBindPort()); } } diff --git a/settings.gradle.kts b/settings.gradle.kts index f899c56..59b7ce2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,9 +29,15 @@ dependencyResolutionManagement { version("cyclonedx", "3.3.0") version("pica", "0.1.1") version("slf4j", "2.0.18") + version("luckperms", "5.5") + version("luckperms-minestom-loader", "5.6-SNAPSHOT") + version("guava", "33.6.0-jre") library("aonyx.bom", "net.onelitefeather", "aonyx-bom").versionRef("aonyx") library("slf4j.api", "org.slf4j", "slf4j-api").versionRef("slf4j") + library("guava", "com.google.guava", "guava").versionRef("guava") + library("luckperms.api", "net.luckperms", "api").versionRef("luckperms") + library("luckperms.minestom.loader", "net.luckperms", "minestom-loader").versionRef("luckperms-minestom-loader") library("minestom", "net.minestom", "minestom").withoutVersion() library("adventure", "net.kyori", "adventure-text-minimessage").withoutVersion() diff --git a/setup/build.gradle.kts b/setup/build.gradle.kts index 98543d8..b2a6649 100644 --- a/setup/build.gradle.kts +++ b/setup/build.gradle.kts @@ -20,6 +20,18 @@ dependencies { implementation(libs.pica) implementation(libs.guira) + //LuckPerms + implementation(libs.guava) + compileOnly(libs.luckperms.api) { + exclude(group = "net.kyori.adventure") + } + compileOnly(libs.luckperms.minestom.loader) { + exclude(group = "net.kyori.adventure") + } + runtimeOnly(libs.luckperms.minestom.loader) { + exclude(group = "net.kyori.adventure") + } + testImplementation(libs.minestom) testImplementation(libs.cyano) testImplementation(libs.xerus) @@ -29,6 +41,10 @@ dependencies { testImplementation(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.engine) } + +configurations.testRuntimeClasspath { + exclude(group = "net.luckperms", module = "minestom-loader") +} tasks { jar { archiveClassifier.set("unshaded") diff --git a/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupLoader.java b/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupLoader.java index 98c6955..a606681 100644 --- a/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupLoader.java +++ b/setup/src/main/java/net/onelitefeather/cygnus/setup/SetupLoader.java @@ -1,15 +1,19 @@ package net.onelitefeather.cygnus.setup; +import me.lucko.luckperms.minestom.loader.MinestomLoader; import net.minestom.server.MinecraftServer; +import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; import net.onelitefeather.cygnus.setup.player.SetupPlayerProvider; public class SetupLoader { static void main() { MinecraftServer minecraftServer = MinecraftServer.init(); + MinestomLoader.get().load().registerShutdownHook().start(); new SetupExtension(); MinecraftServer.getConnectionManager().setPlayerProvider(new SetupPlayerProvider()); - minecraftServer.start("localhost", 25565); + ServiceBootstrap.installShutdownHandling(); + minecraftServer.start(ServiceBootstrap.resolveBindHost(), ServiceBootstrap.resolveBindPort()); } private SetupLoader() {