mirror of
https://github.com/netbirdio/plugins.git
synced 2026-05-22 18:44:07 -07:00
Nginx: Refactored "ngx_autoblock" to reduce cpu consumption (#1773)
* Nginx/Autoblock: Refactored "ngx_autoblock.php" to reduce resource & cpu consumption. Also fixed issues: - $is_ten_minutes triggered always except every 10 minutes. - IPs are not just added but also removed (fixes race conditions between UI and script-invocation). * Nginx/Autoblock: Fixed errors caused by concurrent invocation. Improved resilience against calling the script while another instance is still running.
This commit is contained in:
@@ -42,6 +42,7 @@ function nginx_print_error($msg)
|
||||
array('status' => 'error', 'message' => $msg)
|
||||
);
|
||||
}
|
||||
|
||||
function exec_hidden($command): void
|
||||
{
|
||||
$descriptorspec = array(
|
||||
@@ -55,110 +56,273 @@ function exec_hidden($command): void
|
||||
proc_close($process);
|
||||
}
|
||||
}
|
||||
function add_to_blocklist($tablename, $ip)
|
||||
|
||||
function modify_blocklist($tablename, array $allIps, $operation = "add"): void
|
||||
{
|
||||
$escaped = escapeshellarg($ip);
|
||||
exec_hidden("/sbin/pfctl -t ${tablename} -T add ${escaped}");
|
||||
if (empty($allIps) || !in_array($operation, ["add", "delete"]))
|
||||
return;
|
||||
|
||||
$tablename = escapeshellarg($tablename);
|
||||
$operation = escapeshellarg($operation);
|
||||
|
||||
$longestIp = array_reduce($allIps, function ($length, $ip) {
|
||||
return max($length, strlen(escapeshellarg($ip)));
|
||||
}, 0);
|
||||
|
||||
$chunkSize = floor(4096 / ($longestIp + 1));
|
||||
$chunkSize = min(128, max(4, $chunkSize));
|
||||
|
||||
foreach (array_chunk($allIps, $chunkSize) as $ips) {
|
||||
$escapedIps = join(" ", array_map("escapeshellarg", $ips));
|
||||
|
||||
exec_hidden("/sbin/pfctl -t ${tablename} -T ${operation} ${escapedIps}");
|
||||
}
|
||||
}
|
||||
|
||||
function read_all_from_blocklist($tablename)
|
||||
{
|
||||
$tablename = escapeshellarg($tablename);
|
||||
|
||||
$descriptorspec = [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['file', "/dev/null", "w"],
|
||||
];
|
||||
|
||||
$process = proc_open("/sbin/pfctl -t ${tablename} -T show", $descriptorspec, $pipes);
|
||||
if (is_resource($process)) {
|
||||
$ips = [];
|
||||
while ($ip = fgets($pipes[1], 96))
|
||||
$ips[] = strtolower(trim($ip));
|
||||
|
||||
fclose($pipes[1]);
|
||||
proc_close($process);
|
||||
|
||||
return $ips;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function get_files_lastmodified(array $files): array
|
||||
{
|
||||
// Maps [file => filemtime]
|
||||
// File times of special files:
|
||||
// - Non existing => random mtime
|
||||
// - No content => -1
|
||||
$times = [];
|
||||
foreach ($files as $file) {
|
||||
$mtime = @filemtime($file) ?: rand();
|
||||
$times[$file] = @filesize($file) === 0 ? -1 : $mtime;
|
||||
}
|
||||
return $times;
|
||||
}
|
||||
|
||||
function reopen_logs()
|
||||
{
|
||||
exec_hidden('/usr/local/sbin/nginx -s reopen');
|
||||
}
|
||||
|
||||
$permanent_ban_file = '/var/log/nginx/permanentban.access.log';
|
||||
$permanent_ban_file_work = $permanent_ban_file . '.work';
|
||||
$autoblock_alias_name = 'nginx_autoblock';
|
||||
define('CRON_RUN_TEN_MINUTES', 10);
|
||||
$is_ten_minutes = intval(date('i')) % CRON_RUN_TEN_MINUTES != 0;
|
||||
const STATE_FILE = '/tmp/ngx_autoblock.state.json';
|
||||
const CONFIG_FILE = '/conf/config.xml';
|
||||
|
||||
const PERMANENT_BAN_FILE = '/var/log/nginx/permanentban.access.log';
|
||||
const PERMANENT_BAN_FILE_WORK = PERMANENT_BAN_FILE . '.work';
|
||||
|
||||
const TLS_HANDSHAKE_FILE = '/var/log/nginx/tls_handshake.log';
|
||||
const TLS_HANDSHAKE_FILE_WORK = TLS_HANDSHAKE_FILE . '.work';
|
||||
const TLS_HANDSHAKE_PROCESSING_TASK = '/usr/local/opnsense/scripts/nginx/tls_ua_fingerprint.php';
|
||||
|
||||
const AUTOBLOCK_ALIAS_NAME = 'nginx_autoblock';
|
||||
|
||||
const CRON_RUN_TEN_MINUTES = 10;
|
||||
$is_ten_minutes = intval(date('i')) % CRON_RUN_TEN_MINUTES == 0;
|
||||
|
||||
// Move log files and inform Nginx that we deleted them
|
||||
function create_work_files($include_tls_handshake)
|
||||
{
|
||||
$mapping = [PERMANENT_BAN_FILE => PERMANENT_BAN_FILE_WORK];
|
||||
if ($include_tls_handshake) {
|
||||
$mapping[TLS_HANDSHAKE_FILE] = TLS_HANDSHAKE_FILE_WORK;
|
||||
}
|
||||
|
||||
$existing_sources = array_filter(array_keys($mapping), "file_exists");
|
||||
$work_files = [];
|
||||
|
||||
if (count($existing_sources) == count($mapping)) {
|
||||
foreach ($mapping as $source => $target) {
|
||||
// Check if we already processing $target in another process and skip it if not stale
|
||||
if (file_exists($target)) {
|
||||
if (time() - (@filemtime($target) ?: 0) > (5 * 60))
|
||||
@unlink($target);
|
||||
else
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to create work and log on failure
|
||||
if (@rename($source, $target)) {
|
||||
@touch($target);
|
||||
$work_files[] = $target;
|
||||
} else {
|
||||
log_error("Failed renaming '$source' to '$target'. Skipping source for next run.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//Concurrent invocation. Can be silently ignored since no work files are collected.
|
||||
//log_error("Skipping processing. Missing: " . join(", ", array_diff(array_keys($mapping), $existing_sources)));
|
||||
}
|
||||
|
||||
if (!file_exists($permanent_ban_file)) {
|
||||
nginx_print_error('No Log exists - nothing to do');
|
||||
// let create it
|
||||
reopen_logs();
|
||||
register_shutdown_function("cleanup_work_files", $work_files);
|
||||
|
||||
return $work_files;
|
||||
}
|
||||
|
||||
function cleanup_work_files($work_files)
|
||||
{
|
||||
foreach ($work_files as $file)
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
// Checking if our sources are modified and create work files as needed (do nothing if sources are unchanged)
|
||||
$work_files = (function () use ($is_ten_minutes) {
|
||||
$sources = get_files_lastmodified([CONFIG_FILE, PERMANENT_BAN_FILE]);
|
||||
|
||||
$state = @json_decode(@file_get_contents(STATE_FILE), true);
|
||||
$changed = empty($state)
|
||||
|| !isset($state["sources"])
|
||||
|| $state["sources"] != $sources;
|
||||
|
||||
if ($changed || $is_ten_minutes) {
|
||||
// Rename sources to ".work" and tell nginx to reopen logs.
|
||||
// Triggering TLS-handshake processor every 10 minutes.
|
||||
$work_files = create_work_files($is_ten_minutes);
|
||||
|
||||
// Store state
|
||||
if (!empty($work_files)) {
|
||||
if (!is_array($state))
|
||||
$state = [];
|
||||
$state["sources"] = get_files_lastmodified(array_keys($sources));
|
||||
@file_put_contents(STATE_FILE, json_encode($state));
|
||||
}
|
||||
|
||||
return $work_files;
|
||||
} else {
|
||||
// Sources are not modified, nothing to do when not in "$is_ten_minutes".
|
||||
exit(0);
|
||||
}
|
||||
})();
|
||||
|
||||
// Triggering TLS-handshake processor when corresponding work file exists.
|
||||
if (in_array(TLS_HANDSHAKE_FILE_WORK, $work_files)) {
|
||||
mwexec(TLS_HANDSHAKE_PROCESSING_TASK);
|
||||
}
|
||||
|
||||
// Abort if permanent ban file is missing
|
||||
if (!in_array(PERMANENT_BAN_FILE_WORK, $work_files)) {
|
||||
nginx_print_error('No Log exists - nothing to do');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// move the file, and inform nginx that we deleted the file
|
||||
rename($permanent_ban_file, $permanent_ban_file_work);
|
||||
if ($is_ten_minutes) {
|
||||
rename('/var/log/nginx/tls_handshake.log', '/var/log/nginx/tls_handshake.log.work');
|
||||
}
|
||||
reopen_logs();
|
||||
if ($is_ten_minutes) {
|
||||
mwexec_bg('/usr/local/opnsense/scripts/nginx/tls_ua_fingerprint.php');
|
||||
}
|
||||
// Verifing autoblock fw-alias and adding it if missing
|
||||
(function () {
|
||||
$model = new Alias();
|
||||
|
||||
$log_parser = new AccessLogParser($permanent_ban_file_work);
|
||||
|
||||
$log_lines = $log_parser->get_result();
|
||||
|
||||
$model = new Alias();
|
||||
|
||||
$blacklist_element = null;
|
||||
foreach ($model->aliases->alias->iterateItems() as $alias) {
|
||||
if ((string)$alias->name == $autoblock_alias_name) {
|
||||
if ((string)$alias->type != 'external') {
|
||||
nginx_print_error('alias is misconfigured - exiting');
|
||||
exit(0);
|
||||
} else {
|
||||
$blacklist_element = $alias;
|
||||
break;
|
||||
$blacklist_element = null;
|
||||
foreach ($model->aliases->alias->iterateItems() as $alias) {
|
||||
if ((string)$alias->name == AUTOBLOCK_ALIAS_NAME) {
|
||||
if ((string)$alias->type != 'external') {
|
||||
nginx_print_error('alias is misconfigured - exiting');
|
||||
exit(0);
|
||||
} else {
|
||||
$blacklist_element = $alias;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// does not exist yet, create it
|
||||
if ($blacklist_element == null) {
|
||||
$blacklist_element = $model->aliases->alias->Add();
|
||||
$blacklist_element->name = $autoblock_alias_name;
|
||||
$blacklist_element->type = "external";
|
||||
$model->serializeToConfig();
|
||||
}
|
||||
// does not exist yet, create it
|
||||
if ($blacklist_element == null) {
|
||||
$blacklist_element = $model->aliases->alias->Add();
|
||||
$blacklist_element->name = AUTOBLOCK_ALIAS_NAME;
|
||||
$blacklist_element->type = "external";
|
||||
$model->serializeToConfig();
|
||||
}
|
||||
})();
|
||||
|
||||
$model = new Nginx();
|
||||
$alias_ips = [];
|
||||
foreach ($model->ban->iterateItems() as $entry) {
|
||||
$alias_ips[] = (string)$entry->ip;
|
||||
}
|
||||
|
||||
$new_ips = array_unique(
|
||||
array_map(function ($row) {
|
||||
if (stripos($row->remote_ip, '.') !== false) {
|
||||
return $row->remote_ip;
|
||||
}
|
||||
// in case of IPv6, we have to use the network address instead
|
||||
// danger of DoS because the attacker should have at least 2 ** 64 IPs
|
||||
return Net_IPv6::getNetmask($row->remote_ip, 64) . '/64';
|
||||
}, $log_lines)
|
||||
);
|
||||
|
||||
$change_required = false;
|
||||
|
||||
foreach (array_diff($new_ips, $alias_ips) as $new_ip) {
|
||||
$entry = $model->ban->Add();
|
||||
$entry->ip = $new_ip;
|
||||
$entry->time = time();
|
||||
$change_required = true;
|
||||
}
|
||||
|
||||
if ($change_required) {
|
||||
$val_result = $model->performValidation(false);
|
||||
if (count($val_result) !== 0) {
|
||||
print_r($val_result);
|
||||
exit(1);
|
||||
// Getting new banned IPs list
|
||||
$banned_ips = (function () {
|
||||
// Reading stored banned IPs from config
|
||||
$model = new Nginx();
|
||||
$alias_ips = [];
|
||||
foreach ($model->ban->iterateItems() as $entry) {
|
||||
$alias_ips[] = (string)$entry->ip;
|
||||
}
|
||||
|
||||
$model->serializeToConfig();
|
||||
Config::getInstance()->save();
|
||||
}
|
||||
// Collecting all new IPs from ban file not yet in $alias_ips.
|
||||
$new_ips = (function () use ($alias_ips) {
|
||||
// Read IPs from the log file
|
||||
$log_parser = new AccessLogParser(PERMANENT_BAN_FILE_WORK);
|
||||
$log_lines = $log_parser->get_result();
|
||||
$new_ips = array_unique(
|
||||
array_map(function ($row) {
|
||||
if (stripos($row->remote_ip, '.') !== false) {
|
||||
return $row->remote_ip;
|
||||
}
|
||||
// in case of IPv6, we have to use the network address instead
|
||||
// danger of DoS because the attacker should have at least 2 ** 64 IPs
|
||||
return Net_IPv6::getNetmask($row->remote_ip, 64) . '/64';
|
||||
}, $log_lines)
|
||||
);
|
||||
|
||||
// Return only IPs not yet in $alias_ips
|
||||
return array_diff($new_ips, $alias_ips);
|
||||
})();
|
||||
|
||||
// Transfering new IPs into $alias_ips and store them permanently.
|
||||
$new_and_alias_ips = (function () use ($model, $new_ips, $alias_ips) {
|
||||
$change_required = false;
|
||||
|
||||
foreach ($new_ips as $new_ip) {
|
||||
$alias_ips[] = $new_ip;
|
||||
|
||||
$entry = $model->ban->Add();
|
||||
$entry->ip = $new_ip;
|
||||
$entry->time = time();
|
||||
$change_required = true;
|
||||
}
|
||||
|
||||
if ($change_required) {
|
||||
$val_result = $model->performValidation(false);
|
||||
if (count($val_result) !== 0) {
|
||||
print_r($val_result);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$model->serializeToConfig();
|
||||
Config::getInstance()->save();
|
||||
}
|
||||
|
||||
return $alias_ips;
|
||||
})();
|
||||
|
||||
// Returning banned IPs (= combination of (new_ips + alias_ips))
|
||||
return $new_and_alias_ips;
|
||||
})();
|
||||
|
||||
echo '{"status":"saved"}';
|
||||
|
||||
// all ips are used because the others may not be set for some reason
|
||||
foreach ($model->ban->iterateItems() as $entry) {
|
||||
add_to_blocklist($autoblock_alias_name, (string)$entry->ip);
|
||||
}
|
||||
// Updating PF table with banned IPs
|
||||
(function () use ($banned_ips) {
|
||||
$ips_to_add = $banned_ips;
|
||||
$ips_to_remove = [];
|
||||
|
||||
@unlink($permanent_ban_file_work);
|
||||
// Checking which IPs are in the table and apply changes
|
||||
$ips_in_table = read_all_from_blocklist(AUTOBLOCK_ALIAS_NAME);
|
||||
if (!empty($ips_in_table)) {
|
||||
$ips_to_add = array_diff($banned_ips, $ips_in_table);
|
||||
$ips_to_remove = array_diff($ips_in_table, $banned_ips);
|
||||
}
|
||||
|
||||
modify_blocklist(AUTOBLOCK_ALIAS_NAME, $ips_to_add, "add");
|
||||
modify_blocklist(AUTOBLOCK_ALIAS_NAME, $ips_to_remove, "delete");
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user