Added a simple filter system that utilizes JavaScript (Rhino)

This makes it possible to filter packet events with arbitrary code.
This commit is contained in:
Kristian 2013-04-07 15:33:19 +02:00
parent 5720994a31
commit 15980d70fb
7 changed files with 505 additions and 5 deletions

View File

@ -55,6 +55,7 @@ abstract class CommandBase implements CommandExecutor {
try {
// Make sure we're dealing with the correct command
if (!command.getName().equalsIgnoreCase(name)) {
reporter.reportWarning(this, "Incorrect command assigned to " + this);
return false;
}
if (permission != null && !sender.hasPermission(permission)) {
@ -66,6 +67,7 @@ abstract class CommandBase implements CommandExecutor {
if (args != null && args.length >= minimumArgumentCount) {
return handleCommand(sender, args);
} else {
sender.sendMessage(ChatColor.RED + "Insufficient commands. You need at least " + minimumArgumentCount);
return false;
}

View File

@ -0,0 +1,394 @@
package com.comphenix.protocol;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender;
import org.bukkit.conversations.Conversable;
import org.bukkit.conversations.Conversation;
import org.bukkit.conversations.ConversationAbandonedEvent;
import org.bukkit.conversations.ConversationAbandonedListener;
import org.bukkit.conversations.ConversationCanceller;
import org.bukkit.conversations.ConversationContext;
import org.bukkit.conversations.ConversationFactory;
import org.bukkit.plugin.Plugin;
import com.comphenix.protocol.concurrency.IntegerSet;
import com.comphenix.protocol.error.ErrorReporter;
import com.comphenix.protocol.events.PacketEvent;
import com.google.common.collect.DiscreteDomains;
import com.google.common.collect.Range;
import com.google.common.collect.Ranges;
/**
* A command to apply JavaScript filtering to the packet command.
*
* @author Kristian
*/
public class CommandFilter extends CommandBase {
@SuppressWarnings("serial")
public static class FilterFailedException extends RuntimeException {
private Filter filter;
public FilterFailedException() {
super();
}
public FilterFailedException(String message, Filter filter, Throwable cause) {
super(message, cause);
this.filter = filter;
}
public Filter getFilter() {
return filter;
}
}
/**
* Possible sub commands.
*
* @author Kristian
*/
private enum SubCommand {
ADD, REMOVE;
}
/**
* A filter that will be used to process a packet event.
* @author Kristian
*/
public static class Filter {
private final String name;
private final String predicate;
private final IntegerSet ranges;
/**
* Construct a new immutable filter.
* @param name - the unique name of the filter.
* @param predicate - the JavaScript predicate that will be used to filter packet events.
* @param ranges - a list of valid packet ID ranges that this filter applies to.
*/
public Filter(String name, String predicate, Set<Integer> packets) {
this.name = name;
this.predicate = predicate;
this.ranges = new IntegerSet(Packets.MAXIMUM_PACKET_ID + 1);
this.ranges.addAll(packets);
}
/**
* Retrieve the unique name of the filter.
* @return Unique name of the filter.
*/
public String getName() {
return name;
}
/**
* Retrieve the JavaScript predicate that will be used to filter packet events.
* @return Predicate itself.
*/
public String getPredicate() {
return predicate;
}
/**
* Retrieve a copy of the set of packets this filter applies to.
* @return Set of packets this filter applies to.
*/
public Set<Integer> getRanges() {
return ranges.toSet();
}
/**
* Determine whether or not a packet event needs to be passed to this filter.
* @param event - the event to test.
* @return TRUE if it does, FALSE otherwise.
*/
private boolean isApplicable(PacketEvent event) {
return ranges.contains(event.getPacketID());
}
/**
* Evaluate the current filter using the provided ScriptEngine as context.
* <p>
* This context may be modified with additional code.
* @param context - the current script context.
* @param event - the packet event to evaluate.
* @return TRUE to pass this packet event on to the debug listeners, FALSE otherwise.
* @throws ScriptException If the compilation failed.
*/
public boolean evaluate(ScriptEngine context, PacketEvent event) throws ScriptException {
if (!isApplicable(event))
return true;
// Ensure that the predicate has been compiled
compile(context);
try {
return (Boolean) ((Invocable) context).invokeFunction(name, event, event.getPacket().getHandle());
} catch (NoSuchMethodException e) {
// Must be a fault with the script engine itself
throw new IllegalStateException("Unable to compile " + name + " into current script engine.", e);
}
}
/**
* Force the compilation of a specific filter.
* @param context - the current script context.
* @throws ScriptException If the compilation failed.
*/
public void compile(ScriptEngine context) throws ScriptException {
if (context.get(name) == null) {
context.eval("var " + name + " = function(event, packet) {\n" + predicate);
}
}
/**
* Clean up all associated code from this filter in the provided script engine.
* @param context - the current script context.
*/
public void close(ScriptEngine context) {
context.put(name, null);
}
}
private static class BracketBalance implements ConversationCanceller {
private String KEY_BRACKET_COUNT = "bracket_balance.count";
// What to set the initial counter
private final int initialBalance;
public BracketBalance(int initialBalance) {
this.initialBalance = initialBalance;
}
@Override
public boolean cancelBasedOnInput(ConversationContext context, String in) {
Object stored = context.getSessionData(KEY_BRACKET_COUNT);
int value = 0;
// Get the stored value
if (stored instanceof Integer) {
value = (Integer)stored;
} else {
value = initialBalance;
}
value += count(in, '{') - count(in, '}');
context.setSessionData(KEY_BRACKET_COUNT, value);
// Cancel if the bracket balance is zero
return value <= 0;
}
private int count(String text, char character) {
int counter = 0;
for (int i=0; i < text.length(); i++) {
if (text.charAt(i) == character) {
counter++;
}
}
return counter;
}
@Override
public void setConversation(Conversation conversation) {
// Whatever
}
@Override
public ConversationCanceller clone() {
return new BracketBalance(initialBalance);
}
}
/**
* Name of this command.
*/
public static final String NAME = "filter";
// Currently registered filters
private List<Filter> filters = new ArrayList<Filter>();
// Owner plugin
private final Plugin plugin;
// Script engine
private ScriptEngine engine;
public CommandFilter(ErrorReporter reporter, Plugin plugin) {
super(reporter, CommandBase.PERMISSION_ADMIN, NAME, 2);
this.plugin = plugin;
// Start the engine
initalizeScript();
}
private void initalizeScript() {
ScriptEngineManager manager = new ScriptEngineManager();
engine = manager.getEngineByName("JavaScript");
// Import useful packages
try {
engine.eval("importPackage(org.bukkit);");
engine.eval("importPackage(com.comphenix.protocol.reflect);");
} catch (ScriptException e) {
throw new IllegalStateException("Unable to initialize packages for JavaScript engine.", e);
}
}
/**
* Determine whether or not to pass the given packet event to the packet listeners.
* @param event - the event.
* @return TRUE if we should, FALSE otherwise.
* @throws FilterFailedException If one of the filters failed.
*/
public boolean filterEvent(PacketEvent event) throws FilterFailedException {
for (Filter filter : filters) {
try {
if (!filter.evaluate(engine, event)) {
return false;
}
} catch (ScriptException e) {
throw new FilterFailedException("Filter failed.", filter, e);
}
}
// Pass!
return true;
}
/*
* Description: Adds or removes a simple packet listener.
Usage: /<command> add|remove name [packet IDs]
*/
@Override
protected boolean handleCommand(CommandSender sender, String[] args) {
final SubCommand command = parseCommand(args, 0);
final String name = args[1];
switch (command) {
case ADD:
// Never overwrite an existing filter
if (findFilter(name) != null) {
sender.sendMessage(ChatColor.RED + "Filter " + name + " already exists. Remove it first.");
return true;
}
final Set<Integer> packets = parseRanges(args, 2);
sender.sendMessage("Enter filter program ('}' to complete or CANCEL):");
// Make sure we can use the conversable interface
if (sender instanceof Conversable) {
final MultipleLinesPrompt prompt =
new MultipleLinesPrompt(new BracketBalance(1), "function(event, packet) {");
new ConversationFactory(plugin).
withFirstPrompt(prompt).
withEscapeSequence("CANCEL").
withLocalEcho(false).
addConversationAbandonedListener(new ConversationAbandonedListener() {
@Override
public void conversationAbandoned(ConversationAbandonedEvent event) {
try {
final Conversable whom = event.getContext().getForWhom();
if (event.gracefulExit()) {
String predicate = prompt.removeAccumulatedInput(event.getContext());
Filter filter = new Filter(name, predicate, packets);
// Print the last line as well
whom.sendRawMessage(prompt.getPromptText(event.getContext()));
try {
// Force early compilation
filter.compile(engine);
filters.add(filter);
whom.sendRawMessage(ChatColor.GOLD + "Added filter " + name);
} catch (ScriptException e) {
e.printStackTrace();
whom.sendRawMessage(ChatColor.GOLD + "Compilation error: " + e.getMessage());
}
} else {
// Too bad
whom.sendRawMessage(ChatColor.RED + "Cancelled filter.");
}
} catch (Exception e) {
reporter.reportDetailed(this, "Cannot handle conversation.", e, event);
}
}
}).
buildConversation((Conversable) sender).
begin();
} else {
sender.sendMessage(ChatColor.RED + "Only console and players are supported!");
}
break;
case REMOVE:
Filter filter = findFilter(name);
// See if it exists before we remove it
if (filter != null) {
filter.close(engine);
filters.remove(filter);
sender.sendMessage(ChatColor.GOLD + "Removed filter " + name);
} else {
sender.sendMessage(ChatColor.RED + "Unable to find a filter by the name " + name);
}
break;
}
return true;
}
private Set<Integer> parseRanges(String[] args, int start) {
List<Range<Integer>> ranges = RangeParser.getRanges(args, 2, args.length - 1, Ranges.closed(0, 255));
Set<Integer> flatten = new HashSet<Integer>();
if (ranges.isEmpty()) {
// Use every packet ID
ranges.add(Ranges.closed(0, 255));
}
// Finally, flatten it all
for (Range<Integer> range : ranges) {
flatten.addAll(range.asSet(DiscreteDomains.integers()));
}
return flatten;
}
/**
* Lookup a filter by its name.
* @param name - the filter name.
* @return The filter, or NULL if not found.
*/
private Filter findFilter(String name) {
// We'll just use a linear scan for now - we don't expect that many filters
for (Filter filter : filters) {
if (filter.getName().equalsIgnoreCase(name)) {
return filter;
}
}
return null;
}
private SubCommand parseCommand(String[] args, int index) {
String text = args[index].toUpperCase();
try {
return SubCommand.valueOf(text);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(text + " is not a valid sub command. Must be add or remove.", e);
}
}
}

View File

@ -93,11 +93,15 @@ class CommandPacket extends CommandBase {
private AbstractIntervalTree<Integer, DetailedPacketListener> clientListeners = createTree(ConnectionSide.CLIENT_SIDE);
private AbstractIntervalTree<Integer, DetailedPacketListener> serverListeners = createTree(ConnectionSide.SERVER_SIDE);
public CommandPacket(ErrorReporter reporter, Plugin plugin, Logger logger, ProtocolManager manager) {
// Filter packet events
private CommandFilter filter;
public CommandPacket(ErrorReporter reporter, Plugin plugin, Logger logger, CommandFilter filter, ProtocolManager manager) {
super(reporter, CommandBase.PERMISSION_ADMIN, NAME, 1);
this.plugin = plugin;
this.logger = logger;
this.manager = manager;
this.filter = filter;
this.chatter = new ChatExtensions(manager);
}
@ -362,7 +366,6 @@ class CommandPacket extends CommandBase {
}
public DetailedPacketListener createPacketListener(final ConnectionSide side, int idStart, int idStop, final boolean detailed) {
Set<Integer> range = Ranges.closed(idStart, idStop).asSet(DiscreteDomains.integers());
Set<Integer> packets;
@ -386,14 +389,14 @@ class CommandPacket extends CommandBase {
return new DetailedPacketListener() {
@Override
public void onPacketSending(PacketEvent event) {
if (side.isForServer()) {
if (side.isForServer() && filter.filterEvent(event)) {
printInformation(event);
}
}
@Override
public void onPacketReceiving(PacketEvent event) {
if (side.isForClient()) {
if (side.isForClient() && filter.filterEvent(event)) {
printInformation(event);
}
}

View File

@ -0,0 +1,81 @@
package com.comphenix.protocol;
import org.bukkit.conversations.ConversationCanceller;
import org.bukkit.conversations.ConversationContext;
import org.bukkit.conversations.ExactMatchConversationCanceller;
import org.bukkit.conversations.Prompt;
import org.bukkit.conversations.StringPrompt;
/**
* Represents a conversation prompt that accepts a list of lines.
*
* @author Kristian
*/
class MultipleLinesPrompt extends StringPrompt {
// Feels a bit like Android
private static final String KEY = "multiple_lines_prompt";
private static final String KEY_LAST = KEY + ".last_line";
private final ConversationCanceller endMarker;
private final String initialPrompt;
/**
* Retrieve and remove the current accumulated input.
* @param context - conversation context.
* @return The accumulated input, or NULL if not found.
*/
public String removeAccumulatedInput(ConversationContext context) {
Object result = context.getSessionData(KEY);
if (result instanceof StringBuilder) {
context.setSessionData(KEY, null);
return ((StringBuilder) result).toString();
} else {
return null;
}
}
/**
* Construct a multiple lines input prompt with a specific end marker.
* <p>
* This is usually an empty string.
* @param endMarker - the end marker.
*/
public MultipleLinesPrompt(String endMarker, String initialPrompt) {
this(new ExactMatchConversationCanceller(endMarker), initialPrompt);
}
public MultipleLinesPrompt(ConversationCanceller endMarker, String initialPrompt) {
this.endMarker = endMarker;
this.initialPrompt = initialPrompt;
}
@Override
public Prompt acceptInput(ConversationContext context, String in) {
StringBuilder result = (StringBuilder) context.getSessionData(KEY);
if (result == null) {
context.setSessionData(KEY, result = new StringBuilder());
}
// Save the last line as well
context.setSessionData(KEY_LAST, in);
result.append(in);
// And we're done
if (endMarker.cancelBasedOnInput(context, in))
return Prompt.END_OF_CONVERSATION;
else
return this;
}
@Override
public String getPromptText(ConversationContext context) {
Object last = context.getSessionData(KEY_LAST);
if (last instanceof String)
return (String) last;
else
return initialPrompt;
}
}

View File

@ -103,6 +103,7 @@ public class ProtocolLibrary extends JavaPlugin {
// Commands
private CommandProtocol commandProtocol;
private CommandPacket commandPacket;
private CommandFilter commandFilter;
// Whether or not disable is not needed
private boolean skipDisable;
@ -161,7 +162,8 @@ public class ProtocolLibrary extends JavaPlugin {
// Initialize command handlers
commandProtocol = new CommandProtocol(detailedReporter, this, updater, config);
commandPacket = new CommandPacket(detailedReporter, this, logger, protocolManager);
commandFilter = new CommandFilter(detailedReporter, this);
commandPacket = new CommandPacket(detailedReporter, this, logger, commandFilter, protocolManager);
// Send logging information to player listeners too
setupBroadcastUsers(PERMISSION_INFO);
@ -256,6 +258,7 @@ public class ProtocolLibrary extends JavaPlugin {
// Set up command handlers
registerCommand(CommandProtocol.NAME, commandProtocol);
registerCommand(CommandPacket.NAME, commandPacket);
registerCommand(CommandFilter.NAME, commandFilter);
// Player login and logout events
protocolManager.registerEvents(manager, this);

View File

@ -18,6 +18,7 @@
package com.comphenix.protocol.concurrency;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
@ -60,6 +61,16 @@ public class IntegerSet {
array[element] = true;
}
/**
* Add the given collection of elements to the set.
* @param packets - elements to add.
*/
public void addAll(Collection<Integer> packets) {
for (Integer id : packets) {
add(id);
}
}
/**
* Remove the given element from the set, or do nothing if it's already removed.
* @param element - element to remove.

View File

@ -17,6 +17,12 @@ commands:
usage: /<command> add|remove|names client|server [ID start]-[ID stop] [detailed]
permission: protocol.admin
permission-message: You don't have <permission>
filter:
description: Add or remove programmable filters to the packet listeners.
usage: /<command> add|remove name [ID start]-[ID stop]
aliases: [packet_filter]
permission: protocol.admin
permission-message: You don't have <permission>
permissions:
protocol.*: