diff --git a/src/bw/lua/bunkerweb/api.lua b/src/bw/lua/bunkerweb/api.lua index 9c2f9a2a8..abbcf4b2e 100644 --- a/src/bw/lua/bunkerweb/api.lua +++ b/src/bw/lua/bunkerweb/api.lua @@ -33,7 +33,6 @@ local get_body_data = ngx_req.get_body_data local get_body_file = ngx_req.get_body_file local decode = cjson.decode local encode = cjson.encode -local floor = math.floor local match = string.match local require_plugin = helpers.require_plugin local new_plugin = helpers.new_plugin @@ -216,7 +215,26 @@ api.global.POST["^/ban$"] = function(self) if not ok then return self:response(HTTP_INTERNAL_SERVER_ERROR, "error", "can't decode JSON : " .. ip) end - datastore:set("bans_ip_" .. ip["ip"], "manual", ip["exp"]) + local ban = { + ip = "", + exp = 86400, + reason = "manual", + } + ban.ip = ip["ip"] + if ip["exp"] then + ban.exp = ip["exp"] + end + if ip["reason"] then + ban.reason = ip["reason"] + end + datastore:set( + "bans_ip_" .. ban["ip"], + encode({ + reason = ban["reason"], + date = os.time(), + }), + ban["exp"] + ) return self:response(HTTP_OK, "success", "ip " .. ip["ip"] .. " banned") end @@ -224,12 +242,12 @@ api.global.GET["^/bans$"] = function(self) local data = {} for _, k in ipairs(datastore:keys()) do if k:find("^bans_ip_") then - local reason, err = datastore:get(k) + local result, err = datastore:get(k) if err then return self:response( HTTP_INTERNAL_SERVER_ERROR, "error", - "can't access " .. k .. " from datastore : " .. reason + "can't access " .. k .. " from datastore : " .. result ) end local ok, ttl = datastore:ttl(k) @@ -240,7 +258,9 @@ api.global.GET["^/bans$"] = function(self) "can't access ttl " .. k .. " from datastore : " .. ttl ) end - local ban = { ip = k:sub(9, #k), reason = reason, exp = floor(ttl) } + local ban_data = decode(result) + local ban = + { ip = k:sub(9, #k), reason = ban_data["reason"], date = ban_data["date"], exp = math.floor(ttl) } table.insert(data, ban) end end diff --git a/src/bw/lua/bunkerweb/utils.lua b/src/bw/lua/bunkerweb/utils.lua index c912cc450..8b200ee59 100644 --- a/src/bw/lua/bunkerweb/utils.lua +++ b/src/bw/lua/bunkerweb/utils.lua @@ -638,15 +638,16 @@ end utils.is_banned = function(ip) -- Check on local datastore - local reason, err = datastore:get("bans_ip_" .. ip) - if not reason and err ~= "not found" then - return nil, "datastore:get() error : " .. reason - elseif reason and err ~= "not found" then + local result, err = datastore:get("bans_ip_" .. ip) + if not result and err ~= "not found" then + return nil, "datastore:get() error : " .. result + elseif result and err ~= "not found" then local ok, ttl = datastore:ttl("bans_ip_" .. ip) + local ban_data = decode(result) if not ok then - return true, reason, -1 + return true, ban_data, -1 end - return true, reason, ttl + return true, ban_data, ttl end -- Redis case local use_redis, err = utils.get_variable("USE_REDIS", false) @@ -701,7 +702,11 @@ end utils.add_ban = function(ip, reason, ttl) -- Set on local datastore - local ok, err = datastore:set("bans_ip_" .. ip, reason, ttl) + local ban_data = encode({ + reason = reason, + date = os.time(), + }) + local ok, err = datastore:set("bans_ip_" .. ip, ban_data, ttl) if not ok then return false, "datastore:set() error : " .. err end @@ -719,7 +724,7 @@ utils.add_ban = function(ip, reason, ttl) return false, "can't connect to redis server : " .. err end -- SET call - ok, err = clusterstore:call("set", "bans_ip_" .. ip, reason, "EX", ttl) + ok, err = clusterstore:call("set", "bans_ip_" .. ip, ban_data, "EX", ttl) if not ok then clusterstore:close() return false, "redis SET failed : " .. err diff --git a/src/common/cli/CLI.py b/src/common/cli/CLI.py index fb5e4a879..1dfb13832 100644 --- a/src/common/cli/CLI.py +++ b/src/common/cli/CLI.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 +from datetime import datetime +from json import dumps, loads +from time import time from dotenv import dotenv_values from os import getenv, sep from os.path import join @@ -19,10 +22,19 @@ from logger import setup_logger # type: ignore def format_remaining_time(seconds): - days, seconds = divmod(seconds, 86400) - hours, seconds = divmod(seconds, 3600) + years, seconds = divmod(seconds, 60 * 60 * 24 * 365) + months, seconds = divmod(seconds, 60 * 60 * 24 * 30) + while months >= 12: + years += 1 + months -= 12 + days, seconds = divmod(seconds, 60 * 60 * 24) + hours, seconds = divmod(seconds, 60 * 60) minutes, seconds = divmod(seconds, 60) time_parts = [] + if years > 0: + time_parts.append(f"{int(years)} year{'' if years == 1 else 's'}") + if months > 0: + time_parts.append(f"{int(months)} month{'' if months == 1 else 's'}") if days > 0: time_parts.append(f"{int(days)} day{'' if days == 1 else 's'}") if hours > 0: @@ -206,14 +218,15 @@ class CLI(ApiCaller): return True, f"IP {ip} has been unbanned" return False, "error" - def ban(self, ip: str, exp: float) -> Tuple[bool, str]: + def ban(self, ip: str, exp: float, reason: str) -> Tuple[bool, str]: if self.__redis: - ok = self.__redis.set(f"bans_ip_{ip}", "manual", ex=exp) + ok = self.__redis.set(f"bans_ip_{ip}", dumps({"reason": reason, "date": time()})) if not ok: self.__logger.error(f"Failed to ban {ip} in redis") + self.__redis.expire(f"bans_ip_{ip}", int(exp)) - if self.send_to_apis("POST", "/ban", data={"ip": ip, "exp": exp}): - return (True, f"IP {ip} has been banned for {format_remaining_time(exp)}") + if self.send_to_apis("POST", "/ban", data={"ip": ip, "exp": exp, "reason": reason}): + return (True, f"IP {ip} has been banned for {format_remaining_time(exp)} with reason {reason}") return False, "error" def bans(self) -> Tuple[bool, str]: @@ -230,8 +243,13 @@ class CLI(ApiCaller): servers["redis"] = [] for key in self.__redis.scan_iter("bans_ip_*"): ip = key.decode("utf-8").replace("bans_ip_", "") + data = self.__redis.get(key) + if not data: + continue exp = self.__redis.ttl(key) - servers["redis"].append({"ip": ip, "exp": exp, "reason": "manual"}) + servers["redis"].append({"ip": ip, "exp": exp} | loads(data)) + + servers = {k: sorted(v, key=lambda x: x["date"]) for k, v in servers.items()} cli_str = "" for server, bans in servers.items(): @@ -240,7 +258,7 @@ class CLI(ApiCaller): cli_str += "No ban found\n" for ban in bans: - cli_str += f"- {ban['ip']} for {format_remaining_time(ban['exp'])} : {ban.get('reason', 'no reason given')}\n" + cli_str += f"- {ban['ip']} ; banned the {datetime.fromtimestamp(ban['date']).strftime('%d-%m-%Y at %H:%M:%S')} for {format_remaining_time(ban['exp'])} remaining with reason \"{ban.get('reason', 'no reason given')}\"\n" cli_str += "\n" return True, cli_str diff --git a/src/common/cli/main.py b/src/common/cli/main.py index 92473b7c7..486e8f5d2 100644 --- a/src/common/cli/main.py +++ b/src/common/cli/main.py @@ -40,6 +40,12 @@ if __name__ == "__main__": help=f"banning time in seconds (default : {ban_time})", default=ban_time, ) + parser_ban.add_argument( + "-reason", + type=str, + help="reason for ban (default : manual)", + default="manual", + ) # Bans subparser parser_bans = subparsers.add_parser("bans", help="list current bans") @@ -55,7 +61,7 @@ if __name__ == "__main__": if args.command == "unban": ret, err = cli.unban(args.ip) elif args.command == "ban": - ret, err = cli.ban(args.ip, args.exp) + ret, err = cli.ban(args.ip, args.exp, args.reason) elif args.command == "bans": ret, err = cli.bans()