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` |
|
| `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
|
||||||
|
|
||||||
|
|||||||
+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.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;
|
||||||
|
|||||||
@@ -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.
|
# 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)
|
||||||
|
|||||||
Reference in new issue
Block a user