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