diff --git a/www/nginx/src/opnsense/scripts/nginx/ngx_autoblock.php b/www/nginx/src/opnsense/scripts/nginx/ngx_autoblock.php index 0cd607499..4c7b712da 100755 --- a/www/nginx/src/opnsense/scripts/nginx/ngx_autoblock.php +++ b/www/nginx/src/opnsense/scripts/nginx/ngx_autoblock.php @@ -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"); +})();