diff --git a/README.md b/README.md index f20be0c..bd82614 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Listenern, Befehlen und eigenem Config-Abschnitt. Braucht LuckPerms auf Velocity | `chat` | öffentlicher Chat, `/msg`, `/r`, `/adminchat`, Join/Leave, Chat-Einstellungen | `mute` | | `tablist` | Spieler anderer Server in der Tabliste | `chat` | | `signup` | erster Server beim Join: Login-Server oder CraftAttack, je nach Anmeldung auf der Website | – | +| `commandwhitelist` | nur erlaubte Befehle sichtbar und ausführbar, Liste in `plugins/proxy/commandwhitelist/commandWhitelist.yml`, `/commandwhitelist reload` | – | ## Aufbau diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/commandwhitelist/CommandFilterListener.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/commandwhitelist/CommandFilterListener.java new file mode 100644 index 0000000..dc92929 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/commandwhitelist/CommandFilterListener.java @@ -0,0 +1,95 @@ +package eu.mhsl.craftattack.proxy.appliances.commandwhitelist; + +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.command.CommandExecuteEvent; +import com.velocitypowered.api.event.command.CommandExecuteEvent.CommandResult; +import com.velocitypowered.api.event.command.CommandExecuteEvent.SignedState; +import com.velocitypowered.api.event.command.PlayerAvailableCommandsEvent; +import com.velocitypowered.api.network.ProtocolVersion; +import com.velocitypowered.api.proxy.Player; +import org.slf4j.Logger; + +import java.util.List; +import java.util.Locale; +import java.util.function.Supplier; + +final class CommandFilterListener { + + private final Supplier config; + private final Logger logger; + + CommandFilterListener(Supplier config, Logger logger) { + this.config = config; + this.logger = logger; + } + + // Highest priority: runs before every other listener, so no other proxy plugin even sees blocked commands. + @Subscribe(priority = Short.MAX_VALUE, async = false) + public void onCommandExecute(CommandExecuteEvent event) { + if (!(event.getCommandSource() instanceof Player player)) { + return; + } + + WhitelistConfig config = this.config.get(); + if (player.hasPermission(config.bypassPermission())) { + return; + } + + String root = rootCommand(event.getCommand()); + if (config.isAllowed(root)) { + return; + } + + event.setResult(this.blockResult(player, event, root)); + } + + // Lowest priority: runs after every other listener, so commands other plugins inject into the tree are removed too. + @Subscribe(priority = Short.MIN_VALUE, async = false) + public void onAvailableCommands(PlayerAvailableCommandsEvent event) { + RootCommandNode root = event.getRootNode(); + WhitelistConfig config = this.config.get(); + + root.removeChildByName(CommandWhitelist.SWALLOW_COMMAND); + + if (event.getPlayer().hasPermission(config.bypassPermission())) { + return; + } + + List blocked = root.getChildren().stream() + .map(CommandNode::getName) + .filter(name -> !config.isAllowed(name.toLowerCase(Locale.ROOT))) + .toList(); + blocked.forEach(root::removeChildByName); + } + + /** + * Picks a result that drops the command silently. Velocity disconnects the player when a command packet carrying + * chat signatures is denied, because the signed message chain would break. + */ + private CommandResult blockResult(Player player, CommandExecuteEvent event, String root) { + // 1.19.1/1.19.2 clients sign every command packet (salt != 0), and Velocity kicks on denied() for them. + // A command that was handled by the proxy is dropped silently instead, so we hand it to our no-op command. + if (player.getProtocolVersion() == ProtocolVersion.MINECRAFT_1_19_1) { + return CommandResult.command(CommandWhitelist.SWALLOW_COMMAND); + } + + if (event.getInvocationInfo().signedState() == SignedState.SIGNED_WITH_ARGS) { + // The filtered tree never lets a client sign a blocked command. This only happens with an outdated tree + // (config reload, bypass permission removed) or a manipulated client. Blocked anyway; Velocity kicks. + this.logger.warn("{} sent signed arguments for blocked command /{}. Blocking it; Velocity will " + + "disconnect them. If this follows a reload, their command tree updates on the next server switch.", + player.getUsername(), root); + } + + return CommandResult.denied(); + } + + /** First token of the command line, without slash, lowercased. "/Msg Foo hi" -> "msg". */ + static String rootCommand(String commandLine) { + String command = commandLine.startsWith("/") ? commandLine.substring(1) : commandLine; + int space = command.indexOf(' '); + return (space == -1 ? command : command.substring(0, space)).toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/commandwhitelist/CommandWhitelist.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/commandwhitelist/CommandWhitelist.java new file mode 100644 index 0000000..6b0d4cc --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/commandwhitelist/CommandWhitelist.java @@ -0,0 +1,81 @@ +package eu.mhsl.craftattack.proxy.appliances.commandwhitelist; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.velocitypowered.api.command.BrigadierCommand; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import eu.mhsl.craftattack.proxy.core.appliance.Appliance; +import eu.mhsl.craftattack.proxy.core.appliance.ApplianceCommand; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.util.List; + +public final class CommandWhitelist extends Appliance { + + static final String ADMIN_PERMISSION = "commandwhitelist.admin"; + + /** + * Internal no-op command. Blocked commands of 1.19.1/1.19.2 clients are redirected here, see + * {@link CommandFilterListener}. It is always removed from the command tree, so nobody can see it. + */ + static final String SWALLOW_COMMAND = "commandwhitelist:swallow"; + + private volatile WhitelistConfig config = WhitelistConfig.EMPTY; + + @Override + public String name() { + return "commandwhitelist"; + } + + @Override + protected void onEnable() { + try { + this.config = WhitelistConfig.load(this.dataDirectory()); + this.logger().info("Loaded {} allowed commands.", this.config.allowedCommands().size()); + } catch (Exception e) { + this.logger().error("Could not load commandWhitelist.yml, blocking ALL commands for non-bypass players.", e); + } + } + + @Override + protected List listeners() { + return List.of(new CommandFilterListener(() -> this.config, this.logger())); + } + + @Override + protected List commands() { + LiteralCommandNode admin = BrigadierCommand.literalArgumentBuilder("commandwhitelist") + .requires(source -> source.hasPermission(ADMIN_PERMISSION)) + .then(BrigadierCommand.literalArgumentBuilder("reload") + .executes(context -> { + this.reload(context.getSource()); + return Command.SINGLE_SUCCESS; + })) + .build(); + + SimpleCommand swallow = invocation -> { }; + + return List.of( + ApplianceCommand.of("commandwhitelist", new BrigadierCommand(admin), "cwl"), + ApplianceCommand.of(SWALLOW_COMMAND, swallow)); + } + + private void reload(CommandSource source) { + try { + this.config = WhitelistConfig.load(this.dataDirectory()); + } catch (Exception e) { + this.logger().error("Could not reload commandWhitelist.yml, keeping the previous configuration.", e); + source.sendMessage(Component.text( + "Reload failed, the previous configuration is still active. See the console for details.", + NamedTextColor.RED)); + return; + } + + source.sendMessage(Component.text( + "CommandWhitelist reloaded: " + this.config.allowedCommands().size() + " allowed commands. " + + "Tab completion updates on each player's next server switch.", + NamedTextColor.GREEN)); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/commandwhitelist/WhitelistConfig.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/commandwhitelist/WhitelistConfig.java new file mode 100644 index 0000000..9f30065 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/commandwhitelist/WhitelistConfig.java @@ -0,0 +1,64 @@ +package eu.mhsl.craftattack.proxy.appliances.commandwhitelist; + +import org.spongepowered.configurate.CommentedConfigurationNode; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Immutable snapshot of commandWhitelist.yml. A reload swaps the whole instance, so readers never see a half-loaded state. + */ +record WhitelistConfig(Set allowedCommands, String bypassPermission) { + + static final String DEFAULT_BYPASS_PERMISSION = "commandwhitelist.bypass"; + private static final String FILE_NAME = "commandWhitelist.yml"; + + /** Used if the config can't be read on startup: block everything for non-bypass players (fail closed). */ + static final WhitelistConfig EMPTY = new WhitelistConfig(Set.of(), DEFAULT_BYPASS_PERMISSION); + + boolean isAllowed(String rootCommand) { + return this.allowedCommands.contains(rootCommand); + } + + static WhitelistConfig load(Path dataDirectory) throws IOException { + Path file = dataDirectory.resolve(FILE_NAME); + if (Files.notExists(file)) { + Files.createDirectories(dataDirectory); + try (InputStream defaults = WhitelistConfig.class.getResourceAsStream("/" + FILE_NAME)) { + Files.copy(Objects.requireNonNull(defaults, "default config missing in jar"), file); + } + } + + CommentedConfigurationNode root = YamlConfigurationLoader.builder().path(file).build().load(); + + Set allowed = root.node("allowed-commands").getList(String.class, List.of()).stream() + .filter(Objects::nonNull) + .map(WhitelistConfig::normalize) + .filter(command -> !command.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + + String bypass = root.node("bypass-permission").getString(DEFAULT_BYPASS_PERMISSION).trim(); + if (bypass.isEmpty()) { + bypass = DEFAULT_BYPASS_PERMISSION; + } + + return new WhitelistConfig(allowed, bypass); + } + + /** Lowercases and strips an accidental leading slash, e.g. "/MSG" -> "msg". */ + static String normalize(String command) { + String trimmed = command.trim(); + if (trimmed.startsWith("/")) { + trimmed = trimmed.substring(1); + } + return trimmed.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java b/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java index c4b7995..54de73b 100644 --- a/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java +++ b/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java @@ -9,6 +9,7 @@ import com.velocitypowered.api.plugin.Plugin; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; import eu.mhsl.craftattack.proxy.appliances.chat.Chat; +import eu.mhsl.craftattack.proxy.appliances.commandwhitelist.CommandWhitelist; import eu.mhsl.craftattack.proxy.appliances.mute.Mute; import eu.mhsl.craftattack.proxy.appliances.signup.Signup; import eu.mhsl.craftattack.proxy.appliances.tablist.NetworkTabList; @@ -44,7 +45,8 @@ public final class Main { Mute::new, Chat::new, NetworkTabList::new, - Signup::new + Signup::new, + CommandWhitelist::new ); private final ProxyServer proxy; diff --git a/src/main/resources/commandWhitelist.yml b/src/main/resources/commandWhitelist.yml new file mode 100644 index 0000000..0c827c5 --- /dev/null +++ b/src/main/resources/commandWhitelist.yml @@ -0,0 +1,17 @@ +# CommandWhitelist +# +# Only the root commands listed here can be seen (tab completion) and executed by +# players without the bypass permission. Everything else is silently swallowed on +# the proxy and never reaches a backend server. +# +# - Without leading slash, case-insensitive. +# - Aliases and namespaces are NOT allowed automatically: +# "minecraft:msg" is only allowed if "minecraft:msg" itself is in this list. +allowed-commands: + - msg + - tell + - r + - spawn + +# Players with this permission are completely unaffected by the plugin. +bypass-permission: commandwhitelist.bypass diff --git a/src/main/resources/config.properties b/src/main/resources/config.properties index db3fb77..b7be306 100644 --- a/src/main/resources/config.properties +++ b/src/main/resources/config.properties @@ -2,7 +2,7 @@ # Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity. # Schlüssel einer Appliance beginnen mit ihrem Namen (chat., tablist. …). -# Appliances, die nicht geladen werden (mute, chat, tablist, signup). chat braucht mute, tablist braucht chat. +# Appliances, die nicht geladen werden (mute, chat, tablist, signup, commandwhitelist). chat braucht mute, tablist braucht chat. disabled-appliances= # Adminchat, /mute und /unmute (LuckPerms auf Velocity)