added commandWhitelist to proxy plugin

This commit is contained in:
jannis committed 2026-11-12 15:48:44 +01:00
1 parent e006396ed3
commit 7878a463d3
7 files changed
+262 -2

No files matched your search

+1
View File
@@ -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` | | `chat` | öffentlicher Chat, `/msg`, `/r`, `/adminchat`, Join/Leave, Chat-Einstellungen | `mute` |
| `tablist` | Spieler anderer Server in der Tabliste | `chat` | | `tablist` | Spieler anderer Server in der Tabliste | `chat` |
| `signup` | erster Server beim Join: Login-Server oder CraftAttack, je nach Anmeldung auf der Website | – | | `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 ## Aufbau
@@ -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<WhitelistConfig> config;
private final Logger logger;
CommandFilterListener(Supplier<WhitelistConfig> 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<String> 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);
}
}
@@ -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<Object> listeners() {
return List.of(new CommandFilterListener(() -> this.config, this.logger()));
}
@Override
protected List<ApplianceCommand> commands() {
LiteralCommandNode<CommandSource> 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));
}
}
@@ -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<String> 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<String> 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);
}
}
@@ -9,6 +9,7 @@ import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.ProxyServer;
import eu.mhsl.craftattack.proxy.appliances.chat.Chat; 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.mute.Mute;
import eu.mhsl.craftattack.proxy.appliances.signup.Signup; import eu.mhsl.craftattack.proxy.appliances.signup.Signup;
import eu.mhsl.craftattack.proxy.appliances.tablist.NetworkTabList; import eu.mhsl.craftattack.proxy.appliances.tablist.NetworkTabList;
@@ -44,7 +45,8 @@ public final class Main {
Mute::new, Mute::new,
Chat::new, Chat::new,
NetworkTabList::new, NetworkTabList::new,
Signup::new Signup::new,
CommandWhitelist::new
); );
private final ProxyServer proxy; private final ProxyServer proxy;
+17
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
# Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity. # Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity.
# Schlüssel einer Appliance beginnen mit ihrem Namen (chat., tablist. …). # 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= disabled-appliances=
# Adminchat, /mute und /unmute (LuckPerms auf Velocity) # Adminchat, /mute und /unmute (LuckPerms auf Velocity)