├── mod.conf ├── .luacheckrc ├── bower.json ├── importers ├── minetest.lua ├── v1.lua └── v2.lua ├── doc ├── API.md └── dbformat.txt ├── dbimport.lua ├── LICENSE ├── serialize.lua ├── gui.lua ├── README.md └── init.lua /mod.conf: -------------------------------------------------------------------------------- 1 | name = xban2 2 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | 2 | unused_args = false 3 | allow_defined_top = true 4 | 5 | read_globals = { 6 | "minetest", 7 | } 8 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xban2", 3 | "description": "Ban system extension with support for temporary bans.", 4 | "keywords": [ 5 | "ban", 6 | "administration", 7 | "system", 8 | "server" 9 | ], 10 | "homepage": "http://github.com/kaeza/minetest-xban2.git", 11 | "authors": [ 12 | "Diego Martínez " 13 | ], 14 | "license": "BSD 2-Clause" 15 | } 16 | -------------------------------------------------------------------------------- /importers/minetest.lua: -------------------------------------------------------------------------------- 1 | 2 | function xban.importers.minetest() 3 | local f, e = io.open(minetest.get_worldpath().."/ipban.txt") 4 | if not f then 5 | return false, "Unable to open `ipban.txt': "..e 6 | end 7 | for line in f:lines() do 8 | local ip, name = line:match("([^|]+)%|(.+)") 9 | if ip and name then 10 | local entry 11 | entry = xban.find_entry(ip, true) 12 | entry.banned = true 13 | entry.reason = "Banned in `ipban.txt'" 14 | entry.names[name] = true 15 | entry.names[ip] = true 16 | entry.time = os.time() 17 | entry.expires = nil 18 | entry.source = "xban:importer_minetest" 19 | table.insert(entry.record, { 20 | source = entry.source, 21 | reason = entry.reason, 22 | time = entry.time, 23 | expires = nil, 24 | }) 25 | end 26 | end 27 | f:close() 28 | return true 29 | end 30 | -------------------------------------------------------------------------------- /importers/v1.lua: -------------------------------------------------------------------------------- 1 | 2 | function xban.importers.v1() 3 | local f, e = io.open(minetest.get_worldpath().."/players.iplist") 4 | if not f then 5 | return false, "Unable to open `players.iplist': "..e 6 | end 7 | for line in f:lines() do 8 | local list = line:split("|") 9 | if #list >= 2 then 10 | local banned = (list[1]:sub(1, 1) == "!") 11 | local entry 12 | entry = xban.find_entry(list[1], true) 13 | entry.banned = banned 14 | for _, name in ipairs(list) do 15 | entry.names[name] = true 16 | end 17 | if banned then 18 | entry.reason = "Banned in `players.iplist'" 19 | entry.time = os.time() 20 | entry.expires = nil 21 | entry.source = "xban:importer_v1" 22 | table.insert(entry.record, { 23 | source = entry.source, 24 | reason = entry.reason, 25 | time = entry.time, 26 | expires = nil, 27 | }) 28 | end 29 | end 30 | end 31 | f:close() 32 | return true 33 | end 34 | -------------------------------------------------------------------------------- /doc/API.md: -------------------------------------------------------------------------------- 1 | 2 | ## Extended Ban Mod API 3 | 4 | ### ban_player 5 | 6 | `xban.ban_player(player_or_ip, source, expires, reason)` 7 | 8 | Ban a player and all of his/her alternative names and IPs. 9 | 10 | #### Arguments: 11 | 12 | * `player_or_ip` - Player to search for and ban. See note 1 below. 13 | * `source` - Source of the ban. See note 2 below. 14 | * `expires` - Time at which the ban expires. If nil, ban is permanent. 15 | * `reason` - Reason for ban. 16 | 17 | ### unban_player 18 | 19 | `xban.unban_player(player_or_ip, source)` 20 | 21 | Unban a player and all of his/her alternative names and IPs. 22 | 23 | #### Arguments: 24 | 25 | * `player_or_ip` - Player to search for and unban. 26 | * `source` - Source of the ban. See note 2 below. 27 | 28 | ### Notes 29 | 30 | * 1: If player is currently online, all his accounts are kicked. 31 | * 2: Mods using the xban API are advised to use the `"modname:source"` 32 | format for `source` (for example: `"anticheat:main"`). 33 | -------------------------------------------------------------------------------- /importers/v2.lua: -------------------------------------------------------------------------------- 1 | 2 | function xban.importers.v2() 3 | return pcall(function() 4 | local f, e = io.open(minetest.get_worldpath().."/players.iplist.v2") 5 | if not f then 6 | error("Unable to open `players.iplist.v2': "..e) 7 | end 8 | local text = f:read("*a") 9 | f:close() 10 | local db = minetest.deserialize(text) 11 | for _, ent in ipairs(db) do 12 | for name in pairs(ent.names) do 13 | local entry = xban.find_entry(name, true) 14 | if entry.source ~= "xban:importer_v2" then 15 | for nm in pairs(e.names) do 16 | entry.names[nm] = true 17 | end 18 | if ent.banned then 19 | entry.banned = true 20 | entry.reason = e.banned 21 | entry.source = "xban:importer_v2" 22 | entry.time = ent.time 23 | entry.expires = ent.expires 24 | table.insert(entry.record, { 25 | source = entry.source, 26 | reason = entry.reason, 27 | time = entry.time, 28 | expires = entry.expires, 29 | }) 30 | end 31 | end 32 | end 33 | end 34 | end) 35 | end 36 | -------------------------------------------------------------------------------- /dbimport.lua: -------------------------------------------------------------------------------- 1 | 2 | xban.importers = { } 3 | 4 | dofile(xban.MP.."/importers/minetest.lua") 5 | dofile(xban.MP.."/importers/v1.lua") 6 | dofile(xban.MP.."/importers/v2.lua") 7 | 8 | minetest.register_chatcommand("xban_dbi", { 9 | description = "Import old databases", 10 | params = "", 11 | privs = { server=true }, 12 | func = function(name, params) 13 | if params == "--list" then 14 | local importers = { } 15 | for importer in pairs(xban.importers) do 16 | table.insert(importers, importer) 17 | end 18 | minetest.chat_send_player(name, 19 | ("[xban] Known importers: %s"):format( 20 | table.concat(importers, ", "))) 21 | return 22 | elseif not xban.importers[params] then 23 | minetest.chat_send_player(name, 24 | ("[xban] Unknown importer `%s'"):format(params)) 25 | minetest.chat_send_player(name, "[xban] Try `--list'") 26 | return 27 | end 28 | local f = xban.importers[params] 29 | local ok, err = f() 30 | if ok then 31 | minetest.chat_send_player(name, 32 | "[xban] Import successfull") 33 | else 34 | minetest.chat_send_player(name, 35 | ("[xban] Import failed: %s"):format(err)) 36 | end 37 | end, 38 | }) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2014-2023, Diego Martínez 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /doc/dbformat.txt: -------------------------------------------------------------------------------- 1 | 2 | Database is a regular Lua script that returns a table. 3 | 4 | Table has a single named field `timestamp' containing the time_t the 5 | DB was last saved. It's not used in the mod and is only meant for 6 | external use (I don't find filesystem timestamps too reliable). 7 | 8 | Next is a simple array (number indices) of entries. 9 | 10 | Each entry contains following fields: 11 | 12 | [1] = { 13 | -- Names/IPs associated with this entry 14 | names = { 15 | ["foo"] = true, 16 | ["bar"] = true, 17 | ["123.45.67.89"] = true, 18 | }, 19 | banned = true, -- Whether this user is banned 20 | -- Other fields do not apply if false 21 | time = 12341234, -- Time of last ban (*1) 22 | expires = 43214321 -- Time at which ban expires (*2) 23 | -- If nil, permanent ban 24 | reason = "asdf", -- Reason for ban 25 | source = "qwerty", -- Source of ban (*2) 26 | record = { 27 | [1] = { 28 | source = "asdf", 29 | reason = "qwerty", 30 | time = 12341234, 31 | expires = 43214321, 32 | }, 33 | [1] = { 34 | source = "asdf", 35 | reason = "Unbanned", -- When unbanned 36 | time = 12341234, 37 | }, 38 | }, 39 | } 40 | 41 | Notes: 42 | (*1) All times are expressed in whatever unit `os.time()' uses 43 | (`time_t' on most (all?) systems). 44 | (*2) Mods using the xban API are advised to use the "modname:source" 45 | format for `source' (for example: "anticheat:main"). 46 | -------------------------------------------------------------------------------- /serialize.lua: -------------------------------------------------------------------------------- 1 | 2 | local function repr(x) 3 | if type(x) == "string" then 4 | return ("%q"):format(x) 5 | else 6 | return tostring(x) 7 | end 8 | end 9 | 10 | local function my_serialize_2(t, level) 11 | level = level or 0 12 | local lines = { } 13 | local indent = ("\t"):rep(level) 14 | for k, v in pairs(t) do 15 | local typ = type(v) 16 | if typ == "table" then 17 | table.insert(lines, 18 | indent..("[%s] = {\n"):format(repr(k)) 19 | ..my_serialize_2(v, level + 1).."\n" 20 | ..indent.."},") 21 | else 22 | table.insert(lines, 23 | indent..("[%s] = %s,"):format(repr(k), repr(v))) 24 | end 25 | end 26 | return table.concat(lines, "\n") 27 | end 28 | 29 | function xban.serialize(t) 30 | minetest.log("warning", "[xban2] xban.serialize() is deprecated") 31 | return "return {\n"..my_serialize_2(t, 1).."\n}" 32 | end 33 | 34 | -- JSON doesn't allow combined string+number keys, this function moves any 35 | -- number keys into an "entries" table 36 | function xban.serialize_db(t) 37 | local res = {} 38 | local entries = {} 39 | for k, v in pairs(t) do 40 | if type(k) == "number" then 41 | entries[k] = v 42 | else 43 | res[k] = v 44 | end 45 | end 46 | res.entries = entries 47 | return minetest.write_json(res, true) 48 | end 49 | 50 | function xban.deserialize_db(s) 51 | if s:sub(1, 1) ~= "{" then 52 | -- Load legacy databases 53 | return minetest.deserialize(s) 54 | end 55 | 56 | local res, err = minetest.parse_json(s) 57 | if not res then 58 | return nil, err 59 | end 60 | 61 | -- Remove all "null"s added by empty tables 62 | for i, entry in ipairs(res.entries or {}) do 63 | entry.names = entry.names or {} 64 | entry.record = entry.record or {} 65 | res[i] = entry 66 | end 67 | res.entries = nil 68 | 69 | return res 70 | end 71 | -------------------------------------------------------------------------------- /gui.lua: -------------------------------------------------------------------------------- 1 | 2 | local FORMNAME = "xban2:main" 3 | local MAXLISTSIZE = 100 4 | 5 | local strfind, format = string.find, string.format 6 | 7 | local ESC = minetest.formspec_escape 8 | 9 | local function make_list(filter) 10 | filter = filter or "" 11 | local list, n, dropped = { }, 0, false 12 | for k in minetest.get_auth_handler().iterate() do 13 | if strfind(k, filter, 1, true) then 14 | if n >= MAXLISTSIZE then 15 | dropped = true 16 | break 17 | end 18 | n=n+1 list[n] = k 19 | end 20 | end 21 | table.sort(list) 22 | return list, dropped 23 | end 24 | 25 | local states = { } 26 | 27 | local function get_state(name) 28 | local state = states[name] 29 | if not state then 30 | state = { index=1, filter="" } 31 | states[name] = state 32 | state.list, state.dropped = make_list() 33 | end 34 | return state 35 | end 36 | 37 | local function get_record_simple(name) 38 | local e = xban.find_entry(name) 39 | if not e then 40 | return nil, ("No entry for `%s'"):format(name) 41 | elseif (not e.record) or (#e.record == 0) then 42 | return nil, ("`%s' has no ban records"):format(name) 43 | end 44 | local record = { } 45 | for _, rec in ipairs(e.record) do 46 | local msg = (os.date("%Y-%m-%d %H:%M:%S", rec.time).." | " 47 | ..(rec.reason or "No reason given.")) 48 | table.insert(record, msg) 49 | end 50 | return record, e.record 51 | end 52 | 53 | local function make_fs(name) 54 | local state = get_state(name) 55 | local list, filter = state.list, state.filter 56 | local pli, ei = state.player_index or 1, state.entry_index or 0 57 | if pli > #list then 58 | pli = #list 59 | end 60 | local fs = { 61 | "size[16,12]", 62 | "label[0,-.1;Filter]", 63 | "field[1.5,0;12.8,1;filter;;"..ESC(filter).."]", 64 | "field_close_on_enter[filter;false]", 65 | "button[14,-.3;2,1;search_submit;Search]", 66 | } 67 | local fsn = #fs 68 | fsn=fsn+1 fs[fsn] = format("textlist[0,.8;4,9.3;player;%s;%d;0]", 69 | table.concat(list, ","), pli) 70 | local record_name = list[pli] 71 | if record_name then 72 | local record, e = get_record_simple(record_name) 73 | if record then 74 | for i, r in ipairs(record) do 75 | record[i] = ESC(r) 76 | end 77 | fsn=fsn+1 fs[fsn] = format( 78 | "textlist[4.2,.8;11.7,9.3;entry;%s;%d;0]", 79 | table.concat(record, ","), ei) 80 | local rec = e[ei] 81 | if rec then 82 | fsn=fsn+1 fs[fsn] = format("label[0,10.3;%s]", 83 | ESC("Source: "..(rec.source or "") 84 | .."\nTime: "..os.date("%c", rec.time) 85 | .."\n"..(rec.expires and 86 | os.date("%c", rec.expires) or "")), 87 | pli) 88 | end 89 | else 90 | fsn=fsn+1 fs[fsn] = "textlist[4.2,.8;11.7,9.3;err;"..ESC(e)..";0]" 91 | fsn=fsn+1 fs[fsn] = "label[0,10.3;"..ESC(e).."]" 92 | end 93 | else 94 | local e = "No entry matches the query." 95 | fsn=fsn+1 fs[fsn] = "textlist[4.2,.8;11.7,9.3;err;"..ESC(e)..";0]" 96 | fsn=fsn+1 fs[fsn] = "label[0,10.3;"..ESC(e).."]" 97 | end 98 | return table.concat(fs) 99 | end 100 | 101 | minetest.register_on_player_receive_fields(function(player, formname, fields) 102 | if formname ~= FORMNAME then return end 103 | local name = player:get_player_name() 104 | if not minetest.check_player_privs(name, { ban=true }) then 105 | minetest.log("warning", 106 | "[xban2] Received fields from unauthorized user: "..name) 107 | return 108 | end 109 | local state = get_state(name) 110 | if fields.player then 111 | local t = minetest.explode_textlist_event(fields.player) 112 | if (t.type == "CHG") or (t.type == "DCL") then 113 | state.player_index = t.index 114 | minetest.show_formspec(name, FORMNAME, make_fs(name)) 115 | end 116 | return 117 | end 118 | if fields.entry then 119 | local t = minetest.explode_textlist_event(fields.entry) 120 | if (t.type == "CHG") or (t.type == "DCL") then 121 | state.entry_index = t.index 122 | minetest.show_formspec(name, FORMNAME, make_fs(name)) 123 | end 124 | return 125 | end 126 | if fields.search_submit or fields.filter then 127 | local filter = fields.filter or "" 128 | state.filter = filter 129 | state.list = make_list(filter) 130 | minetest.show_formspec(name, FORMNAME, make_fs(name)) 131 | end 132 | end) 133 | 134 | minetest.register_chatcommand("xban_gui", { 135 | description = "Show XBan GUI", 136 | params = "", 137 | privs = { ban=true, }, 138 | func = function(name, params) 139 | minetest.show_formspec(name, FORMNAME, make_fs(name)) 140 | end, 141 | }) 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Extended Ban Mod for Minetest 2 | This mod attempts to be an improvement to Minetest's ban system. 3 | * It supports normal bans and temporary bans (from 60 seconds up to the end of 4 | time, with 1 second granularity). 5 | * Records and joins all accounts using the same IP address and several IP 6 | addresses using the same name into a single record, and can ban/unban them as 7 | a single user. 8 | * Can ban offline players if you know their IP or username. 9 | * Holds a record of bans for each user, so moderators and administrators can 10 | consult it to know if a player is a repeat offender. 11 | * Does not modify the default ban database in any way (`ipban.txt'). 12 | * Has an API to ban and check the ban database to allows other mods to manage 13 | users (for example, anticheat mods). 14 | 15 | ## Wildcard/Subnet Ban Feature 16 | 17 | The mod now supports wildcard (subnet) bans for IP addresses using trailing `*` notation. This allows you to ban entire IP ranges or subnets with a single command. 18 | 19 | ### How It Works 20 | 21 | * **IPv4 Wildcard Bans**: Use a trailing `*` to match any IP address that starts with the specified prefix. 22 | * Example: `192.168.1.*` will match all IPs from `192.168.1.0` to `192.168.1.255` 23 | * Example: `10.0.*` will match all IPs from `10.0.0.0` to `10.0.255.255` 24 | * Example: `172.*` will match all IPs from `172.0.0.0` to `172.255.255.255` 25 | 26 | * **IPv6 Wildcard Bans**: Use a trailing `*` to match any IPv6 address that starts with the specified prefix. 27 | * Example: `2001:db8:*` will match all IPv6 addresses starting with `2001:db8:` 28 | * Example: `fe80:*` will match all link-local IPv6 addresses 29 | 30 | ### Usage Examples 31 | 32 | **Ban an entire IPv4 subnet:** 33 | ``` 34 | /xban 192.168.1.* Banning entire subnet due to spam 35 | ``` 36 | 37 | **Temporarily ban an IPv4 range:** 38 | ``` 39 | /xtempban 10.0.* 24h Temporary subnet ban for suspected bot activity 40 | ``` 41 | 42 | **Ban an IPv6 prefix:** 43 | ``` 44 | /xban 2001:db8:* Banning IPv6 prefix 45 | ``` 46 | 47 | **Unban a wildcard entry:** 48 | ``` 49 | /xunban 192.168.1.* 50 | ``` 51 | 52 | ### Notes 53 | 54 | * Wildcard bans are checked when a player attempts to connect. 55 | * Individual IP addresses can still be whitelisted even if they match a wildcard ban. 56 | * The wildcard character `*` must be at the end of the IP address. 57 | * For IPv4, you can use wildcards at any octet boundary (e.g., `192.*`, `192.168.*`, `192.168.1.*`). 58 | * For IPv6, the wildcard matches the remaining part of the address after the specified prefix. 59 | 60 | ## Chat commands 61 | The mod provides the following chat commands. All commands require the `ban` 62 | privilege. 63 | 64 | ### `xban` 65 | Bans a player permanently. 66 | 67 | **Usage:** `/xban ` 68 | 69 | **Example:** `/xban 127.0.0.1 Some reason.` 70 | 71 | **Wildcard Example:** `/xban 192.168.1.* Subnet ban` 72 | 73 | ### `xtempban` 74 | Bans a player temporarily. 75 | 76 | **Usage:** `/xtempban