------------------------------------------------------------------------- -- ANTI PRIVS detects if a custom client without interacting "zips" around the map -- Copyright (c) 2021 mt-mods/BuckarooBanzay MIT -- Copyright 2020-2023 improvements and few fixes, mckaygerhard CC-BY-SA-NC 4.0 ------------------------------------------------------------------------- -- per-player data local player_data = {} local is_player = function(player) -- a table being a player is also supported because it quacks sufficiently -- like a player if it has the is_player function local t = type(player) return (t == "userdata" or t == "table") and type(player.is_player) == "function" end -- obtain player data cheats, improved respect beowulf with missing checks and methods local function get_player_data(name) local player = minetest.get_player_by_name(name) if not player_data[name] then player_data[name] = { fliyers = 0, -- number of checks for fly ( added only with governing) strikes = 0, -- number of "strikes" (odd movements) checked = 0, -- number of checks pre_pos = player:get_pos() -- position track (missing in beowulf as bug) } end return player_data[name] -- WARNING pos its always current so checked must be after an interval using callbacks end -- clear player data for cheats local function track_player_clear(player) if is_player(player) then if player:get_player_name() then player_data[player:get_player_name()] = nil end end end -- store player data cheats strikes and checks local function track_player(player) if not is_player(player) then return end local name = player:get_player_name() local data = get_player_data(name) local pos = player:get_pos() if data.pre_pos then -- compare positions local d = vector.distance(pos, data.pre_pos) if d > 200 then data.strikes = data.strikes + 1 end data.checked = data.checked + 1 end if data.checked >= 10 then -- check strike-count after 10 movement checks if data.strikes > 8 then -- suspicious movement, log it -- TODO: if this doesn't yield any false-positives, add a kick/ban option local msg = "suspicious movement detected for player: '" .. name .. "'" minetest.log("action", "[governing][beowulf] " .. msg) end -- reset counters data.checked = 0 data.strikes = 0 end -- store current position data.pre_pos = pos end ------------------------------------------------------------------------- -- ANTI CHEAT for MT4 and old MT5 engines by rnd -- Copyright 2016 rnd LGPL v3 -- Copyright 2020-2023 improvements and few fixes, mckaygerhard CC-BY-SA-NC 4.0 ------------------------------------------------------------------------- local cheat = {}; local version = "09/08/2017"; anticheatsettings = {}; anticheatsettings.moderators = {} anticheatsettings.CHEAT_TIMESTEP = tonumber(minetest.settings:get("governing.timestep")) or 15; -- check timestep all players anticheatsettings.CHECK_AGAIN = tonumber(minetest.settings:get("governing.timeagain")) or 15; -- after player found in bad position check again after this to make sure its not lag, this should be larger than expected lag in seconds anticheatsettings.STRING_MODERA = minetest.settings:get("governing.moderators") or "admin,singleplayer"; -- moderators list, those players can use cheat debug and will see full cheat message for str in string.gmatch(anticheatsettings.STRING_MODERA, "([^,]+)") do table.insert(anticheatsettings.moderators, str) end local CHEAT_TIMESTEP = anticheatsettings.CHEAT_TIMESTEP; local CHECK_AGAIN = anticheatsettings.CHECK_AGAIN; cheat.moderators = anticheatsettings.moderators; bonemod = minetest.get_modpath("boneworld") anticheatdb = {}; -- data about detected cheaters cheat.suspect = ""; cheat.players = {}; -- temporary cheat detection db cheat.message = ""; cheat.debuglist = {}; -- [name]=true -- who gets to see debug msgs cheat.scan_timer = 0; -- global scan of players cheat.stat_timer = 0; -- used to collect stats cheat.nodelist = {}; cheat.timestep = CHEAT_TIMESTEP; -- list of forbidden nodes cheat.nodelist = {["default:stone"] = false, ["default:cobble"]= false, ["default:dirt"] = false, ["default:sand"]=false,["default:tree"]= false}; local punish_cheat = function(name) local player = minetest.get_player_by_name(name); local ip = tostring(minetest.get_player_ip(name)); if not player then return end local text=""; local logtext = ""; if cheat.players[name].cheattype == 1 then text = "#anticheat: ".. name .. " was caught walking inside wall"; logtext = os.date("%H:%M.%S").." #anticheat: ".. name .. " was caught walking inside wall at " .. minetest.pos_to_string(cheat.players[name].cheatpos); --player:set_hp(0); elseif cheat.players[name].cheattype == 2 then local gravity = player:get_physics_override().gravity; if gravity<1 then return end logtext= os.date("%H:%M.%S").." #anticheat: ".. name .. " was caught flying at " .. minetest.pos_to_string(cheat.players[name].cheatpos); if cheat.players[name].cheatpos.y>5 then -- only above height 5 it directly damages flyer text = "#anticheat: ".. name .. " was caught flying"; --player:set_hp(0); end end if text~="" then minetest.chat_send_all(text); end if logtext~="" then minetest.log("action", logtext); cheat.message = logtext; anticheatdb[ip] = {name = name, msg = logtext}; cheat.players[name].count=0; -- reset counter cheat.players[name].cheattype = 0; for namem,_ in pairs(cheat.moderators) do -- display full message to moderators minetest.chat_send_player(namem,logtext); end end end -- CHECKS -- DETAILED NOCLIP CHECK local check_noclip = function(pos) local nodename = minetest.get_node(pos).name; local clear=true; if nodename ~= "air" then -- check if forbidden material! clear = cheat.nodelist[nodename]; -- test clip violation if clear == nil then clear = true end end if not clear then -- more detailed check local anodes = minetest.find_nodes_in_area({x=pos.x-1, y=pos.y-1, z=pos.z-1}, {x=pos.x+1, y=pos.y+1, z=pos.z+1}, {"air"}); if #anodes == 0 then return false end clear=true; end return clear; end -- DETAILED FLY CHECK local check_fly = function(pos) -- return true if player not flying local fly = (minetest.get_node(pos).name=="air" and minetest.get_node({x=pos.x,y=pos.y-1,z=pos.z}).name=="air"); -- prerequisite for flying is this to be "air", but not sufficient condition if not fly then return true end; local anodes = minetest.find_nodes_in_area({x=pos.x-1, y=pos.y-1, z=pos.z-1}, {x=pos.x+1, y=pos.y, z=pos.z+1}, {"air"}); if #anodes == 18 then -- player standing on air? return false else return true end end local round = function (x) if x > 0 then return math.floor(x+0.5) else return -math.floor(-x+0.5) end end --main check routine local check_player = function(player) local name = player:get_player_name(); local privs = minetest.get_player_privs(name).kick;if privs then return end -- dont check moderators local pos = player:getpos(); -- feet position pos.x = round(pos.x*10)/10;pos.z = round(pos.z*10)/10; -- less useless clutter pos.y = round(pos.y*10)/10; -- minetest buggy collision - need to do this or it returns wrong materials for feet position: aka magic number 0.498?????228 if pos.y<0 then pos.y=pos.y+1 end -- weird, without this it fails to check feet block where y<0, it checks one below feet local nodename = minetest.get_node(pos).name; local clear=true; if nodename ~= "air" then -- check if forbidden material! clear = cheat.nodelist[nodename]; -- test clip violation if clear == nil then clear = true end end local fly = (nodename=="air" and minetest.get_node({x=pos.x,y=pos.y-1,z=pos.z}).name=="air"); -- prerequisite for flying, but not sufficient condition if cheat.players[name].count == 0 then -- player hasnt "cheated" yet, remember last clear position cheat.players[name].clearpos = cheat.players[name].lastpos end -- manage noclip cheats if not clear then -- player caught inside walls local moved = (cheat.players[name].lastpos.x~=pos.x) or (cheat.players[name].lastpos.y~=pos.y) or (cheat.players[name].lastpos.z~=pos.z); if moved then -- if player stands still whole time do nothing if cheat.players[name].count == 0 then cheat.players[name].cheatpos = pos end -- remember first position where player found inside wall if cheat.players[name].count == 0 then minetest.after(CHECK_AGAIN+math.random(5), function() cheat.players[name].count = 0; if not check_noclip(pos) then punish_cheat(name)-- we got a cheater! else cheat.players[name].count = 0; -- reset cheat.players[name].cheattype = 0; end end ) end if cheat.players[name].count == 0 then -- mark as suspect cheat.players[name].count = 1; cheat.players[name].cheattype = 1; end end end -- manage flyers if fly then local fpos; fly,fpos = minetest.line_of_sight(pos, {x = pos.x, y = pos.y - 4, z = pos.z}, 1); --checking node maximal jump height below feet if fly then -- so we are in air, are we flying? if player:get_player_control().sneak then -- player sneaks, maybe on border? --search 18 nodes to find non air local anodes = minetest.find_nodes_in_area({x=pos.x-1, y=pos.y-1, z=pos.z-1}, {x=pos.x+1, y=pos.y, z=pos.z+1}, {"air"}); if #anodes < 18 then fly = false end end -- if at this point fly = true means player is not standing on border if pos.y>=cheat.players[name].lastpos.y and fly then -- we actually didnt go down from last time and not on border -- was lastpos in air too? local lastpos = cheat.players[name].lastpos; local anodes = minetest.find_nodes_in_area({x=lastpos.x-1, y=lastpos.y-1, z=lastpos.z-1}, {x=lastpos.x+1, y=lastpos.y, z=lastpos.z+1}, {"air"}); if #anodes == 18 then fly = true else fly = false end if fly then -- so now in air above previous position, which was in air too? if cheat.players[name].count == 0 then cheat.players[name].cheatpos = pos end -- remember first position where player found "cheating" if cheat.players[name].count == 0 then minetest.after(CHECK_AGAIN, function() cheat.players[name].count = 0; if not check_fly(pos) then punish_cheat(name)-- we got a cheater! else cheat.players[name].count = 0; cheat.players[name].cheattype = 0; end end ) end if cheat.players[name].count == 0 then -- mark as suspect cheat.players[name].count = 1; cheat.players[name].cheattype = 2; end end end end end cheat.players[name].lastpos = pos end minetest.register_globalstep(function(dtime) cheat.scan_timer = cheat.scan_timer + dtime -- GENERAL SCAN OF ALL PLAYERS if cheat.scan_timer>cheat.timestep then cheat.stat_timer = cheat.stat_timer + cheat.timestep; -- dig xp stats every 2 minutes if bonemod and cheat.stat_timer>120 then cheat.stat_timer = 0; local players = minetest.get_connected_players(); for _,player in pairs(players) do local pname = player:get_player_name(); if cheat.players[pname].stats.state == 1 then -- only if dig xp loaded to prevent anomalous stats if boneworld.digxp[pname] then local deltadig = cheat.players[pname].stats.digxp; cheat.players[pname].stats.digxp = boneworld.digxp[pname]; deltadig = boneworld.digxp[pname]-deltadig; cheat.players[pname].stats.deltadig = deltadig; if deltadig>cheat.players[pname].stats.maxdeltadig then cheat.players[pname].stats.maxdeltadig = deltadig; end if deltadig>2 then -- unnaturally high deltadig local ip = tostring(minetest.get_player_ip(pname)); local logtext = os.date("%H:%M.%S") .. " #anticheat: " .. pname .. " (ip " .. ip .. ") is mining resources too fast, deltadig " .. deltadig; anticheatdb[ip] = {name = pname, msg = logtext}; minetest.log("action", logtext); end end end end end cheat.timestep = CHEAT_TIMESTEP + (2*math.random()-1)*2; -- randomize step so its unpredictable cheat.scan_timer=0; --local t = minetest.get_gametime(); local players = minetest.get_connected_players(); for _,player in pairs(players) do check_player(player); end for name,_ in pairs(cheat.debuglist) do -- show suspects in debug for _,player in pairs(players) do local pname = player:get_player_name(); if cheat.players[pname].count>0 then minetest.chat_send_player(name, "name " .. pname .. ", cheat pos " .. minetest.pos_to_string(cheat.players[pname].cheatpos) .. " last clear pos " .. minetest.pos_to_string(cheat.players[pname].clearpos) .. " cheat type " .. cheat.players[pname].cheattype .. " cheatcount " .. cheat.players[pname].count ); end end end end end) -- long range dig check local check_can_dig = function(pos, digger) local cpos = minetest.pos_to_string(pos) or "missing pos seems nul returned" local logtext = os.date("%H:%M.%S") .. "#anticheat: long range dig made by some entity, could be a player or huge tnt explotion at ".. cpos; if not digger then minetest.log("warning", "[governing] "..logtext) return end local p = digger:getpos(); if p.y<0 then p.y=p.y+2 else p.y=p.y+1 end -- head position local dist = math.max(math.abs(p.x-pos.x),math.abs(p.y-pos.y),math.abs(p.z-pos.z)); if dist>6 then -- here 5 dist = math.floor(dist*100)/100; local pname = digger:get_player_name(); logtext = os.date("%H:%M.%S") .. "#anticheat: long range dig " .. pname ..", distance " .. dist .. ", pos " .. cpos; for name,_ in pairs(cheat.debuglist) do -- show to all watchers minetest.chat_send_player(name,logtext) minetest.log("warning", "[governing] "..logtext) end local ip = tostring(minetest.get_player_ip(pname)); anticheatdb[ip] = {name = pname, msg = logtext}; return false end return true end local set_check_can_dig = function(name) local tabl = minetest.registered_nodes[name]; if not tabl then return end tabl.can_dig = check_can_dig; minetest.override_item(name, {can_dig = check_can_dig}) --minetest.register_node(":"..name, tabl); end local function is_player(player) if player then if type(player) == "userdata" or type(player) == "table" then return true end end return false end minetest.register_on_joinplayer(function(player) -- init stuff on player join if not is_player(player) then return end local name = player:get_player_name(); if type(name) ~= "string" then return end local pos = player:getpos(); -- no matter if are incomplete info, save most possible of and start recolection of stats if cheat.players[name] == nil then cheat.players[name]={count=0,cheatpos = pos, clearpos = pos, lastpos = pos, cheattype = 0}; -- type 0: none, 1 noclip, 2 fly end -- try to fill some stats or retrieve previously if cheat.players[name] and cheat.players[name].stats == nil then cheat.players[name].stats = {maxdeltadig=0,deltadig = 0,digxp = 0, state = 0}; -- various statistics about player: max dig xp increase in 2 minutes, current dig xp increase if bonemod then minetest.after(4, -- load digxp after boneworld loads it function() if boneworld.xp then cheat.players[name].stats.digxp = boneworld.digxp[name] or 0; cheat.players[name].stats.state = 1; end end ) --state 0 = stats not loaded yet end end -- check anticheat db for cheaters clients -- =================================== local ip = tostring(minetest.get_player_ip(name)); local msgiplevelone = ""; local msgipleveltwo = ""; --check ip first try of info manually, later from player info if anticheatdb[ip] then msgiplevelone = "#anticheat: welcome back detected cheater, ip = " .. ip .. ", name " .. anticheatdb[ip].name .. ", new name = " .. name; end; --check names from stats for ip,v in pairs(anticheatdb) do if v.name == name then msgiplevelone = "#anticheat: welcome back detected cheater, ip = " .. ip .. ", name = newname = " .. v.name; break; end end -- send detection msg before try to check info (cos info may be incomplete detection) if msgiplevelone~="" then minetest.after(1, function() for namemd,_ in pairs(cheat.moderators) do minetest.chat_send_player(namemd,msgiplevelone); end end) end end) minetest.register_chatcommand("cchk", { privs = { interact = true, server = true }, description = "cchk NAME, checks if player is cheating in this moment", func = function(name, param) local privs = minetest.get_player_privs(name).privs; if not cheat.moderators[name] and not privs then return end local player = minetest.get_player_by_name(param); if not player then return end check_player(player); local ip = tostring(minetest.get_player_ip(param)); local info = minetest.get_player_information(param) local msgm = "#anticheat detected a cheater with "..ip.." named "..param if info.version_string then local dfv = gapi.isdf(info.version_string) if dfv then msgm = msgm.." using "..info.version_string minetest.chat_send_player(param, "..... cheat"); if minetest.settings:get_bool("beowulf.dfdetect.enable_kick", false) then minetest.kick_player(param, "Are you a cheater stupid user? change your cracked client for play") end minetest.chat_send_player(name,msgm); -- advertise moderators for namemd,_ in pairs(cheat.moderators) do minetest.chat_send_player(namemd,msgm); -- advertise moderators end minetest.log(msgm) else msgm = "Still just suspicius for "..param.." at "..ip.." using "..info.version_string minetest.chat_send_player(name,msgm); -- advertise command executor end else for namemd,_ in pairs(cheat.moderators) do minetest.chat_send_player(namemd,msgm); -- advertise moderators end minetest.log(msgm) end local players = minetest.get_connected_players(); for name,_ in pairs(cheat.debuglist) do -- show suspects in debug for _,player in pairs(players) do local pname = player:get_player_name(); if cheat.players[pname].count>0 then minetest.chat_send_player(name, "name " .. pname .. ", cheat pos " .. minetest.pos_to_string(cheat.players[pname].cheatpos) .. " last clear pos " .. minetest.pos_to_string(cheat.players[pname].clearpos) .. " cheat type " .. cheat.players[pname].cheattype .. " cheatcount " .. cheat.players[pname].count ); end end end end }) minetest.register_chatcommand("crep", { -- see cheat report privs = { interact = true }, description = "crep 0/1, 0 = default cheat report, 1 = connected player stats", func = function(name, param) local privs = minetest.get_player_privs(name).privs; if not cheat.moderators[name] and not privs then return end if param == "" then minetest.chat_send_player(name,"use: crep type, types: 0(default) cheat report, 1 connected player stats (".. version ..")"); end param = tonumber(param) or 0; if param == 0 then -- show cheat report local text = ""; for ip,v in pairs(anticheatdb) do if v and v.name and v.msg then text = text .. "ip " .. ip .. " ".. v.msg .. "\n"; end end if text ~= "" then local form = "size [6,7] textarea[0,0;6.5,8.5;creport;CHEAT REPORT;".. text.."]" minetest.show_formspec(name, "anticheatreport", form) end elseif param == 1 then -- show cheat stats local text = ""; local players = minetest.get_connected_players(); for _,player in pairs(players) do local pname = player:get_player_name(); local ip = tostring(minetest.get_player_ip(pname)); text = text .. "\nname " .. pname .. ", digxp " .. math.floor(1000*cheat.players[pname].stats.digxp)/1000 .. ", deltadigxp(2min) " .. math.floor(1000*cheat.players[pname].stats.deltadig)/1000 .. ", maxdeltadigxp " .. math.floor(1000*cheat.players[pname].stats.maxdeltadig)/1000; -- .. " ".. string.gsub(dump(cheat.players[pname].stats), "\n", " "); if anticheatdb[ip] then text = text .. " (DETECTED) ip ".. ip .. ", name " .. anticheatdb[ip].name end end if text ~= "" then local form = "size [10,8] textarea[0,0;10.5,9.;creport;CHEAT STATISTICS;".. text.."]" minetest.show_formspec(name, "anticheatreport", form) end end -- suspects info local players = minetest.get_connected_players(); for _,player in pairs(players) do local pname = player:get_player_name(); if cheat.players[pname].count>0 then minetest.chat_send_player(name, "name " .. pname .. ", cheat pos " .. minetest.pos_to_string(cheat.players[pname].cheatpos) .. " last clear pos " .. minetest.pos_to_string(cheat.players[pname].lastpos) .. " cheat type " .. cheat.players[pname].cheattype .. " cheatcount " .. cheat.players[pname].count ); end end end }) minetest.register_chatcommand("cdebug", { -- toggle cdebug= display of stats on/off for this player privs = { interact = true }, func = function(name, param) local privs = minetest.get_player_privs(name).privs; if not cheat.moderators[name] and not privs then return end if cheat.debuglist[name] == nil then cheat.debuglist[name] = true else cheat.debuglist[name] = nil end; minetest.chat_send_player(name,"#anticheat: " .. version); if cheat.debuglist[name]==true then minetest.chat_send_player(name,"#anticheat: display of debug messages is ON"); else minetest.chat_send_player(name,"#anticheat: display of debug messages is OFF"); end end }) ------------------------------------------------------------------------- -- GOVERNONR custom action for features mod code improvements -- minetest routines to implement the mod features -- Copyright 2020-2023 mckaygerhard CC-BY-SA-NC 4.0 ------------------------------------------------------------------------- -- cleanup after the player leaves minetest.register_on_leaveplayer(function(player) if is_player(player) then track_player_clear(player) end end) -- list of nodes to enable damage if noclip or to check if player its diggin too fast local node_list_check = {} -- TODO move this to a comma separted list in config, and adde the check below in node check for fly if minetest.get_modpath("default") then table.insert(node_list_check, "default:stone") table.insert(node_list_check, "default:stone_with_iron") table.insert(node_list_check, "default:stone_with_copper") table.insert(node_list_check, "default:stone_with_coal") table.insert(node_list_check, "default:stone_with_gold") table.insert(node_list_check, "default:stone_with_mese") table.insert(node_list_check, "default:stone_with_diamond") end local function interval_fn() if not governing.modbeowulf then for _, player in ipairs(minetest.get_connected_players()) do track_player(player) end end if not governing.modanticheat then for _, nodename in ipairs(node_list_check) do set_check_can_dig(nodename); end end minetest.after(2, interval_fn) end -- TODO: beowulf was pretty unneficient on large player servers.. cos only its made each 1 second and again each x inside function if governing.is_50 then minetest.register_on_mods_loaded(interval_fn ) else minetest.after(0.1, interval_fn) end -- damaged if a player its making noclip, no matter if admin is doing for _, nodename in ipairs(node_list_check) do minetest.override_item(nodename, { damage_per_second = 1 }) end