diff --git a/README.md b/README.md index fd104a5..bc30fe4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,12 @@ # Sanctioner +[![](https://i.loli.net/2019/06/02/5cf3ac062cc3478578.png)](http://www.mcbbs.net/thread-870745-1-1.html "制裁者") + Sanctioner plugin for Nukkit + +Allows banned players to join the game, but everything they do is in vain. Quietly punishes them, they may not know they are banned :/ + +Please see [mcbbs](http://www.mcbbs.net/thread-870745-1-1.html) for more information. +## Commands +| Command | Permission | Description | Default | +| - | - | - | - | - | +| /crash | sanctioner.crash | Crashs the player's client | OP | diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0a4b6f2 --- /dev/null +++ b/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + cn.wode490390.nukkit + sanctioner + 1.0.0 + Sanctioner + Sanctioner plugin for Nukkit + 2019 + http://wode490390.cn/ + jar + + + GNU General Public License, Version 3.0 + http://www.gnu.org/licenses/gpl.html + + + + 1.8 + 1.8 + UTF-8 + + + + nukkitx + http://repo.nukkitx.com/main/ + + + + + cn.nukkit + nukkit + 1.0-SNAPSHOT + provided + + + + wodeSanctioner-${project.version} + clean package + + + . + true + ${basedir}/src/main/resources + + *.yml + + + + + diff --git a/src/main/java/cn/wode490390/nukkit/sanctioner/CrashCommand.java b/src/main/java/cn/wode490390/nukkit/sanctioner/CrashCommand.java new file mode 100644 index 0000000..a5469ea --- /dev/null +++ b/src/main/java/cn/wode490390/nukkit/sanctioner/CrashCommand.java @@ -0,0 +1,60 @@ +package cn.wode490390.nukkit.sanctioner; + +import cn.nukkit.Player; +import cn.nukkit.command.Command; +import cn.nukkit.command.CommandSender; +import cn.nukkit.command.PluginIdentifiableCommand; +import cn.nukkit.command.data.CommandParamType; +import cn.nukkit.command.data.CommandParameter; +import cn.nukkit.item.Item; +import cn.nukkit.lang.TranslationContainer; +import cn.nukkit.network.protocol.InventoryContentPacket; +import cn.nukkit.network.protocol.types.ContainerIds; +import cn.nukkit.plugin.Plugin; +import cn.nukkit.utils.TextFormat; + +public class CrashCommand extends Command implements PluginIdentifiableCommand { + + private static final InventoryContentPacket CRASH_PACKET = new InventoryContentPacket(); + + static { + CRASH_PACKET.inventoryId = ContainerIds.CREATIVE; + CRASH_PACKET.slots = new Item[]{Item.get(230)}; + } + + private final Plugin plugin; + + public CrashCommand(Plugin plugin) { + super("crash", "Crashs the player's client", "/crash "); + this.setPermission("sanctioner.crash"); + this.getCommandParameters().clear(); + this.addCommandParameters("default", new CommandParameter[]{ + new CommandParameter("player", CommandParamType.TARGET, false) + }); + this.plugin = plugin; + } + + @Override + public boolean execute(CommandSender sender, String label, String[] args) { + if (!this.plugin.isEnabled() || !this.testPermission(sender)) { + return false; + } + if (args.length > 0) { + Player player = plugin.getServer().getPlayer(args[0]); + if (player != null) { + player.dataPacket(CRASH_PACKET); + Command.broadcastCommandMessage(sender, TextFormat.YELLOW + "Successfully crashed " + args[0] + "'s client"); + } else { + sender.sendMessage("No targets matched selector"); + } + } else { + sender.sendMessage(new TranslationContainer("commands.generic.usage", this.getUsage())); + } + return true; + } + + @Override + public Plugin getPlugin() { + return this.plugin; + } +} diff --git a/src/main/java/cn/wode490390/nukkit/sanctioner/MetricsLite.java b/src/main/java/cn/wode490390/nukkit/sanctioner/MetricsLite.java new file mode 100644 index 0000000..8b6550c --- /dev/null +++ b/src/main/java/cn/wode490390/nukkit/sanctioner/MetricsLite.java @@ -0,0 +1,363 @@ +package cn.wode490390.nukkit.sanctioner; + +import cn.nukkit.Server; +import cn.nukkit.plugin.Plugin; +import cn.nukkit.plugin.service.NKServiceManager; +import cn.nukkit.plugin.service.RegisteredServiceProvider; +import cn.nukkit.plugin.service.ServicePriority; +import cn.nukkit.utils.Config; +import com.google.common.base.Preconditions; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; +import java.util.UUID; +import java.util.zip.GZIPOutputStream; +import javax.net.ssl.HttpsURLConnection; + +/** + * bStats collects some data for plugin authors. + *

+ * Check out https://bStats.org/ to learn more about bStats! + */ +@SuppressWarnings({"WeakerAccess", "unused"}) +public class MetricsLite { + + static { + // You can use the property to disable the check in your test environment + if (System.getProperty("bstats.relocatecheck") == null || !System.getProperty("bstats.relocatecheck").equals("false")) { + // Maven's Relocate is clever and changes strings, too. So we have to use this little "trick" ... :D + final String defaultPackage = new String(new byte[]{'o', 'r', 'g', '.', 'b', 's', 't', 'a', 't', 's', '.', 'n', 'u', 'k', 'k', 'i', 't'}); + final String examplePackage = new String(new byte[]{'y', 'o', 'u', 'r', '.', 'p', 'a', 'c', 'k', 'a', 'g', 'e'}); + // We want to make sure nobody just copy & pastes the example and use the wrong package names + if (MetricsLite.class.getPackage().getName().equals(defaultPackage) || MetricsLite.class.getPackage().getName().equals(examplePackage)) { + throw new IllegalStateException("bStats Metrics class has not been relocated correctly!"); + } + } + } + + // The version of this bStats class + public static final int B_STATS_VERSION = 1; + + // The url to which the data is sent + private static final String URL = "https://bStats.org/submitData/bukkit"; + + // Is bStats enabled on this server? + private boolean enabled; + + // Should failed requests be logged? + private static boolean logFailedRequests; + + // Should the sent data be logged? + private static boolean logSentData; + + // Should the response text be logged? + private static boolean logResponseStatusText; + + // The uuid of the server + private static String serverUUID; + + // The plugin + private final Plugin plugin; + + /** + * Class constructor. + * + * @param plugin The plugin which stats should be submitted. + */ + public MetricsLite(Plugin plugin) { + Preconditions.checkNotNull(plugin); + this.plugin = plugin; + + // Get the config file + File bStatsFolder = new File(plugin.getDataFolder().getParentFile(), "bStats"); + File configFile = new File(bStatsFolder, "config.yml"); + Config config = new Config(configFile); + + // Check the config + LinkedHashMap map = (LinkedHashMap) config.getAll(); + // Every server gets it's unique random id. + if (!config.isString("serverUuid")) { + map.put("serverUuid", UUID.randomUUID().toString()); + } else { + try { + // Check the UUID + UUID.fromString(config.getString("serverUuid")); + } catch (Exception ignored){ + map.put("serverUuid", UUID.randomUUID().toString()); + } + } + // Add default values + if (!config.isBoolean("enabled")) { + map.put("enabled", true); + } + // Should failed request be logged? + if (!config.isBoolean("logFailedRequests")) { + map.put("logFailedRequests", false); + } + // Should the sent data be logged? + if (!config.isBoolean("logSentData")) { + map.put("logSentData", false); + } + // Should the response text be logged? + if (!config.isBoolean("logResponseStatusText")) { + map.put("logResponseStatusText", false); + } + config.setAll(map); + config.save(); + + // Load the data + enabled = config.getBoolean("enabled", true); + serverUUID = config.getString("serverUuid"); + logFailedRequests = config.getBoolean("logFailedRequests", false); + logSentData = config.getBoolean("logSentData", false); + logResponseStatusText = config.getBoolean("logResponseStatusText", false); + + if (enabled) { + boolean found = false; + // Search for all other bStats Metrics classes to see if we are the first one + for (Class service : Server.getInstance().getServiceManager().getKnownService()) { + try { + service.getField("B_STATS_VERSION"); // Our identifier :) + found = true; // We aren't the first + break; + } catch (NoSuchFieldException ignored) { } + } + // Register our service + Server.getInstance().getServiceManager().register(MetricsLite.class, this, plugin, ServicePriority.NORMAL); + if (!found) { + // We are the first! + startSubmitting(); + } + } + } + + /** + * Checks if bStats is enabled. + * + * @return Whether bStats is enabled or not. + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Starts the Scheduler which submits our data every 30 minutes. + */ + private void startSubmitting() { + final Timer timer = new Timer(true); // We use a timer cause want to be independent from the server tps + timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + if (!plugin.isEnabled()) { // Plugin was disabled + timer.cancel(); + return; + } + // Nevertheless we want our code to run in the Nukkit main thread, so we have to use the Nukkit scheduler + // Don't be afraid! The connection to the bStats server is still async, only the stats collection is sync ;) + Server.getInstance().getScheduler().scheduleTask(plugin, () -> submitData()); + } + }, 1000 * 60 * 5, 1000 * 60 * 30); + // Submit the data every 30 minutes, first time after 5 minutes to give other plugins enough time to start + // WARNING: Changing the frequency has no effect but your plugin WILL be blocked/deleted! + // WARNING: Just don't do it! + } + + /** + * Gets the plugin specific data. + * This method is called using Reflection. + * + * @return The plugin specific data. + */ + public JsonObject getPluginData() { + JsonObject data = new JsonObject(); + + String pluginName = plugin.getName(); + String pluginVersion = plugin.getDescription().getVersion(); + + data.addProperty("pluginName", pluginName); // Append the name of the plugin + data.addProperty("pluginVersion", pluginVersion); // Append the version of the plugin + + JsonArray customCharts = new JsonArray(); + data.add("customCharts", customCharts); + + return data; + } + + /** + * Gets the server specific data. + * + * @return The server specific data. + */ + private JsonObject getServerData() { + // Minecraft specific data + int playerAmount = Server.getInstance().getOnlinePlayers().size(); + int onlineMode = Server.getInstance().getPropertyBoolean("xbox-auth", false) ? 1 : 0; + String minecraftVersion = Server.getInstance().getVersion(); + + // OS/Java specific data + String javaVersion = System.getProperty("java.version"); + String osName = System.getProperty("os.name"); + String osArch = System.getProperty("os.arch"); + String osVersion = System.getProperty("os.version"); + int coreCount = Runtime.getRuntime().availableProcessors(); + + JsonObject data = new JsonObject(); + + data.addProperty("serverUUID", serverUUID); + + data.addProperty("playerAmount", playerAmount); + data.addProperty("onlineMode", onlineMode); + data.addProperty("bukkitVersion", minecraftVersion); + + data.addProperty("javaVersion", javaVersion); + data.addProperty("osName", osName); + data.addProperty("osArch", osArch); + data.addProperty("osVersion", osVersion); + data.addProperty("coreCount", coreCount); + + return data; + } + + /** + * Collects the data and sends it afterwards. + */ + @SuppressWarnings("unchecked") + private void submitData() { + final JsonObject data = getServerData(); + + JsonArray pluginData = new JsonArray(); + // Search for all other bStats Metrics classes to get their plugin data + Server.getInstance().getServiceManager().getKnownService().forEach((service) -> { + try { + service.getField("B_STATS_VERSION"); // Our identifier :) + + List> providers = null; + try { + Field field = Field.class.getDeclaredField("modifiers"); + field.setAccessible(true); + Field handle = NKServiceManager.class.getDeclaredField("handle"); + field.setInt(handle, handle.getModifiers() & ~Modifier.FINAL); + handle.setAccessible(true); + providers = ((Map, List>>) handle.get((NKServiceManager) (Server.getInstance().getServiceManager()))).get(service); + } catch(IllegalAccessException | IllegalArgumentException | SecurityException e) { + // Something went wrong! :( + if (logFailedRequests) { + plugin.getLogger().warning("Failed to link to metrics class " + service.getName(), e); + } + } + + if (providers != null) { + for (RegisteredServiceProvider provider : providers) { + try { + Object plugin = provider.getService().getMethod("getPluginData").invoke(provider.getProvider()); + if (plugin instanceof JsonObject) { + pluginData.add((JsonElement) plugin); + } + } catch (SecurityException | NoSuchMethodException | IllegalAccessException | IllegalArgumentException | InvocationTargetException ignored) { } + } + } + } catch (NoSuchFieldException ignored) { } + }); + + data.add("plugins", pluginData); + + // Create a new thread for the connection to the bStats server + new Thread(() -> { + try { + // Send the data + sendData(plugin, data); + } catch (Exception e) { + // Something went wrong! :( + if (logFailedRequests) { + plugin.getLogger().warning("Could not submit plugin stats of " + plugin.getName(), e); + } + } + }).start(); + } + + /** + * Sends the data to the bStats server. + * + * @param plugin Any plugin. It's just used to get a logger instance. + * @param data The data to send. + * @throws Exception If the request failed. + */ + private static void sendData(Plugin plugin, JsonObject data) throws Exception { + Preconditions.checkNotNull(data); + if (Server.getInstance().isPrimaryThread()) { + throw new IllegalAccessException("This method must not be called from the main thread!"); + } + if (logSentData) { + plugin.getLogger().info("Sending data to bStats: " + data.toString()); + } + HttpsURLConnection connection = (HttpsURLConnection) new URL(URL).openConnection(); + + // Compress the data to save bandwidth + byte[] compressedData = compress(data.toString()); + + // Add headers + connection.setRequestMethod("POST"); + connection.addRequestProperty("Accept", "application/json"); + connection.addRequestProperty("Connection", "close"); + connection.addRequestProperty("Content-Encoding", "gzip"); // We gzip our request + connection.addRequestProperty("Content-Length", String.valueOf(compressedData.length)); + connection.setRequestProperty("Content-Type", "application/json"); // We send our data in JSON format + connection.setRequestProperty("User-Agent", "MC-Server/" + B_STATS_VERSION); + + // Send data + connection.setDoOutput(true); + try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) { + outputStream.write(compressedData); + outputStream.flush(); + } + + InputStream inputStream = connection.getInputStream(); + StringBuilder builder; + try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) { + builder = new StringBuilder(); + String line; + while ((line = bufferedReader.readLine()) != null) { + builder.append(line); + } + } + if (logResponseStatusText) { + plugin.getLogger().info("Sent data to bStats and received response: " + builder.toString()); + } + } + + /** + * Gzips the given String. + * + * @param str The string to gzip. + * @return The gzipped String. + * @throws IOException If the compression failed. + */ + private static byte[] compress(final String str) throws IOException { + if (str == null) { + return null; + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(outputStream)) { + gzip.write(str.getBytes(StandardCharsets.UTF_8)); + } + return outputStream.toByteArray(); + } + +} diff --git a/src/main/java/cn/wode490390/nukkit/sanctioner/Sanctioner.java b/src/main/java/cn/wode490390/nukkit/sanctioner/Sanctioner.java new file mode 100644 index 0000000..8dfd98c --- /dev/null +++ b/src/main/java/cn/wode490390/nukkit/sanctioner/Sanctioner.java @@ -0,0 +1,143 @@ +package cn.wode490390.nukkit.sanctioner; + +import cn.nukkit.Player; +import cn.nukkit.command.Command; +import cn.nukkit.event.EventHandler; +import cn.nukkit.event.EventPriority; +import cn.nukkit.event.Listener; +import cn.nukkit.event.player.PlayerJoinEvent; +import cn.nukkit.event.player.PlayerPreLoginEvent; +import cn.nukkit.event.player.PlayerQuitEvent; +import cn.nukkit.event.server.DataPacketReceiveEvent; +import cn.nukkit.network.protocol.ProtocolInfo; +import cn.nukkit.permission.BanEntry; +import cn.nukkit.permission.BanList; +import cn.nukkit.plugin.PluginBase; +import cn.nukkit.utils.TextFormat; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import java.util.List; + +public class Sanctioner extends PluginBase implements Listener { + + private static final List IGNORE_PACKETS = ImmutableList.builder() + .add(ProtocolInfo.ADVENTURE_SETTINGS_PACKET) + .add(ProtocolInfo.ANIMATE_PACKET) + .add(ProtocolInfo.BLOCK_ENTITY_DATA_PACKET) + .add(ProtocolInfo.BLOCK_PICK_REQUEST_PACKET) + .add(ProtocolInfo.BOOK_EDIT_PACKET) + .add(ProtocolInfo.BOSS_EVENT_PACKET) + .add(ProtocolInfo.COMMAND_BLOCK_UPDATE_PACKET) + .add(ProtocolInfo.COMMAND_REQUEST_PACKET) + .add(ProtocolInfo.CONTAINER_CLOSE_PACKET) + .add(ProtocolInfo.CRAFTING_EVENT_PACKET) + .add(ProtocolInfo.ENTITY_EVENT_PACKET) + .add(ProtocolInfo.ENTITY_FALL_PACKET) + .add(ProtocolInfo.ENTITY_PICK_REQUEST_PACKET) + .add(ProtocolInfo.INTERACT_PACKET) + .add(ProtocolInfo.INVENTORY_TRANSACTION_PACKET) + .add(ProtocolInfo.ITEM_FRAME_DROP_ITEM_PACKET) + //.add(ProtocolInfo.LAB_TABLE_PACKET) + .add(ProtocolInfo.LECTERN_UPDATE_PACKET) + .add(ProtocolInfo.LEVEL_SOUND_EVENT_PACKET) + .add(ProtocolInfo.LEVEL_SOUND_EVENT_PACKET_V1) + .add(ProtocolInfo.LEVEL_SOUND_EVENT_PACKET_V2) + .add(ProtocolInfo.MAP_CREATE_LOCKED_COPY_PACKET) + .add(ProtocolInfo.MAP_INFO_REQUEST_PACKET) + .add(ProtocolInfo.MOB_ARMOR_EQUIPMENT_PACKET) + .add(ProtocolInfo.MOB_EQUIPMENT_PACKET) + .add(ProtocolInfo.MODAL_FORM_RESPONSE_PACKET) + .add(ProtocolInfo.MOVE_ENTITY_ABSOLUTE_PACKET) + .add(ProtocolInfo.MOVE_PLAYER_PACKET) + .add(ProtocolInfo.NETWORK_STACK_LATENCY_PACKET) + .add(ProtocolInfo.NPC_REQUEST_PACKET) + .add(ProtocolInfo.PLAYER_ACTION_PACKET) + .add(ProtocolInfo.PLAYER_HOTBAR_PACKET) + .add(ProtocolInfo.PLAYER_INPUT_PACKET) + .add(ProtocolInfo.PLAYER_SKIN_PACKET) + .add(ProtocolInfo.PURCHASE_RECEIPT_PACKET) + .add(ProtocolInfo.REQUEST_CHUNK_RADIUS_PACKET) + .add(ProtocolInfo.RIDER_JUMP_PACKET) + .add(ProtocolInfo.SCRIPT_CUSTOM_EVENT_PACKET) + .add(ProtocolInfo.SERVER_SETTINGS_REQUEST_PACKET) + .add(ProtocolInfo.SET_DEFAULT_GAME_TYPE_PACKET) + .add(ProtocolInfo.SET_DIFFICULTY_PACKET) + .add(ProtocolInfo.SET_ENTITY_DATA_PACKET) + .add(ProtocolInfo.SET_PLAYER_GAME_TYPE_PACKET) + .add(ProtocolInfo.SHOW_CREDITS_PACKET) + .add(ProtocolInfo.SIMPLE_EVENT_PACKET) + .add(ProtocolInfo.SPAWN_EXPERIENCE_ORB_PACKET) + .add(ProtocolInfo.STRUCTURE_BLOCK_UPDATE_PACKET) + .add(ProtocolInfo.TEXT_PACKET) + .build(); + + private final List banned = Lists.newArrayList(); + private BanList ban; + + @Override + public void onEnable() { + try { + new MetricsLite(this); + } catch (Exception ignore) { + + } + this.ban = this.getServer().getNameBans(); + this.getServer().getPluginManager().registerEvents(this, this); + this.getServer().getCommandMap().register("sanctioner", new CrashCommand(this)); + } + + @Override + public void onDisable() { + for (BanEntry entry : Lists.newArrayList(this.banned)) { + this.ban.add(entry); + this.banned.remove(entry); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerPreLogin(PlayerPreLoginEvent event) { + Player player = event.getPlayer(); + String name = player.getName(); + if (this.ban.isBanned(name)) { + this.banned.add(this.ban.getEntires().get(name.toLowerCase())); + Command.broadcastCommandMessage(this.getServer().getConsoleSender(), TextFormat.YELLOW + "Player " + name + " has been banned! Enable ghost mode for " + name); + this.ban.remove(name); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + for (BanEntry entry : Lists.newArrayList(this.banned)) { + if (entry.getName().equalsIgnoreCase(player.getName())) { + this.ban.add(entry); + break; + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onDataPacketReceive(DataPacketReceiveEvent event) { + Player player = event.getPlayer(); + if (player != null && IGNORE_PACKETS.contains(event.getPacket().pid())) { + for (BanEntry entry : this.banned) { + if (entry.getName().equalsIgnoreCase(player.getName())) { + event.setCancelled(); + break; + } + } + } + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + for (BanEntry entry : Lists.newArrayList(this.banned)) { + if (entry.getName().equalsIgnoreCase(player.getName())) { + this.ban.add(entry); + this.banned.remove(entry); + break; + } + } + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..da6ba68 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,12 @@ +main: cn.wode490390.nukkit.sanctioner.Sanctioner +name: "Sanctioner" +description: "Sanctioner plugin for Nukkit" +author: "wode490390" +website: "http://wode490390.cn/" +version: "${pom.version}" +api: ["1.0.0"] +load: POSTWORLD +permissions: + sanctioner.crash: + description: "Allows player to use /crash" + default: op