Skip to content

Instantly share code, notes, and snippets.

@ammarfaizi2
Last active December 23, 2024 16:37
Show Gist options
  • Save ammarfaizi2/a59a56a5dd2449d7d17715f9b1089edc to your computer and use it in GitHub Desktop.
Save ammarfaizi2/a59a56a5dd2449d7d17715f9b1089edc to your computer and use it in GitHub Desktop.
<?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