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
96 changes: 96 additions & 0 deletions bridge/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import org.apache.tools.ant.filters.ReplaceTokens

plugins {
id("cygnus.java-conventions")
`maven-publish`
}

// 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(libs.minestom.ce.extensions)

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

// Stamp the version into extension.json (@version@ placeholder). Subprojects do not inherit the
// root version, so read it from the root project - the same source the publications use.
tasks.processResources {
val tokens = mapOf("version" to rootProject.version.toString())
inputs.properties(tokens)
filesMatching("extension.json") {
filter<ReplaceTokens>("tokens" to tokens)
}
}

publishing {
repositories {
maven {
authentication {
credentials(PasswordCredentials::class) {
// Those credentials need to be set under "Settings -> Secrets -> Actions" in your repository
username = System.getenv("ONELITEFEATHER_MAVEN_USERNAME")
password = System.getenv("ONELITEFEATHER_MAVEN_PASSWORD")
}
}
name = "OneLiteFeatherRepository"
url = if (rootProject.version.toString().contains("SNAPSHOT")) {
uri("https://repo.onelitefeather.dev/onelitefeather-snapshots")
} else {
uri("https://repo.onelitefeather.dev/onelitefeather-releases")
}
}
}
publications {
create<MavenPublication>("maven") {
artifact(project.tasks.getByName("jar"))
version = rootProject.version as String
artifactId = "cygnus-bridge"
groupId = rootProject.group as String
pom {
description.set("CloudNet bridge extension that resolves permissions through LuckPerms")
name = "Cygnus Bridge Component"
url = "https://github.com/OneLiteFeatherNET/Cygnus"
licenses {
license {
name = "AGPL-3.0 License"
url = "https://www.gnu.org/licenses/agpl-3.0.en.html"
}
}
developers {
developer {
name.set("OneliteFeather")
contributors {
contributor {
name.set("theEvilReaper")
}
contributor {
name.set("TheMeinerLP")
}
}
}
}

issueManagement {
system.set("Github")
url.set("https://github.com/OneLiteFeatherNET/Cygnus/issues")
}

scm {
connection = "scm:git:git://github.com:OneLiteFeatherNET/Cygnus.git"
developerConnection = "scm:git:ssh://git@github.com:OneLiteFeatherNET/Cygnus.git"
url = "https://github.com/OneLiteFeatherNET/Cygnus"
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package net.onelitefeather.cygnus.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;

/**
* Minestom extension that teaches the CloudNet bridge how Cygnus 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 our {@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. Declaring a dependency on the {@code CloudNet_Bridge}
* extension (see {@code extension.json}) 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.
*
* @author TheMeinerLP
* @version 1.0.0
* @since 2.6.7
**/
public final class CygnusBridgePermissionExtension 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, "cygnus-luckperms", checker)
.markAsDefaultService();
}

@Override
public void terminate() {
}
}
7 changes: 7 additions & 0 deletions bridge/src/main/resources/extension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "CygnusCloudNetPermissions",
"version": "@version@",
"entrypoint": "net.onelitefeather.cygnus.bridge.CygnusBridgePermissionExtension",
"authors": ["OneLiteFeather"],
"dependencies": ["CloudNet_Bridge"]
}
3 changes: 3 additions & 0 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ dependencies {
}

testImplementation(libs.minestom)
testImplementation(libs.luckperms.api) {
exclude(group = "net.kyori.adventure")
}
testImplementation(libs.cyano)
testImplementation(libs.aves)
testImplementation(libs.xerus)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package net.onelitefeather.cygnus.common.bootstrap;

import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.model.user.User;
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;

Expand All @@ -11,7 +12,7 @@
* players holding {@value #PERMISSION}, since a service should not be stoppable by regular players.
*
* @author TheMeinerLP
* @version 1.0.0
* @version 1.1.0
* @since 2.6.7
**/
public final class StopCommand extends Command {
Expand All @@ -23,25 +24,24 @@ public final class StopCommand extends Command {
*/
public StopCommand() {
super("stop");
setCondition((sender, commandString) -> !(sender instanceof Player player) || hasStopPermission(player));
setCondition((sender, commandString) -> !(sender instanceof Player) || hasStopPermission(sender));
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.
* Checks whether the given sender is allowed to run this command.
* <p>
* Assumes LuckPerms has already been bootstrapped (see {@code MinestomLoader}), which is
* guaranteed by the time a player can connect and send commands.
* Reads Adventure's {@link PermissionChecker#POINTER}, which our player implementation backs
* with LuckPerms (see {@code PermissionAwarePlayer}). A sender without that pointer is denied.
*
* @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)
* @param sender the sender to check
* @return {@code true} if the sender holds {@value #PERMISSION}, {@code false} otherwise
*/
private static boolean hasStopPermission(Player player) {
User user = LuckPermsProvider.get().getUserManager().getUser(player.getUuid());
return user != null && user.getCachedData().getPermissionData().checkPermission(PERMISSION).asBoolean();
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,32 @@
package net.onelitefeather.cygnus.common.permission;

import net.kyori.adventure.util.TriState;
import net.luckperms.api.util.Tristate;

/**
* Converts between LuckPerms' and Adventure's tri-state types, which model the same three values
* under two unrelated types.
*
* @author TheMeinerLP
* @version 1.0.0
* @since 2.6.7
**/
public final class TriStates {

private TriStates() {
}

/**
* Converts a LuckPerms tri-state into its Adventure counterpart.
*
* @param tristate the LuckPerms value to convert
* @return the matching Adventure value, where {@code UNDEFINED} maps to {@code NOT_SET}
*/
public static TriState fromLuckPerms(Tristate tristate) {
return switch (tristate) {
case TRUE -> TriState.TRUE;
case FALSE -> TriState.FALSE;
case UNDEFINED -> TriState.NOT_SET;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@
*
* <p><b>Remove this class</b> once the server runs a Minestom build that contains PR #3308 — as of
* {@code 2026.07.22-26.2} the newest published build predates the merge. Upstream itself intends to
* revert the workaround for 26.3, where the client bug is fixed.</p>
* revert the workaround for 26.3, where the client bug is fixed. When that happens, let the
* subclasses extend {@link PermissionAwarePlayer} directly — the permission pointer it installs is
* unrelated to this workaround and must survive its removal.</p>
*
* @author TheMeinerLP
* @version 1.0.0
* @since 2.6.7
*/
public abstract class InstanceSwitchChunkPlayer extends Player {
public abstract class InstanceSwitchChunkPlayer extends PermissionAwarePlayer {

private volatile @Nullable TargetView targetView;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package net.onelitefeather.cygnus.common.player;

import net.kyori.adventure.permission.PermissionChecker;
import net.kyori.adventure.pointer.Pointers;
import net.kyori.adventure.util.TriState;
import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.model.user.User;
import net.luckperms.api.query.QueryOptions;
import net.minestom.server.entity.Player;
import net.minestom.server.network.player.GameProfile;
import net.minestom.server.network.player.PlayerConnection;
import net.onelitefeather.cygnus.common.permission.TriStates;
import org.jetbrains.annotations.NotNull;

/**
* A {@link Player} that answers permission questions through LuckPerms.
* <p>
* Minestom has no permission system of its own — it only carries Adventure's
* {@link PermissionChecker#POINTER}, and everything that asks about permissions reads it from
* there: LuckPerms' own command sender factory, our {@code /stop} command, and the CloudNet bridge
* extension. Neither Minestom nor LuckPerms ever <em>installs</em> that pointer, though; the server
* implementation has to supply it. Without it every permission check silently resolves to
* {@code false}, which would lock staff out of CloudNet maintenance mode just like everyone else.
* <p>
* The pointer is dynamic, so no LuckPerms class is touched until a permission is actually queried.
*
* @author TheMeinerLP
* @version 1.0.0
* @since 2.6.7
**/
public abstract class PermissionAwarePlayer extends Player implements PermissionChecker {

private final @NotNull Pointers pointers = PermissionAwarePlayer.super.pointers()
.toBuilder()
.withDynamic(PermissionChecker.POINTER, () -> this)
.build();

/**
* {@inheritDoc}
*/
protected PermissionAwarePlayer(PlayerConnection playerConnection, GameProfile gameProfile) {
super(playerConnection, gameProfile);
}

/**
* {@inheritDoc}
*/
@Override
public Pointers pointers() {
return this.pointers;
}

/**
* Resolves a permission for this player through LuckPerms, honouring the contexts LuckPerms
* has calculated for them.
*
* @param permission the permission node to check
* @return the value LuckPerms holds for the node, or {@link TriState#FALSE} when LuckPerms has
* no user data for this player
*/
@Override
public @NotNull TriState value(@NotNull String permission) {
User user = LuckPermsProvider.get().getUserManager().getUser(getUuid());
if (user == null) {
return TriState.FALSE;
}
QueryOptions queryOptions = LuckPermsProvider.get().getContextManager().getQueryOptions(this);
return TriStates.fromLuckPerms(user.getCachedData().getPermissionData(queryOptions).checkPermission(permission));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package net.onelitefeather.cygnus.common.permission;

import net.kyori.adventure.util.TriState;
import net.luckperms.api.util.Tristate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

class TriStatesTest {

@Test
void testConvertTrue() {
assertEquals(TriState.TRUE, TriStates.fromLuckPerms(Tristate.TRUE));
}

@Test
void testConvertFalse() {
assertEquals(TriState.FALSE, TriStates.fromLuckPerms(Tristate.FALSE));
}

@Test
void testConvertUndefined() {
assertEquals(TriState.NOT_SET, TriStates.fromLuckPerms(Tristate.UNDEFINED));
}

@ParameterizedTest
@EnumSource(Tristate.class)
void testEveryValueIsMapped(Tristate tristate) {
assertNotNull(TriStates.fromLuckPerms(tristate));
}
}
Loading
Loading