Last active
December 23, 2024 16:37
-
-
Save ammarfaizi2/a59a56a5dd2449d7d17715f9b1089edc to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
const MAIL_LOG_PATH = __DIR__."/users/ubuntu/gwmail/storage/gwmail-master/var/log/mail.log"; | |
const BAN_LIST_PATH = __DIR__."/storage/mail_ip_ban_list.txt"; | |
const IPSET_PATH = "/usr/sbin/ipset"; | |
const FAIL_THRESHOLD = 200; | |
const CHECK_INTERVAL_SECS = 120; | |
const CIDR_PRECISION = 24; | |
function grab_ban_list_map(string $path): array | |
{ | |
if (!file_exists($path)) | |
return []; | |
$ban_list = []; | |
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); | |
foreach ($lines as $l) | |
$ban_list[$l] = 1; | |
return $ban_list; | |
} | |
function apply_ipset_ban(array $ban_list): void | |
{ | |
/* | |
* If the IP has already been in the set, it's fine, ipset will just | |
* error out and continue. We can ignore the error. | |
*/ | |
foreach ($ban_list as $ip => $v) { | |
$cmd = sprintf("%s add drop_list_ipv4 %s 2>&1", IPSET_PATH, escapeshellarg($ip)); | |
shell_exec($cmd); | |
} | |
} | |
function del_ipset_ban(array $ban_list): void | |
{ | |
foreach ($ban_list as $ip => $v) { | |
$cmd = sprintf("%s del drop_list_ipv4 %s 2>&1", IPSET_PATH, escapeshellarg($ip)); | |
shell_exec($cmd); | |
} | |
} | |
/* | |
* Filter out failed login attempts from mail log. | |
* | |
* $log: Mail log content. | |
* $min_fail: Minimum number of failed attempts to be considered returned. | |
* | |
* Return an array of failed login attempts. | |
* - key: IP address | |
* - val: Number of failed attempts | |
*/ | |
function parse_fail_log(string $log, int $min_fail = 10): array | |
{ | |
$failures = []; | |
$lines = explode("\n", $log); | |
foreach ($lines as $l) { | |
/* | |
* Fail line example: | |
* "warning: blabla.com[183.165.134.221]: SASL LOGIN authentication failed: UGFzc3dvcmQ6" | |
*/ | |
if (preg_match("/warning: [a-zA-Z0-9\.\-]+?\[(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]: SASL LOGIN authentication failed:/", $l, $m)) { | |
$ip = $m[1]; | |
if (!isset($failures[$ip])) | |
$failures[$ip] = 0; | |
$failures[$ip]++; | |
} | |
} | |
foreach ($failures as $ip => $count) { | |
if ($count < $min_fail) | |
unset($failures[$ip]); | |
} | |
return $failures; | |
} | |
function aggregate_ban_list_subnet(array $ban_list, int $cidr): array | |
{ | |
$prefixes = []; | |
$to_del = []; | |
if ($cidr != 24 && $cidr != 16 && $cidr) | |
throw new Exception("Only CIDR 24 and 16 are supported"); | |
foreach ($ban_list as $ip => $v) { | |
$ex = explode(".", $ip); | |
if ($cidr == 24) | |
$prefix = "{$ex[0]}.{$ex[1]}.{$ex[2]}"; | |
else if ($cidr == 16) | |
$prefix = "{$ex[0]}.{$ex[1]}"; | |
if (!isset($prefixes[$prefix])) | |
$prefixes[$prefix] = 0; | |
if (++$prefixes[$prefix] > 1) { | |
unset($ban_list[$ip]); | |
$to_del[$ip] = 1; | |
} | |
} | |
// Remove stale entry. | |
foreach ($ban_list as $ip => $v) { | |
$ex = explode(".", $ip); | |
if ($cidr == 24) | |
$prefix = "{$ex[0]}.{$ex[1]}.{$ex[2]}"; | |
else if ($cidr == 16) | |
$prefix = "{$ex[0]}.{$ex[1]}"; | |
$c = (isset($prefixes[$prefix])) ? $prefixes[$prefix] : 0; | |
if ($c > 1) { | |
unset($ban_list[$ip]); | |
$to_del[$ip] = 1; | |
} | |
} | |
foreach ($prefixes as $prefix => $count) { | |
if ($count <= 1) | |
continue; | |
if ($cidr == 24) | |
$prefix .= ".0/24"; | |
else if ($cidr == 16) | |
$prefix .= ".0.0/16"; | |
$ban_list[$prefix] = 1; | |
} | |
return [ | |
"new_list" => $ban_list, | |
"to_del" => $to_del | |
]; | |
} | |
// Initialize ipset ban. | |
$ban_list = grab_ban_list_map(BAN_LIST_PATH); | |
apply_ipset_ban($ban_list); | |
while (true) { | |
$ban_list = grab_ban_list_map(BAN_LIST_PATH); | |
$full_log = file_get_contents(MAIL_LOG_PATH); | |
$failures = parse_fail_log($full_log, FAIL_THRESHOLD); | |
$nr_new_ips = 0; | |
foreach ($failures as $ip => $count) { | |
$ex = explode(".", $ip); | |
if (CIDR_PRECISION == 24) | |
$ip_cidr = "{$ex[0]}.{$ex[1]}.{$ex[2]}.0/24"; | |
else if (CIDR_PRECISION == 16) | |
$ip_cidr = "{$ex[0]}.{$ex[1]}.0.0/16"; | |
/* | |
* Check if the reported CIDR is already in the ban list | |
* in a range format. If it is, we don't need to ban the | |
* IP. | |
*/ | |
if (isset($ban_list[$ip_cidr])) | |
continue; | |
if (!isset($ban_list[$ip])) { | |
$ban_list[$ip] = 1; | |
$nr_new_ips++; | |
printf("Banned IP %s due to %d failed attempts\n", $ip, $count); | |
} | |
} | |
$n = aggregate_ban_list_subnet($ban_list, CIDR_PRECISION); | |
$ban_list = $n["new_list"]; | |
if (count($n["to_del"]) > 0) | |
printf("Summary: %d IPs have been aggregated into CIDR range block\n", count($n["to_del"])); | |
del_ipset_ban($n["to_del"]); | |
apply_ipset_ban($ban_list); | |
if ($nr_new_ips > 0) | |
printf("Summary: %d new IPs have been banned\n", $nr_new_ips); | |
file_put_contents(BAN_LIST_PATH, implode("\n", array_keys($ban_list))); | |
/* | |
* Clean up memory before sleep as the log and ban list can be huge. | |
*/ | |
unset($ban_list, $full_log, $failures, $new_ips, $n); | |
sleep(CHECK_INTERVAL_SECS); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment