added commandWhitelist to proxy plugin
This commit is contained in:
1 parent
e006396ed3
commit
7878a463d3
7 files changed
+262
-2
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+95
@@ -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);
|
||||
}
|
||||
}
|
||||
+81
@@ -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));
|
||||
}
|
||||
}
|
||||
+64
@@ -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.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;
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user