package mindustry.net; import arc.*; import arc.func.*; import arc.struct.*; import arc.util.*; import arc.util.Log.*; import arc.util.pooling.Pool.*; import arc.util.pooling.*; import mindustry.*; import mindustry.gen.*; import mindustry.type.*; import mindustry.world.*; import static mindustry.Vars.*; import static mindustry.game.EventType.*; public class Administration{ public Seq bannedIPs = new Seq<>(); public Seq whitelist = new Seq<>(); public Seq chatFilters = new Seq<>(); public Seq actionFilters = new Seq<>(); public Seq subnetBans = new Seq<>(); public ObjectSet dosBlacklist = new ObjectSet<>(); public ObjectMap kickedIPs = new ObjectMap<>(); /** All player info. Maps UUIDs to info. This persists throughout restarts. Do not access directly. */ private ObjectMap playerInfo = new ObjectMap<>(); public Administration(){ load(); //anti-spam addChatFilter((player, message) -> { long resetTime = Config.messageRateLimit.num() * 1000L; if(Config.antiSpam.bool() && !player.isLocal() && !player.admin){ //prevent people from spamming messages quickly if(resetTime > 0 && Time.timeSinceMillis(player.getInfo().lastMessageTime) < resetTime){ //supress message player.sendMessage("[scarlet]You may only send messages every " + Config.messageRateLimit.num() + " seconds."); player.getInfo().messageInfractions ++; //kick player for spamming and prevent connection if they've done this several times if(player.getInfo().messageInfractions >= Config.messageSpamKick.num() && Config.messageSpamKick.num() != 0){ player.con.kick("You have been kicked for spamming.", 1000 * 60 * 2); } return null; }else{ player.getInfo().messageInfractions = 0; } //prevent players from sending the same message twice in the span of 50 seconds if(message.equals(player.getInfo().lastSentMessage) && Time.timeSinceMillis(player.getInfo().lastMessageTime) < 1000 * 50){ player.sendMessage("[scarlet]You may not send the same message twice."); return null; } player.getInfo().lastSentMessage = message; player.getInfo().lastMessageTime = Time.millis(); } return message; }); //block interaction rate limit addActionFilter(action -> { if(action.type != ActionType.breakBlock && action.type != ActionType.placeBlock && action.type != ActionType.commandUnits && Config.antiSpam.bool()){ Ratekeeper rate = action.player.getInfo().rate; if(rate.allow(Config.interactRateWindow.num() * 1000L, Config.interactRateLimit.num())){ return true; }else{ if(rate.occurences > Config.interactRateKick.num()){ action.player.kick("You are interacting with too many blocks.", 1000 * 30); }else if(action.player.getInfo().messageTimer.get(60f * 2f)){ action.player.sendMessage("[scarlet]You are interacting with blocks too quickly."); } return false; } } return true; }); } public synchronized void blacklistDos(String address){ dosBlacklist.add(address); } public synchronized boolean isDosBlacklisted(String address){ return dosBlacklist.contains(address); } /** @return time at which a player would be pardoned for a kick (0 means they were never kicked) */ public long getKickTime(String uuid, String ip){ return Math.max(getInfo(uuid).lastKicked, kickedIPs.get(ip, 0L)); } /** Sets up kick duration for a player. */ public void handleKicked(String uuid, String ip, long duration){ kickedIPs.put(ip, Math.max(kickedIPs.get(ip, 0L), Time.millis() + duration)); PlayerInfo info = getInfo(uuid); info.timesKicked++; info.lastKicked = Math.max(Time.millis() + duration, info.lastKicked); } public Seq getSubnetBans(){ return subnetBans; } public void removeSubnetBan(String ip){ subnetBans.remove(ip); save(); } public void addSubnetBan(String ip){ subnetBans.add(ip); save(); } public boolean isSubnetBanned(String ip){ return subnetBans.contains(ip::startsWith); } /** Adds a chat filter. This will transform the chat messages of every player. * This functionality can be used to implement things like swear filters and special commands. * Note that commands (starting with /) are not filtered.*/ public void addChatFilter(ChatFilter filter){ chatFilters.add(filter); } /** Filters out a chat message. */ public @Nullable String filterMessage(Player player, String message){ String current = message; for(ChatFilter f : chatFilters){ current = f.filter(player, current); if(current == null) return null; } return current; } /** Add a filter to actions, preventing things such as breaking or configuring blocks. */ public void addActionFilter(ActionFilter filter){ actionFilters.add(filter); } /** @return whether this action is allowed by the action filters. */ public boolean allowAction(Player player, ActionType type, Tile tile, Cons setter){ return allowAction(player, type, action -> setter.get(action.set(player, type, tile))); } /** @return whether this action is allowed by the action filters. */ public boolean allowAction(Player player, ActionType type, Cons setter){ //some actions are done by the server (null player) and thus are always allowed if(player == null) return true; PlayerAction act = Pools.obtain(PlayerAction.class, PlayerAction::new); act.player = player; act.type = type; setter.get(act); for(ActionFilter filter : actionFilters){ if(!filter.allow(act)){ Pools.free(act); return false; } } Pools.free(act); return true; } public int getPlayerLimit(){ return Core.settings.getInt("playerlimit", headless ? 30 : 0); } public void setPlayerLimit(int limit){ Core.settings.put("playerlimit", limit); } public boolean isStrict(){ return Config.strict.bool(); } public boolean allowsCustomClients(){ return Config.allowCustomClients.bool(); } /** Call when a player joins to update their information here. */ public void updatePlayerJoined(String id, String ip, String name){ PlayerInfo info = getCreateInfo(id); info.lastName = name; info.lastIP = ip; info.timesJoined++; if(!info.names.contains(name, false)) info.names.add(name); if(!info.ips.contains(ip, false)) info.ips.add(ip); } public boolean banPlayer(String uuid){ return banPlayerID(uuid) || banPlayerIP(getInfo(uuid).lastIP); } /** * Bans a player by IP; returns whether this player was already banned. * If there are players who at any point had this IP, they will be UUID banned as well. */ public boolean banPlayerIP(String ip){ if(bannedIPs.contains(ip, false)) return false; for(PlayerInfo info : playerInfo.values()){ if(info.ips.contains(ip, false)){ info.banned = true; } } bannedIPs.add(ip); save(); Events.fire(new PlayerIpBanEvent(ip)); return true; } /** Bans a player by UUID; returns whether this player was already banned. */ public boolean banPlayerID(String id){ if(playerInfo.containsKey(id) && playerInfo.get(id).banned) return false; getCreateInfo(id).banned = true; save(); Events.fire(new PlayerBanEvent(Groups.player.find(p -> id.equals(p.uuid())), id)); return true; } /** * Unbans a player by IP; returns whether this player was banned in the first place. * This method also unbans any player that was banned and had this IP. */ public boolean unbanPlayerIP(String ip){ boolean found = bannedIPs.contains(ip, false); for(PlayerInfo info : playerInfo.values()){ if(info.ips.contains(ip, false)){ info.banned = false; found = true; } } bannedIPs.remove(ip, false); if(found){ save(); Events.fire(new PlayerIpUnbanEvent(ip)); } return found; } /** * Unbans a player by ID; returns whether this player was banned in the first place. * This also unbans all IPs the player used. */ public boolean unbanPlayerID(String id){ PlayerInfo info = getCreateInfo(id); if(!info.banned) return false; info.banned = false; bannedIPs.removeAll(info.ips, false); save(); Events.fire(new PlayerUnbanEvent(Groups.player.find(p -> id.equals(p.uuid())), id)); return true; } /** * Returns list of all players with admin status */ public Seq getAdmins(){ Seq result = new Seq<>(); for(PlayerInfo info : playerInfo.values()){ if(info.admin){ result.add(info); } } return result; } /** * Returns list of all players which are banned */ public Seq getBanned(){ Seq result = new Seq<>(); for(PlayerInfo info : playerInfo.values()){ if(info.banned){ result.add(info); } } return result; } /** * Returns all banned IPs. This does not include the IPs of ID-banned players. */ public Seq getBannedIPs(){ return bannedIPs; } /** * Makes a player an admin. * @return whether this player was already an admin. */ public boolean adminPlayer(String id, String usid){ PlayerInfo info = getCreateInfo(id); var wasAdmin = info.admin; info.adminUsid = usid; info.admin = true; save(); return wasAdmin; } /** * Makes a player no longer an admin. * @return whether this player was an admin in the first place. */ public boolean unAdminPlayer(String id){ PlayerInfo info = getCreateInfo(id); if(!info.admin) return false; info.admin = false; save(); return true; } public boolean isWhitelistEnabled(){ return Config.whitelist.bool(); } public boolean isWhitelisted(String id, String usid){ return !isWhitelistEnabled() || whitelist.contains(usid + id); } public boolean whitelist(String id){ PlayerInfo info = getCreateInfo(id); if(whitelist.contains(info.adminUsid + id)) return false; whitelist.add(info.adminUsid + id); save(); return true; } public boolean unwhitelist(String id){ PlayerInfo info = getCreateInfo(id); if(whitelist.contains(info.adminUsid + id)){ whitelist.remove(info.adminUsid + id); save(); return true; } return false; } public boolean isIPBanned(String ip){ return bannedIPs.contains(ip, false) || (findByIP(ip) != null && findByIP(ip).banned); } public boolean isIDBanned(String uuid){ return getCreateInfo(uuid).banned; } public boolean isAdmin(String id, String usid){ PlayerInfo info = getCreateInfo(id); return info.admin && usid.equals(info.adminUsid); } /** Finds player info by IP, UUID and name. */ public ObjectSet findByName(String name){ ObjectSet result = new ObjectSet<>(); for(PlayerInfo info : playerInfo.values()){ if(info.lastName.equalsIgnoreCase(name) || info.names.contains(name, false) || Strings.stripColors(Strings.stripColors(info.lastName)).equals(name) || info.ips.contains(name, false) || info.id.equals(name)){ result.add(info); } } return result; } /** Finds by name, using contains(). */ public ObjectSet searchNames(String name){ ObjectSet result = new ObjectSet<>(); for(PlayerInfo info : playerInfo.values()){ if(info.names.contains(n -> n.toLowerCase().contains(name.toLowerCase()) || Strings.stripColors(n).trim().toLowerCase().contains(name))){ result.add(info); } } return result; } public Seq findByIPs(String ip){ Seq result = new Seq<>(); for(PlayerInfo info : playerInfo.values()){ if(info.ips.contains(ip, false)){ result.add(info); } } return result; } public PlayerInfo getInfo(String id){ return getCreateInfo(id); } public PlayerInfo getInfoOptional(String id){ return playerInfo.get(id); } public PlayerInfo findByIP(String ip){ for(PlayerInfo info : playerInfo.values()){ if(info.ips.contains(ip, false)){ return info; } } return null; } public Seq getWhitelisted(){ return playerInfo.values().toSeq().select(p -> isWhitelisted(p.id, p.adminUsid)); } private PlayerInfo getCreateInfo(String id){ if(playerInfo.containsKey(id)){ return playerInfo.get(id); }else{ PlayerInfo info = new PlayerInfo(id); playerInfo.put(id, info); save(); return info; } } public void save(){ Core.settings.putJson("player-data", playerInfo); Core.settings.putJson("ip-bans", String.class, bannedIPs); Core.settings.putJson("whitelist-ids", String.class, whitelist); Core.settings.putJson("banned-subnets", String.class, subnetBans); } @SuppressWarnings("unchecked") private void load(){ //load default data playerInfo = Core.settings.getJson("player-data", ObjectMap.class, ObjectMap::new); bannedIPs = Core.settings.getJson("ip-bans", Seq.class, Seq::new); whitelist = Core.settings.getJson("whitelist-ids", Seq.class, Seq::new); subnetBans = Core.settings.getJson("banned-subnets", Seq.class, Seq::new); } /** * Server configuration definition. Each config value can be a string, boolean or number. * Creating a new Config instance implicitly adds it to the list of server configs. This can be used for custom plugin configuration. * */ public static class Config{ public static final Seq all = new Seq<>(); public static final Config serverName = new Config("name", "The server name as displayed on clients.", "Server", "servername"), desc = new Config("desc", "The server description, displayed under the name. Max 100 characters.", "off"), port = new Config("port", "The port to host on.", Vars.port), autoUpdate = new Config("autoUpdate", "Whether to auto-update and exit when a new bleeding-edge update arrives.", false), showConnectMessages = new Config("showConnectMessages", "Whether to display connect/disconnect messages.", true), enableVotekick = new Config("enableVotekick", "Whether votekick is enabled.", true), startCommands = new Config("startCommands", "Commands run at startup. This should be a comma-separated list.", ""), logging = new Config("logging", "Whether to log everything to files.", true), strict = new Config("strict", "Whether strict mode is on - corrects positions and prevents duplicate UUIDs.", true), antiSpam = new Config("antiSpam", "Whether spammers are automatically kicked and rate-limited.", headless), interactRateWindow = new Config("interactRateWindow", "Block interaction rate limit window, in seconds.", 6), interactRateLimit = new Config("interactRateLimit", "Block interaction rate limit.", 25), interactRateKick = new Config("interactRateKick", "How many times a player must interact inside the window to get kicked.", 60), messageRateLimit = new Config("messageRateLimit", "Message rate limit in seconds. 0 to disable.", 0), messageSpamKick = new Config("messageSpamKick", "How many times a player must send a message before the cooldown to get kicked. 0 to disable.", 3), packetSpamLimit = new Config("packetSpamLimit", "Limit for packet count sent within 3sec that will lead to a blacklist + kick.", 300), chatSpamLimit = new Config("packetSpamLimit", "Limit for chat packet count sent within 2sec that will lead to a blacklist + kick. Not the same as a rate limit.", 20), socketInput = new Config("socketInput", "Allows a local application to control this server through a local TCP socket.", false, "socket", () -> Events.fire(Trigger.socketConfigChanged)), socketInputPort = new Config("socketInputPort", "The port for socket input.", 6859, () -> Events.fire(Trigger.socketConfigChanged)), socketInputAddress = new Config("socketInputAddress", "The bind address for socket input.", "localhost", () -> Events.fire(Trigger.socketConfigChanged)), allowCustomClients = new Config("allowCustomClients", "Whether custom clients are allowed to connect.", !headless, "allow-custom"), whitelist = new Config("whitelist", "Whether the whitelist is used.", false), motd = new Config("motd", "The message displayed to people on connection.", "off"), autosave = new Config("autosave", "Whether the periodically save the map when playing.", false), autosaveAmount = new Config("autosaveAmount", "The maximum amount of autosaves. Older ones get replaced.", 10), autosaveSpacing = new Config("autosaveSpacing", "Spacing between autosaves in seconds.", 60 * 5), debug = new Config("debug", "Enable debug logging.", false, () -> Log.level = debug() ? LogLevel.debug : LogLevel.info), snapshotInterval = new Config("snapshotInterval", "Client entity snapshot interval in ms.", 200), autoPause = new Config("autoPause", "Whether the game should pause when nobody is online.", false); public final Object defaultValue; public final String name, key, description; final Runnable changed; public Config(String name, String description, Object def){ this(name, description, def, null, null); } public Config(String name, String description, Object def, String key){ this(name, description, def, key, null); } public Config(String name, String description, Object def, Runnable changed){ this(name, description, def, null, changed); } public Config(String name, String description, Object def, String key, Runnable changed){ this.name = name; this.description = description; this.key = key == null ? name : key; this.defaultValue = def; this.changed = changed == null ? () -> {} : changed; all.add(this); } public boolean isNum(){ return defaultValue instanceof Integer; } public boolean isBool(){ return defaultValue instanceof Boolean; } public boolean isString(){ return defaultValue instanceof String; } public Object get(){ return Core.settings.get(key, defaultValue); } public boolean bool(){ return Core.settings.getBool(key, (Boolean)defaultValue); } public int num(){ return Core.settings.getInt(key, (Integer)defaultValue); } public String string(){ return Core.settings.getString(key, (String)defaultValue); } public void set(Object value){ Core.settings.put(key, value); changed.run(); } private static boolean debug(){ return Config.debug.bool(); } } public static class PlayerInfo{ public String id; public String lastName = "", lastIP = ""; public Seq ips = new Seq<>(); public Seq names = new Seq<>(); public String adminUsid; public int timesKicked; public int timesJoined; public boolean banned, admin; public long lastKicked; //last kicked time to expiration public transient long lastMessageTime, lastSyncTime; public transient String lastSentMessage; public transient int messageInfractions; public transient Ratekeeper rate = new Ratekeeper(); public transient Interval messageTimer = new Interval(); PlayerInfo(String id){ this.id = id; } public PlayerInfo(){ } public String plainLastName(){ return Strings.stripColors(lastName); } } /** Handles chat messages from players and changes their contents. */ public interface ChatFilter{ /** @return the filtered message; a null string signals that the message should not be sent. */ @Nullable String filter(Player player, String message); } /** Allows or disallows player actions. */ public interface ActionFilter{ /** @return whether this action should be permitted. if applicable, make sure to send this player a message specify why the action was prohibited. */ boolean allow(PlayerAction action); } public static class TraceInfo{ public String ip, uuid; public boolean modded, mobile; public int timesJoined, timesKicked; public TraceInfo(String ip, String uuid, boolean modded, boolean mobile, int timesJoined, int timesKicked){ this.ip = ip; this.uuid = uuid; this.modded = modded; this.mobile = mobile; this.timesJoined = timesJoined; this.timesKicked = timesKicked; } } /** Defines a (potentially dangerous) action that a player has done in the world. * These objects are pooled; do not cache them! */ public static class PlayerAction implements Poolable{ public Player player; public ActionType type; public @Nullable Tile tile; /** valid for block placement events only */ public @Nullable Block block; public int rotation; /** valid for configure and rotation-type events only. */ public Object config; /** valid for item-type events only. */ public @Nullable Item item; public int itemAmount; /** valid for unit-type events only, and even in that case may be null. */ public @Nullable Unit unit; /** valid only for removePlanned events only; contains packed positions. */ public @Nullable int[] plans; /** valid only for command unit events */ public @Nullable int[] unitIDs; /** valid only for command building events */ public @Nullable int[] buildingPositions; public PlayerAction set(Player player, ActionType type, Tile tile){ this.player = player; this.type = type; this.tile = tile; return this; } public PlayerAction set(Player player, ActionType type, Unit unit){ this.player = player; this.type = type; this.unit = unit; return this; } @Override public void reset(){ item = null; itemAmount = 0; config = null; player = null; type = null; tile = null; block = null; unit = null; plans = null; } } public enum ActionType{ breakBlock, placeBlock, rotate, configure, withdrawItem, depositItem, control, buildSelect, command, removePlanned, commandUnits, commandBuilding, respawn } }