diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index c60638fa..b61f8775 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -210,8 +210,10 @@ jobs: run: php tests/regression/cli_schema_upgrade_test.php working-directory: ${{ github.workspace }}/cacti/plugins/syslog - - name: Run threshold recovery database checks - run: php tests/regression/threshold_recovery_database_test.php + - name: Run Syslog database regression checks + run: | + php tests/regression/threshold_recovery_database_test.php + php tests/regression/quality_must_fix_database_test.php working-directory: ${{ github.workspace }}/cacti/plugins/syslog env: SYSLOG_TEST_MYSQL_DSN: 'mysql:host=127.0.0.1;port=3306' diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe40ae3..b681bef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ --- develop --- +* style: Keep all SonarCloud-reported lines within the 120-character limit by reflowing long SQL calls, ternaries, mappings and misaligned PHPDoc comment blocks, and associate the Device Alert Rule filter controls (Search, Enabled, Rules) with accessible labels +* bug: Suppress automatic schema repair during plugin lifecycle operations and while Syslog is disabled or uninstalled; stop stale processor launches before they can recreate removed tables + +* bug: Repair missing tables automatically on the main collector as well as remotes, and skip processing when schema repair fails + +* bug: Resolve Cacti 1.2.x main-poller default database connections explicitly for Syslog rule replication + +* bug: Automatically repair collector-local schema before remote Syslog queries and processing after reinstall; keep failed message queries from disabling the plugin with a return-type error + +* bug: Remove all plugin-owned tables from remote collectors through Cacti's native API when uninstalling all Syslog data + +* security: Escape stored removal and report rule names in all bulk-action confirmations +* bug: Route rule imports to the configured Syslog database and publish complete remote rule snapshots with source-aware schema transfer and failure-safe replacement +* bug: Bound digest backfill to the registered master and a fixed migration range, retaining the last durable cursor when a batch or progress write fails +* bug: Report dashboard write failures and roll back partial deletes, copies and panel ordering changes +* bug: Count equal retained and removed log totals correctly and remove all plugin-owned tables during uninstall-all +* test: Repair the schema CLI fixture and add dedicated-database, rule synchronization, migration, output escaping and dashboard rollback regression coverage +* test: Measure lib/QueryBuilder.php in the patch-coverage gate and cover the QueryBuilder availability guards, the timeseries dashboard panel branch, invalid-database partition guards, retroactive removal-rule field maps, dMaxValue metadata extraction and the metadata label/filter button renderers + * bug: Restore the incoming-to-syslog archive INSERT so its outer SELECT no longer redeclares a second unbound poller_id placeholder (SQLSTATE HY093 "Invalid parameter number" made every transfer fail with "Unable to archive incoming records") and reads the derived table's columns instead of the out-of-scope syslog_incoming alias * feature: Add an optional Recovery command to threshold alert rules, with correlated breach/clear script context and durable collector-owned clear retries * feature: Add poller-driven pipeline health alerts for stalled processing, incoming backlogs, remote synchronization failures or delays, and blocked partition maintenance; expand the existing Status health card with check results and email delivery status, with optional problem, reminder, and recovery notifications diff --git a/cli/syslog_schema.php b/cli/syslog_schema.php index 9bdeb04a..0ead94a5 100644 --- a/cli/syslog_schema.php +++ b/cli/syslog_schema.php @@ -27,7 +27,7 @@ require_once(dirname(__DIR__) . '/includes/schema.php'); require_once(dirname(__DIR__) . '/includes/settings.php'); -/* process calling arguments */ +// process calling arguments $parms = $_SERVER['argv']; array_shift($parms); @@ -44,12 +44,15 @@ switch ($arg) { case '--audit': $audit = true; + break; case '--repair': $repair = true; + break; case '--upgrade': $upgrade = true; + break; case '--help': case '-H': @@ -68,12 +71,14 @@ } syslog_determine_config(); + if (!defined('SYSLOG_CONFIG')) { fwrite(STDERR, 'ERROR: Syslog database configuration is missing.' . PHP_EOL); exit(1); } $database_last_error = ''; + if (!syslog_connect()) { fwrite(STDERR, 'ERROR: Unable to connect to the Syslog database.' . PHP_EOL); exit(1); @@ -100,12 +105,15 @@ function syslog_schema_repair_database(): bool { 'db_type' => 'part', 'days' => read_config_option('syslog_install_days') ], true); + if (!syslog_check_upgrade(true)) { fwrite(STDERR, 'ERROR: Syslog schema upgrade failed; check the Cacti log.' . PHP_EOL); + return false; } $success = syslog_schema_report_audit_results(false); + if ($success) { print 'Syslog schema upgrade completed; all required tables exist.' . PHP_EOL; } @@ -132,12 +140,14 @@ function syslog_schema_report_audit_results(bool $audit = true): bool { 'syslog_saved_searches_perm', 'syslog_device_rule', 'syslog_replication_output', 'syslog_replication_recovery' ]; + if ((int) $config['poller_id'] <= 1) { $tables[] = 'syslog_replication_receipts'; $tables[] = 'syslog_replication_collectors'; } $missing = []; + foreach ($tables as $table) { if (!syslog_db_table_exists($table, false)) { $missing[] = $table; @@ -146,16 +156,20 @@ function syslog_schema_report_audit_results(bool $audit = true): bool { if (!empty($database_last_error)) { fwrite(STDERR, 'ERROR: Schema check encountered a database error; check the Cacti log.' . PHP_EOL); + return false; } + if ($missing) { fwrite(STDERR, 'ERROR: Missing tables: ' . implode(', ', $missing) . PHP_EOL); + return false; } if ($audit) { print 'Syslog schema audit passed; all required tables exist.' . PHP_EOL; } + return true; } diff --git a/docs/setup-modules.md b/docs/setup-modules.md index a487eb40..31a2a5cf 100644 --- a/docs/setup-modules.md +++ b/docs/setup-modules.md @@ -62,3 +62,10 @@ rule changes reach remote collectors during Cacti's next full replication cycle, through the registered `syslog_replicate_out` hook. The save handler does not query or change Cacti's collector tables, or discover and connect to collectors. Synchronized remote collectors continue to reject local rule edits. + +The rule snapshot helper uses the main Syslog connection for source schema and +Cacti's supplied remote connection for delivery. It builds each snapshot in a +plugin-owned staging table and publishes it with an atomic table rename, so an +empty source clears deleted rules and a failed copy preserves the live table. +Remote collectors copy Cacti's local rule snapshot into a dedicated Syslog +database only from the registered master process. diff --git a/includes/database.php b/includes/database.php index fa879d6b..e3c93646 100644 --- a/includes/database.php +++ b/includes/database.php @@ -74,9 +74,9 @@ function syslog_db_execute($sql, $log = true): bool { /** * syslog_db_execute_prepared - run an sql query and do not return any output * - * @param string $sql The sql query to execute + * @param string $sql The sql query to execute * @param array $parms The sql params for the prepare - * @param bool $log Whether to log error messages, defaults to true + * @param bool $log Whether to log error messages, defaults to true * * @return bool true for success, false for error */ @@ -106,10 +106,10 @@ function syslog_db_fetch_cell($sql, $col_name = '', $log = true) { * syslog_db_fetch_cell_prepared - run a 'select' sql query and return the first column of the * first row found * - * @param string $sql The sql query to execute + * @param string $sql The sql query to execute * @param array $params An array of parameters - * @param string $col_name Use this column name instead of the first one - * @param bool $log Whether to log error messages, defaults to true + * @param string $col_name Use this column name instead of the first one + * @param bool $log Whether to log error messages, defaults to true * * @return mixed The output of the sql query as a single variable */ @@ -136,9 +136,9 @@ function syslog_db_fetch_row($sql, $log = true) { /** * syslog_db_fetch_row_prepared - run a 'select' sql query and return the first row found * - * @param string $sql The sql query to execute + * @param string $sql The sql query to execute * @param array $params An array of parameters - * @param bool $log Whether to log error messages, defaults to true + * @param bool $log Whether to log error messages, defaults to true * * @return array|false The first row of the result as a hash */ @@ -165,9 +165,9 @@ function syslog_db_fetch_assoc($sql, $log = true) { /** * syslog_db_fetch_assoc_prepared - run a 'select' sql query and return all rows found * - * @param string $sql The sql query to execute + * @param string $sql The sql query to execute * @param array $params An array of parameters - * @param bool $log Whether to log error messages, defaults to true + * @param bool $log Whether to log error messages, defaults to true * * @return list>|false The entire result set as a multi-dimensional hash */ @@ -191,9 +191,9 @@ function syslog_db_fetch_insert_id(): int { /** * syslog_db_replace - replaces the data contained in a particular row * - * @param string $table_name The name of the table to make the replacement in - * @param array $array_items An array containing each column -> value mapping in the row - * @param string|array $keyCols The name of the column containing the primary key + * @param string $table_name The name of the table to make the replacement in + * @param array $array_items An array containing each column -> value mapping in the row + * @param string|array $keyCols The name of the column containing the primary key * * @return int|false The auto incriment id column (if applicable), or false for error */ @@ -272,9 +272,9 @@ function syslog_db_column_exists($table, $column, $log = true): bool { /** * syslog_db_add_column - adds a column to a table * - * @param string $table The name of the table + * @param string $table The name of the table * @param array $column The column definition - * @param bool $log Whether to log error messages, defaults to true + * @param bool $log Whether to log error messages, defaults to true * * @return bool true if the column was added, false otherwise */ diff --git a/includes/functions.php b/includes/functions.php index ae8bb57b..fb7a6636 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -23,6 +23,7 @@ */ $syslog_query_builder = dirname(__DIR__) . '/lib/QueryBuilder.php'; + if (file_exists($syslog_query_builder)) { require_once $syslog_query_builder; } @@ -34,9 +35,9 @@ */ function syslog_search_fields(): array { return ['message' => 'Message', 'host' => 'Host', 'program' => 'Program', - 'facility' => 'Facility', 'priority' => 'Priority', 'logtime' => 'Date', - 'seq' => 'Sequence', 'host_id' => 'Host ID', 'program_id' => 'Program ID', - 'facility_id' => 'Facility ID', 'priority_id' => 'Priority ID']; + 'facility' => 'Facility', 'priority' => 'Priority', 'logtime' => 'Date', + 'seq' => 'Sequence', 'host_id' => 'Host ID', 'program_id' => 'Program ID', + 'facility_id' => 'Facility ID', 'priority_id' => 'Priority ID']; } /** @@ -47,14 +48,23 @@ function syslog_search_fields(): array { function syslog_search_choices(): array { global $syslogdb_default; $choices = []; + foreach (['facility' => 'syslog_facilities', 'priority' => 'syslog_priorities', 'program' => 'syslog_programs'] as $field => $table) { $choices[$field . '_id'] = []; - if ($field !== 'program') { $choices[$field] = []; } + + if ($field !== 'program') { + $choices[$field] = []; + } + foreach (syslog_db_fetch_assoc("SELECT {$field}_id AS id, $field AS name FROM `$syslogdb_default`.`$table` ORDER BY $field") as $record) { $choices[$field . '_id'][] = [(string) $record['id'], $record['name'] . ' (' . $record['id'] . ')']; - if ($field !== 'program') { $choices[$field][] = [$record['name'], $record['name']]; } + + if ($field !== 'program') { + $choices[$field][] = [$record['name'], $record['name']]; + } } } + return $choices; } @@ -70,28 +80,34 @@ function syslog_search_choices(): array { */ function syslog_search_suggestions(string $field, string $term, string $tab, string $removal): array { global $syslogdb_default; + if (!isset(syslog_search_fields()[$field]) || $field === 'logtime' || strlen($term) > 1024) { return []; } $pattern = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $term) . '%'; - $base = substr($field, -3) === '_id' ? substr($field, 0, -3) : $field; - $tables = ['host' => 'syslog_hosts', 'program' => 'syslog_programs', 'facility' => 'syslog_facilities', 'priority' => 'syslog_priorities']; + $base = substr($field, -3) === '_id' ? substr($field, 0, -3) : $field; + $tables = ['host' => 'syslog_hosts', 'program' => 'syslog_programs', 'facility' => 'syslog_facilities', 'priority' => 'syslog_priorities']; + if (isset($tables[$base]) && !($base === 'host' && $tab === 'alerts')) { - $table = $tables[$base]; + $table = $tables[$base]; $records = syslog_db_fetch_assoc_prepared("SELECT $field AS value, $base AS label FROM `$syslogdb_default`.`$table` WHERE $base LIKE ? ESCAPE '!' OR CAST($field AS CHAR) LIKE ? ESCAPE '!' ORDER BY $base LIMIT 30", [$pattern, $pattern]); } else { - if (!in_array($field, ['message', 'seq', 'host'], true)) { return []; } - $column = $field === 'message' && $tab === 'alerts' ? 'logmsg' : $field; - $tables = $tab === 'alerts' ? ['syslog_logs'] : ($removal === '1' ? ['syslog', 'syslog_removed'] : [$removal === '-1' ? 'syslog' : 'syslog_removed']); + if (!in_array($field, ['message', 'seq', 'host'], true)) { + return []; + } + $column = $field === 'message' && $tab === 'alerts' ? 'logmsg' : $field; + $tables = $tab === 'alerts' ? ['syslog_logs'] : ($removal === '1' ? ['syslog', 'syslog_removed'] : [$removal === '-1' ? 'syslog' : 'syslog_removed']); $queries = []; + foreach ($tables as $table) { $queries[] = "SELECT $column AS value FROM (SELECT $column FROM `$syslogdb_default`.`$table` ORDER BY seq DESC LIMIT 1000) AS recent_$table"; } $records = syslog_db_fetch_assoc_prepared('SELECT DISTINCT value, value AS label FROM (' . implode(' UNION ALL ', $queries) . ") AS suggestions WHERE value LIKE ? ESCAPE '!' ORDER BY value LIMIT 30", [$pattern]); } + return array_map(function ($record) { return ['value' => (string) $record['value'], 'label' => (string) $record['label']]; }, $records); @@ -105,7 +121,10 @@ function syslog_search_suggestions(string $field, string $term, string $tab, str * @return array Array of operator strings. */ function syslog_search_operators(string $field): array { - if ($field === 'logtime') { return ['=', '!=', '>', '>=', '<', '<=', 'last']; } + if ($field === 'logtime') { + return ['=', '!=', '>', '>=', '<', '<=', 'last']; + } + return $field === 'seq' || substr($field, -3) === '_id' || $field === 'logtime' ? ['=', '!=', '>', '>=', '<', '<='] : ['contains', '=', '!=', 'like']; } @@ -126,35 +145,44 @@ function syslog_parse_logical_search(string $input): ?array { $tokens = []; $length = strlen($input); + for ($i = 0; $i < $length;) { if (ctype_space($input[$i])) { $i++; + continue; } + if (preg_match('/\G([a-z_]+)\s+(contains|like|last|regex|!=|>=|<=|=|>|<)\s+(?=")/', $input, $match, 0, $i)) { if (!isset(syslog_search_fields()[$match[1]]) || !in_array($match[2], syslog_search_operators($match[1]), true)) { throw new InvalidArgumentException('Invalid field or operator.'); } $tokens[] = ['field', $match[1], $match[2]]; $i += strlen($match[0]); + continue; } + if ($input[$i] == '(' || $input[$i] == ')') { $tokens[] = [$input[$i++], '']; } elseif ($input[$i] == '"') { - $value = ''; + $value = ''; $closed = false; + for ($i++; $i < $length; $i++) { if ($input[$i] == '"') { $i++; $closed = true; + break; } + if ($input[$i] == '\\' && $i + 1 < $length && ($input[$i + 1] == '"' || $input[$i + 1] == '\\')) { $i++; } $value .= $input[$i]; } + if (!$closed || $value === '') { throw new InvalidArgumentException('Use a nonempty phrase with a closing double quote.'); } @@ -164,6 +192,7 @@ function syslog_parse_logical_search(string $input): ?array { $i += strlen($match[1]); } else { $start = $i++; + while ($i < $length && strpos('()"', $input[$i]) === false) { if (ctype_space($input[$i - 1]) && preg_match('/\G(AND|OR|NOT)(?=\s|[()"]|$)/', $input, $match, 0, $i)) { break; @@ -173,37 +202,45 @@ function syslog_parse_logical_search(string $input): ?array { $tokens[] = ['term', trim(substr($input, $start, $i - $start))]; } } + if (!$tokens) { return null; } + if (count($tokens) > 256) { throw new InvalidArgumentException('Search is too complex (maximum 256 tokens).'); } $position = 0; - $parse = function ($minimum = 0, $depth = 0) use (&$parse, &$position, $tokens) { + $parse = function ($minimum = 0, $depth = 0) use (&$parse, &$position, $tokens) { if ($depth > 32) { throw new InvalidArgumentException('Search nesting is too deep (maximum 32 levels).'); } /** @var array $token A token: ['field', name, operator], ['term', value], ['NOT'|'AND'|'OR'|'('|')', '']. */ $token = $tokens[$position++] ?? ['', '']; + if ($token[0] == 'NOT') { $node = ['NOT', $parse(3, $depth + 1)]; } elseif ($token[0] == '(') { $node = $parse(0, $depth + 1); + if (($tokens[$position++][0] ?? '') != ')') { throw new InvalidArgumentException('Expected a closing parenthesis.'); } } elseif ($token[0] == 'field') { $value = $tokens[$position++] ?? []; + if (($value[0] ?? '') !== 'term') { throw new InvalidArgumentException('Expected a quoted field value.'); } + if (($token[1] === 'seq' || substr($token[1], -3) === '_id') && !ctype_digit($value[1])) { throw new InvalidArgumentException('IDs must be nonnegative integers.'); } + if ($token[2] === 'last' && !in_array($value[1], ['3600', '21600', '86400', '604800', '1209600', '2592000', '3months', '6months'], true)) { throw new InvalidArgumentException('Invalid date preset.'); } + if ($token[1] === 'logtime' && $token[2] !== 'last' && (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value[1]) || strtotime($value[1]) === false)) { throw new InvalidArgumentException('Use a date in YYYY-MM-DD HH:MM:SS format.'); } @@ -213,21 +250,26 @@ function syslog_parse_logical_search(string $input): ?array { } else { throw new InvalidArgumentException('Expected a search term, NOT, or an opening parenthesis.'); } + while (isset($tokens[$position])) { - $operator = $tokens[$position][0]; + $operator = $tokens[$position][0]; $precedence = ['OR' => 1, 'AND' => 2][$operator] ?? 0; + if (!$precedence || $precedence < $minimum) { break; } $position++; $node = [$operator, $node, $parse($precedence + 1, $depth + 1)]; } + return $node; }; $tree = $parse(); + if ($position != count($tokens)) { throw new InvalidArgumentException('Expected AND or OR between terms, or found an extra closing parenthesis.'); } + return $tree; } @@ -235,7 +277,7 @@ function syslog_parse_logical_search(string $input): ?array { * LOCATE treats wildcard and regex characters literally and uses column collation. * * @param array|null $tree The parsed search tree. - * @param string $column The column name ('message' or 'logmsg'). + * @param string $column The column name ('message' or 'logmsg'). * * @return string The SQL WHERE clause fragment. * @@ -245,46 +287,58 @@ function syslog_logical_search_sql(?array $tree, string $column): string { if (!in_array($column, ['message', 'logmsg'], true)) { throw new InvalidArgumentException('Invalid message column.'); } + if ($tree === null) { return ''; } + if ($tree[0] === 'predicate') { global $syslogdb_default; [, $field, $operator, $value] = $tree; + if (!isset(syslog_search_fields()[$field]) || !in_array($operator, syslog_search_operators($field), true)) { throw new InvalidArgumentException('Invalid field or operator.'); } + if ($field === 'host_id' && $column === 'logmsg') { throw new InvalidArgumentException('Host ID is only available for system logs.'); } + if ($operator === 'last') { if (!in_array($value, ['3600', '21600', '86400', '604800', '1209600', '2592000', '3months', '6months'], true)) { throw new InvalidArgumentException('Invalid date preset.'); } $interval = ['3months' => '3 MONTH', '6months' => '6 MONTH'][$value] ?? ((int) $value . ' SECOND'); + return '(syslog.logtime BETWEEN DATE_SUB(NOW(), INTERVAL ' . $interval . ') AND NOW())'; } $target = $field === 'message' ? $column : 'syslog.' . $field; + if (in_array($field, ['host', 'program', 'facility', 'priority'], true)) { if ($field === 'host' && $column === 'logmsg') { $target = 'syslog.host'; } else { - $table = ['host' => 'syslog_hosts', 'program' => 'syslog_programs', 'facility' => 'syslog_facilities', 'priority' => 'syslog_priorities'][$field]; + $table = ['host' => 'syslog_hosts', 'program' => 'syslog_programs', 'facility' => 'syslog_facilities', 'priority' => 'syslog_priorities'][$field]; $target = "(SELECT search_lookup.$field FROM `$syslogdb_default`.`$table` AS search_lookup WHERE search_lookup.{$field}_id = syslog.{$field}_id)"; } } + if ($operator === 'contains') { return '(LOCATE(' . db_qstr($value) . ', ' . $target . ') > 0)'; } $operator = ['like' => 'LIKE'][$operator] ?? $operator; + return '(' . $target . ' ' . $operator . ' ' . db_qstr($value) . ')'; } + if ($tree[0] == 'term') { return '(LOCATE(' . db_qstr($tree[1]) . ', ' . $column . ') > 0)'; } + if ($tree[0] == 'NOT') { return '(NOT ' . syslog_logical_search_sql($tree[1], $column) . ')'; } + return '(' . syslog_logical_search_sql($tree[1], $column) . ' ' . $tree[0] . ' ' . syslog_logical_search_sql($tree[2], $column) . ')'; } @@ -292,7 +346,7 @@ function syslog_logical_search_sql(?array $tree, string $column): string { * Extract positive terms from a parsed search tree. * * @param array|null $tree The parsed search tree. - * @param bool $negative Whether to extract negative terms. + * @param bool $negative Whether to extract negative terms. * * @return array Array of search terms. */ @@ -300,15 +354,19 @@ function syslog_logical_positive_terms(?array $tree, bool $negative = false): ar if ($tree === null) { return []; } + if ($tree[0] === 'predicate') { return !$negative && $tree[1] === 'message' && in_array($tree[2], ['contains', '='], true) ? [$tree[3]] : []; } + if ($tree[0] == 'term') { return $negative ? [] : [$tree[1]]; } + if ($tree[0] == 'NOT') { return syslog_logical_positive_terms($tree[1], !$negative); } + return array_merge(syslog_logical_positive_terms($tree[1], $negative), syslog_logical_positive_terms($tree[2], $negative)); } @@ -323,8 +381,8 @@ function syslog_logical_positive_terms(?array $tree, bool $negative = false): ar * @return string The search string with auto dates removed. */ function syslog_strip_auto_dates(string $search, mixed $date1, mixed $date2): string { - $d1 = str_replace(['\\', '"'], ['\\\\', '\\"'], (string) $date1); - $d2 = str_replace(['\\', '"'], ['\\\\', '\\"'], (string) $date2); + $d1 = str_replace(['\\', '"'], ['\\\\', '\\"'], (string) $date1); + $d2 = str_replace(['\\', '"'], ['\\\\', '\\"'], (string) $date2); $suffix = 'logtime >= "' . $d1 . '" AND logtime <= "' . $d2 . '"'; if (substr($search, -strlen($suffix)) === $suffix) { @@ -472,7 +530,7 @@ function syslog_shared_item_ids(string $item): array { return []; } - list($table, $column) = $share_table; + [$table, $column] = $share_table; $group_ids = syslog_user_group_ids($user_id); @@ -509,7 +567,7 @@ function syslog_shared_item_ids(string $item): array { /** * Current grants on one item, shaped for the drop_multi form fields. * - * @param string $item The item kind. + * @param string $item The item kind. * @param int $item_id The item ID. * * @return array{users: array, groups: array} @@ -525,7 +583,7 @@ function syslog_fetch_item_shares(string $item, int $item_id): array { return $shares; } - list($table, $column) = $share_table; + [$table, $column] = $share_table; $rows = syslog_db_fetch_assoc_prepared("SELECT type, item_id FROM `$syslogdb_default`.`$table` @@ -585,10 +643,10 @@ function syslog_parse_share_ids(string $name): array { * they simply never match a real user or group. The 'all' sentinel grants * the item to every signed-in user with a single row (item_id 0). * - * @param string $item The item kind. - * @param int $item_id The item ID. - * @param array $users Array of user IDs or 'all'. - * @param array $groups Array of group IDs or 'all'. + * @param string $item The item kind. + * @param int $item_id The item ID. + * @param array $users Array of user IDs or 'all'. + * @param array $groups Array of group IDs or 'all'. * * @return void */ @@ -601,7 +659,7 @@ function syslog_save_item_shares(string $item, int $item_id, array $users, array return; } - list($table, $column) = $share_table; + [$table, $column] = $share_table; syslog_db_execute_prepared("DELETE FROM `$syslogdb_default`.`$table` WHERE $column = ?", @@ -653,26 +711,29 @@ function syslog_message_filter_value(string $value, string $filter, string $href $terms = syslog_logical_positive_terms($GLOBALS['syslog_search_tree'] ?? null); usort($terms, function ($a, $b) { return strlen($b) - strlen($a); }); $pattern = $terms ? '~(' . implode('|', array_map(function ($term) { return preg_quote($term, '~'); }, $terms)) . ')~iu' : ''; - $parts = $pattern ? preg_split($pattern, $value, -1, PREG_SPLIT_DELIM_CAPTURE) : [$value]; + $parts = $pattern ? preg_split($pattern, $value, -1, PREG_SPLIT_DELIM_CAPTURE) : [$value]; + if ($parts === false) { $parts = [$value]; } $output = ''; + foreach ($parts as $index => $part) { $escaped = htmlspecialchars($part, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $output .= $index % 2 ? '' . $escaped . '' : $escaped; } + return $href === '' ? $output : '' . $output . ''; } /** * Apply a bulk action to selected items. * - * @param array|false $selected_items Array of selected item IDs, or false. - * @param string $drp_action The selected action. + * @param array|false $selected_items Array of selected item IDs, or false. + * @param string $drp_action The selected action. * @param array $action_map Map of actions to function names. - * @param string $export_action Optional export action name. - * @param string $export_items Optional export items config. + * @param string $export_action Optional export action name. + * @param string $export_items Optional export items config. * * @return void */ @@ -716,6 +777,7 @@ function syslog_export_form_end(bool $export): void { global $form_id; form_end(false); + if (!$export) { return; } @@ -763,7 +825,7 @@ function syslog_include_js(): void { * @return void */ function syslog_syslog_legend(): void { - html_start_box('', '100%', '', '3', 'center', ''); + html_start_box('', '100%', false, 3, 'center', ''); $items = [ 'logEmergency' => __('Emergency', 'syslog'), @@ -777,6 +839,7 @@ function syslog_syslog_legend(): void { ]; $chip_min = 0; + foreach ($items as $label) { $chip_min = max($chip_min, mb_strlen($label)); } @@ -803,7 +866,7 @@ function syslog_syslog_legend(): void { * @return void */ function syslog_log_legend(): void { - html_start_box('', '100%', '', '3', 'center', ''); + html_start_box('', '100%', false, 3, 'center', ''); $items = [ 'logAlert' => __('Alert', 'syslog'), @@ -812,6 +875,7 @@ function syslog_log_legend(): void { ]; $chip_min = 0; + foreach ($items as $label) { $chip_min = max($chip_min, mb_strlen($label)); } @@ -883,11 +947,13 @@ function syslog_allow_rule_edits(): bool { function syslog_sync_save(array $data, string $table, string $primary = ''): void { if (!syslog_allow_edits()) { raise_message('syslog_denied', __('Save Failed. Remote Data Collectors in Sync Mode are not allowed to Save Rules. Save from the Main Cacti Server instead.', 'syslog'), MESSAGE_LEVEL_ERROR); + return; } // Cacti's full replication invokes syslog_replicate_out() for remote rules. $id = syslog_sql_save($data, $table, $primary); + if ($id > 0) { raise_message(1); } else { @@ -898,11 +964,11 @@ function syslog_sync_save(array $data, string $table, string $primary = ''): voi /** * Send email alert with optional SMS support. * - * @param string $to Recipient email address (may include sms@ addresses). - * @param array $from Sender email and name as a list: [email, name]. - * @param string $subject Email subject. - * @param string $message Email message body (HTML). - * @param string $smsmessage SMS message body. + * @param string $to Recipient email address (may include sms@ addresses). + * @param array $from Sender email and name as a list: [email, name]. + * @param string $subject Email subject. + * @param string $message Email message body (HTML). + * @param string $smsmessage SMS message body. * * @return void */ @@ -1035,8 +1101,8 @@ function syslog_read_import_file(string $filename): string|false { /** * syslog_rules_array2json - encode a list of rule rows as a JSON export document * - * @param string $table Source rule table, used for uniqueness/version metadata - * @param array> $rules Rule rows (the 'id' key is removed before export) + * @param string $table Source rule table, used for uniqueness/version metadata + * @param array> $rules Rule rows (the 'id' key is removed before export) * * @return string JSON document suitable for download */ @@ -1102,6 +1168,7 @@ function syslog_parse_rule_import(string $payload, string $expected_table): arra if (isset($decoded['table']) && $decoded['table'] !== $expected_table) { return false; } + if (isset($decoded['version']) && $decoded['version'] !== SYSLOG_IMPORT_VERSION) { return false; } @@ -1135,14 +1202,17 @@ function syslog_parse_rule_import(string $payload, string $expected_table): arra return false; } $templates = xml2array($payload); + if (!is_array($templates)) { return false; } + foreach ($templates as $template) { if (!is_array($template) || !syslog_import_matches_table($template, $expected_table)) { return false; } } + return $templates; } @@ -1158,6 +1228,7 @@ function syslog_import_matches_table(array $template, string $table): bool { if (!isset($template['name']) || !is_string($template['name']) || trim($template['name']) === '') { return false; } + switch ($table) { case 'syslog_alert': return isset($template['severity'], $template['method'], $template['message']) && @@ -1170,6 +1241,7 @@ function syslog_import_matches_table(array $template, string $table): bool { case 'syslog_dashboards': return isset($template['panels']) && is_array($template['panels']); } + return false; } @@ -1491,8 +1563,8 @@ function syslog_status_record_phase(string $phase, int $start, int $end, float $ * that have never run. * * @return array - * Map of phase name to its telemetry document, or null when - * the phase has not recorded a run yet. + * Map of phase name to its telemetry document, or null when the + * phase has not recorded a run yet. */ function syslog_status_phase_telemetry(): array { global $syslogdb_default; @@ -1548,23 +1620,23 @@ function syslog_status_get(): array { syslog_status_ensure_table(); $status = [ - 'pipeline_health_monitor' => '', - 'last_polling_time' => '', - 'last_start_time' => '', - 'last_end_time' => '', - 'last_record_count' => '', - 'polling_runtime_last' => '', - 'polling_runtime_min' => '', - 'polling_runtime_avg' => '', - 'polling_runtime_max' => '', - 'polling_runtime_count' => '', - 'polling_runtime_sum' => '', - 'last_alert_rules_processed' => '', - 'total_alert_rules_processed' => '', - 'last_delete_rules_processed' => '', - 'total_delete_rules_processed' => '', - 'last_alert_rules_fired' => '', - 'last_delete_rules_fired' => '', + 'pipeline_health_monitor' => '', + 'last_polling_time' => '', + 'last_start_time' => '', + 'last_end_time' => '', + 'last_record_count' => '', + 'polling_runtime_last' => '', + 'polling_runtime_min' => '', + 'polling_runtime_avg' => '', + 'polling_runtime_max' => '', + 'polling_runtime_count' => '', + 'polling_runtime_sum' => '', + 'last_alert_rules_processed' => '', + 'total_alert_rules_processed' => '', + 'last_delete_rules_processed' => '', + 'total_delete_rules_processed' => '', + 'last_alert_rules_fired' => '', + 'last_delete_rules_fired' => '', 'partition_maintenance_last_attempt' => '', 'partition_maintenance_last_success' => '', 'partition_maintenance_outcome' => '', @@ -1572,10 +1644,10 @@ function syslog_status_get(): array { 'partition_maintenance_reason' => '', 'partition_recovery_progress' => '', 'partition_maintenance_history' => '', - 'replication_state' => '', - 'replication_last_success' => '', - 'replication_last_error' => '', - 'replication_last_error_time' => '', + 'replication_state' => '', + 'replication_last_success' => '', + 'replication_last_error' => '', + 'replication_last_error_time' => '', ]; $rows = syslog_db_fetch_assoc("SELECT `name`, `value`, `updated` @@ -1622,43 +1694,48 @@ function syslog_status_get(): array { function syslog_pipeline_health(): array { global $config, $syslogdb_default, $syslog_incoming_config; - $status = syslog_status_get(); + $status = syslog_status_get(); $monitor = json_decode($status['pipeline_health_monitor'], true); $monitor = is_array($monitor) ? $monitor : []; - $health = ['state' => 'up', 'issues' => [], 'checks' => [], 'monitor' => $monitor]; + $health = ['state' => 'up', 'issues' => [], 'checks' => [], 'monitor' => $monitor]; if (read_config_option('syslog_enabled') !== 'on' || ((int) ($config['poller_id'] ?? 1) > 1 && read_config_option('syslog_remote_enabled') !== 'on')) { $health['state'] = 'disabled'; + return $health; } - $now = time(); - $interval = max(60, (int) read_config_option('poller_interval')); - $delay = max($interval * 3, (int) read_config_option('syslog_stale_threshold'), 300); - $limit = (int) read_config_option('syslog_backlog_threshold'); - $limit = $limit > 0 ? $limit : 10000; + $now = time(); + $interval = max(60, (int) read_config_option('poller_interval')); + $delay = max($interval * 3, (int) read_config_option('syslog_stale_threshold'), 300); + $limit = (int) read_config_option('syslog_backlog_threshold'); + $limit = $limit > 0 ? $limit : 10000; $completed = (int) $status['last_end_time']; - $baseline = max($completed, (int) ($monitor['first_checked'] ?? 0)); + $baseline = max($completed, (int) ($monitor['first_checked'] ?? 0)); + if (($monitor['signature'] ?? '') === 'disabled:') { $baseline = 0; } $health['checks']['processing'] = $completed === 0 || $baseline === 0 ? __('Waiting for a completed processing run.', 'syslog') : __('Processing is completing within the allowed delay.', 'syslog'); + if ($baseline > 0 && $now - $baseline > $delay) { $health['issues']['processing'] = __('No processing run has completed for more than %d seconds.', $delay, 'syslog'); } - $column = $syslog_incoming_config['timeField'] ?? 'logtime'; + $column = $syslog_incoming_config['timeField'] ?? 'logtime'; $incoming = []; + if (is_string($column) && preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { // Above the threshold, the exact count and oldest timestamp cannot change the verdict. $sample_limit = $limit + 1; - $incoming = syslog_db_fetch_row("SELECT COUNT(*) AS pending, UNIX_TIMESTAMP(MIN(`$column`)) AS oldest + $incoming = syslog_db_fetch_row("SELECT COUNT(*) AS pending, UNIX_TIMESTAMP(MIN(`$column`)) AS oldest FROM (SELECT `$column` FROM `$syslogdb_default`.`syslog_incoming` LIMIT $sample_limit) AS incoming_sample", false); } $health['checks']['backlog'] = __('Incoming messages are within the configured limits.', 'syslog'); + if (!isset($incoming['pending']) || !is_numeric($incoming['pending'])) { $health['issues']['backlog'] = __('Incoming queue metrics are unavailable.', 'syslog'); } elseif ((int) $incoming['pending'] > $limit) { @@ -1671,11 +1748,13 @@ function syslog_pipeline_health(): array { } } - $replication = syslog_replication_operational_status(true); + $replication = syslog_replication_operational_status(true); $health['checks']['replication'] = __('Synchronization is not enabled on this collector.', 'syslog'); + if (!empty($replication['enabled'])) { $health['checks']['replication'] = __('Synchronization is within the allowed delay.', 'syslog'); - $oldest = $replication['oldest_pending_epoch']; + $oldest = $replication['oldest_pending_epoch']; + if ($replication['pending'] === null) { $health['issues']['replication'] = __('Synchronization queue metrics are unavailable.', 'syslog'); } elseif ($replication['pending'] > 0 && $oldest === null) { @@ -1687,6 +1766,7 @@ function syslog_pipeline_health(): array { } $health['checks']['partition'] = __('Partition maintenance is not blocked.', 'syslog'); + if ((int) ($status['partition_maintenance_blocked'] ?? 0) === 1) { $health['issues']['partition'] = __('Partition maintenance is blocked: %s', $status['partition_maintenance_reason'] ?? '', 'syslog'); } @@ -1696,6 +1776,7 @@ function syslog_pipeline_health(): array { } elseif ($completed === 0 || $baseline === 0) { $health['state'] = 'recovering'; } + return $health; } @@ -1704,62 +1785,70 @@ function syslog_pipeline_health_check(): void { global $config, $syslogdb_default; $lock = 'syslog_health_' . sha1($syslogdb_default . ':' . (int) ($config['poller_id'] ?? 1)); - if ((int) syslog_db_fetch_cell_prepared('SELECT GET_LOCK(?, 0)', [$lock], false) !== 1) { + + if ((int) syslog_db_fetch_cell_prepared('SELECT GET_LOCK(?, 0)', [$lock], '', false) !== 1) { return; } try { - $health = syslog_pipeline_health(); + $health = syslog_pipeline_health(); $monitor = $health['monitor']; - $now = time(); - $keys = array_keys($health['issues']); + $now = time(); + $keys = array_keys($health['issues']); sort($keys); - $signature = $health['state'] . ':' . implode(',', $keys); - $prior = $monitor['signature'] ?? ''; + $signature = $health['state'] . ':' . implode(',', $keys); + $prior = $monitor['signature'] ?? ''; $monitor['first_checked'] = $prior === 'disabled:' ? $now : ($monitor['first_checked'] ?? $now); - $monitor['checked'] = $now; - $monitor['signature'] = $signature; + $monitor['checked'] = $now; + $monitor['signature'] = $signature; if ($signature !== $prior && $health['state'] !== 'disabled' && ($health['issues'] || str_starts_with($prior, 'down:'))) { cacti_log('SYSLOG PIPELINE: ' . ($health['issues'] ? implode(' ', $health['issues']) : 'Pipeline recovered'), false, 'SYSLOG'); } $recipients = trim((string) read_config_option('syslog_pipeline_health_email'), ' ,'); - $bcc = ''; - $list_id = (int) read_config_option('syslog_pipeline_health_notify'); + $bcc = ''; + $list_id = (int) read_config_option('syslog_pipeline_health_notify'); + if ($list_id > 0 && db_table_exists('plugin_notification_lists')) { $bcc_column = db_column_exists('plugin_notification_lists', 'bcc_emails') ? 'bcc_emails' : "'' AS bcc_emails"; - $list = db_fetch_row_prepared("SELECT emails, $bcc_column FROM plugin_notification_lists WHERE id = ? AND enabled = ?", [$list_id, 'on']); + $list = db_fetch_row_prepared("SELECT emails, $bcc_column FROM plugin_notification_lists WHERE id = ? AND enabled = ?", [$list_id, 'on']); $recipients = implode(', ', array_filter([$recipients, trim((string) ($list['emails'] ?? ''), ' ,')])); - $bcc = trim((string) ($list['bcc_emails'] ?? ''), ' ,'); + $bcc = trim((string) ($list['bcc_emails'] ?? ''), ' ,'); } + if (($recipients === '' && $bcc === '') || $health['state'] === 'disabled') { $monitor['notification'] = 'disabled'; unset($monitor['notified_signature']); } else { $notified = $monitor['notified_signature'] ?? ''; - $repeat = max(300, (int) (read_config_option('syslog_pipeline_health_repeat') ?: 3600)); - $due = ($health['issues'] && ($signature !== $notified || $now - (int) ($monitor['last_notified'] ?? 0) >= $repeat)) + $repeat = max(300, (int) (read_config_option('syslog_pipeline_health_repeat') ?: 3600)); + $due = ($health['issues'] + && ($signature !== $notified || $now - (int) ($monitor['last_notified'] ?? 0) >= $repeat)) || (!$health['issues'] && str_starts_with($notified, 'down:')); + // Bound failed mail retries even if poller hooks overlap or run more often. if ($due && $now - (int) ($monitor['last_attempt'] ?? 0) >= 300) { $monitor['last_attempt'] = $now; $monitor['notification'] = 'pending'; + if (!syslog_status_set('pipeline_health_monitor', (string) json_encode($monitor))) { return; } $subject = __('Syslog pipeline health - collector %d', (int) ($config['poller_id'] ?? 1), 'syslog'); $message = $health['issues'] ? implode("\n", $health['issues']) : __('The Syslog pipeline has recovered.', 'syslog'); - $from = [(string) (read_config_option('settings_from_email') ?: 'Cacti@cacti.net'), (string) (read_config_option('settings_from_name') ?: 'Cacti')]; + $from = [(string) (read_config_option('settings_from_email') ?: 'Cacti@cacti.net'), (string) (read_config_option('settings_from_name') ?: 'Cacti')]; + try { - $error = mailer($from, $recipients, '', $bcc, '', $subject, '', $message, '', '', false); + $error = mailer($from, $recipients, '', $bcc, '', $subject, '', $message, '', '', false); $monitor['notification'] = $error === '' ? 'sent' : 'failed'; } catch (Throwable $error) { $monitor['notification'] = 'failed'; } + if ($monitor['notification'] === 'sent') { $monitor['notified_signature'] = $signature; - $monitor['last_notified'] = $now; + $monitor['last_notified'] = $now; } else { cacti_log('SYSLOG PIPELINE: Health notification failed; retrying on a later poller run', false, 'SYSLOG'); } @@ -1771,7 +1860,7 @@ function syslog_pipeline_health_check(): void { } syslog_status_set('pipeline_health_monitor', (string) json_encode($monitor)); } finally { - syslog_db_fetch_cell_prepared('SELECT RELEASE_LOCK(?)', [$lock], false); + syslog_db_fetch_cell_prepared('SELECT RELEASE_LOCK(?)', [$lock], '', false); } } @@ -1786,7 +1875,7 @@ function syslog_pipeline_health_check(): void { * db helper is required here. * * @return array Array with running workers, configured workers, and per - * child stats keyed by child number + * child stats keyed by child number */ function syslog_worker_stats_get() { $stats = [ @@ -1893,6 +1982,7 @@ function syslog_aggregate_worker_stats($workers) { WHERE name LIKE 'stats_syslog_child_%'"); $success = $workers > 0; + for ($worker = 1; $worker <= $workers; $worker++) { if (empty($reported[$worker])) { $success = false; @@ -2035,7 +2125,7 @@ function syslog_partition_observability(): array { continue; } - $dmax_rows = $row['table_rows'] ?? $row['TABLE_ROWS'] ?? null; + $dmax_rows = $row['table_rows'] ?? $row['TABLE_ROWS'] ?? null; $data = $row['data_length'] ?? $row['DATA_LENGTH'] ?? null; $index = $row['index_length'] ?? $row['INDEX_LENGTH'] ?? null; @@ -2092,10 +2182,10 @@ function syslog_partition_maintenance_record(bool $successful, array $recovery, 'pruned' => $pruned, 'reason' => $reason ]; - $history = json_decode($status['partition_maintenance_history'] ?? '', true); - $history = is_array($history) ? $history : []; + $history = json_decode($status['partition_maintenance_history'] ?? '', true); + $history = is_array($history) ? $history : []; $history[] = $event; - $history = array_slice($history, -10); + $history = array_slice($history, -10); syslog_status_set('partition_maintenance_last_attempt', $now); syslog_status_set('partition_maintenance_outcome', $successful ? 'success' : 'deferred'); @@ -2213,9 +2303,9 @@ function syslog_partition_manage(): int { * @param int $ahead_days Number of future days to maintain. * * @return array{created: int, missing: int, stop_reason: string, retention_deferred: bool, dmax_risk: bool} - * Recovery telemetry: partitions created this run, the - * remaining gap, why the run stopped, whether retention - * was deferred, and whether writes landed in dMaxValue. + * Recovery telemetry: partitions created this run, the remaining + * gap, why the run stopped, whether retention was deferred, and + * whether writes landed in dMaxValue. */ function syslog_partition_recover($table, $base_time, $ahead_days) { $limit = read_config_option('syslog_partition_recover_limit'); @@ -2372,7 +2462,7 @@ function syslog_compute_slices($seq_start, $seq_end, $workers) { return []; } - $total = $seq_end - $seq_start + 1; + $total = $seq_end - $seq_start + 1; $per_slice = (int) ceil($total / $workers); $slices = []; @@ -2561,7 +2651,7 @@ function syslog_partition_report_state($table) { } if (preg_match('/^[a-zA-Z0-9_]+$/', $syslogdb_default) !== 1) { - cacti_log("SYSLOG ERROR: Invalid database name; partition state check aborted", false, 'SYSLOG'); + cacti_log('SYSLOG ERROR: Invalid database name; partition state check aborted', false, 'SYSLOG'); return false; } @@ -2636,7 +2726,7 @@ function syslog_partition_report_state($table) { * Create a new partition for the specified table. * * @param mixed $table The table to rotate - * @param int $time Assume this time for the partition rotation + * @param int $time Assume this time for the partition rotation * * @return bool true on success, false on lock failure or disallowed table. */ @@ -2648,7 +2738,7 @@ function syslog_partition_create($table, $time = null) { } if (preg_match('/^[a-zA-Z0-9_]+$/', $syslogdb_default) !== 1) { - cacti_log("SYSLOG ERROR: Invalid database name; partition create aborted", false, 'SYSLOG'); + cacti_log('SYSLOG ERROR: Invalid database name; partition create aborted', false, 'SYSLOG'); return false; } @@ -2802,7 +2892,7 @@ function syslog_partition_remove($table) { } if (preg_match('/^[a-zA-Z0-9_]+$/', $syslogdb_default) !== 1) { - cacti_log("SYSLOG ERROR: Invalid database name; partition remove aborted", false, 'SYSLOG'); + cacti_log('SYSLOG ERROR: Invalid database name; partition remove aborted', false, 'SYSLOG'); return 0; } @@ -2851,6 +2941,7 @@ function syslog_partition_remove($table) { if (preg_match('/^[a-zA-Z0-9_]+$/', $part_name) !== 1) { cacti_log("SYSLOG ERROR: Invalid partition name '$part_name' for '$table'; skipping drop", false, 'SYSLOG'); + break; } @@ -2858,6 +2949,7 @@ function syslog_partition_remove($table) { // The dMaxValue partition is the write-path fail // safe and must never be dropped. cacti_log("SYSLOG ERROR: Refusing to drop the dMaxValue safety partition on '$table'; stopping retention prune", false, 'SYSLOG'); + break; } @@ -2865,11 +2957,13 @@ function syslog_partition_remove($table) { syslog_debug("Removing partition '" . $part_name . "'"); - /* $table passed syslog_partition_table_allowed() at function entry; $part_name is regex-validated above. DDL identifiers cannot be parameterized. */ + // $table passed syslog_partition_table_allowed() at function entry; + // $part_name is regex-validated above. DDL identifiers cannot be parameterized. $result = syslog_db_execute_prepared("ALTER TABLE `$syslogdb_default`.`$table` DROP PARTITION `$part_name`", []); if ($result === false) { cacti_log("SYSLOG ERROR: Failed to drop partition '$part_name' from '$table' after $i successful drop(s); aborting further drops", false, 'SYSLOG'); + break; } @@ -2954,6 +3048,7 @@ function syslog_get_removal_rule_fields($table = 'syslog_incoming', $prefix = '' if (!isset($syslog_incoming_config['programField'])) { $syslog_incoming_config['programField'] = 'program'; } + foreach (['textField' => 'message', 'hostField' => 'host', 'facilityField' => 'facility_id', 'priorityField' => 'priority_id'] as $setting => $default) { if (!isset($syslog_incoming_config[$setting])) { $syslog_incoming_config[$setting] = $default; @@ -2961,9 +3056,9 @@ function syslog_get_removal_rule_fields($table = 'syslog_incoming', $prefix = '' } return [ - 'message' => ['column' => $column($syslog_incoming_config['textField']), 'operators' => ['contains', 'begins', 'ends', '=', '!=']], - 'host' => ['column' => $column($syslog_incoming_config['hostField']), 'operators' => ['contains', 'begins', 'ends', '=', '!=']], - 'program' => ['column' => $column($syslog_incoming_config['programField']), 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'message' => ['column' => $column($syslog_incoming_config['textField']), 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'host' => ['column' => $column($syslog_incoming_config['hostField']), 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'program' => ['column' => $column($syslog_incoming_config['programField']), 'operators' => ['contains', 'begins', 'ends', '=', '!=']], 'facility_id' => ['column' => $column($syslog_incoming_config['facilityField']), 'operators' => ['=', '!='], 'type' => 'integer'], 'priority_id' => ['column' => $column($syslog_incoming_config['priorityField']), 'operators' => ['=', '!='], 'type' => 'integer'] ]; @@ -2971,9 +3066,9 @@ function syslog_get_removal_rule_fields($table = 'syslog_incoming', $prefix = '' // The syslog (and syslog_removed) tables store normalized id columns. return [ - 'message' => ['column' => $column('message'), 'operators' => ['contains', 'begins', 'ends', '=', '!=']], - 'host_id' => ['column' => $column('host_id'), 'operators' => ['=', '!='], 'type' => 'integer'], - 'program_id' => ['column' => $column('program_id'), 'operators' => ['=', '!='], 'type' => 'integer'], + 'message' => ['column' => $column('message'), 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'host_id' => ['column' => $column('host_id'), 'operators' => ['=', '!='], 'type' => 'integer'], + 'program_id' => ['column' => $column('program_id'), 'operators' => ['=', '!='], 'type' => 'integer'], 'facility_id' => ['column' => $column('facility_id'), 'operators' => ['=', '!='], 'type' => 'integer'], 'priority_id' => ['column' => $column('priority_id'), 'operators' => ['=', '!='], 'type' => 'integer'] ]; @@ -3007,7 +3102,7 @@ function syslog_removal_filter_normalize($json, $table) { } $maps = [ - 'host' => ['table' => 'syslog_hosts', 'name' => 'host', 'id' => 'host_id'], + 'host' => ['table' => 'syslog_hosts', 'name' => 'host', 'id' => 'host_id'], 'program' => ['table' => 'syslog_programs', 'name' => 'program', 'id' => 'program_id'] ]; @@ -3022,24 +3117,30 @@ function syslog_removal_filter_normalize($json, $table) { switch ($operator) { case '=': $pattern = $value; + break; case '!=': $pattern = $value; + break; case 'contains': $pattern = '%' . $value . '%'; + break; case 'begins': $pattern = $value . '%'; + break; case 'ends': $pattern = '%' . $value; + break; default: return null; } $ids = []; + foreach (syslog_db_fetch_assoc_prepared("SELECT {$map['id']} AS id FROM `{$map['table']}` WHERE `{$map['name']}` LIKE ? ESCAPE '!'", @@ -3054,6 +3155,7 @@ function syslog_removal_filter_normalize($json, $table) { } $rows = []; + foreach ($ids as $index => $id) { $rows[] = [ 'join' => $index === 0 ? 'AND' : 'OR', @@ -3104,13 +3206,28 @@ function syslog_removal_filter_normalize($json, $table) { return $document; } +/** + * syslog_query_builder_unavailable - Guard body shared by every structured + * filter compile: record the reason and hand back an unusable fragment. + * Hoisted so the partial-deploy degradation stays observable and testable + * when the QueryBuilder class itself is always loaded in-process. + * + * @return array Always an empty array. + */ +function syslog_query_builder_unavailable(): array { + $GLOBALS['syslog_rule_filter_error'] = 'The structured query builder is unavailable.'; + + return []; +} + /** * syslog_get_removal_rule_sql - Compile a structured filter removal rule * into parameterized SQL for the given table. * - * @param array $remove The removal rule attributes - * @param string $table The syslog table the rule will run against - * @param string $prefix Optional table alias prefixed to each column + * @param array $remove The removal rule attributes + * @param string $table The syslog table the rule will run against + * @param string $prefix Optional table alias prefixed to each column + * @param mixed $processing_boundary * * @return array The SQL and params, or an empty array on failure */ @@ -3118,9 +3235,7 @@ function syslog_get_removal_rule_sql(&$remove, $table = 'syslog_incoming', $pref global $syslogdb_default; if (!class_exists('Cacti\\Syslog\\QueryBuilder')) { - $GLOBALS['syslog_rule_filter_error'] = 'The structured query builder is unavailable.'; - - return []; + return syslog_query_builder_unavailable(); } $document = syslog_removal_filter_normalize($remove['message'], $table); @@ -3210,7 +3325,7 @@ function syslog_get_removal_rule_sql(&$remove, $table = 'syslog_incoming', $pref * @param int $rows Maximum number of matching messages to return. * * @return array{error: string, count: int, rows: array>} - * The bounded preview result, or an error message. + * The bounded preview result, or an error message. */ function syslog_rule_preview(array $rule, string $rule_type = 'alert', int $rows = 10): array { global $syslogdb_default, $syslog_incoming_config; @@ -3221,7 +3336,7 @@ function syslog_rule_preview(array $rule, string $rule_type = 'alert', int $rows return ['error' => __('Unknown rule type.', 'syslog'), 'count' => 0, 'rows' => []]; } - $type = isset($rule['type']) ? (string) $rule['type'] : ''; + $type = isset($rule['type']) ? (string) $rule['type'] : ''; $rule['message'] = isset($rule['message']) ? (string) $rule['message'] : ''; $sql = ''; @@ -3536,7 +3651,7 @@ function syslog_remove_items($table, $max_seq) { // alias, so the shared WHERE would be ambiguous there while // the plain DELETE needs unqualified columns. Compile both // shapes up front and track them separately. - $insert_where = ''; + $insert_where = ''; $insert_params = []; if ($remove['type'] == 'filter') { @@ -3559,7 +3674,7 @@ function syslog_remove_items($table, $max_seq) { $insert_where = $insert_compiled['sql']; $insert_params = $insert_compiled['params']; } else { - $insert_where = ''; + $insert_where = ''; $insert_params = []; } } @@ -3686,6 +3801,7 @@ function syslog_remove_items($table, $max_seq) { if ($remove['method'] != 'del') { if (!syslog_db_execute('START TRANSACTION')) { cacti_log("SYSLOG ERROR: Unable to start transaction for removal rule '" . $remove['name'] . "'", false, 'SYSLOG'); + continue; } @@ -3697,6 +3813,7 @@ function syslog_remove_items($table, $max_seq) { // for this joined INSERT separately. if ($insert_where == '') { syslog_db_execute('ROLLBACK'); + continue; } @@ -3728,6 +3845,7 @@ function syslog_remove_items($table, $max_seq) { if ($move_failed) { syslog_db_execute('ROLLBACK'); cacti_log("SYSLOG ERROR: Rolled back removal rule '" . $remove['name'] . "' after archive insert failed", false, 'SYSLOG'); + continue; } @@ -3741,6 +3859,7 @@ function syslog_remove_items($table, $max_seq) { if (!syslog_replication_enqueue_incoming($replication_where, $replication_params, 'syslog_removed')) { syslog_db_execute('ROLLBACK'); cacti_log("SYSLOG ERROR: Rolled back removal rule '" . $remove['name'] . "' after replication outbox insert failed", false, 'SYSLOG'); + continue; } } @@ -3765,6 +3884,7 @@ function syslog_remove_items($table, $max_seq) { if ($transaction_started && !syslog_db_execute('COMMIT')) { syslog_db_execute('ROLLBACK'); cacti_log("SYSLOG ERROR: Unable to commit transaction for removal rule '" . $remove['name'] . "'", false, 'SYSLOG'); + continue; } @@ -3779,7 +3899,7 @@ function syslog_remove_items($table, $max_seq) { } } } - } + } syslog_status_set('last_delete_rules_fired', syslog_status_rule_activity_json($fired)); @@ -3794,7 +3914,7 @@ function syslog_remove_items($table, $max_seq) { * displayed. The classes reuse the same Kiwi-style pastel tints that the * main syslog table applies per priority, so both tables share one look. * - * @param mixed $severity The alert severity of the row. + * @param mixed $severity The alert severity of the row. * @param mixed $tip_title The row tooltip title (unused, kept for compatibility). * * @return void @@ -3888,13 +4008,13 @@ function syslog_row_color($priority, $message): string { * Render compact metadata labels without changing the surrounding table theme. * * @param mixed $value The facility or priority value to display. - * @param string $type The label type ('priority' or 'facility'). + * @param string $type The label type ('priority' or 'facility'). * * @return string The formatted HTML label. */ function syslog_metadata_label($value, string $type): string { - $value = (string) $value; - $class = $type === 'priority' ? 'syslogSeverity' : 'syslogFacility'; + $value = (string) $value; + $class = $type === 'priority' ? 'syslogSeverity' : 'syslogFacility'; $modifier = preg_replace('/[^a-z]/', '', strtolower($value)); return '' . html_escape($value) . ''; @@ -3910,13 +4030,24 @@ function syslog_metadata_label($value, string $type): string { */ function syslog_value_filter_button($value, string $field): string { $value = (string) $value; - if ($value === '') return html_escape(__('Unknown', 'syslog')); + + if ($value === '') { + return html_escape(__('Unknown', 'syslog')); + } $class = 'syslogValueFilter'; - if ($field === 'host') $class .= ' syslogHostLabel'; - if ($field === 'program') $class .= ' syslogProgramLabel'; + + if ($field === 'host') { + $class .= ' syslogHostLabel'; + } + + if ($field === 'program') { + $class .= ' syslogProgramLabel'; + } + if ($field === 'priority') { $class .= ' syslogSeverity syslogSeverity-' . html_escape(preg_replace('/[^a-z]/', '', strtolower($value))); } + return ''; } @@ -3973,7 +4104,7 @@ function sql_hosts_where($tab) { * itself a formula trigger in some importers and must stay detectable as * the first character rather than being treated as skippable whitespace. * - * @param mixed $value Value destined for CSV output. + * @param mixed $value Value destined for CSV output. * @return mixed Sanitized CSV value. */ function syslog_csv_safe(mixed $value): mixed { @@ -4011,6 +4142,7 @@ function syslog_export($tab) { http_response_code(400); header('Content-Type: text/plain; charset=UTF-8'); print $GLOBALS['syslog_search_error']; + return; } @@ -4103,7 +4235,6 @@ function syslog_export($tab) { fputcsv($fp, $line, ',', '"', ''); } - } fclose($fp); @@ -4170,13 +4301,13 @@ function syslog_debug(string $message): void { * Log a triggered alert to the syslog_logs table and notify the host alarm * hook. Both single message alerts and threshold alerts are supported. * - * @param mixed $alert_id The alert rule ID. - * @param mixed $alert_name The alert rule name. - * @param mixed $severity The alert severity. - * @param array $msg The matching syslog message record. - * @param int $count The number of matching messages. - * @param string $html The HTML body for the alert log entry. - * @param array $hosts The hosts to associate with the alarm. + * @param mixed $alert_id The alert rule ID. + * @param mixed $alert_name The alert rule name. + * @param mixed $severity The alert severity. + * @param array $msg The matching syslog message record. + * @param int $count The number of matching messages. + * @param string $html The HTML body for the alert log entry. + * @param array $hosts The hosts to associate with the alarm. * * @return int|false The sequence of the new alert log entry, or false on failure. */ @@ -4364,6 +4495,7 @@ function syslog_manage_items($from_table, $to_table) { if (!syslog_db_execute('START TRANSACTION')) { cacti_log("SYSLOG ERROR: Unable to start transaction for removal rule '" . $remove['message'] . "'", false, 'SYSLOG'); + continue; } @@ -4374,6 +4506,7 @@ function syslog_manage_items($from_table, $to_table) { WHERE seq IN (" . $all_seq . '))')) { syslog_db_execute('ROLLBACK'); cacti_log("SYSLOG ERROR: Rolled back removal rule '" . $remove['message'] . "' after move insert failed", false, 'SYSLOG'); + continue; } @@ -4384,6 +4517,7 @@ function syslog_manage_items($from_table, $to_table) { WHERE seq IN ($all_seq)")) { syslog_db_execute('ROLLBACK'); cacti_log("SYSLOG ERROR: Rolled back removal rule '" . $remove['message'] . "' after move delete failed", false, 'SYSLOG'); + continue; } } @@ -4391,6 +4525,7 @@ function syslog_manage_items($from_table, $to_table) { if (!syslog_db_execute('COMMIT')) { syslog_db_execute('ROLLBACK'); cacti_log("SYSLOG ERROR: Unable to commit transaction for removal rule '" . $remove['message'] . "'", false, 'SYSLOG'); + continue; } @@ -4565,23 +4700,25 @@ function syslog_execute_alert_command($alert, $results, $hostname, string $event $cparts = preg_split('/\s+/', trim($command)) ?: ['']; $executable = trim($cparts[0], '"\''); - $label = $event === 'clear' ? 'Recovery' : 'Alert'; + $label = $event === 'clear' ? 'Recovery' : 'Alert'; $output = []; $return = 0; if (cacti_sizeof($cparts) && is_executable($executable)) { $environment = [ - 'SYSLOG_EVENT' => $event, + 'SYSLOG_EVENT' => $event, 'SYSLOG_BREACH_ID' => $alert['_threshold_state']['breach_key'] ?? '', - 'SYSLOG_ALERT_ID' => (string) $alert['id'], - 'SYSLOG_HOSTNAME' => $hostname, + 'SYSLOG_ALERT_ID' => (string) $alert['id'], + 'SYSLOG_HOSTNAME' => $hostname, 'SYSLOG_POLLER_ID' => (string) ($alert['_threshold_state']['source_poller_id'] ?? ($GLOBALS['config']['poller_id'] ?? 1)) ]; $previous = []; + foreach ($environment as $name => $value) { $previous[$name] = getenv($name); putenv($name . '=' . $value); } + try { exec($command, $output, $return); } finally { @@ -4597,6 +4734,7 @@ function syslog_execute_alert_command($alert, $results, $hostname, string $event if ($return !== 0) { cacti_log(sprintf('ERROR: %s command failed. Alert:%s, Exit:%s, Output:%s', $label, $alert['name'], $return, implode(', ', $output)), false, 'SYSLOG'); } + return $return === 0; } else { $reason = (strpos($executable, DIRECTORY_SEPARATOR) === false) @@ -4604,6 +4742,7 @@ function syslog_execute_alert_command($alert, $results, $hostname, string $event : 'file not found or not marked executable'; cacti_log("SYSLOG ERROR: $label command is not executable: '$command' -- $reason", false, 'SYSTEM'); } + return false; } @@ -4623,7 +4762,7 @@ function syslog_execute_alert_command($alert, $results, $hostname, string $event * and more importantly, to be able to have a separate re-alert cycles for that very same message as there can be similar messages * happening all the time at the system level, so it's hard to target a single host for re-alert rules. * - * @param int $max_seq The max_seq to process + * @param int $max_seq The max_seq to process * * @return array An array of the number of alerts processed and the number of alerts generated */ @@ -4631,7 +4770,7 @@ function syslog_execute_alert_command($alert, $results, $hostname, string $event * Return true when a day expression contains the given ISO weekday (1=Mon). */ function syslog_alert_schedule_day_matches(string $expression, int $weekday): bool { - $days = ['mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6, 'sun' => 7, '1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7]; + $days = ['mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6, 'sun' => 7, '1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7]; $expression = strtolower(trim($expression)); if ($expression === '*') { @@ -4642,6 +4781,7 @@ function syslog_alert_schedule_day_matches(string $expression, int $weekday): bo $range = array_map('trim', explode('-', $part, 2)); $start = $days[substr($range[0], 0, 3)] ?? 0; $end = $days[substr($range[1] ?? $range[0], 0, 3)] ?? 0; + if ($start && $end && (($start <= $end && $weekday >= $start && $weekday <= $end) || ($start > $end && ($weekday >= $start || $weekday <= $end)))) { return true; } @@ -4670,12 +4810,15 @@ function syslog_alert_schedule_is_active(string $schedule, ?int $timestamp = nul } $start = ((int) $matches[2] * 60) + (int) $matches[3]; $end = ((int) $matches[4] * 60) + (int) $matches[5]; + if ($start > 1439 || $end > 1439) { continue; } + if ($start <= $end && syslog_alert_schedule_day_matches($matches[1], $weekday) && $minute >= $start && $minute <= $end) { return true; } + if ($start > $end && (($minute >= $start && syslog_alert_schedule_day_matches($matches[1], $weekday)) || ($minute <= $end && syslog_alert_schedule_day_matches($matches[1], $weekday === 1 ? 7 : $weekday - 1)))) { return true; } @@ -4712,17 +4855,18 @@ function syslog_alert_datetime_window_is_active(string $start, string $end, ?int */ function syslog_alert_suppression_check(array $alert, string $hostname, array $matches): array { $scope_key = $hostname !== '' ? $hostname : 'system'; - $messages = array_unique(array_map(static fn($match) => (string) ($match['host'] ?? '') . "\n" . (string) ($match['message'] ?? ''), $matches)); + $messages = array_unique(array_map(static fn ($match) => (string) ($match['host'] ?? '') . "\n" . (string) ($match['message'] ?? ''), $matches)); sort($messages, SORT_STRING); - $dedup_hash = sha1(implode("\n", $messages)); + $dedup_hash = sha1(implode("\n", $messages)); $cooldown_value = (int) ($alert['cooldown_minutes'] ?? -1); $dedup_value = (int) ($alert['deduplication_minutes'] ?? -1); - $cooldown = $cooldown_value >= 0 ? $cooldown_value : (int) read_config_option('syslog_alert_cooldown_minutes'); - $dedup = $dedup_value >= 0 ? $dedup_value : (int) read_config_option('syslog_alert_deduplication_minutes'); - $now = time(); + $cooldown = $cooldown_value >= 0 ? $cooldown_value : (int) read_config_option('syslog_alert_cooldown_minutes'); + $dedup = $dedup_value >= 0 ? $dedup_value : (int) read_config_option('syslog_alert_deduplication_minutes'); + $now = time(); if ($cooldown > 0) { $last_sent = (int) syslog_db_fetch_cell_prepared('SELECT MAX(last_sent) FROM syslog_alert_suppression WHERE alert_id = ? AND scope_key = ?', [$alert['id'], $scope_key]); + if ($last_sent > $now - ($cooldown * 60)) { return ['allowed' => false, 'scope_key' => $scope_key, 'dedup_hash' => $dedup_hash]; } @@ -4730,6 +4874,7 @@ function syslog_alert_suppression_check(array $alert, string $hostname, array $m if ($dedup > 0) { $last_sent = (int) syslog_db_fetch_cell_prepared('SELECT last_sent FROM syslog_alert_suppression WHERE alert_id = ? AND scope_key = ? AND dedup_hash = ?', [$alert['id'], $scope_key, $dedup_hash]); + if ($last_sent > $now - ($dedup * 60)) { return ['allowed' => false, 'scope_key' => $scope_key, 'dedup_hash' => $dedup_hash]; } @@ -4746,9 +4891,11 @@ function syslog_alert_suppression_record(array $alert, array $state): void { /** Return whether this alert's configured maintenance window is active. */ function syslog_alert_maintenance_is_active(array $alert): bool { $mode = $alert['maintenance_mode'] ?? 'inherit'; + if ($mode === 'disabled') { return false; } + if ($mode === 'custom') { return syslog_alert_datetime_window_is_active((string) ($alert['maintenance_datetime_start'] ?? ''), (string) ($alert['maintenance_datetime_end'] ?? '')) || syslog_alert_schedule_is_active(syslog_alert_maintenance_window((string) ($alert['maintenance_days'] ?? ''), (string) ($alert['maintenance_start'] ?? ''), (string) ($alert['maintenance_end'] ?? ''))); @@ -4769,15 +4916,16 @@ function syslog_alert_maintenance_is_active(array $alert): bool { */ function syslog_device_rule_sql(bool $maintenance_active): array { global $syslogdb_default, $syslog_incoming_config; - $host_field = $syslog_incoming_config['hostField'] ?? 'host'; + $host_field = $syslog_incoming_config['hostField'] ?? 'host'; $priority_field = $syslog_incoming_config['priorityField'] ?? 'priority_id'; - $now = time(); + $now = time(); // Qualify the outer staging columns explicitly: an unqualified `host` // inside these correlated EXISTS resolves to dr.host (the innermost // scope wins), which turns the mute filter into a silent tautology. - $active_mute = "(dr.mute_mode = 'indefinite' OR (dr.mute_mode = 'until' AND dr.mute_until > ?))"; + $active_mute = "(dr.mute_mode = 'indefinite' OR (dr.mute_mode = 'until' AND dr.mute_until > ?))"; $pass_through = "(dr.pass_through_priority >= 0 AND COALESCE(`syslog_incoming`.`$priority_field`, 99) <= dr.pass_through_priority)"; - $mute_filter = "\n\t\t\t\tAND NOT EXISTS (SELECT 1 FROM `$syslogdb_default`.`syslog_device_rule` AS dr WHERE dr.host = `syslog_incoming`.`$host_field` AND dr.enabled = 'on' AND NOT $pass_through AND $active_mute)"; + $mute_filter = "\n\t\t\t\tAND NOT EXISTS (SELECT 1 FROM `$syslogdb_default`.`syslog_device_rule` AS dr WHERE dr.host = `syslog_incoming`.`$host_field` AND dr.enabled = 'on' AND NOT $pass_through AND $active_mute)"; + if ($maintenance_active) { // Normal maintenance still mutes every device. Only an explicit device // exception may admit a record, so the absence of a device rule is not @@ -4788,7 +4936,7 @@ function syslog_device_rule_sql(bool $maintenance_active): array { } return [ - 'sql' => $maintenance_filter . $mute_filter, + 'sql' => $maintenance_filter . $mute_filter, 'params' => [$now] ]; } @@ -4797,7 +4945,7 @@ function syslog_device_rule_sql(bool $maintenance_active): array { function syslog_alert_window_seconds(array $alert): int { $units = ['minute' => 60, 'hour' => 3600, 'day' => 86400]; $value = (string) ($alert['threshold_window'] ?? '0'); - $unit = $alert['threshold_unit'] ?? 'minute'; + $unit = $alert['threshold_unit'] ?? 'minute'; if (!ctype_digit($value) || (float) $value > 4294967295 || !isset($units[$unit])) { return 0; @@ -4814,9 +4962,10 @@ function syslog_threshold_rule_hash(array $alert): string { /** Recover quiet breaches without scanning or rewriting message history. */ function syslog_threshold_recover(?int $now = null): void { global $config; - $now = $now ?? time(); + $now = $now ?? time(); $source = max(1, (int) ($config['poller_id'] ?? 1)); - $ended = "(s.expires_at <= ? OR a.id IS NULL OR a.enabled <> 'on' OR a.method <> 1 OR s.rule_date <> a.date)"; + $ended = "(s.expires_at <= ? OR a.id IS NULL OR a.enabled <> 'on' OR a.method <> 1 OR s.rule_date <> a.date)"; + if (!syslog_db_execute('START TRANSACTION')) { return; } @@ -4831,12 +4980,15 @@ function syslog_threshold_recover(?int $now = null): void { ["DELETE m FROM syslog_threshold_state AS s JOIN syslog_threshold_messages AS m ON m.source_poller_id = s.source_poller_id AND m.breach_key = s.breach_key LEFT JOIN syslog_alert AS a ON a.id = s.alert_id WHERE (s.source_poller_id = ? AND $ended) OR s.expires_at <= ?", [$source, $now, $now]], ["DELETE s FROM syslog_threshold_state AS s LEFT JOIN syslog_alert AS a ON a.id = s.alert_id WHERE (s.source_poller_id = ? AND $ended) OR s.expires_at <= ?", [$source, $now, $now]] ]; + foreach ($queries as [$sql, $params]) { if (!syslog_db_execute_prepared($sql, $params)) { syslog_db_execute('ROLLBACK'); + return; } } + if (!syslog_db_execute('COMMIT')) { syslog_db_execute('ROLLBACK'); } @@ -4845,28 +4997,34 @@ function syslog_threshold_recover(?int $now = null): void { /** Deliver only origin-owned clears; the connection lock also covers slow commands. */ function syslog_threshold_deliver_recoveries(?int $now = null): void { global $config, $syslogdb_default; - $now = $now ?? time(); + $now = $now ?? time(); $source = max(1, (int) ($config['poller_id'] ?? 1)); - $lock = 'syslog-clear:' . sha1($syslogdb_default . ':' . $source); + $lock = 'syslog-clear:' . sha1($syslogdb_default . ':' . $source); + if ((int) syslog_db_fetch_cell_prepared('SELECT GET_LOCK(?, 0)', [$lock]) !== 1) { return; } + try { if (!syslog_db_execute_prepared("DELETE q FROM syslog_threshold_recovery_queue AS q LEFT JOIN syslog_alert AS a ON a.id = q.alert_id WHERE q.source_poller_id = ? AND (a.id IS NULL OR a.enabled <> 'on' OR a.method <> 1 OR a.date <> q.rule_date OR COALESCE(a.recovery_command, '') = '')", [$source])) { return; } $pending = syslog_db_fetch_assoc_prepared('SELECT breach_key, context, attempts FROM syslog_threshold_recovery_queue WHERE source_poller_id = ? AND available_at <= ? ORDER BY available_at, breach_key LIMIT 25', [$source, $now]); + foreach ($pending ?: [] as $row) { $context = json_decode($row['context'], true); + if (is_array($context['alert'] ?? null) && is_array($context['results'] ?? null) && is_array($context['hostlist'] ?? null) && is_string($context['hostname'] ?? null) && isset($context['alert']['id'], $context['alert']['name'], $context['alert']['message'], $context['alert']['severity'], $context['alert']['command'], $context['alert']['_threshold_state']['breach_key'], $context['alert']['_threshold_state']['source_poller_id'], $context['results']['message'], $context['results']['priority_id'], $context['results']['facility_id'])) { alert_setup_environment($context['alert'], $context['results'], $context['hostlist'], $context['hostname']); + if (syslog_execute_alert_command($context['alert'], $context['results'], $context['hostname'], 'clear')) { syslog_db_execute_prepared('DELETE FROM syslog_threshold_recovery_queue WHERE source_poller_id = ? AND breach_key = ?', [$source, $row['breach_key']]); + continue; } } else { @@ -4884,78 +5042,93 @@ function syslog_threshold_deliver_recoveries(?int $now = null): void { function syslog_threshold_evaluate(array &$alert, int $batch_count, string $hostname = '', ?int $now = null): int { global $config; unset($alert['_threshold_state']); + if ((int) $alert['method'] !== 1 || $batch_count <= 0) { return $batch_count; } - $now = $now ?? time(); + $now = $now ?? time(); $source = max(1, (int) ($config['poller_id'] ?? 1)); - $scope = (int) $alert['level'] === 1 ? $hostname : ''; - $hash = syslog_threshold_rule_hash($alert); - $state = syslog_db_fetch_row_prepared('SELECT * FROM syslog_threshold_state WHERE source_poller_id = ? AND alert_id = ? AND scope_key = ?', [$source, $alert['id'], $scope]); + $scope = (int) $alert['level'] === 1 ? $hostname : ''; + $hash = syslog_threshold_rule_hash($alert); + $state = syslog_db_fetch_row_prepared('SELECT * FROM syslog_threshold_state WHERE source_poller_id = ? AND alert_id = ? AND scope_key = ?', [$source, $alert['id'], $scope]); + if ($state === false) { return 0; } + // Preserve an undelivered clear if transactional recovery failed this cycle. if ($state && (int) $state['expires_at'] <= $now && $state['rule_hash'] === $hash && !empty($state['recovery_context'])) { return 0; } + if ($state && ((int) $state['expires_at'] <= $now || $state['rule_hash'] !== $hash)) { if (!syslog_db_execute_prepared('DELETE FROM syslog_alert_counts WHERE alert_id = ? AND scope_key = ?', [$alert['id'], $scope])) { return 0; } + if (!syslog_db_execute_prepared('DELETE FROM syslog_threshold_messages WHERE source_poller_id = ? AND breach_key = ?', [$source, $state['breach_key']])) { return 0; } + if (!syslog_db_execute_prepared('DELETE FROM syslog_threshold_state WHERE source_poller_id = ? AND alert_id = ? AND scope_key = ?', [$source, $alert['id'], $scope])) { return 0; } $state = []; } $count = syslog_alert_window_count($alert, $batch_count, $hostname, $now); + if ($count <= 0 || (!$state && $count < (int) $alert['num'])) { return $count; } - $new = !$state; - $key = $state['breach_key'] ?? bin2hex(random_bytes(16)); + $new = !$state; + $key = $state['breach_key'] ?? bin2hex(random_bytes(16)); $expires = $now + max(60, syslog_alert_window_seconds([ 'threshold_window' => $alert['threshold_recovery_minutes'] ?? 5, - 'threshold_unit' => $alert['threshold_recovery_unit'] ?? 'minute' + 'threshold_unit' => $alert['threshold_recovery_unit'] ?? 'minute' ])); + if (!syslog_db_execute_prepared('INSERT INTO syslog_threshold_state (source_poller_id, alert_id, scope_key, rule_hash, rule_date, breach_key, breached_at, last_match, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE rule_hash = VALUES(rule_hash), rule_date = VALUES(rule_date), breach_key = VALUES(breach_key), breached_at = VALUES(breached_at), last_match = VALUES(last_match), expires_at = VALUES(expires_at)', [$source, $alert['id'], $scope, $hash, (int) ($alert['date'] ?? 0), $key, $new ? $now : ($state['breached_at'] ?? null), $now, $expires])) { return 0; } $alert['_threshold_state'] = ['source_poller_id' => $source, 'breach_key' => $key, 'new_breach' => $new]; + return $count; } /** Indexed active-state lookup; NULL identities from older history display No. */ function syslog_message_breach_sql(): string { global $syslogdb_default; + return "EXISTS (SELECT 1 FROM `$syslogdb_default`.`syslog_threshold_messages` AS tm JOIN `$syslogdb_default`.`syslog_threshold_state` AS ts ON ts.source_poller_id = tm.source_poller_id AND ts.breach_key = tm.breach_key WHERE tm.source_poller_id = syslog.replication_source_poller_id AND tm.source_event_id = syslog.replication_source_event_id AND ts.expires_at > UNIX_TIMESTAMP())"; } /** Fetch active state for an entire replication batch, avoiding per-message queries. */ function syslog_threshold_replication_states(array $events): array|false { - $where = []; + $where = []; $params = []; + foreach ($events as $event) { if (!empty($event['threshold_breached'])) { - $where[] = '(m.source_poller_id = ? AND m.source_event_id = ?)'; + $where[] = '(m.source_poller_id = ? AND m.source_event_id = ?)'; $params[] = $event['source_poller_id']; $params[] = $event['source_event_id']; } } + if (!$where) { return []; } $rows = syslog_db_fetch_assoc_prepared('SELECT m.source_event_id, s.source_poller_id, s.alert_id, s.scope_key, s.rule_hash, s.rule_date, s.breach_key, s.breached_at, s.last_match, s.expires_at FROM syslog_threshold_messages AS m JOIN syslog_threshold_state AS s ON s.source_poller_id = m.source_poller_id AND s.breach_key = m.breach_key WHERE s.expires_at > UNIX_TIMESTAMP() AND (' . implode(' OR ', $where) . ')', $params); + if ($rows === false) { return false; } $result = []; + foreach ($rows as $row) { $result[$row['source_poller_id'] . ':' . $row['source_event_id']][] = $row; } + return $result; } @@ -4965,28 +5138,33 @@ function syslog_threshold_accept_central(array $event, $central, string $databas if (!db_execute_prepared("DELETE m FROM `$database`.`syslog_threshold_state` AS s JOIN `$database`.`syslog_threshold_messages` AS m ON m.source_poller_id = s.source_poller_id AND m.breach_key = s.breach_key WHERE s.source_poller_id = ? AND s.alert_id = ? AND s.scope_key = ? AND s.breach_key <> ? AND s.last_match <= ?", [$event['source_poller_id'], $state['alert_id'], $state['scope_key'], $state['breach_key'], $state['last_match']], true, $central)) { return false; } + if (!db_execute_prepared("INSERT INTO `$database`.`syslog_threshold_state` (source_poller_id, alert_id, scope_key, rule_hash, rule_date, breach_key, breached_at, last_match, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE rule_hash = IF(VALUES(last_match) >= last_match, VALUES(rule_hash), rule_hash), rule_date = IF(VALUES(last_match) >= last_match, VALUES(rule_date), rule_date), breached_at = IF(VALUES(last_match) >= last_match, VALUES(breached_at), breached_at), breach_key = IF(VALUES(last_match) >= last_match, VALUES(breach_key), breach_key), expires_at = IF(VALUES(last_match) >= last_match, VALUES(expires_at), expires_at), last_match = GREATEST(last_match, VALUES(last_match))", [$event['source_poller_id'], $state['alert_id'], $state['scope_key'], $state['rule_hash'], $state['rule_date'], $state['breach_key'], $state['breached_at'] ?? null, $state['last_match'], $state['expires_at']], true, $central) || !db_execute_prepared("INSERT IGNORE INTO `$database`.`syslog_threshold_messages` (source_poller_id, source_event_id, breach_key) SELECT source_poller_id, ?, breach_key FROM `$database`.`syslog_threshold_state` WHERE source_poller_id = ? AND breach_key = ?", [$event['source_event_id'], $event['source_poller_id'], $state['breach_key']], true, $central)) { return false; } } + return true; } /** Persist one batch count and return the rolling total for this reporting scope. */ function syslog_alert_window_count(array $alert, int $count, string $hostname = '', ?int $now = null): int { $seconds = syslog_alert_window_seconds($alert); + if ((int) $alert['method'] !== 1 || $seconds === 0 || $count <= 0) { return $count; } $now = $now ?? time(); // A changed match definition or window must not inherit counts from the old rule. - $hash = syslog_threshold_rule_hash($alert); + $hash = syslog_threshold_rule_hash($alert); $scope = (int) $alert['level'] === 1 ? $hostname : ''; + if (!syslog_db_execute_prepared('DELETE FROM syslog_alert_counts WHERE alert_id = ? AND (observed_at <= ? OR rule_hash <> ?)', [$alert['id'], $now - $seconds, $hash])) { return 0; } + if (!syslog_db_execute_prepared('INSERT INTO syslog_alert_counts (alert_id, rule_hash, scope_key, observed_at, matches) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE matches = matches + VALUES(matches)', [$alert['id'], $hash, $scope, $now, $count])) { return 0; } @@ -5030,9 +5208,9 @@ function syslog_process_alerts($max_seq) { // Phase 1 - compile every rule once and evaluate all rule conditions // together in a single scan of the staging table. One scan per rule // does not scale with the number of enabled alert rules. - $compiled = []; - $sums = []; - $counts = []; + $compiled = []; + $sums = []; + $counts = []; $all_params = []; foreach ($alerts as $alert) { @@ -5058,7 +5236,7 @@ function syslog_process_alerts($max_seq) { 'device_params' => $device_rule['params'] ]; - $sums[] = "SUM(CASE WHEN (" . $sql_data['sql'] . $device_rule['sql'] . ") THEN 1 ELSE 0 END) AS m$index"; + $sums[] = 'SUM(CASE WHEN (' . $sql_data['sql'] . $device_rule['sql'] . ") THEN 1 ELSE 0 END) AS m$index"; $all_params = array_merge($all_params, $sql_data['params'], $device_rule['params']); } @@ -5089,9 +5267,9 @@ function syslog_process_alerts($max_seq) { continue; } - $alert = $rule['alert']; - $count = isset($counts[$index]) ? $counts[$index] : 0; - $sql = ''; + $alert = $rule['alert']; + $count = isset($counts[$index]) ? $counts[$index] : 0; + $sql = ''; $params = []; // we roll up statistics depending on the level @@ -5125,7 +5303,7 @@ function syslog_process_alerts($max_seq) { $sql = $sql_data['sql']; $params = $sql_data['params']; - $sql .= $rule['device_sql']; + $sql .= $rule['device_sql']; $params = array_merge($params, $rule['device_params']); if ($sql != '') { @@ -5141,7 +5319,7 @@ function syslog_process_alerts($max_seq) { $asql = $sql . ' AND host = ?'; $result['count'] = syslog_threshold_evaluate($alert, (int) $result['count'], $result['host']); - $triggered = syslog_process_alert($alert, $asql, $aparams, $result['count'], $result['host']); + $triggered = syslog_process_alert($alert, $asql, $aparams, $result['count'], $result['host']); $syslog_alarms += $triggered; if ($triggered > 0) { @@ -5157,7 +5335,7 @@ function syslog_process_alerts($max_seq) { // Windowed thresholds need every cycle's count recorded, // and their state machine decides what may fire, so // evaluation must run even below the per-cycle threshold. - $count = syslog_threshold_evaluate($alert, $count); + $count = syslog_threshold_evaluate($alert, $count); $triggered = $count > 0 ? syslog_process_alert($alert, $sql, $params, $count) : 0; $syslog_alarms += $triggered; @@ -5167,7 +5345,7 @@ function syslog_process_alerts($max_seq) { } else { // method 0 fires on any match; skip the matching-record // fetch when the consolidated count already proved zero - $at = $count > 0 ? null : []; + $at = $count > 0 ? null : []; $triggered = syslog_process_alert($alert, $sql, $params, 0, '', $at); $syslog_alarms += $triggered; @@ -5180,6 +5358,7 @@ function syslog_process_alerts($max_seq) { } $activity = []; + foreach ($fired as $name => $count) { $activity[] = ['name' => $name, 'count' => $count]; } @@ -5196,13 +5375,14 @@ function syslog_mark_threshold_messages(array $alert, int $count, array $matches } if ($matches && !empty($alert['_threshold_state']) && trim($alert['recovery_command'] ?? '') !== '') { - $state = $alert['_threshold_state']; + $state = $alert['_threshold_state']; $context = json_encode([ - 'alert' => ['id' => $alert['id'], 'name' => $alert['name'], 'severity' => $alert['severity'], 'message' => $alert['message'], 'command' => $alert['recovery_command'], '_threshold_state' => $state], - 'results' => ['priority_id' => $matches[0]['priority_id'], 'facility_id' => $matches[0]['facility_id'], 'message' => $matches[0]['message']], + 'alert' => ['id' => $alert['id'], 'name' => $alert['name'], 'severity' => $alert['severity'], 'message' => $alert['message'], 'command' => $alert['recovery_command'], '_threshold_state' => $state], + 'results' => ['priority_id' => $matches[0]['priority_id'], 'facility_id' => $matches[0]['facility_id'], 'message' => $matches[0]['message']], 'hostname' => (int) $alert['level'] === 1 ? $matches[0]['host'] : '', 'hostlist' => array_values(array_unique(array_column($matches, 'host'))) ], JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + if (!syslog_db_execute_prepared('UPDATE syslog_threshold_state SET recovery_context = ? WHERE source_poller_id = ? AND breach_key = ? AND recovery_context IS NULL', [$context, $state['source_poller_id'], $state['breach_key']])) { return false; } @@ -5210,39 +5390,44 @@ function syslog_mark_threshold_messages(array $alert, int $count, array $matches foreach (array_chunk(array_column($matches, 'seq'), 1000) as $sequences) { if (!empty($alert['_threshold_state'])) { - $state = $alert['_threshold_state']; + $state = $alert['_threshold_state']; $values = implode(',', array_fill(0, count($sequences), '(?, ?, ?)')); $params = []; + foreach ($sequences as $sequence) { array_push($params, $state['source_poller_id'], $sequence, $state['breach_key']); } + if (!syslog_db_execute_prepared('INSERT IGNORE INTO syslog_threshold_messages (source_poller_id, source_event_id, breach_key) VALUES ' . $values, $params)) { return false; } } $placeholders = implode(',', array_fill(0, count($sequences), '?')); + if (!syslog_db_execute_prepared("UPDATE syslog_incoming SET threshold_breached = 1 WHERE status = 1 AND seq IN ($placeholders)", $sequences)) { cacti_log('SYSLOG ERROR: Unable to mark threshold-breaching messages for alert ' . (int) $alert['id'], false, 'SYSLOG'); + return false; } } + return true; } /** * syslog_process_alert - Process the Alert and generate notifications, execute commands, etc. * - * @param array $alert The alert to process - * @param string $sql The SQL to search for the Alert - * @param array $params The SQL parameters to be prepared into the SQL - * @param int $count In the case of a threshold alert, the number of occurrents - * of hosts with occurrences that were encountered through - * pre-processing the message - * @param string $hostname The hostname that this alert rule is for - * @param array|null $at Pre-fetched matching records; an empty array means - * zero matches are known and the fetch is skipped, and - * null fetches the records from $sql/$params + * @param array $alert The alert to process + * @param string $sql The SQL to search for the Alert + * @param array $params The SQL parameters to be prepared into the SQL + * @param int $count In the case of a threshold alert, the number of occurrents + * of hosts with occurrences that were encountered through + * pre-processing the message + * @param string $hostname The hostname that this alert rule is for + * @param array|null $at Pre-fetched matching records; an empty array means + * zero matches are known and the fetch is skipped, and + * null fetches the records from $sql/$params * * @return int 1 if the alert triggered, else 0 */ @@ -5252,6 +5437,7 @@ function syslog_process_alert($alert, $sql, $params, $count, $hostname = '', $at if (!empty($alert['_threshold_state']) && !$alert['_threshold_state']['new_breach'] && (int) $alert['repeat_alert'] <= 0) { $matches = syslog_db_fetch_assoc_prepared($sql, $params); syslog_mark_threshold_messages($alert, (int) $count, $matches ?: []); + return 0; } @@ -5514,6 +5700,7 @@ function syslog_process_alert($alert, $sql, $params, $count, $hostname = '', $at } $suppression_state = syslog_alert_suppression_check($alert, $hostname, $at); + if ($send && !$suppression_state['allowed']) { $send = false; syslog_debug("Alert Rule '" . $alert['name'] . "' notification suppressed by cooldown or duplicate detection"); @@ -5611,11 +5798,11 @@ function syslog_process_alert($alert, $sql, $params, $count, $hostname = '', $at * syslog_get_alert_sql - Get the SQL and params for the alert to * checi. * - * @param array $alert The alert attributes to process - * @param int $max_seq The max sequence + * @param array $alert The alert attributes to process + * @param int $max_seq The max sequence * @param bool $processing_boundary Include the status/seq processing gate - * @param bool $condition_only Return only the WHERE condition and its - * params, without SELECT/columns/boundary + * @param bool $condition_only Return only the WHERE condition and its + * params, without SELECT/columns/boundary * * @return array The SQL and the prepared array for the SQL */ @@ -5629,6 +5816,7 @@ function syslog_get_alert_sql(&$alert, $max_seq, $processing_boundary = true, $c if (!isset($syslog_incoming_config['programField'])) { $syslog_incoming_config['programField'] = 'program'; } + foreach (['textField' => 'message', 'hostField' => 'host', 'facilityField' => 'facility_id', 'priorityField' => 'priority_id'] as $setting => $default) { if (!isset($syslog_incoming_config[$setting])) { $syslog_incoming_config[$setting] = $default; @@ -5644,10 +5832,13 @@ function syslog_get_alert_sql(&$alert, $max_seq, $processing_boundary = true, $c // scan and apply `status`/`seq` gating a single time. switch ($alert['type']) { case 'filter': + if (!class_exists('Cacti\\Syslog\\QueryBuilder')) { + return syslog_query_builder_unavailable(); + } $fields = [ - 'message' => ['column' => '`' . $syslog_incoming_config['textField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], - 'host' => ['column' => '`' . $syslog_incoming_config['hostField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], - 'program' => ['column' => '`' . $syslog_incoming_config['programField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'message' => ['column' => '`' . $syslog_incoming_config['textField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'host' => ['column' => '`' . $syslog_incoming_config['hostField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'program' => ['column' => '`' . $syslog_incoming_config['programField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], 'facility_id' => ['column' => '`' . $syslog_incoming_config['facilityField'] . '`', 'operators' => ['=', '!='], 'type' => 'integer'], 'priority_id' => ['column' => '`' . $syslog_incoming_config['priorityField'] . '`', 'operators' => ['=', '!='], 'type' => 'integer'] ]; @@ -5682,14 +5873,12 @@ function syslog_get_alert_sql(&$alert, $max_seq, $processing_boundary = true, $c if ($alert['type'] == 'filter') { if (!class_exists('Cacti\\Syslog\\QueryBuilder')) { - $GLOBALS['syslog_rule_filter_error'] = 'The structured query builder is unavailable.'; - - return []; + return syslog_query_builder_unavailable(); } $fields = [ - 'message' => ['column' => '`' . $syslog_incoming_config['textField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], - 'host' => ['column' => '`' . $syslog_incoming_config['hostField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], - 'program' => ['column' => '`' . $syslog_incoming_config['programField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'message' => ['column' => '`' . $syslog_incoming_config['textField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'host' => ['column' => '`' . $syslog_incoming_config['hostField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], + 'program' => ['column' => '`' . $syslog_incoming_config['programField'] . '`', 'operators' => ['contains', 'begins', 'ends', '=', '!=']], 'facility_id' => ['column' => '`' . $syslog_incoming_config['facilityField'] . '`', 'operators' => ['=', '!='], 'type' => 'integer'], 'priority_id' => ['column' => '`' . $syslog_incoming_config['priorityField'] . '`', 'operators' => ['=', '!='], 'type' => 'integer'] ]; @@ -5788,9 +5977,9 @@ function syslog_get_alert_sql(&$alert, $max_seq, $processing_boundary = true, $c * then if syslog is configured to strip domains, perform that first. * * @return array{max_seq: int, incoming: int} The maximum sequence id, allowing - * syslog messages that come in randomly to be differentiated - * between messages to process and messages to be left till the - * next polling cycle, and the number of incoming records found. + * syslog messages that come in randomly to be differentiated + * between messages to process and messages to be left till the + * next polling cycle, and the number of incoming records found. */ function syslog_preprocess_incoming_records(): array { global $syslogdb_default; @@ -5948,21 +6137,23 @@ function syslog_message_digest_ready(string $table = ''): bool { } /** - * syslog_backfill_message_digest - fill the digest column for rows stored - * before the column existed. + * Backfill at most one bounded primary-key range per archived table. * - * Processes a bounded seq range per poller run so the migration never - * blocks ingest; progress is kept per table in the settings table. New - * rows are written with their digest by the archival statements, so the - * backfill only ever has to look behind its cursor once. + * Progress is plugin-owned and independent of later archival inserts. Restart + * the older settings-based migration once, since it could skip failed batches. * - * @param int $rows_per_run Maximum rows to digest per call + * @param int $rows_per_run Maximum sequence range per table and call. * - * @return int Rows still awaiting a digest + * @return int Remaining sequence span, or -1 when progress could not be saved. */ function syslog_backfill_message_digest(int $rows_per_run = 50000): int { global $syslogdb_default; + if ($rows_per_run < 1) { + return -1; + } + + syslog_status_ensure_table(); $remaining = 0; foreach (['syslog', 'syslog_removed'] as $table) { @@ -5971,53 +6162,54 @@ function syslog_backfill_message_digest(int $rows_per_run = 50000): int { continue; } - $max_seq = (int) syslog_db_fetch_cell("SELECT COALESCE(MAX(seq), 0) FROM `$syslogdb_default`.`$table`"); + $option = 'digest_backfill_' . $table; + $saved = syslog_db_fetch_cell_prepared("SELECT COALESCE((SELECT value FROM `$syslogdb_default`.`syslog_status` WHERE name = ?), '')", [$option]); - if ($max_seq == 0) { - continue; + if ($saved === false) { + return -1; } - $option = 'syslog_digest_backfill_seq_' . $table; - $cursor = (int) read_config_option($option, true); - $done = (int) read_config_option($option . '_done', true); + if ($saved === '' || $saved === null) { + $target = syslog_db_fetch_cell("SELECT COALESCE(MAX(seq), 0) FROM `$syslogdb_default`.`$table`"); - // A completed migration holds whenever the cursor has reached the - // high-water mark once; archival keeps writing digests from there. - if ($done && $cursor >= $max_seq) { - continue; - } + if ($target === false) { + return -1; + } - $rows_remaining = (int) syslog_db_fetch_cell("SELECT COUNT(seq) FROM `$syslogdb_default`.`$table` WHERE `message_digest` = ''"); + $state = ['cursor' => 0, 'target' => (int) $target]; - if ($rows_remaining == 0) { - set_config_option($option . '_done', (string) $max_seq); - continue; - } + if (!syslog_status_set($option, json_encode($state))) { + return -1; + } + } else { + $state = json_decode($saved, true); - // Chunk along the primary key so each pass is one index range scan - // instead of rescanning the entire table for the empty digest flag. - $left_to_digest = $rows_per_run; - $chunk = max($rows_per_run, 10000); + if (!is_array($state) || !isset($state['cursor'], $state['target']) + || !is_int($state['cursor']) || !is_int($state['target']) + || $state['cursor'] < 0 || $state['target'] < $state['cursor']) { + return -1; + } + } - while ($left_to_digest > 0 && $cursor < $max_seq) { - $stop = min($cursor + $chunk, $max_seq); + if ($state['cursor'] >= $state['target']) { + continue; + } - syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`$table` - SET `message_digest` = MD5(`message`) - WHERE `seq` > ? AND `seq` <= ? AND `message_digest` = ''", [$cursor, $stop]); + $stop = min($state['cursor'] + $rows_per_run, $state['target']); - $left_to_digest -= ($stop - $cursor); - $cursor = $stop; + if (!syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`$table` + SET `message_digest` = MD5(`message`) + WHERE `seq` > ? AND `seq` <= ? AND `message_digest` = ''", [$state['cursor'], $stop])) { + return -1; } - set_config_option($option, (string) $cursor); - - $rows_remaining = (int) syslog_db_fetch_cell("SELECT COUNT(seq) FROM `$syslogdb_default`.`$table` WHERE `message_digest` = ''"); - $remaining += $rows_remaining; + $state['cursor'] = $stop; - if ($rows_remaining == 0) { - set_config_option($option . '_done', (string) $max_seq); + if (!syslog_status_set($option, json_encode($state))) { + return -1; } + + $remaining += $state['target'] - $stop; } return $remaining; @@ -6234,6 +6426,7 @@ function syslog_replication_cleanup_local_history(array $events): bool { $clauses = []; $params = []; + foreach ($events as $event) { if (($event['disposition'] ?? '') !== 'syslog') { continue; @@ -6252,9 +6445,9 @@ function syslog_replication_cleanup_local_history(array $events): bool { * this inside the same transaction as the local archival disposition. * INSERT IGNORE makes retries idempotent on (source_poller_id, source_event_id). * - * @param string $where A WHERE clause scoped to syslog_incoming AS si. - * @param array $params Prepared-statement values for $where. - * @param string $disposition syslog or syslog_removed. + * @param string $where A WHERE clause scoped to syslog_incoming AS si. + * @param array $params Prepared-statement values for $where. + * @param string $disposition syslog or syslog_removed. * * @return bool */ @@ -6286,6 +6479,7 @@ function syslog_replication_record_error(string $message): void { /** Read on-demand, indexed operational status for this local collector. */ function syslog_replication_operational_status(bool $bounded = false): array { global $syslogdb_default; + if (!syslog_replication_is_enabled()) { return ['enabled' => false]; } @@ -6300,17 +6494,18 @@ function syslog_replication_operational_status(bool $bounded = false): array { $lease = syslog_db_fetch_row("SELECT acquired_at, heartbeat_at FROM `$syslogdb_default`.`syslog_replication_recovery` WHERE name = 'recovery'", false); $status = syslog_status_get(); + return [ - 'enabled' => true, - 'state' => syslog_replication_get_state(), - 'pending' => isset($row['pending']) ? (int) $row['pending'] : null, - 'oldest_pending' => (string) ($row['oldest_pending'] ?? ''), + 'enabled' => true, + 'state' => syslog_replication_get_state(), + 'pending' => isset($row['pending']) ? (int) $row['pending'] : null, + 'oldest_pending' => (string) ($row['oldest_pending'] ?? ''), 'oldest_pending_epoch' => isset($row['oldest_pending_epoch']) ? (int) $row['oldest_pending_epoch'] : null, - 'recovery_active' => !empty($lease) && isset($lease['heartbeat_at']) && (int) $lease['heartbeat_at'] >= time() - SYSLOG_REPLICATION_RECOVERY_LEASE_SECONDS, - 'recovery_started' => (string) ($lease['acquired_at'] ?? ''), - 'last_success' => $status['replication_last_success'] ?? '', - 'last_error' => $status['replication_last_error'] ?? '', - 'last_error_time' => $status['replication_last_error_time'] ?? '', + 'recovery_active' => !empty($lease) && isset($lease['heartbeat_at']) && (int) $lease['heartbeat_at'] >= time() - SYSLOG_REPLICATION_RECOVERY_LEASE_SECONDS, + 'recovery_started' => (string) ($lease['acquired_at'] ?? ''), + 'last_success' => $status['replication_last_success'] ?? '', + 'last_error' => $status['replication_last_error'] ?? '', + 'last_error_time' => $status['replication_last_error_time'] ?? '', ]; } @@ -6377,6 +6572,7 @@ function syslog_replication_main_database() { global $syslog_replication_main_db_ssl_ca; $database = (string) ($syslog_replication_main_db_default ?? $database_default ?? ''); + if (!preg_match('/^[A-Za-z0-9_$-]+$/', $database)) { return false; } @@ -6442,6 +6638,7 @@ function syslog_replication_get_state(): ?string { global $config; $connection = defined('CACTI_CONNECTION') ? CACTI_CONNECTION : ($config['connection'] ?? 'offline'); + return $connection === 'recovery' && syslog_replication_has_backlog() ? 'recovery' : 'online'; } @@ -6458,17 +6655,20 @@ function syslog_replication_mark_connection_failure(): void { function syslog_replication_record_state(): ?string { $state = syslog_replication_get_state(); + if ($state === null) { return null; } $status = syslog_status_get(); $prior = $status['replication_state'] ?? ''; + if ($prior === $state) { return $state; } syslog_status_set('replication_state', $state); + if ($state === 'offline') { cacti_log('SYSLOG: Central synchronization unavailable; local processing and outbox retention continue', false, 'SYSLOG'); } elseif ($state === 'recovery') { @@ -6498,7 +6698,7 @@ function syslog_replication_recovery_records_per_run(): int { /** Return the administrator-selected pause between bounded central batches. */ function syslog_replication_recovery_batch_delay_us(): int { $milliseconds = (int) read_config_option('syslog_replication_recovery_batch_delay_ms'); - $allowed = [0, 25, 50, 100, 250, 500, 1000]; + $allowed = [0, 25, 50, 100, 250, 500, 1000]; if (!in_array($milliseconds, $allowed, true)) { $milliseconds = SYSLOG_REPLICATION_RECOVERY_DEFAULT_DELAY_MS; @@ -6515,8 +6715,9 @@ function syslog_replication_recovery_acquire(string $token): bool { return false; } - $now = time(); + $now = time(); $stale = $now - SYSLOG_REPLICATION_RECOVERY_LEASE_SECONDS; + if (!syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_replication_recovery` (name, owner_token, acquired_at, heartbeat_at, pid) VALUES ('recovery', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE @@ -6529,12 +6730,14 @@ function syslog_replication_recovery_acquire(string $token): bool { } $owner = syslog_db_fetch_cell("SELECT owner_token FROM `$syslogdb_default`.`syslog_replication_recovery` WHERE name = 'recovery'", '', false); + return hash_equals($token, (string) $owner); } /** Refresh an owned lease without extending another worker's ownership. */ function syslog_replication_recovery_heartbeat(string $token): bool { global $syslogdb_default; + return syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_replication_recovery` SET heartbeat_at = ? WHERE name = 'recovery' AND owner_token = ?", [time(), $token]); } @@ -6560,34 +6763,43 @@ function syslog_replication_recovery_run(): int { } $token = bin2hex(random_bytes(16)); + if (!syslog_replication_recovery_acquire($token)) { cacti_log('SYSLOG: Recovery worker skipped; an active lease already owns backlog convergence', false, 'SYSLOG'); + return 0; } $accepted = 0; cacti_log('SYSLOG: Recovery worker acquired lease', false, 'SYSLOG'); + try { - $max_batches = (int) ceil(syslog_replication_recovery_records_per_run() / SYSLOG_REPLICATION_BATCH_SIZE); + $max_batches = (int) ceil(syslog_replication_recovery_records_per_run() / SYSLOG_REPLICATION_BATCH_SIZE); $batch_delay_us = syslog_replication_recovery_batch_delay_us(); for ($batch = 0; $batch < $max_batches; $batch++) { if (!syslog_replication_delivery_is_online()) { cacti_log('SYSLOG: Recovery worker paused because Main is unavailable', false, 'SYSLOG'); + break; } + if (!syslog_replication_has_backlog()) { cacti_log('SYSLOG: Recovery worker completed; Syslog outbox is empty', false, 'SYSLOG'); + break; } $delivered = syslog_replication_deliver_online(); + if ($delivered <= 0) { cacti_log('SYSLOG ERROR: Recovery worker retained the oldest unaccepted batch; retry will occur on a future execution', false, 'SYSLOG'); + break; } $accepted += $delivered; syslog_replication_recovery_heartbeat($token); + if ($batch_delay_us > 0) { usleep($batch_delay_us); } @@ -6607,23 +6819,28 @@ function syslog_replication_recovery_run(): int { function syslog_replication_recovery_is_active(): bool { global $syslogdb_default; $heartbeat = syslog_db_fetch_cell("SELECT heartbeat_at FROM `$syslogdb_default`.`syslog_replication_recovery` WHERE name = 'recovery'", '', false); + return is_numeric($heartbeat) && (int) $heartbeat >= time() - SYSLOG_REPLICATION_RECOVERY_LEASE_SECONDS; } /** Start a detached recovery worker only when this collector has backlog. */ function syslog_replication_start_recovery_worker(): bool { global $config; + if (syslog_replication_get_state() !== 'recovery' || syslog_replication_recovery_is_active()) { return false; } $php = (string) read_config_option('path_php_binary'); + if ($php === '') { cacti_log('SYSLOG ERROR: Recovery worker was not started because path_php_binary is empty', false, 'SYSLOG'); + return false; } exec_background($php, ' -q ' . cacti_escapeshellarg($config['base_path'] . '/plugins/syslog/syslog_recovery.php')); + return true; } @@ -6639,15 +6856,16 @@ function syslog_replication_start_recovery_worker(): bool { * @return bool */ function syslog_replication_accept_central(array $event, $central, string $database): bool { - if (!in_array($event['disposition'], ['syslog', 'syslog_removed'], true)) { cacti_log('SYSLOG ERROR: Replication event has an invalid disposition', false, 'SYSLOG'); + return false; } $receipt = db_execute_prepared("INSERT IGNORE INTO `$database`.`syslog_replication_receipts` (source_poller_id, source_event_id, disposition) VALUES (?, ?, ?)", [(int) $event['source_poller_id'], (int) $event['source_event_id'], $event['disposition']], true, $central); + if (!$receipt) { return false; } @@ -6720,6 +6938,7 @@ function syslog_replication_deliver_online(): int { } $main_database = syslog_replication_main_database(); + if ($main_database === false) { syslog_replication_mark_connection_failure(); syslog_replication_record_error('Main Collector Syslog database connection is unavailable'); @@ -6735,18 +6954,21 @@ function syslog_replication_deliver_online(): int { FROM `$syslogdb_default`.`syslog_replication_output` ORDER BY created_at ASC, source_poller_id ASC, source_event_id ASC LIMIT " . SYSLOG_REPLICATION_BATCH_SIZE); + if (empty($events)) { return 0; } $threshold_states = syslog_threshold_replication_states($events); + if ($threshold_states === false) { return 0; } $batch_id = bin2hex(random_bytes(16)); + foreach ($events as &$event) { $event['delivery_batch_id'] = $batch_id; - $event['threshold_states'] = $threshold_states[$event['source_poller_id'] . ':' . $event['source_event_id']] ?? []; + $event['threshold_states'] = $threshold_states[$event['source_poller_id'] . ':' . $event['source_event_id']] ?? []; } unset($event); @@ -6754,6 +6976,7 @@ function syslog_replication_deliver_online(): int { syslog_replication_mark_connection_failure(); syslog_replication_record_error('Unable to begin central replication transaction'); cacti_log('SYSLOG ERROR: Unable to begin central replication transaction; retaining local outbox', false, 'SYSLOG'); + return 0; } @@ -6763,6 +6986,7 @@ function syslog_replication_deliver_online(): int { db_execute('ROLLBACK', false, $central); syslog_replication_record_error('Main Collector rejected replication batch'); cacti_log('SYSLOG ERROR: Main Collector rejected replication batch; retaining local outbox', false, 'SYSLOG'); + return 0; } } @@ -6772,17 +6996,20 @@ function syslog_replication_deliver_online(): int { db_execute('ROLLBACK', false, $central); syslog_replication_record_error('Central replication commit was not confirmed'); cacti_log('SYSLOG ERROR: Central replication commit was not confirmed; retaining local outbox for idempotent retry', false, 'SYSLOG'); + return 0; } if (!syslog_replication_cleanup_local_history($events)) { syslog_replication_record_error('Unable to remove temporary local history after central delivery'); cacti_log('SYSLOG ERROR: Main Collector accepted replication batch but temporary local history cleanup failed; retry is safe', false, 'SYSLOG'); + return 0; } $clauses = []; $params = []; + foreach ($events as $event) { $clauses[] = '(source_poller_id = ? AND source_event_id = ?)'; $params[] = (int) $event['source_poller_id']; @@ -6792,14 +7019,17 @@ function syslog_replication_deliver_online(): int { if (!syslog_db_execute_prepared("DELETE FROM `$syslogdb_default`.`syslog_replication_output` WHERE " . implode(' OR ', $clauses), $params)) { syslog_replication_record_error('Local replication acknowledgement failed'); cacti_log('SYSLOG ERROR: Main Collector accepted replication batch but local acknowledgement failed; retry is safe', false, 'SYSLOG'); + return 0; } $acknowledged = db_affected_rows($syslog_cnn); + if ($acknowledged > 0) { syslog_status_set('replication_last_success', time()); syslog_status_set('replication_last_error', ''); } + return $acknowledged; } @@ -6842,7 +7072,7 @@ function syslog_incoming_to_syslog($max_seq, $seq_start = 0, $seq_end = 0) { } $replication_transaction = true; - $replication_where = 'WHERE si.`status` = 1 AND si.`seq` <= ?' . $slice_where; + $replication_where = 'WHERE si.`status` = 1 AND si.`seq` <= ?' . $slice_where; if (!syslog_replication_enqueue_incoming($replication_where, array_merge([$max_seq], $slice_param), 'syslog')) { syslog_db_execute('ROLLBACK'); @@ -6853,6 +7083,7 @@ function syslog_incoming_to_syslog($max_seq, $seq_start = 0, $seq_end = 0) { } $retain_local_history = syslog_replication_should_retain_local_history(); + if (!$retain_local_history && syslog_replication_is_enabled() && !syslog_remote_store_records()) { $archived = true; } else { @@ -7254,14 +7485,14 @@ function syslog_get_report_sql(&$report) { * generate a Cacti log message and save settings in the settings table for use * by various graph templates * - * @param float $start_time The start time of the polling process - * @param int $deleted The number of syslog messages deleted - * @param int $incoming The number of syslog incoming messages - * @param int $removed The number of syslog messages removed - * @param int $xferred The number of syslog messages transferred - * @param int $alerts The number of alerts processed - * @param int $alarms The number of alerts triggered - * @param int $reports The number of reports sent + * @param float $start_time The start time of the polling process + * @param int $deleted The number of syslog messages deleted + * @param int $incoming The number of syslog incoming messages + * @param int $removed The number of syslog messages removed + * @param int $xferred The number of syslog messages transferred + * @param int $alerts The number of alerts processed + * @param int $alarms The number of alerts triggered + * @param int $reports The number of reports sent * * @return void */ @@ -7439,10 +7670,11 @@ function alert_replace_variables($alert, $results, $hostname = '') { * @return string The details trigger button HTML. */ function syslog_message_button($message, $device, $program, $facility, $severity, $received, $id = 0, $source = ''): string { - $details = compact('device', 'program', 'facility', 'severity', 'received'); + $details = compact('device', 'program', 'facility', 'severity', 'received'); $details['message'] = (string) $message; - $details['rules'] = syslog_message_rule_links($id, $source, $received, $device); - $text = title_trim((string) $message, 100); + $details['rules'] = syslog_message_rule_links($id, $source, $received, $device); + $text = title_trim((string) $message, 100); + return ''; } @@ -7453,24 +7685,29 @@ function syslog_message_button($message, $device, $program, $facility, $severity * @param int|string $id The sequence id of the record. * @param string $source The table the record came from. * @param string $received The logtime of the record. + * @param mixed $host * * @return array Map of action name to rule editor URL. */ function syslog_message_rule_links($id, $source, $received, $host = ''): array { $links = []; + if ($source !== 'main' || !ctype_digit((string) $id) || (int) $id < 1) { return $links; } $query = http_build_query(['id' => $id, 'action' => 'newedit', 'type' => '0', 'date' => $received]); + if (syslog_realm_allowed('Rule Viewer') && syslog_allow_rule_edits()) { foreach (['alarm' => 'syslog_alerts.php', 'removal' => 'syslog_removal.php'] as $action => $page) { $links[$action] = $page . '?' . $query; } } + if ($host !== '' && syslog_allow_rule_edits()) { $links['device'] = 'syslog_device_rules.php?' . http_build_query(['action' => 'edit', 'host' => $host]); } + return $links; } diff --git a/includes/installer.php b/includes/installer.php index dca7e7a3..d381b756 100644 --- a/includes/installer.php +++ b/includes/installer.php @@ -7,13 +7,13 @@ +-------------------------------------------------------------------------+ */ -/* Install and uninstall adviser UI. */ +// Install and uninstall adviser UI. /** Confirm an existing plugin upgrade without sending it through Cacti's install action. */ function syslog_upgrade_advisor(string $installed, string $current): void { top_header(); print "'; html_end_box(); print ""; @@ -145,7 +145,7 @@ function syslog_install_advisor(int $syslog_exists, array $submitted_options = [ } print "
"; - html_start_box(__('Syslog Upgrade Advisor', 'syslog'), '100%', '', '3', 'center', ''); + html_start_box(__('Syslog Upgrade Advisor', 'syslog'), '100%', false, 3, 'center', ''); print '
' . __('Upgrade Syslog from %s to %s? Existing messages and rules will be preserved. Schema changes may take time.', html_escape($installed), html_escape($current), 'syslog') . '
"; - html_start_box(__('Syslog %s Advisor', $type, 'syslog'), '100%', '', '3', 'center', ''); + html_start_box(__('Syslog %s Advisor', $type, 'syslog'), '100%', false, 3, 'center', ''); print '
'; if ($syslog_exists) { @@ -161,7 +161,7 @@ function syslog_install_advisor(int $syslog_exists, array $submitted_options = [ print ""; - html_start_box(__('Syslog %s Settings', $type, 'syslog'), '100%', '', '3', 'center', ''); + html_start_box(__('Syslog %s Settings', $type, 'syslog'), '100%', false, 3, 'center', ''); draw_edit_form([ 'config' => ['no_form_tag' => true], @@ -259,7 +259,7 @@ function syslog_uninstall_advisor(): void { print "'; + foreach (['breached_at', 'last_match', 'expires_at'] as $field) { print ''; } print ''; } + if (!$breaches) { print ''; } print ''; }; @@ -886,20 +889,24 @@ function syslog_status_card_body(string $key): string { case 'replication': $replication = syslog_status_replication(); print '
"; - html_start_box(__('Syslog Uninstall Preferences', 'syslog'), '100%', '', '3', 'center', ''); + html_start_box(__('Syslog Uninstall Preferences', 'syslog'), '100%', false, 3, 'center', ''); draw_edit_form([ 'config' => [], diff --git a/includes/navigation.php b/includes/navigation.php index 1018026f..e9ddc7d3 100644 --- a/includes/navigation.php +++ b/includes/navigation.php @@ -7,7 +7,7 @@ +-------------------------------------------------------------------------+ */ -/* Navigation callbacks. */ +// Navigation callbacks. require_once(dirname(__DIR__) . '/setup.php'); @@ -32,7 +32,6 @@ function syslog_show_tab(): void { } } - /** * Add the Syslog pages to the Cacti navigation structure. * @@ -64,7 +63,6 @@ function syslog_draw_navigation_text($nav) { return $nav; } - /** * Display a link to the Syslog page filtered for the graphed device. * diff --git a/includes/processing.php b/includes/processing.php index 51b7789e..f2547e71 100644 --- a/includes/processing.php +++ b/includes/processing.php @@ -7,7 +7,7 @@ +-------------------------------------------------------------------------+ */ -/* Poller and replication callbacks. */ +// Poller and replication callbacks. require_once(dirname(__DIR__) . '/setup.php'); @@ -21,24 +21,18 @@ * @return array The replication data, unmodified. */ function syslog_replicate_out($data) { - syslog_connect(); - - if (read_config_option('syslog_remote_enabled') == 'on' && read_config_option('syslog_remote_sync_rules') == 'on') { - $remote_poller_id = $data['remote_poller_id']; - $rcnn_id = $data['rcnn_id']; - $class = $data['class']; - - cacti_log('INFO: Replicating for the Syslog Plugin', false, 'REPLICATE'); - - if ($class == 'all') { - $tdata = syslog_db_fetch_assoc('SELECT * FROM syslog_alert'); - replicate_out_table($rcnn_id, $tdata, 'syslog_alert', $remote_poller_id); - $tdata = syslog_db_fetch_assoc('SELECT * FROM syslog_device_rule'); - replicate_out_table($rcnn_id, $tdata, 'syslog_device_rule', $remote_poller_id); - $tdata = syslog_db_fetch_assoc('SELECT * FROM syslog_remove'); - replicate_out_table($rcnn_id, $tdata, 'syslog_remove', $remote_poller_id); - $tdata = syslog_db_fetch_assoc('SELECT * FROM syslog_reports'); - replicate_out_table($rcnn_id, $tdata, 'syslog_reports', $remote_poller_id); + global $syslog_cnn; + + if (syslog_connect() && read_config_option('syslog_remote_enabled') == 'on' + && read_config_option('syslog_remote_sync_rules') == 'on' && $data['class'] == 'all') { + foreach (['syslog_alert', 'syslog_device_rule', 'syslog_remove', 'syslog_reports'] as $table) { + $rows = syslog_db_fetch_assoc("SELECT * FROM `$table`"); + + if (!syslog_replace_data($table, $rows, $data['rcnn_id'], $syslog_cnn)) { + cacti_log("ERROR: Unable to replicate Syslog rule table $table", false, 'REPLICATE'); + + break; + } } } @@ -46,26 +40,29 @@ function syslog_replicate_out($data) { } /** - * Pull the Syslog rules tables from the main Cacti database. + * Pull the authoritative rule snapshot from Cacti's local replicated tables. * - * @return void + * @return bool Whether every rule table was synchronized successfully. */ -function syslog_replicate_in(): void { - syslog_connect(); +function syslog_replicate_in(): bool { + global $local_db_cnn_id, $syslog_cnn; - if (read_config_option('syslog_remote_enabled') == 'on' && read_config_option('syslog_remote_sync_rules') == 'on') { - $data = db_fetch_assoc('SELECT * FROM syslog_alert'); - syslog_replace_data('syslog_alert', $data); - - $data = db_fetch_assoc('SELECT * FROM syslog_device_rule'); - syslog_replace_data('syslog_device_rule', $data); + if (!syslog_connect()) { + return false; + } - $data = db_fetch_assoc('SELECT * FROM syslog_remove'); - syslog_replace_data('syslog_remove', $data); + if (read_config_option('syslog_remote_enabled') == 'on' && read_config_option('syslog_remote_sync_rules') == 'on' + && $syslog_cnn !== $local_db_cnn_id) { + foreach (['syslog_alert', 'syslog_device_rule', 'syslog_remove', 'syslog_reports'] as $table) { + $rows = db_fetch_assoc("SELECT * FROM `$table`", true, $local_db_cnn_id); - $data = db_fetch_assoc('SELECT * FROM syslog_reports'); - syslog_replace_data('syslog_reports', $data); + if (!syslog_replace_data($table, $rows)) { + return false; + } + } } + + return true; } /** @@ -76,13 +73,21 @@ function syslog_replicate_in(): void { function syslog_poller_bottom(): void { global $config; + if (!api_plugin_is_enabled('syslog')) { + return; + } + if (syslog_config_safe()) { - syslog_connect(); + if (!syslog_connect()) { + cacti_log('ERROR: Syslog database or schema is unavailable; skipping processing.', false, 'SYSLOG'); + + return; + } syslog_pipeline_health_check(); syslog_status_set('last_polling_time', time()); $command_string = (string) read_config_option('path_php_binary'); - $extra_args = ' -q ' . $config['base_path'] . '/plugins/syslog/syslog_process.php'; + $extra_args = ' -q ' . cacti_escapeshellarg($config['base_path'] . '/plugins/syslog/syslog_process.php'); exec_background($command_string, $extra_args); } else { cacti_log('WARNING: You have installed the Syslog plugin, but you have not properly set a config.php or config_local.php', false, 'POLLER'); @@ -90,63 +95,118 @@ function syslog_poller_bottom(): void { } /** - * Replace the data in the local table with the replicated data. + * Publish a complete rule snapshot, including an empty table, atomically. * - * @param string $table The name of the table to replace. - * @param array> $data The replicated rows, passed by reference. + * Cacti's replicate_out_table() reads schema from its default connection. + * Explicit connections here also support a dedicated main Syslog database. * - * @return void + * @param string $table Allowlisted plugin-owned rule table. + * @param array|false $data Source rows; false must never clear a table. + * @param object|null $target Destination, defaulting to the local Syslog DB. + * @param object|null $source Schema source, defaulting to Cacti's local DB. + * + * @return bool Whether the full snapshot was published. */ -function syslog_replace_data($table, &$data) { - if (cacti_sizeof($data)) { - $sqlData = []; - $sqlQuery = []; - $columns = array_keys($data[0]); - $create_sql = ''; - - $create = db_fetch_row('SHOW CREATE TABLE ' . $table); - - if (is_array($create)) { - if (isset($create["CREATE TABLE `$table`"])) { - $create_sql = $create["CREATE TABLE `$table`"]; - } elseif (isset($create['Create Table'])) { - $create_sql = $create['Create Table']; - } - } +function syslog_replace_data($table, &$data, $target = null, $source = null): bool { + global $syslog_cnn, $local_db_cnn_id; - if (!syslog_db_table_exists($table)) { - if ($create_sql == '') { - cacti_log('WARNING: Unable to derive CREATE TABLE SQL for `' . $table . '` during Syslog replication.', false, 'REPLICATE'); + if (!in_array($table, ['syslog_alert', 'syslog_device_rule', 'syslog_remove', 'syslog_reports'], true) + || !is_array($data)) { + return false; + } - return; - } + $target = $target ?? $syslog_cnn; + $source = $source ?? $local_db_cnn_id; - syslog_db_execute($create_sql); - syslog_db_execute("TRUNCATE TABLE $table"); + if (!is_object($target) || !is_object($source)) { + return false; + } + + if ($target === $source) { + return true; + } + + $create = db_fetch_row("SHOW CREATE TABLE `$table`", true, $source); + $create_sql = is_array($create) ? ($create['Create Table'] ?? $create["CREATE TABLE `$table`"] ?? '') : ''; + $stage = $table . '_sync_' . bin2hex(random_bytes(6)); + $backup = $stage . '_old'; + $create_sql = preg_replace( + '/^CREATE TABLE `' . preg_quote($table, '/') . '`/', + 'CREATE TABLE `' . $stage . '`', + $create_sql, + 1, + $replaced + ); + + if ($replaced !== 1) { + return false; + } + + if ($data && (!isset($data[0]) || !is_array($data[0]) || !$data[0])) { + return false; + } + + $columns = $data ? array_keys($data[0]) : []; + + foreach ($columns as $column) { + if (!is_string($column) || !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { + return false; } + } - // Make the prefix - $sql_prefix = "INSERT INTO $table (`" . implode('`,`', $columns) . '`) VALUES '; + foreach ($data as $row) { + if (!is_array($row) || array_keys($row) !== $columns) { + return false; + } + } - // Make the suffix - $sql_suffix = ' ON DUPLICATE KEY UPDATE '; + $lock = 'syslog_rule_snapshot_' . $table; - foreach ($columns as $c) { - $sql_suffix .= " $c = VALUES($c),"; + if (!db_fetch_cell_prepared('SELECT GET_LOCK(?, 5)', [$lock], '', true, $target)) { + return false; + } + + try { + if (!db_execute($create_sql, true, $target)) { + return false; } - $sql_suffix = trim($sql_suffix, ','); - // Construct the prepared statement - foreach ($data as $row) { - $sqlQuery[] = '(' . trim(str_repeat('?, ', cacti_sizeof($columns)), ', ') . ')'; + foreach (array_chunk($data, 250) as $rows) { + $values = []; + $params = []; - foreach ($row as $col) { - $sqlData[] = $col; + foreach ($rows as $row) { + $values[] = '(' . implode(', ', array_fill(0, cacti_sizeof($columns), '?')) . ')'; + $params = array_merge($params, array_values($row)); } + + if (!db_execute_prepared( + "INSERT INTO `$stage` (`" . implode('`, `', $columns) . '`) VALUES ' . implode(', ', $values), + $params, + true, + $target + )) { + return false; + } + } + + $exists = db_fetch_cell_prepared( + 'SELECT EXISTS (SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?)', + [$table], + '', + true, + $target + ); + + if ($exists === false) { + return false; } - $sql = implode(', ', $sqlQuery); + $rename = $exists ? "`$table` TO `$backup`, " : ''; - syslog_db_execute_prepared($sql_prefix . $sql . $sql_suffix, $sqlData); + return db_execute("RENAME TABLE $rename`$stage` TO `$table`", true, $target); + } finally { + db_execute("DROP TABLE IF EXISTS `$stage`, `$backup`", true, $target); + db_fetch_cell_prepared('SELECT RELEASE_LOCK(?)', [$lock], '', true, $target); } } diff --git a/includes/schema.php b/includes/schema.php index 1c367453..3517533f 100644 --- a/includes/schema.php +++ b/includes/schema.php @@ -7,7 +7,7 @@ +-------------------------------------------------------------------------+ */ -/* Schema and migration helpers. */ +// Schema and migration helpers. /** * Ensure message columns use TEXT storage. @@ -156,7 +156,7 @@ function syslog_ensure_saved_search_tables(): void { global $syslog_cnn; db_update_table('syslog_saved_searches', [ - 'type' => 'InnoDB', + 'type' => 'InnoDB', 'columns' => [ ['name' => 'id', 'type' => 'int(10)', 'NULL' => false, 'auto_increment' => true], ['name' => 'hash', 'type' => 'varchar(32)', 'NULL' => false, 'default' => ''], @@ -169,7 +169,7 @@ function syslog_ensure_saved_search_tables(): void { ['name' => 'date', 'type' => 'int(16)', 'NULL' => false, 'default' => '0'] ], 'primary' => ['id'], - 'keys' => [['name' => 'owner', 'columns' => ['user']]] + 'keys' => [['name' => 'owner', 'columns' => ['user']]] ], false, true, $syslog_cnn); } @@ -179,7 +179,7 @@ function syslog_ensure_share_tables(): void { foreach (['syslog_dashboards_perm' => 'dashboard_id', 'syslog_saved_searches_perm' => 'search_id'] as $table => $id) { db_update_table($table, [ - 'type' => 'InnoDB', + 'type' => 'InnoDB', 'columns' => [ ['name' => $id, 'type' => 'int(10)', 'NULL' => false, 'default' => '0'], ['name' => 'type', 'type' => 'varchar(5)', 'NULL' => false, 'default' => ''], @@ -195,7 +195,7 @@ function syslog_ensure_dashboard_tables(): void { global $syslog_cnn; db_update_table('syslog_dashboards', [ - 'type' => 'InnoDB', + 'type' => 'InnoDB', 'columns' => [ ['name' => 'id', 'type' => 'int(10)', 'NULL' => false, 'auto_increment' => true], ['name' => 'hash', 'type' => 'varchar(32)', 'NULL' => false, 'default' => ''], @@ -206,11 +206,11 @@ function syslog_ensure_dashboard_tables(): void { ['name' => 'updated', 'type' => 'int(16)', 'NULL' => false, 'default' => '0'] ], 'primary' => ['id'], - 'keys' => [['name' => 'owner', 'columns' => ['user']]] + 'keys' => [['name' => 'owner', 'columns' => ['user']]] ], false, true, $syslog_cnn); db_update_table('syslog_dashboard_panels', [ - 'type' => 'InnoDB', + 'type' => 'InnoDB', 'columns' => [ ['name' => 'id', 'type' => 'int(10)', 'NULL' => false, 'auto_increment' => true], ['name' => 'dashboard_id', 'type' => 'int(10)', 'NULL' => false, 'default' => '0'], @@ -230,7 +230,7 @@ function syslog_ensure_dashboard_tables(): void { ['name' => 'date', 'type' => 'int(16)', 'NULL' => false, 'default' => '0'] ], 'primary' => ['id'], - 'keys' => [['name' => 'dashboard', 'columns' => ['dashboard_id']]] + 'keys' => [['name' => 'dashboard', 'columns' => ['dashboard_id']]] ], false, true, $syslog_cnn); } @@ -239,7 +239,7 @@ function syslog_ensure_status_table(): void { global $syslog_cnn; db_update_table('syslog_status', [ - 'type' => 'InnoDB', + 'type' => 'InnoDB', 'columns' => [ ['name' => 'name', 'type' => 'varchar(64)', 'NULL' => false, 'default' => ''], ['name' => 'value', 'type' => 'text', 'NULL' => false], @@ -609,11 +609,11 @@ function syslog_setup_table_new(array $options, bool $repair = false): void { ENGINE=InnoDB ROW_FORMAT=Dynamic"); -syslog_create_alert_counts_table(); -syslog_create_replication_output_table(); -syslog_create_replication_receipts_table(); -syslog_create_replication_collectors_table(); -syslog_create_replication_recovery_table(); + syslog_create_alert_counts_table(); + syslog_create_replication_output_table(); + syslog_create_replication_receipts_table(); + syslog_create_replication_collectors_table(); + syslog_create_replication_recovery_table(); syslog_db_execute("CREATE TABLE IF NOT EXISTS `$syslogdb_default`.`syslog_alert_suppression` ( `alert_id` int(10) unsigned NOT NULL, @@ -791,6 +791,7 @@ function syslog_setup_table_new(array $options, bool $repair = false): void { syslog_ensure_share_tables(); syslog_ensure_dashboard_tables(); syslog_create_device_rule_table(); + if (!$repair) { syslog_dashboard_seed_default(); } @@ -852,7 +853,6 @@ function syslog_create_alert_counts_table(): void { `available_at` bigint unsigned NOT NULL, PRIMARY KEY (`source_poller_id`, `breach_key`), KEY `available_at` (`source_poller_id`, `available_at`)) ENGINE=InnoDB"); - } /** Complete an interrupted install or upgrade without dropping existing data. */ @@ -869,6 +869,7 @@ function syslog_ensure_table_structures(): bool { 'syslog_removed', 'syslog_logs', 'syslog_device_rule', 'syslog_replication_output', 'syslog_replication_recovery' ]; + if ((int) $config['poller_id'] <= 1) { $tables[] = 'syslog_replication_receipts'; $tables[] = 'syslog_replication_collectors'; @@ -879,6 +880,7 @@ function syslog_ensure_table_structures(): bool { if (!syslog_db_table_exists($table, false)) { syslog_setup_table_new([], true); $rebuilt = true; + break; } } @@ -934,6 +936,7 @@ function syslog_ensure_table_structures(): bool { foreach ($tables as $table) { if (!syslog_db_table_exists($table, false)) { cacti_log("SYSLOG ERROR: Schema upgrade did not create $table", false, 'SYSLOG'); + return false; } } diff --git a/includes/settings.php b/includes/settings.php index 2b6fe679..724c4119 100644 --- a/includes/settings.php +++ b/includes/settings.php @@ -7,20 +7,21 @@ +-------------------------------------------------------------------------+ */ -/* Settings callbacks. */ +// Settings callbacks. require_once(dirname(__DIR__) . '/setup.php'); /** - * Register the Syslog settings in the Cacti settings table. + * Build the half-hour choices for alert maintenance schedules. * - * @return void + * @return array */ function syslog_alert_maintenance_time_options(): array { $options = []; + for ($hour = 0; $hour < 24; $hour++) { foreach ([0, 30] as $minute) { - $value = sprintf('%02d:%02d', $hour, $minute); + $value = sprintf('%02d:%02d', $hour, $minute); $options[$value] = $value; } } @@ -30,9 +31,9 @@ function syslog_alert_maintenance_time_options(): array { function syslog_alert_maintenance_day_options(bool $include_disabled = false): array { $options = [ - '1' => __('Monday', 'syslog'), '2' => __('Tuesday', 'syslog'), '3' => __('Wednesday', 'syslog'), - '4' => __('Thursday', 'syslog'), '5' => __('Friday', 'syslog'), '6' => __('Saturday', 'syslog'), '7' => __('Sunday', 'syslog'), - '1,2,3,4,5' => __('Monday through Friday', 'syslog'), '6,7' => __('Saturday and Sunday', 'syslog'), + '1' => __('Monday', 'syslog'), '2' => __('Tuesday', 'syslog'), '3' => __('Wednesday', 'syslog'), + '4' => __('Thursday', 'syslog'), '5' => __('Friday', 'syslog'), '6' => __('Saturday', 'syslog'), '7' => __('Sunday', 'syslog'), + '1,2,3,4,5' => __('Monday through Friday', 'syslog'), '6,7' => __('Saturday and Sunday', 'syslog'), '1,2,3,4,5,6,7' => __('Every day', 'syslog') ]; @@ -53,6 +54,7 @@ function syslog_config_settings(): void { $tabs['syslog'] = __('Syslog', 'syslog'); $notification_lists = [0 => __('None', 'syslog')]; + if (db_table_exists('plugin_notification_lists')) { foreach (db_fetch_assoc('SELECT id, name FROM plugin_notification_lists ORDER BY name') as $list) { $notification_lists[(int) $list['id']] = $list['name']; @@ -248,8 +250,8 @@ function syslog_config_settings(): void { 'method' => 'drop_array', 'default' => '3600', 'array' => [ - '900' => __('%d minutes', 15, 'syslog'), - '3600' => __('%d hours', 1, 'syslog'), + '900' => __('%d minutes', 15, 'syslog'), + '3600' => __('%d hours', 1, 'syslog'), '21600' => __('%d hours', 6, 'syslog'), '86400' => __('%d days', 1, 'syslog') ] @@ -526,12 +528,12 @@ function syslog_config_arrays(): void { $menu2[$temp] = $temp2; if ($temp == __('Import/Export')) { - $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_alerts.php'] = __('Alert Rules', 'syslog'); - $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_device_rules.php'] = __('Device Alert Rules', 'syslog'); - $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_removal.php'] = __('Removal Rules', 'syslog'); - $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_reports.php'] = __('Report Rules', 'syslog'); + $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_alerts.php'] = __('Alert Rules', 'syslog'); + $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_device_rules.php'] = __('Device Alert Rules', 'syslog'); + $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_removal.php'] = __('Removal Rules', 'syslog'); + $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_reports.php'] = __('Report Rules', 'syslog'); $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_saved_searches.php'] = __('Saved Search Templates', 'syslog'); - $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_dashboards.php'] = __('Dashboards', 'syslog'); + $menu2[__('Syslog Settings', 'syslog')]['plugins/syslog/syslog_dashboards.php'] = __('Dashboards', 'syslog'); } } $menu = $menu2; diff --git a/includes/utilities.php b/includes/utilities.php index 691a88f3..c39c42fd 100644 --- a/includes/utilities.php +++ b/includes/utilities.php @@ -7,7 +7,7 @@ +-------------------------------------------------------------------------+ */ -/* Utilities callbacks. */ +// Utilities callbacks. require_once(dirname(__DIR__) . '/setup.php'); require_once(__DIR__ . '/functions.php'); @@ -17,12 +17,11 @@ * * @param string $action The action requested from the utilities page. * - * @return string|void The action, unmodified when it is not a Syslog action, or - * nothing when the Syslog config file is not available. + * @return string The action, unmodified for other plugins in the hook chain. */ function syslog_utilities_action($action) { if (!syslog_config_safe()) { - return; + return $action; } if ($action === 'purge_syslog_hosts') { diff --git a/lib/QueryBuilder.php b/lib/QueryBuilder.php index 63103d41..7ca1bb79 100644 --- a/lib/QueryBuilder.php +++ b/lib/QueryBuilder.php @@ -16,12 +16,12 @@ /** Compile versioned visual-filter documents into parameterized SQL. */ class QueryBuilder { - private const MAX_DEPTH = 16; + private const MAX_DEPTH = 16; private const MAX_CONDITIONS = 128; private const MAX_JSON_BYTES = 8192; /** - * @param string $json Versioned filter document. + * @param string $json Versioned filter document. * @param array $fields Map of logical field names to trusted SQL columns. * * @return array{sql:string,params:array} @@ -52,11 +52,11 @@ public static function compile(string $json, array $fields): array { /** * Recursively compile a condition list into SQL. * - * @param mixed $conditions The condition list from the untrusted filter document. - * @param array $fields Map of logical field names to trusted SQL columns. - * @param array $params The bound parameters, accumulated by reference. - * @param int $count The number of conditions compiled so far, passed by reference. - * @param int $depth The current nesting depth. + * @param mixed $conditions The condition list from the untrusted filter document. + * @param array $fields Map of logical field names to trusted SQL columns. + * @param array $params The bound parameters, accumulated by reference. + * @param int $count The number of conditions compiled so far, passed by reference. + * @param int $depth The current nesting depth. * * @return string The compiled SQL fragment. * @@ -68,15 +68,18 @@ private static function compileConditions($conditions, array $fields, array &$pa } $parts = []; + foreach (array_values($conditions) as $index => $condition) { if (!is_array($condition)) { throw new InvalidArgumentException('Each filter condition must be an object.'); } + if (++$count > self::MAX_CONDITIONS) { throw new InvalidArgumentException('The filter contains too many conditions.'); } $join = $index === 0 ? '' : strtoupper((string) ($condition['join'] ?? '')); + if ($index > 0 && !in_array($join, ['AND', 'OR'], true)) { throw new InvalidArgumentException('A filter connector must be AND or OR.'); } @@ -112,28 +115,35 @@ private static function compilePredicate(array $condition, array $fields, array $definition = $fields[$field]; $allowed = $definition['operators'] ?? []; + if (!in_array($operator, $allowed, true)) { throw new InvalidArgumentException('The selected operator is not allowed for this field.'); } $value = (string) $value; + if ($value === '' || strlen($value) > 2048) { throw new InvalidArgumentException('Filter values must contain between 1 and 2048 bytes.'); } + if (($definition['type'] ?? 'string') === 'integer' && !ctype_digit($value)) { throw new InvalidArgumentException('The selected field requires a nonnegative integer.'); } $column = $definition['column']; + switch ($operator) { case 'contains': $params[] = '%' . self::escapeLike($value) . '%'; + return "($column LIKE ? ESCAPE '!')"; case 'begins': $params[] = self::escapeLike($value) . '%'; + return "($column LIKE ? ESCAPE '!')"; case 'ends': $params[] = '%' . self::escapeLike($value); + return "($column LIKE ? ESCAPE '!')"; case '=': case '!=': @@ -142,6 +152,7 @@ private static function compilePredicate(array $condition, array $fields, array case '<': case '<=': $params[] = $value; + return "($column $operator ?)"; } diff --git a/lib/syslog_dashboard.php b/lib/syslog_dashboard.php index 35fdf9f5..99609894 100644 --- a/lib/syslog_dashboard.php +++ b/lib/syslog_dashboard.php @@ -180,7 +180,7 @@ function syslog_dashboard_bucket_seconds(int $seconds, string $interval): int { // (minute → 10 minutes → hour → day) until the chart stays under the // bucket cap. $ladder = [60, 600, 3600, 86400]; - $cap = syslog_dashboard_bucket_cap(); + $cap = syslog_dashboard_bucket_cap(); foreach ($ladder as $step) { if ($step > $bucket) { @@ -198,8 +198,8 @@ function syslog_dashboard_bucket_seconds(int $seconds, string $interval): int { /** * Format a bucket epoch for chart labels. * - * @param int $epoch Bucket start as a Unix timestamp. - * @param int $bucket Bucket size in seconds. + * @param int $epoch Bucket start as a Unix timestamp. + * @param int $bucket Bucket size in seconds. * * @return string Human-readable local time label. */ @@ -245,8 +245,8 @@ function syslog_dashboard_label_safe($label) { * @param string $source Panel source ('syslog' or 'alerts'). * * @return array|null [table, id_column, name_column], or null - * when the dimension reads the log row - * directly. + * when the dimension reads the log row + * directly. */ function syslog_dashboard_breakdown_lookup(string $field, string $source): ?array { if ($source === 'alerts' && $field === 'host') { @@ -336,7 +336,7 @@ function syslog_dashboard_breakdown_sql(string $source, string $field, string $r $dimension = 'syslog.host'; } else { [$table, $id_column, $name_column] = $lookup; - $dimension = "(SELECT lookup.$name_column FROM `$syslogdb_default`.`$table` AS lookup + $dimension = "(SELECT lookup.$name_column FROM `$syslogdb_default`.`$table` AS lookup WHERE lookup.$id_column = syslog.$id_column)"; } @@ -475,7 +475,7 @@ function syslog_dashboard_panel_settings(array $panel) { * @param int $panel_id Panel id. * * @return array|null Panel row joined with dashboard ownership, - * or null. + * or null. */ function syslog_dashboard_load_panel(int $panel_id): ?array { global $syslogdb_default; @@ -499,7 +499,7 @@ function syslog_dashboard_load_panel(int $panel_id): ?array { * @param array $panel Row from syslog_dashboard_load_panel(). * * @return array|null ['id' => ..., 'user' => ..., 'is_global' => ...], - * or null. + * or null. */ function syslog_dashboard_panel_owner(?array $panel): ?array { if ($panel === null || !isset($panel['dashboard_user'])) { @@ -534,8 +534,8 @@ function syslog_dashboard_panel_data(array $panel, int $dashboard_timespan): arr $expression = (string) $panel['expression']; try { - $tree = syslog_parse_logical_search($expression); - $column = $settings['source'] === 'alerts' ? 'logmsg' : 'message'; + $tree = syslog_parse_logical_search($expression); + $column = $settings['source'] === 'alerts' ? 'logmsg' : 'message'; $predicate = syslog_logical_search_sql($tree, $column); } catch (InvalidArgumentException $error) { return ['error' => __('Invalid logical search: %s', $error->getMessage(), 'syslog')]; @@ -554,7 +554,7 @@ function syslog_dashboard_panel_data(array $panel, int $dashboard_timespan): arr $sql = syslog_dashboard_timeseries_sql($settings['source'], $settings['removal'], $where, $bucket, $start); - $rows = syslog_db_fetch_assoc($sql); + $rows = syslog_db_fetch_assoc($sql); $labels = []; $values = []; @@ -584,7 +584,7 @@ function syslog_dashboard_panel_data(array $panel, int $dashboard_timespan): arr $labels = []; $values = []; - $total = 0; + $total = 0; if (cacti_sizeof($rows)) { $rank = 0; @@ -797,6 +797,47 @@ function syslog_dashboard_next_position(int $dashboard_id): int { return $position === null || $position === false ? 1 : (int) $position; } +/** + * Delete a dashboard, its panels and shares as one transaction. + * + * @param int $id Authorized dashboard id. + * + * @return bool Whether every delete committed. + */ +function syslog_dashboard_delete(int $id): bool { + global $syslogdb_default; + + if (!syslog_db_execute('START TRANSACTION')) { + return false; + } + + $committed = false; + + try { + foreach ([ + 'syslog_dashboard_panels' => 'dashboard_id', + 'syslog_dashboards_perm' => 'dashboard_id', + 'syslog_dashboards' => 'id' + ] as $table => $key) { + if (!syslog_db_execute_prepared("DELETE FROM `$syslogdb_default`.`$table` WHERE `$key` = ?", [$id])) { + return false; + } + } + + if (!syslog_db_execute('COMMIT')) { + return false; + } + + $committed = true; + + return true; + } finally { + if (!$committed) { + syslog_db_execute('ROLLBACK'); + } + } +} + /** * JSON endpoint: create or rename a dashboard (upsert by name), or delete * one (with its panels). All actions are scoped to the current user. @@ -817,7 +858,7 @@ function syslog_dashboard_save() { return json_encode(['error' => __('A name of up to 128 characters is required.', 'syslog')]); } - $id = get_filter_request_var('id', FILTER_VALIDATE_INT); + $id = get_filter_request_var('id', FILTER_VALIDATE_INT); $delete = get_nfilter_request_var('dashboard_delete') === '1'; if ($delete) { @@ -831,15 +872,9 @@ function syslog_dashboard_save() { return json_encode(['error' => __('Dashboard not found.', 'syslog')]); } - syslog_db_execute_prepared("DELETE FROM `$syslogdb_default`.`syslog_dashboard_panels` - WHERE dashboard_id = ?", - [$id]); - syslog_db_execute_prepared("DELETE FROM `$syslogdb_default`.`syslog_dashboards` - WHERE id = ?", - [$id]); - syslog_db_execute_prepared("DELETE FROM `$syslogdb_default`.`syslog_dashboards_perm` - WHERE dashboard_id = ?", - [$id]); + if (!syslog_dashboard_delete((int) $id)) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } return json_encode(['id' => (int) $id, 'deleted' => true]); } @@ -851,18 +886,22 @@ function syslog_dashboard_save() { return json_encode(['error' => __('Dashboard not found.', 'syslog')]); } - syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboards` + if (!syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboards` SET name = ?, updated = ? WHERE id = ?", - [$name, time(), $id]); + [$name, time(), $id])) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } return json_encode(['id' => (int) $id]); } - syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_dashboards` + if (!syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_dashboards` (name, `user`, `date`, updated) VALUES (?, ?, ?, ?)", - [$name, $username, time(), time()]); + [$name, $username, time(), time()])) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } // Upsert semantics: resolve the row by owner and name when the // connection's insert id is unavailable on some Cacti builds. @@ -876,7 +915,7 @@ function syslog_dashboard_save() { [$username, $name]); } - return json_encode(['id' => $id]); + return json_encode($id > 0 ? ['id' => $id] : ['error' => __('Unable to save dashboard changes.', 'syslog')]); } /** @@ -916,10 +955,12 @@ function syslog_dashboard_global() { $is_global = $row['is_global'] === 'on' ? '' : 'on'; - syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboards` + if (!syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboards` SET is_global = ?, updated = ? WHERE id = ?", - [$is_global, time(), $id]); + [$is_global, time(), $id])) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } return json_encode(['id' => (int) $id, 'is_global' => $is_global]); } @@ -960,44 +1001,68 @@ function syslog_dashboard_copy() { return json_encode(['error' => __('A name of up to 128 characters is required.', 'syslog')]); } - syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_dashboards` - (name, `user`, is_global, `date`, updated) - VALUES (?, ?, '', ?, ?)", - [$name, $username, time(), time()]); + if (!syslog_db_execute('START TRANSACTION')) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } - // Resolve the new id before copying: some Cacti builds do not expose a - // reliable insert id, so fall back to an owner-scoped lookup. - $new_id = (int) syslog_db_fetch_insert_id(); + $committed = false; - if ($new_id <= 0) { - $new_id = (int) syslog_db_fetch_cell_prepared("SELECT id - FROM `$syslogdb_default`.`syslog_dashboards` - WHERE `user` = ? AND name = ? - ORDER BY id DESC", - [$username, $name]); - } + try { + if (!syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_dashboards` + (name, `user`, is_global, `date`, updated) + VALUES (?, ?, '', ?, ?)", + [$name, $username, time(), time()])) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } - if ($new_id <= 0) { - return json_encode(['error' => __('Dashboard could not be copied.', 'syslog')]); - } + // Resolve the new id before copying: some Cacti builds do not expose a + // reliable insert id, so fall back to an owner-scoped lookup. + $new_id = (int) syslog_db_fetch_insert_id(); - $panels = syslog_dashboard_panels($id); + if ($new_id <= 0) { + $new_id = (int) syslog_db_fetch_cell_prepared("SELECT id + FROM `$syslogdb_default`.`syslog_dashboards` + WHERE `user` = ? AND name = ? + ORDER BY id DESC", + [$username, $name]); + } - if (cacti_sizeof($panels)) { - foreach ($panels as $panel) { - syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_dashboard_panels` - (dashboard_id, title, expression, source, removal, kind, chart, field, - `interval`, timespan, top_n, width, height, position, `date`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - [$new_id, $panel['title'], $panel['expression'], $panel['source'], - (int) $panel['removal'], $panel['kind'], $panel['chart'], $panel['field'], - $panel['interval'], $panel['timespan'], (int) $panel['top_n'], - (int) $panel['width'], (int) $panel['height'], - (int) $panel['position'], time()]); + if ($new_id <= 0) { + return json_encode(['error' => __('Dashboard could not be copied.', 'syslog')]); } - } - return json_encode(['id' => $new_id]); + $panels = syslog_dashboard_panels($id); + + if (cacti_sizeof($panels)) { + foreach ($panels as $panel) { + if (!syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_dashboard_panels` + (dashboard_id, title, expression, source, removal, kind, chart, field, + `interval`, timespan, top_n, width, height, position, `date`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [$new_id, $panel['title'], $panel['expression'], $panel['source'], + (int) $panel['removal'], $panel['kind'], $panel['chart'], $panel['field'], + $panel['interval'], $panel['timespan'], (int) $panel['top_n'], + (int) $panel['width'], (int) $panel['height'], + (int) $panel['position'], time()])) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } + } + } + + if (!syslog_db_execute('COMMIT')) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } + + $committed = true; + + return json_encode(['id' => $new_id]); + } catch (Throwable $error) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } finally { + if (!$committed) { + syslog_db_execute('ROLLBACK'); + } + } } /** @@ -1016,8 +1081,8 @@ function syslog_dashboard_panel_save() { $dashboard_id = get_filter_request_var('dashboard_id', FILTER_VALIDATE_INT); $panel_id = get_filter_request_var('panel_id', FILTER_VALIDATE_INT); $move = (string) get_nfilter_request_var('panel_move'); - $delete = get_nfilter_request_var('panel_delete') === '1'; - $resize = get_nfilter_request_var('panel_resize') === '1'; + $delete = get_nfilter_request_var('panel_delete') === '1'; + $resize = get_nfilter_request_var('panel_resize') === '1'; $reposition = get_nfilter_request_var('panel_position') === '1'; $position = get_filter_request_var('position', FILTER_VALIDATE_INT); @@ -1043,9 +1108,11 @@ function syslog_dashboard_panel_save() { } if ($delete) { - syslog_db_execute_prepared("DELETE FROM `$syslogdb_default`.`syslog_dashboard_panels` + if (!syslog_db_execute_prepared("DELETE FROM `$syslogdb_default`.`syslog_dashboard_panels` WHERE id = ?", - [$panel_id]); + [$panel_id])) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } return json_encode(['id' => (int) $panel_id, 'deleted' => true]); } @@ -1055,33 +1122,39 @@ function syslog_dashboard_panel_save() { return json_encode(['error' => __('A valid panel position is required.', 'syslog')]); } - syslog_dashboard_panel_reposition($panel, $dashboard_id, (int) $position); + if (!syslog_dashboard_panel_reposition($panel, $dashboard_id, (int) $position)) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } return json_encode(['id' => (int) $panel_id, 'position' => (int) $position]); } if ($resize) { $size = syslog_dashboard_panel_settings([ - 'source' => $panel['source'], 'kind' => $panel['kind'], 'chart' => $panel['chart'], - 'field' => $panel['field'], 'interval' => $panel['interval'], 'timespan' => $panel['timespan'], + 'source' => $panel['source'], 'kind' => $panel['kind'], 'chart' => $panel['chart'], + 'field' => $panel['field'], 'interval' => $panel['interval'], 'timespan' => $panel['timespan'], 'removal' => $panel['removal'], 'top_n' => $panel['top_n'], - 'width' => (string) get_nfilter_request_var('width'), - 'height' => (string) get_nfilter_request_var('height') + 'width' => (string) get_nfilter_request_var('width'), + 'height' => (string) get_nfilter_request_var('height') ]); if (is_string($size)) { return json_encode(['error' => $size]); } - syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboard_panels` + if (!syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboard_panels` SET width = ?, height = ? WHERE id = ?", - [$size['width'], $size['height'], $panel_id]); + [$size['width'], $size['height'], $panel_id])) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } return json_encode(['id' => (int) $panel_id, 'width' => $size['width'], 'height' => $size['height']]); } - syslog_dashboard_panel_move($panel, $dashboard_id, $move === 'up' ? -1 : 1); + if (!syslog_dashboard_panel_move($panel, $dashboard_id, $move === 'up' ? -1 : 1)) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } return json_encode(['id' => (int) $panel_id, 'moved' => $move]); } @@ -1140,7 +1213,7 @@ function syslog_dashboard_panel_save() { $settings['height'] = (int) $existing['height']; } - syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboard_panels` + if (!syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboard_panels` SET title = ?, expression = ?, source = ?, removal = ?, kind = ?, chart = ?, field = ?, `interval` = ?, timespan = ?, top_n = ?, width = ?, height = ? @@ -1148,20 +1221,24 @@ function syslog_dashboard_panel_save() { [$title, $expression, $settings['source'], (int) $settings['removal'], $settings['kind'], $settings['chart'], $settings['field'], $settings['interval'], $settings['timespan'], $settings['top_n'], - $settings['width'], $settings['height'], $panel_id]); + $settings['width'], $settings['height'], $panel_id])) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } return json_encode(['id' => (int) $panel_id]); } $position = syslog_dashboard_next_position($dashboard_id); - syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_dashboard_panels` + if (!syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_dashboard_panels` (dashboard_id, title, expression, source, removal, kind, chart, field, `interval`, timespan, top_n, width, height, position, `date`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [$dashboard_id, $title, $expression, $settings['source'], (int) $settings['removal'], $settings['kind'], $settings['chart'], $settings['field'], $settings['interval'], $settings['timespan'], $settings['top_n'], $settings['width'], $settings['height'], - $position, time()]); + $position, time()])) { + return json_encode(['error' => __('Unable to save dashboard changes.', 'syslog')]); + } // Fall back to a positional lookup when the connection's insert id is // unavailable on some Cacti builds. @@ -1175,7 +1252,7 @@ function syslog_dashboard_panel_save() { [$dashboard_id]); } - return json_encode(['id' => $id]); + return json_encode($id > 0 ? ['id' => $id] : ['error' => __('Unable to save dashboard changes.', 'syslog')]); } /** @@ -1186,30 +1263,59 @@ function syslog_dashboard_panel_save() { * @param int $dashboard_id Owning dashboard id. * @param int $direction -1 for up, 1 for down. */ -function syslog_dashboard_panel_move(array $panel, int $dashboard_id, int $direction): void { +function syslog_dashboard_panel_move(array $panel, int $dashboard_id, int $direction): bool { global $syslogdb_default; - $panels = syslog_dashboard_panels($dashboard_id); - $ids = []; - - foreach ($panels as $row) { - $ids[] = (int) $row['id']; + if (!syslog_db_execute('START TRANSACTION')) { + return false; } - $index = array_search((int) $panel['id'], $ids, true); - $swap = $index + $direction; + $committed = false; - if ($index === false || $swap < 0 || $swap >= cacti_sizeof($ids)) { - return; - } + try { + $panels = syslog_db_fetch_assoc_prepared("SELECT id FROM `$syslogdb_default`.`syslog_dashboard_panels` + WHERE dashboard_id = ? ORDER BY position, id FOR UPDATE", [$dashboard_id]); - [$ids[$index], $ids[$swap]] = [$ids[$swap], $ids[$index]]; + if (!is_array($panels)) { + return false; + } + $ids = []; - foreach ($ids as $position => $id) { - syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboard_panels` - SET position = ? - WHERE id = ?", - [$position + 1, $id]); + foreach ($panels as $row) { + $ids[] = (int) $row['id']; + } + + $index = array_search((int) $panel['id'], $ids, true); + $swap = $index + $direction; + + if ($index === false || $swap < 0 || $swap >= cacti_sizeof($ids)) { + $committed = syslog_db_execute('COMMIT'); + + return $committed; + } + + [$ids[$index], $ids[$swap]] = [$ids[$swap], $ids[$index]]; + + foreach ($ids as $position => $id) { + if (!syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboard_panels` + SET position = ? + WHERE id = ?", + [$position + 1, $id])) { + return false; + } + } + + if (!syslog_db_execute('COMMIT')) { + return false; + } + + $committed = true; + + return true; + } finally { + if (!$committed) { + syslog_db_execute('ROLLBACK'); + } } } @@ -1221,27 +1327,54 @@ function syslog_dashboard_panel_move(array $panel, int $dashboard_id, int $direc * @param int $dashboard_id Owning dashboard id. * @param int $position Requested 1-based position. */ -function syslog_dashboard_panel_reposition(array $panel, int $dashboard_id, int $position): void { +function syslog_dashboard_panel_reposition(array $panel, int $dashboard_id, int $position): bool { global $syslogdb_default; - $panels = syslog_dashboard_panels($dashboard_id); - $ids = []; + if (!syslog_db_execute('START TRANSACTION')) { + return false; + } - foreach ($panels as $row) { - if ((int) $row['id'] !== (int) $panel['id']) { - $ids[] = (int) $row['id']; + $committed = false; + + try { + $panels = syslog_db_fetch_assoc_prepared("SELECT id FROM `$syslogdb_default`.`syslog_dashboard_panels` + WHERE dashboard_id = ? ORDER BY position, id FOR UPDATE", [$dashboard_id]); + + if (!is_array($panels)) { + return false; } - } + $ids = []; - // Clamp into the valid range, then splice the panel in. - $position = min(max($position, 1), cacti_sizeof($ids) + 1); - array_splice($ids, $position - 1, 0, [(int) $panel['id']]); + foreach ($panels as $row) { + if ((int) $row['id'] !== (int) $panel['id']) { + $ids[] = (int) $row['id']; + } + } - foreach ($ids as $index => $id) { - syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboard_panels` - SET position = ? - WHERE id = ?", - [$index + 1, $id]); + // Clamp into the valid range, then splice the panel in. + $position = min(max($position, 1), cacti_sizeof($ids) + 1); + array_splice($ids, $position - 1, 0, [(int) $panel['id']]); + + foreach ($ids as $index => $id) { + if (!syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`syslog_dashboard_panels` + SET position = ? + WHERE id = ?", + [$index + 1, $id])) { + return false; + } + } + + if (!syslog_db_execute('COMMIT')) { + return false; + } + + $committed = true; + + return true; + } finally { + if (!$committed) { + syslog_db_execute('ROLLBACK'); + } } } @@ -1271,6 +1404,7 @@ function syslog_dashboard(): void { foreach ($dashboards as $dashboard) { if ($dashboard['user'] === $username) { $dashboard_id = (int) $dashboard['id']; + break; } } @@ -1280,6 +1414,7 @@ function syslog_dashboard(): void { foreach ($dashboards as $dashboard) { if ($dashboard['user'] === '' && $dashboard['is_global'] === 'on' && $dashboard['name'] === 'default') { $dashboard_id = (int) $dashboard['id']; + break; } } @@ -1328,7 +1463,7 @@ function syslog_dashboard(): void { $username = isset($_SESSION['sess_user_id']) ? get_username($_SESSION['sess_user_id']) : ''; - $sql_where = "`user` = ? OR is_global = 'on'"; + $sql_where = "`user` = ? OR is_global = 'on'"; $shared_searches = $username === '' ? [] : syslog_shared_item_ids('saved_search'); if (cacti_sizeof($shared_searches)) { @@ -1385,7 +1520,7 @@ function syslog_dashboard(): void {
@@ -1403,37 +1538,37 @@ function syslog_dashboard(): void {
@@ -1480,7 +1615,7 @@ function syslog_dashboard(): void { print ''; } } - ?> + ?> '> @@ -1520,7 +1655,7 @@ function syslog_dashboard(): void { } // Panel editor dialog scaffold (SyslogFilterBuilder attaches here). - $fields = syslog_search_fields(); + $fields = syslog_search_fields(); $choices = syslog_search_choices(); ?> diff --git a/lib/syslog_thresholds.php b/lib/syslog_thresholds.php index 58ced553..e1c8be3e 100644 --- a/lib/syslog_thresholds.php +++ b/lib/syslog_thresholds.php @@ -25,42 +25,49 @@ function syslog_breached_threshold_count(): int { function syslog_breached_thresholds(): void { global $config; - $total = syslog_breached_threshold_count(); - $page = max(1, min((int) get_filter_request_var('breach_page'), max(1, (int) ceil($total / 50)))); - $offset = ($page - 1) * 50; + $total = syslog_breached_threshold_count(); + $page = max(1, min((int) get_filter_request_var('breach_page'), max(1, (int) ceil($total / 50)))); + $offset = ($page - 1) * 50; $breaches = syslog_db_fetch_assoc('SELECT s.*, a.name, a.level, (SELECT MIN(h.host_id) FROM syslog_hosts AS h WHERE h.host = s.scope_key) AS host_id FROM syslog_threshold_state AS s JOIN syslog_alert AS a ON a.id = s.alert_id WHERE ' . syslog_breached_threshold_where() . " ORDER BY s.last_match DESC, s.source_poller_id, s.alert_id, s.scope_key LIMIT 50 OFFSET $offset"); $base = $config['url_path'] . 'plugins/syslog/'; - html_start_box(__('Breached Thresholds', 'syslog'), '100%', '', '3', 'center', ''); + html_start_box(__('Breached Thresholds', 'syslog'), '100%', false, 3, 'center', ''); html_header([__('Rule', 'syslog'), __('Device', 'syslog'), __('Collector', 'syslog'), __('Breached Since', 'syslog'), __('Last Matching Message', 'syslog'), __('Recovers After', 'syslog')]); + foreach ($breaches as $breach) { $rule = html_escape($breach['name']); + if (api_plugin_user_realm_auth('syslog_alerts.php')) { $rule = '' . $rule . ''; } $device = (int) $breach['level'] === 1 ? html_escape($breach['scope_key']) : __('All matching devices', 'syslog'); + if ((int) $breach['level'] === 1 && !empty($breach['host_id'])) { $device = '' . $device . ''; } form_alternate_row('', true); print '
' . $rule . '' . $device . '' . (int) $breach['source_poller_id'] . '' . html_escape(empty($breach[$field]) ? __('Unknown', 'syslog') : date('Y-m-d H:i:s', (int) $breach[$field])) . '
' . __('No thresholds are currently breached.', 'syslog') . '
'; print '

' . __('Recovery requires no matching messages until the displayed time. Every match restarts the quiet period.', 'syslog') . '

'; print '

' . __('Refresh', 'syslog') . ''; + if ($page > 1) { print ' | ' . __('Previous', 'syslog') . ''; } + if ($offset + 50 < $total) { print ' | ' . __('Next', 'syslog') . ''; } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index f510552e..8b65e0c2 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.2.32\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-15 17:38-0400\n" +"POT-Creation-Date: 2026-10-16 14:29-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -133,7 +133,6 @@ msgid "The oldest synchronization record timestamp is unavailable." msgstr "" #: includes/functions.php -#, php-format msgid "Synchronization has failed or queued records exceed the allowed delay." msgstr "" @@ -1182,6 +1181,10 @@ msgstr "" msgid "Dashboard not found." msgstr "" +#: lib/syslog_dashboard.php syslog_dashboards.php +msgid "Unable to save dashboard changes." +msgstr "" + #: lib/syslog_dashboard.php msgid "You may only share your own dashboards." msgstr "" diff --git a/phpunit.xml b/phpunit.xml index b95fbc3d..69ef371f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -29,6 +29,7 @@ includes/database.php includes/functions.php + lib/QueryBuilder.php lib/syslog_dashboard.php lib/syslog_thresholds.php diff --git a/setup.php b/setup.php index ae120e31..aeeb3eb7 100644 --- a/setup.php +++ b/setup.php @@ -121,8 +121,9 @@ function syslog_execute_update(int $syslog_exists, array $options): void { exit; } - $requested_engine = isset($options['engine']) ? $options['engine'] : 'InnoDB'; + $requested_engine = isset($options['engine']) ? $options['engine'] : 'InnoDB'; $compatibility_options = $options; + if (isset($options['install'])) { foreach ($remote_options as $option) { $compatibility_options[$option] = isset($options[$option]) && $options[$option] === 'on' ? 'on' : ''; @@ -185,6 +186,13 @@ function plugin_syslog_uninstall(): void { syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_removed`"); // do the settings tables last + syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_programs`"); + syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_status`"); + syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_device_rule`"); + syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_alert_counts`"); + syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_alert_suppression`"); + syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_threshold_state`"); + syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_threshold_messages`"); syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_incoming`"); syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_threshold_recovery_queue`"); syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_alert`"); @@ -205,6 +213,43 @@ function plugin_syslog_uninstall(): void { syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_replication_receipts`"); syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_replication_collectors`"); syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_replication_recovery`"); + + // Remote collectors may also own message history and operational tables. + if ($config['poller_id'] == 1) { + foreach ([ + 'syslog', + 'syslog_removed', + 'syslog_programs', + 'syslog_status', + 'syslog_device_rule', + 'syslog_alert_counts', + 'syslog_alert_suppression', + 'syslog_threshold_state', + 'syslog_threshold_messages', + 'syslog_incoming', + 'syslog_threshold_recovery_queue', + 'syslog_alert', + 'syslog_remove', + 'syslog_reports', + 'syslog_facilities', + 'syslog_statistics', + 'syslog_host_facilities', + 'syslog_priorities', + 'syslog_logs', + 'syslog_hosts', + 'syslog_saved_searches', + 'syslog_dashboard_panels', + 'syslog_dashboards', + 'syslog_dashboards_perm', + 'syslog_saved_searches_perm', + 'syslog_replication_output', + 'syslog_replication_receipts', + 'syslog_replication_collectors', + 'syslog_replication_recovery' + ] as $table) { + api_plugin_drop_remote_table($table); + } + } } else { syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog`"); syslog_db_execute("DROP TABLE IF EXISTS `$syslogdb_default`.`syslog_removed`"); @@ -228,8 +273,9 @@ function plugin_syslog_check_config(): bool { return false; } - $version = plugin_syslog_version(); + $version = plugin_syslog_version(); $installed = db_fetch_cell_prepared('SELECT version FROM plugin_config WHERE directory = ?', ['syslog']); + if (get_current_page() === 'plugins.php' && get_nfilter_request_var('mode') === 'enable' && $installed !== false && isset($version['version']) && version_compare((string) $installed, $version['version'], '<') && !isset_request_var('syslog_upgrade_confirm')) { @@ -239,6 +285,7 @@ function plugin_syslog_check_config(): bool { require_once(__DIR__ . '/includes/settings.php'); syslog_upgrade_hook_module_paths(); + return syslog_check_upgrade(); } @@ -273,10 +320,12 @@ function syslog_upgrade_hook_module_paths(): void { 'replicate_out' => ['syslog_replicate_out', 'includes/processing.php'] ]; $registered = db_fetch_assoc_prepared('SELECT hook FROM plugin_hooks WHERE name = ? AND file = ?', ['syslog', 'setup.php']); + if (!is_array($registered) || !$registered) { return; } $modules = []; + foreach ($registered as $row) { if (isset($hooks[$row['hook']])) { [$function, $file] = $hooks[$row['hook']]; @@ -334,6 +383,11 @@ function syslog_connect(): bool { syslog_determine_config(); + global $database_sessions, $database_hostname, $database_port, $database_default; + + static $schema_inprocess = false; + static $schema_ready = false; + // Handle remote syslog processing if (defined('SYSLOG_CONFIG')) { include(SYSLOG_CONFIG); @@ -342,6 +396,9 @@ function syslog_connect(): bool { require_once(__DIR__ . '/includes/functions.php'); require_once(__DIR__ . '/includes/database.php'); + $cacti_local_cnn = $local_db_cnn_id + ?? ($database_sessions["$database_hostname:$database_port:$database_default"] ?? false); + $connect_remote = false; $connected = true; @@ -350,22 +407,22 @@ function syslog_connect(): bool { /** @var bool $use_cacti_db Whether the Syslog tables live in the local Cacti database, defined in the Syslog config file. */ if ($config['poller_id'] == 1) { if ($use_cacti_db == true) { - $syslog_cnn = $local_db_cnn_id; - $connected = true; + $syslog_cnn = $cacti_local_cnn; + $connected = is_object($syslog_cnn); } else { $connect_remote = true; } } elseif (isset($config['syslog_remote_db'])) { if ($use_cacti_db == true) { - $syslog_cnn = $local_db_cnn_id; - $connected = true; + $syslog_cnn = $cacti_local_cnn; + $connected = is_object($syslog_cnn); } else { $connect_remote = true; } } else { if ($use_cacti_db == true) { $syslog_cnn = $remote_db_cnn_id; - $connected = true; + $connected = is_object($syslog_cnn); } else { $connect_remote = true; } @@ -409,7 +466,20 @@ function syslog_connect(): bool { $connected = false; } } + } + // Repair missing tables before any main or enabled remote collector queries. + if ($connected && ((int) $config['poller_id'] <= 1 || read_config_option('syslog_remote_enabled') === 'on') + && !$schema_inprocess && !$schema_ready && !defined('IN_PLUGIN_INSTALL') && api_plugin_is_enabled('syslog')) { + $schema_inprocess = true; + + try { + require_once(__DIR__ . '/includes/schema.php'); + $schema_ready = syslog_ensure_table_structures(); + $connected = $schema_ready; + } finally { + $schema_inprocess = false; + } } return $connected; @@ -421,16 +491,18 @@ function syslog_connect(): bool { * @return void */ function syslog_upgrade_saved_search_realm(): void { - $admin = null; + $admin = null; $template = null; - $realms = db_fetch_assoc_prepared('SELECT id, file, display FROM plugin_realms WHERE plugin = ?', ['syslog']); + $realms = db_fetch_assoc_prepared('SELECT id, file, display FROM plugin_realms WHERE plugin = ?', ['syslog']); if (is_array($realms)) { foreach ($realms as $realm) { $files = explode(',', $realm['file']); + if (in_array('syslog_alerts.php', $files, true) && ($realm['display'] ?? '') !== 'Rule Viewer') { $admin = $realm; } + if (in_array('syslog_saved_searches.php', $files, true)) { $template = $realm; } @@ -449,7 +521,6 @@ function syslog_upgrade_saved_search_realm(): void { } $template = $admin; } - } /** @@ -463,6 +534,7 @@ function syslog_upgrade_dashboard_realm(): void { if (is_array($realms)) { foreach ($realms as $realm) { $files = explode(',', $realm['file']); + if (!in_array('syslog_alerts.php', $files, true) || ($realm['display'] ?? '') === 'Rule Viewer') { continue; } @@ -485,20 +557,25 @@ function syslog_upgrade_dashboard_realm(): void { /** Give Rule Administrators access to device alert rules. */ function syslog_upgrade_device_rule_realm(): void { $realms = db_fetch_assoc_prepared('SELECT id, file, display FROM plugin_realms WHERE plugin = ?', ['syslog']); + if (!is_array($realms)) { return; } + foreach ($realms as $realm) { if (($realm['display'] ?? '') !== 'Rule Administrator') { continue; } $files = explode(',', $realm['file']); + if (!in_array('syslog_device_rules.php', $files, true)) { $files[] = 'syslog_device_rules.php'; + if (!db_execute_prepared('UPDATE plugin_realms SET file = ? WHERE id = ? AND plugin = ?', [implode(',', $files), $realm['id'], 'syslog'])) { return; } } + return; } } @@ -523,6 +600,7 @@ function syslog_upgrade_rule_permissions(): void { foreach ($realms as $realm) { $files = explode(',', $realm['file']); + if (($realm['display'] ?? '') !== 'Syslog Administration' || !in_array('syslog_alerts.php', $files, true) || in_array('syslog_rule_administrator.php', $files, true)) { continue; } @@ -559,13 +637,14 @@ function syslog_upgrade_rule_permissions(): void { function syslog_upgrade_consolidate_rule_realms(): bool { foreach (['Rule Viewer', 'Rule Administrator'] as $display) { $realms = db_fetch_assoc_prepared('SELECT id, file FROM plugin_realms WHERE plugin = ? AND display = ? ORDER BY id', ['syslog', $display]); - $files = $display === 'Rule Viewer' ? 'syslog_alerts.php,syslog_removal.php,syslog_reports.php' : 'syslog_rule_administrator.php,syslog_device_rules.php'; + $files = $display === 'Rule Viewer' ? 'syslog_alerts.php,syslog_removal.php,syslog_reports.php' : 'syslog_rule_administrator.php,syslog_device_rules.php'; if (!is_array($realms) || !$realms || (count($realms) === 1 && $realms[0]['file'] === $files)) { continue; } $keeper = array_shift($realms); + if (!db_begin_transaction()) { return false; } @@ -575,11 +654,14 @@ function syslog_upgrade_consolidate_rule_realms(): bool { if (!db_execute_prepared("INSERT IGNORE INTO $table ($owner, realm_id) SELECT $owner, ? FROM $table WHERE realm_id = ?", [(int) $keeper['id'] + 100, (int) $realm['id'] + 100]) || !db_execute_prepared("DELETE FROM $table WHERE realm_id = ?", [(int) $realm['id'] + 100])) { db_rollback_transaction(); + return false; } } + if (!db_execute_prepared('DELETE FROM plugin_realms WHERE id = ? AND plugin = ?', [(int) $realm['id'], 'syslog'])) { db_rollback_transaction(); + return false; } } @@ -588,72 +670,88 @@ function syslog_upgrade_consolidate_rule_realms(): bool { // Never union those files: doing so merges the two permission levels. if (!db_execute_prepared('UPDATE plugin_realms SET file = ? WHERE id = ?', [$files, (int) $keeper['id']]) || !db_commit_transaction()) { db_rollback_transaction(); + return false; } } + return true; } /** Replace synthetic role inheritance with explicit Cacti realm grants. */ function syslog_upgrade_explicit_realm_grants(): bool { $realms = db_fetch_assoc_prepared('SELECT id, display FROM plugin_realms WHERE plugin = ?', ['syslog']); + if (!is_array($realms)) { return false; } $ids = []; + foreach ($realms as $realm) { $ids[$realm['display']][] = (int) $realm['id'] + 100; } - $viewer = $ids['Rule Viewer'][0] ?? 0; + $viewer = $ids['Rule Viewer'][0] ?? 0; $administrator = $ids['Rule Administrator'][0] ?? 0; - $umbrella = $ids['Syslog Administrator'] ?? []; + $umbrella = $ids['Syslog Administrator'] ?? []; + if (!$viewer || !$administrator || !db_begin_transaction()) { return false; } - $source_ids = array_merge([$viewer, $administrator], $umbrella); + $source_ids = array_merge([$viewer, $administrator], $umbrella); $placeholders = implode(',', array_fill(0, count($source_ids), '?')); - $direct = db_fetch_assoc_prepared("SELECT user_id, realm_id FROM user_auth_realm WHERE realm_id IN ($placeholders)", $source_ids); - $groups = db_fetch_assoc_prepared("SELECT group_id, realm_id FROM user_auth_group_realm WHERE realm_id IN ($placeholders)", $source_ids); + $direct = db_fetch_assoc_prepared("SELECT user_id, realm_id FROM user_auth_realm WHERE realm_id IN ($placeholders)", $source_ids); + $groups = db_fetch_assoc_prepared("SELECT group_id, realm_id FROM user_auth_group_realm WHERE realm_id IN ($placeholders)", $source_ids); + if (!is_array($direct) || !is_array($groups)) { db_rollback_transaction(); + return false; } $direct_grants = []; - $group_grants = []; + $group_grants = []; + foreach ($direct as $grant) { $direct_grants[(int) $grant['user_id']][(int) $grant['realm_id']] = true; } + foreach ($groups as $grant) { $group_grants[(int) $grant['group_id']][(int) $grant['realm_id']] = true; } $affected = []; + foreach ($direct_grants as $user_id => $granted) { if (array_intersect($umbrella, array_keys($granted)) || (isset($granted[$administrator]) && !isset($granted[$viewer]))) { $affected[$user_id] = true; } } + foreach ($group_grants as $group_id => $granted) { if (array_intersect($umbrella, array_keys($granted)) || (isset($granted[$administrator]) && !isset($granted[$viewer]))) { $members = db_fetch_assoc_prepared('SELECT user_id FROM user_auth_group_members WHERE group_id = ?', [$group_id]); + if (!is_array($members)) { db_rollback_transaction(); + return false; } + foreach ($members as $member) { $affected[(int) $member['user_id']] = true; } } } - $grants = [$administrator => [$viewer]]; + $grants = [$administrator => [$viewer]]; $all_pages = []; + foreach ($ids as $display => $realm_ids) { if ($display !== 'Syslog Administrator') { $all_pages = array_merge($all_pages, $realm_ids); } } + foreach ($umbrella as $source) { $grants[$source] = array_values(array_unique($all_pages)); } @@ -663,6 +761,7 @@ function syslog_upgrade_explicit_realm_grants(): bool { foreach (['user_auth_realm' => 'user_id', 'user_auth_group_realm' => 'group_id'] as $table => $owner) { if (!db_execute_prepared("INSERT IGNORE INTO $table ($owner, realm_id) SELECT $owner, ? FROM $table WHERE realm_id = ?", [$target, $source])) { db_rollback_transaction(); + return false; } } @@ -673,11 +772,14 @@ function syslog_upgrade_explicit_realm_grants(): bool { foreach (['user_auth_realm', 'user_auth_group_realm'] as $table) { if (!db_execute_prepared("DELETE FROM $table WHERE realm_id = ?", [$source])) { db_rollback_transaction(); + return false; } } + if (!db_execute_prepared('DELETE FROM plugin_realms WHERE plugin = ? AND id = ?', ['syslog', $source - 100])) { db_rollback_transaction(); + return false; } } @@ -686,14 +788,17 @@ function syslog_upgrade_explicit_realm_grants(): bool { if (!db_execute_prepared('DELETE FROM user_auth_cache WHERE user_id = ?', [$user_id]) || !db_execute_prepared('UPDATE user_auth SET reset_perms = FLOOR(RAND() * 4294967295) + 1 WHERE id = ?', [$user_id])) { db_rollback_transaction(); + return false; } } if (!db_commit_transaction()) { db_rollback_transaction(); + return false; } + if (isset($_SESSION['sess_user_id'], $affected[(int) $_SESSION['sess_user_id']])) { unset($_SESSION['sess_user_realms'], $_SESSION['sess_auth_names']); } @@ -708,13 +813,14 @@ function syslog_upgrade_explicit_realm_grants(): bool { */ function syslog_upgrade_create_permission_realms(): bool { $targets = [ - 'Rule Viewer' => 'syslog_alerts.php,syslog_removal.php,syslog_reports.php', - 'Rule Administrator' => 'syslog_rule_administrator.php,syslog_device_rules.php', + 'Rule Viewer' => 'syslog_alerts.php,syslog_removal.php,syslog_reports.php', + 'Rule Administrator' => 'syslog_rule_administrator.php,syslog_device_rules.php', 'Syslog Administration' => 'syslog_saved_searches.php,syslog_dashboards.php', 'Share Saved Templates' => 'syslog_saved_searches_share.php', - 'Share Dashboards' => 'syslog_dashboards_share.php' + 'Share Dashboards' => 'syslog_dashboards_share.php' ]; $realms = db_fetch_assoc_prepared('SELECT id, file, display FROM plugin_realms WHERE plugin = ?', ['syslog']); + if (!is_array($realms)) { return false; } @@ -722,11 +828,14 @@ function syslog_upgrade_create_permission_realms(): bool { $legacy_viewers = []; $legacy_admins = []; $missing = []; + foreach ($realms as $realm) { $files = explode(',', $realm['file']); + if (in_array('syslog.php', $files, true) && ($realm['display'] ?? '') === 'Syslog User') { $legacy_viewers[] = (int) $realm['id'] + 100; } + if (array_intersect($files, ['syslog_alerts.php', 'syslog_removal.php', 'syslog_reports.php']) && !in_array($realm['display'] ?? '', ['Rule Viewer', 'Rule Administrator'], true)) { $legacy_admins[] = (int) $realm['id'] + 100; @@ -735,16 +844,20 @@ function syslog_upgrade_create_permission_realms(): bool { foreach ($targets as $display => $files) { $found = false; + foreach ($realms as $realm) { if (($realm['display'] ?? '') === $display && $realm['file'] === $files) { $found = true; + break; } } + if (!$found) { $missing[$display] = $files; } } + if (!$missing) { return true; } @@ -752,19 +865,24 @@ function syslog_upgrade_create_permission_realms(): bool { if (!db_begin_transaction()) { return false; } + foreach ($missing as $display => $files) { if (!db_execute_prepared('INSERT INTO plugin_realms (plugin, file, display) VALUES (?, ?, ?)', ['syslog', $files, $display])) { db_rollback_transaction(); + return false; } } $updated_realms = db_fetch_assoc_prepared('SELECT id, file, display FROM plugin_realms WHERE plugin = ?', ['syslog']); + if (!is_array($updated_realms)) { db_rollback_transaction(); + return false; } $target_ids = []; + foreach ($updated_realms as $realm) { foreach ($targets as $display => $files) { if (($realm['display'] ?? '') === $display && $realm['file'] === $files) { @@ -774,18 +892,21 @@ function syslog_upgrade_create_permission_realms(): bool { } $sources = [ - 'Rule Viewer' => $legacy_viewers, - 'Rule Administrator' => $legacy_admins, + 'Rule Viewer' => $legacy_viewers, + 'Rule Administrator' => $legacy_admins, 'Syslog Administration' => $legacy_admins ]; + foreach ($sources as $display => $source_ids) { if (!isset($missing[$display], $target_ids[$display])) { continue; } + foreach ($source_ids as $source_id) { foreach (['user_auth_realm' => 'user_id', 'user_auth_group_realm' => 'group_id'] as $table => $owner) { if (!db_execute_prepared("INSERT IGNORE INTO $table ($owner, realm_id) SELECT $owner, ? FROM $table WHERE realm_id = ?", [$target_ids[$display], $source_id])) { db_rollback_transaction(); + return false; } } @@ -793,35 +914,45 @@ function syslog_upgrade_create_permission_realms(): bool { } $new_ids = array_values(array_intersect_key($target_ids, $missing)); + if (!$new_ids) { db_rollback_transaction(); + return false; } $placeholders = implode(',', array_fill(0, count($new_ids), '?')); - $affected = db_fetch_assoc_prepared("SELECT user_id FROM user_auth_realm WHERE realm_id IN ($placeholders) + $affected = db_fetch_assoc_prepared("SELECT user_id FROM user_auth_realm WHERE realm_id IN ($placeholders) UNION SELECT ugm.user_id FROM user_auth_group_realm AS ugr INNER JOIN user_auth_group_members AS ugm ON ugm.group_id = ugr.group_id WHERE ugr.realm_id IN ($placeholders)", array_merge($new_ids, $new_ids)); + if (!is_array($affected)) { db_rollback_transaction(); + return false; } + foreach ($affected as $row) { $user_id = (int) $row['user_id']; + if (!db_execute_prepared('DELETE FROM user_auth_cache WHERE user_id = ?', [$user_id]) || !db_execute_prepared('UPDATE user_auth SET reset_perms = FLOOR(RAND() * 4294967295) + 1 WHERE id = ?', [$user_id])) { db_rollback_transaction(); + return false; } } if (!db_commit_transaction()) { db_rollback_transaction(); + return false; } + foreach ($affected as $row) { if (isset($_SESSION['sess_user_id']) && (int) $_SESSION['sess_user_id'] === (int) $row['user_id']) { unset($_SESSION['sess_user_realms'], $_SESSION['sess_auth_names']); + break; } } @@ -839,18 +970,21 @@ function syslog_check_upgrade(bool $force = false): bool { if (!syslog_connect()) { return false; } + if (!syslog_upgrade_create_permission_realms()) { return false; } syslog_upgrade_saved_search_realm(); syslog_upgrade_dashboard_realm(); syslog_upgrade_rule_permissions(); + if (!syslog_upgrade_consolidate_rule_realms()) { return false; } // Cacti's registration API merges overlapping filenames; preserve legacy // realm IDs and grants while moving those pages to their explicit realms. syslog_upgrade_device_rule_realm(); + if (!syslog_upgrade_explicit_realm_grants()) { return false; } @@ -864,12 +998,14 @@ function syslog_check_upgrade(bool $force = false): bool { // don't let this script timeout ini_set('max_execution_time', 0); - $version = plugin_syslog_version(); + $version = plugin_syslog_version(); $installed = db_fetch_cell_prepared('SELECT version FROM plugin_config WHERE directory = ?', ['syslog']); + if (!$force && $installed !== false && version_compare((string) $installed, $version['version'], '>=')) { syslog_notice_traditional_tables(true); include_once($config['base_path'] . '/lib/poller.php'); api_plugin_replicate_config(); + return true; } @@ -1006,10 +1142,10 @@ function syslog_check_upgrade(bool $force = false): bool { syslog_refresh_plugin_version(); include_once($config['base_path'] . '/lib/poller.php'); api_plugin_replicate_config(); + return true; } - /** Create dashboard structures and seed the shared starter during install/upgrade. */ function syslog_dashboard_seed_default(): void { global $syslogdb_default; @@ -1017,6 +1153,7 @@ function syslog_dashboard_seed_default(): void { syslog_ensure_dashboard_tables(); $lock = 'syslog_dashboard_' . md5($syslogdb_default); + if (!syslog_db_fetch_cell_prepared('SELECT GET_LOCK(?, 5)', [$lock])) { return; } @@ -1031,15 +1168,19 @@ function syslog_dashboard_seed_default(): void { } $now = time(); + if (!syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_dashboards` (hash, name, `user`, is_global, `date`, updated) VALUES (?, ?, ?, ?, ?, ?)", [md5('syslog_default_dashboard'), 'default', '', 'on', $now, $now])) { syslog_db_execute('ROLLBACK'); + return; } $id = (int) syslog_db_fetch_insert_id(); + if ($id <= 0) { syslog_db_execute('ROLLBACK'); + return; } @@ -1058,6 +1199,7 @@ function syslog_dashboard_seed_default(): void { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [$id, $title, '', $source, 1, $kind, $chart, $field, 'auto', $timespan, 10, 1, 0, $position + 1, $now])) { syslog_db_execute('ROLLBACK'); + return; } } @@ -1067,13 +1209,13 @@ function syslog_dashboard_seed_default(): void { } } catch (Throwable $error) { syslog_db_execute('ROLLBACK'); + throw $error; } finally { syslog_db_fetch_cell_prepared('SELECT RELEASE_LOCK(?)', [$lock]); } } - /** * Return the version information for the Syslog plugin. * @@ -1092,8 +1234,9 @@ function plugin_syslog_version(): array { /** Keep the installed plugin metadata aligned with INFO without reinstalling. */ function syslog_refresh_plugin_version(): void { - $version = plugin_syslog_version(); + $version = plugin_syslog_version(); $stored_version = db_fetch_cell_prepared('SELECT version FROM plugin_config WHERE directory = ?', ['syslog']); + if (!isset($version['version']) || ($stored_version !== false && version_compare((string) $stored_version, $version['version'], '>='))) { return; } diff --git a/syslog.php b/syslog.php index e1c10b5e..585810c8 100644 --- a/syslog.php +++ b/syslog.php @@ -217,7 +217,7 @@ function get_ajax_hosts(): string { // Step 1: candidate syslog host rows, resolved straight from the // syslog_hosts primary key. This is the fast path both with and // without the Cacti host table present. - $hosts = syslog_db_fetch_assoc_prepared("SELECT sh.host_id, sh.host, " . + $hosts = syslog_db_fetch_assoc_prepared('SELECT sh.host_id, sh.host, ' . ($has_host_table ? 'NULL' : "'0'") . " AS id FROM `$syslogdb_default`.`syslog_hosts` AS sh WHERE sh.host LIKE ? @@ -273,7 +273,7 @@ function get_ajax_hosts(): string { $extra_hosts = syslog_db_fetch_assoc_prepared("SELECT sh.host_id, sh.host, NULL AS id FROM `$syslogdb_default`.`syslog_hosts` AS sh - WHERE sh.host IN (" . implode(', ', array_fill(0, cacti_sizeof($bind), '?')) . ")", + WHERE sh.host IN (" . implode(', ', array_fill(0, cacti_sizeof($bind), '?')) . ')', $bind); foreach ($extra_hosts as $extra_host) { @@ -532,7 +532,7 @@ function syslog_status_collector_health(): array { $last_received_raw = syslog_db_fetch_cell("SELECT MAX(`$time_field`) FROM `$syslogdb_default`.`syslog_incoming`"); - $last_received = __('Unavailable', 'syslog'); + $last_received = __('Unavailable', 'syslog'); $oldest_seconds = null; if (is_string($last_received_raw) && $last_received_raw !== '' && $last_received_raw !== 'NULL') { @@ -557,7 +557,7 @@ function syslog_status_collector_health(): array { if ($age >= 0) { $oldest_seconds = $age; - $oldest_age = syslog_status_format_age($age); + $oldest_age = syslog_status_format_age($age); } } } @@ -588,7 +588,7 @@ function syslog_status_collector_health(): array { } if (is_numeric($backlog_raw) && (int) $backlog_raw > 0 && $oldest_seconds !== null && $oldest_seconds > $stale_threshold) { - $warning = true; + $warning = true; $warn_parts[] = __('unprocessed messages are %s old', syslog_status_format_age($oldest_seconds), 'syslog'); } @@ -671,20 +671,21 @@ function syslog_status_storage(): array { global $syslogdb_default, $syslog_retentions, $syslog_alert_retentions; $incoming = syslog_db_fetch_cell("SELECT COUNT(*) FROM `$syslogdb_default`.`syslog_incoming`"); - $bytes = syslog_db_fetch_cell_prepared('SELECT DATA_LENGTH + INDEX_LENGTH + $bytes = syslog_db_fetch_cell_prepared('SELECT DATA_LENGTH + INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?', [$syslogdb_default, 'syslog']); $size = syslog_status_format_bytes($bytes); - $retention = read_config_option('syslog_retention'); + $retention = read_config_option('syslog_retention'); $alert_retention = read_config_option('syslog_alert_retention'); return [ - __('Rows in syslog_incoming', 'syslog') => is_numeric($incoming) ? number_format((int) $incoming) : __('Unavailable', 'syslog'), + __('Rows in syslog_incoming', 'syslog') => is_numeric($incoming) ? number_format((int) $incoming) : __('Unavailable', 'syslog'), __('Syslog table size (data + indexes)', 'syslog') => $size, - __('Syslog retention', 'syslog') => $syslog_retentions[$retention] ?? __('Unavailable', 'syslog'), - __('Alert retention', 'syslog') => $syslog_alert_retentions[$alert_retention] ?? __('Unavailable', 'syslog') + __('Syslog retention', 'syslog') => $syslog_retentions[$retention] ?? __('Unavailable', 'syslog'), + __('Alert retention', 'syslog') => $syslog_alert_retentions[$alert_retention] + ?? __('Unavailable', 'syslog') ]; } @@ -726,6 +727,7 @@ function syslog_status_format_partition_progress(string $value): string { foreach (['syslog' => 'syslog', 'syslog_removed' => 'syslog_removed'] as $key => $label) { $state = $progress[$key] ?? null; + if (!is_array($state)) { continue; } @@ -850,6 +852,7 @@ function syslog_status_replication(): array { /** Share the same live health snapshot between the card header and body. */ function syslog_status_pipeline(): array { static $health = null; + return $health ??= syslog_pipeline_health(); } @@ -864,7 +867,7 @@ function syslog_status_card_body(string $key): string { $status = syslog_status_data(); $worker_stats = syslog_status_workers(); - $kv = function(string $label, string $value): void { + $kv = function (string $label, string $value): void { print '

' . html_escape($label) . '' . html_escape($value) . '
'; + foreach ([ - __('State', 'syslog') => strtoupper((string) $replication['state']), - __('Queued Recovery Records', 'syslog') => $replication['pending'] === null ? __('Unavailable', 'syslog') : number_format((int) $replication['pending']), - __('Oldest pending', 'syslog') => $replication['oldest_pending'] !== '' ? (string) $replication['oldest_pending'] : __('None', 'syslog'), + __('State', 'syslog') => strtoupper((string) $replication['state']), + __('Queued Recovery Records', 'syslog') => $replication['pending'] === null ? __('Unavailable', 'syslog') : number_format((int) $replication['pending']), + __('Oldest pending', 'syslog') => $replication['oldest_pending'] !== '' ? (string) $replication['oldest_pending'] : __('None', 'syslog'), __('Last successful synchronization', 'syslog') => syslog_status_format_time((string) $replication['last_success']), - __('Recovery worker', 'syslog') => !empty($replication['recovery_active']) ? __('Active', 'syslog') : __('Inactive', 'syslog'), - __('Last synchronization error', 'syslog') => $replication['last_error'] !== '' ? $replication['last_error'] : __('None', 'syslog') - ] as $label => $value) { $kv($label, (string) $value); } + __('Recovery worker', 'syslog') => !empty($replication['recovery_active']) ? __('Active', 'syslog') : __('Inactive', 'syslog'), + __('Last synchronization error', 'syslog') => $replication['last_error'] !== '' ? $replication['last_error'] : __('None', 'syslog') + ] as $label => $value) { + $kv($label, (string) $value); + } print '
'; break; case 'remote_collectors': $remote_collectors = syslog_replication_collector_status(); print ''; + if (cacti_sizeof($remote_collectors)) { foreach ($remote_collectors as $collector) { print ''; @@ -912,9 +919,13 @@ function syslog_status_card_body(string $key): string { break; case 'storage': print '
' . html_escape(__('Remote Poller', 'syslog')) . '' . html_escape(__('Last Batch Count', 'syslog')) . '' . html_escape(__('Last Record Received', 'syslog')) . '
' . html_escape(!empty($collector['hostname']) ? $collector['hostname'] : __('Unknown remote poller', 'syslog')) . '' . html_escape(number_format((int) $collector['last_batch_count'])) . '' . html_escape(syslog_status_format_time((string) $collector['last_received'])) . '
'; - foreach (syslog_status_storage() as $label => $value) { $kv((string) $label, (string) $value); } + + foreach (syslog_status_storage() as $label => $value) { + $kv((string) $label, (string) $value); + } print '
'; $partition_block = syslog_partition_blocked_state(); + if ($partition_block['blocked']) { print '

' . __esc('Partition maintenance blocked', 'syslog') . ': ' . html_escape($partition_block['reason'] !== '' ? $partition_block['reason'] : __('Partition maintenance is stopped; writes continue into the dMaxValue safety partition.', 'syslog')) . '

'; } @@ -923,8 +934,9 @@ function syslog_status_card_body(string $key): string { case 'partition_health': print ''; $partition_health = syslog_partition_observability(); + foreach (['syslog', 'syslog_removed'] as $table) { - $health = $partition_health[$table] ?? []; + $health = $partition_health[$table] ?? []; $coverage = !empty($health['coverage_start']) && !empty($health['coverage_end']) ? syslog_status_format_partition_date((string) $health['coverage_start']) . ' — ' . syslog_status_format_partition_date((string) $health['coverage_end']) . ' (' . number_format((int) $health['partitions']) . ')' : __('Unavailable', 'syslog'); print ''; } @@ -943,6 +955,7 @@ function syslog_status_card_body(string $key): string { case 'maintenance_recent': print '
' . html_escape(__('Table', 'syslog')) . '' . html_escape(__('Date coverage', 'syslog')) . '' . html_escape(__('dMaxValue rows (estimated)', 'syslog')) . '' . html_escape(__('dMaxValue size', 'syslog')) . '
' . html_escape($table) . '' . html_escape($coverage) . '' . html_escape(isset($health['dmax_rows']) && $health['dmax_rows'] !== null ? number_format((float) $health['dmax_rows']) : __('Unavailable', 'syslog')) . '' . html_escape(syslog_status_format_bytes($health['dmax_bytes'] ?? null)) . '
'; $history = json_decode($status['partition_maintenance_history'], true); + if (!is_array($history) || !cacti_sizeof($history)) { print ''; } else { @@ -955,23 +968,24 @@ function syslog_status_card_body(string $key): string { break; case 'collector_health': $pipeline = syslog_status_pipeline(); - $labels = [ - 'processing' => __('Processing', 'syslog'), 'backlog' => __('Incoming backlog', 'syslog'), + $labels = [ + 'processing' => __('Processing', 'syslog'), 'backlog' => __('Incoming backlog', 'syslog'), 'replication' => __('Synchronization', 'syslog'), 'partition' => __('Partition maintenance', 'syslog') ]; print '
' . html_escape(__('When', 'syslog')) . '' . html_escape(__('Outcome', 'syslog')) . '' . html_escape(__('Recovery', 'syslog')) . '' . html_escape(__('Details', 'syslog')) . '
' . __esc('No partition maintenance activity recorded yet. Activity appears after the next poller run.', 'syslog') . '
'; $states = [ - 'up' => __('Healthy', 'syslog'), 'down' => __('Attention required', 'syslog'), + 'up' => __('Healthy', 'syslog'), 'down' => __('Attention required', 'syslog'), 'disabled' => __('Processing disabled', 'syslog'), 'recovering' => __('Waiting for processing', 'syslog') ]; $kv(__('Health', 'syslog'), $states[$pipeline['state']]); + foreach ($pipeline['checks'] as $check => $detail) { $kv($labels[$check], $pipeline['issues'][$check] ?? $detail); } - $monitor = $pipeline['monitor']; + $monitor = $pipeline['monitor']; $notifications = [ 'disabled' => __('Disabled', 'syslog'), 'ready' => __('Ready', 'syslog'), 'sent' => __('Sent', 'syslog'), - 'failed' => __('Failed; retry pending', 'syslog'), 'pending' => __('Pending; next poller check', 'syslog') + 'failed' => __('Failed; retry pending', 'syslog'), 'pending' => __('Pending; next poller check', 'syslog') ]; $kv(__('Last health check', 'syslog'), syslog_status_format_time((string) ($monitor['checked'] ?? ''))); $kv(__('Email notifications', 'syslog'), $notifications[$monitor['notification'] ?? ''] ?? __('Not checked yet', 'syslog')); @@ -995,6 +1009,7 @@ function syslog_status_card_body(string $key): string { break; case 'worker_processes': print '
'; + if (cacti_sizeof($worker_stats['children'])) { foreach ($worker_stats['children'] as $child) { print ''; @@ -1008,7 +1023,7 @@ function syslog_status_card_body(string $key): string { case 'phases': print '
' . html_escape(__('Process', 'syslog')) . '' . html_escape(__('Records handled', 'syslog')) . '' . html_escape(__('Hosts resolved', 'syslog')) . '' . html_escape(__('Runtime', 'syslog')) . '
' . html_escape(syslog_status_format_count($child['child'])) . '' . html_escape(syslog_status_format_count($child['moved'])) . '' . html_escape(syslog_status_format_count($child['resolved'])) . '' . html_escape(syslog_status_format_seconds($child['runtime'])) . '
'; $phase_telemetry = syslog_status_phase_telemetry(); - $phase_labels = [ + $phase_labels = [ 'partition' => __('Partition maintenance', 'syslog'), 'references' => __('Reference updates', 'syslog'), 'removal' => __('Removal rules', 'syslog'), @@ -1018,21 +1033,27 @@ function syslog_status_card_body(string $key): string { ]; $slowest_phase = ''; $slowest_seconds = -1.0; + foreach ($phase_telemetry as $phase => $telemetry) { if ($telemetry === null) { continue; } + if ($telemetry['seconds'] > $slowest_seconds) { $slowest_seconds = $telemetry['seconds']; $slowest_phase = $phase; } } $any_phase = false; + foreach ($phase_telemetry as $phase => $telemetry) { - if ($telemetry === null) { continue; } + if ($telemetry === null) { + continue; + } $any_phase = true; print ''; } + if (!$any_phase) { print ''; } @@ -1041,6 +1062,7 @@ function syslog_status_card_body(string $key): string { break; case 'rules': print '
' . html_escape(__('Phase', 'syslog')) . '' . html_escape(__('Started', 'syslog')) . '' . html_escape(__('Duration', 'syslog')) . '' . html_escape(__('Records', 'syslog')) . '
' . html_escape($phase_labels[$phase] ?? $phase) . ($phase === $slowest_phase ? ' (' . __esc('slowest', 'syslog') . ')' : '') . '' . html_escape(syslog_status_format_time((string) $telemetry['start'])) . '' . html_escape(syslog_status_format_seconds((string) $telemetry['seconds'])) . '' . html_escape(syslog_status_format_count((string) $telemetry['count'])) . '
' . __esc('No phase timings recorded yet. Phase timings appear after the next poller run.', 'syslog') . '
'; + foreach (['alert' => __('Alert rules', 'syslog'), 'delete' => __('Delete rules', 'syslog')] as $type => $label) { print ''; print ''; @@ -1069,11 +1091,13 @@ function syslog_status_render_card(string $key, bool $expanded = false): string } $def = $meta[$key]; + if ($key === 'collector_health') { $def['state'] = syslog_status_pipeline()['state']; } $tools = ''; + if ($def['expandable']) { $tools .= ''; $tools .= ''; @@ -1083,9 +1107,11 @@ function syslog_status_render_card(string $key, bool $expanded = false): string $tools .= ''; $classes = 'syslogStatusCard syslogStatusSpan' . (int) $def['span']; + if ($def['expandable']) { $classes .= ' syslogStatusCardExpandable'; } + if ($expanded) { $classes .= ' syslogStatusCardExpanded'; } @@ -1093,6 +1119,7 @@ function syslog_status_render_card(string $key, bool $expanded = false): string // Header severity variant: up / recovering / down, or a neutral grey when the // card carries no severity. $state = isset($def['state']) ? preg_replace('/[^a-z]/', '', strtolower((string) $def['state'])) : ''; + if (!in_array($state, ['up', 'recovering', 'down'], true)) { $state = 'none'; } @@ -1125,6 +1152,7 @@ function syslog_status_layout(): array { } $order = []; + foreach ($saved['order'] as $key) { if (is_string($key) && in_array($key, $available, true) && !in_array($key, $order, true)) { $order[] = $key; @@ -1132,6 +1160,7 @@ function syslog_status_layout(): array { } $expanded = []; + if (isset($saved['expanded']) && is_array($saved['expanded'])) { foreach ($saved['expanded'] as $key => $on) { if ($on && in_array($key, $available, true)) { @@ -1222,6 +1251,7 @@ function syslog_status(): void { print '
'; print ''; print ''; + foreach ($page_refresh_interval as $seconds => $display_text) { print ''; } @@ -1242,6 +1273,7 @@ function syslog_status(): void { print '
'; print '
'; + foreach ($layout['order'] as $key) { print syslog_status_render_card($key, !empty($layout['expanded'][$key])); } @@ -1253,11 +1285,13 @@ function syslog_status(): void { print '
'; $catalog = []; + foreach (syslog_status_available_cards() as $key) { $catalog[$key] = $meta[$key]['title']; } - print ""; + print "'; } /** @@ -1423,7 +1457,7 @@ function syslog_request_validation(string $current_tab, bool $force = false): vo 'default' => read_user_setting('syslog_grouping', '0', $force), ], 'search_mode' => [ - 'filter' => FILTER_CALLBACK, + 'filter' => FILTER_CALLBACK, 'options' => ['options' => function ($value) { return $value === 'logical' ? 'logical' : 'regex'; }], 'pageset' => true, 'default' => 'logical' @@ -1459,6 +1493,7 @@ function syslog_request_validation(string $current_tab, bool $force = false): vo $logical_input = $search_mode === 'logical' && isset_request_var('rfilter') && !isset_request_var('clear') ? get_nfilter_request_var('rfilter') : null; validate_store_request_vars($filters, 'sess_sl_' . $current_tab); + // Preserve literal text, including Cacti's special 'undefined' sentinel. if (is_string($logical_input)) { set_request_var('rfilter', $logical_input); @@ -1481,19 +1516,24 @@ function syslog_request_validation(string $current_tab, bool $force = false): vo set_request_var('rfilter', $legacy === '' ? '' : 'message contains ' . '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $legacy) . '"'); set_request_var('search_mode', 'logical'); } + // New submissions contain the entire query. Convert old saved dropdown state only on entry. if (!$filter_submitted && !isset_request_var('clear')) { $conditions = []; + foreach (['eprogram' => 'program_id', 'efacility' => 'facility_id', 'epriority' => 'priority_id'] as $old => $field) { $value = (string) get_request_var($old); + if ($value !== '-1' && $value !== '') { - $operator = $old === 'epriority' && substr($value, -1) !== 'o' ? '<=' : '='; + $operator = $old === 'epriority' && substr($value, -1) !== 'o' ? '<=' : '='; $conditions[] = $field . ' ' . $operator . ' "' . (int) $value . '"'; } } $hosts = (string) get_request_var('host'); + if ($hosts !== '' && $hosts !== '0') { $host_conditions = []; + foreach (explode(',', $hosts) as $host) { if (ctype_digit($host)) { if ($current_tab === 'syslog') { @@ -1501,30 +1541,39 @@ function syslog_request_validation(string $current_tab, bool $force = false): vo } else { global $syslogdb_default; $hostname = syslog_db_fetch_cell_prepared("SELECT host FROM `$syslogdb_default`.`syslog_hosts` WHERE host_id = ?", [$host]); + if ($hostname !== false && $hostname !== '') { $host_conditions[] = 'host = "' . str_replace(['\\', '"'], ['\\\\', '\\"'], $hostname) . '"'; } } } } - if ($host_conditions) { $conditions[] = '(' . implode(' OR ', $host_conditions) . ')'; } + + if ($host_conditions) { + $conditions[] = '(' . implode(' OR ', $host_conditions) . ')'; + } } + if ($conditions) { $query = get_request_var('rfilter'); set_request_var('rfilter', ($query === '' ? '' : '(' . $query . ') AND ') . implode(' AND ', $conditions)); } } + foreach (['host' => '0', 'eprogram' => '-1', 'efacility' => '-1', 'epriority' => '-1'] as $key => $value) { set_request_var($key, $value); $_SESSION['sess_sl_' . $current_tab . '_' . $key] = $value; } + foreach (['rfilter', 'search_mode'] as $key) { $_SESSION['sess_sl_' . $current_tab . '_' . $key] = get_request_var($key); } set_shift_span($shift_span, 'sess_sl_' . $current_tab); + if (get_request_var('search_mode') === 'logical') { $query = get_request_var('rfilter'); + // Keep authored date conditions intact; the default last-day limit is // reapplied whenever the search carries no date condition of its own. try { @@ -1532,6 +1581,7 @@ function syslog_request_validation(string $current_tab, bool $force = false): vo } catch (InvalidArgumentException $error) { $has_dates = true; // Leave invalid input intact for the validation error below. } + if (!$has_dates) { $dates = $shift_span === false ? 'logtime last "86400"' : 'logtime >= "' . get_request_var('date1') . '" AND logtime <= "' . get_request_var('date2') . '"'; @@ -1540,8 +1590,9 @@ function syslog_request_validation(string $current_tab, bool $force = false): vo $_SESSION['sess_sl_' . $current_tab . '_rfilter'] = get_request_var('rfilter'); } - $GLOBALS['syslog_search_tree'] = null; + $GLOBALS['syslog_search_tree'] = null; $GLOBALS['syslog_search_error'] = ''; + if (get_request_var('search_mode') === 'logical') { try { $GLOBALS['syslog_search_tree'] = syslog_parse_logical_search(get_request_var('rfilter')); @@ -1553,7 +1604,6 @@ function syslog_request_validation(string $current_tab, bool $force = false): vo // ================= input validation ================= - api_plugin_hook_function('syslog_request_val'); if (isset_request_var('host')) { @@ -1581,7 +1631,7 @@ function saved_search_apply(string $tab, int $saved_id): void { $username = get_username($_SESSION['sess_user_id']); $sql_where = "`user` = ? OR is_global = 'on'"; - $shared = syslog_shared_item_ids('saved_search'); + $shared = syslog_shared_item_ids('saved_search'); if (cacti_sizeof($shared)) { $sql_where .= ' OR id IN (' . implode(',', $shared) . ')'; @@ -1666,7 +1716,7 @@ function saved_search_save(): string { VALUES (?, ?, ?, ?, ?, '', ?)", [$name, $search, $removal, $grouping, $username, time()]); - return (string) json_encode(['id' => (int) syslog_db_fetch_insert_id()]); + return (string) json_encode(['id' => (int) syslog_db_fetch_insert_id()]); } /** @@ -1723,7 +1773,7 @@ function saved_search_global(): string { return (string) json_encode(['error' => __('Permission denied.', 'syslog')]); } - $id = get_filter_request_var('id', FILTER_VALIDATE_INT); + $id = get_filter_request_var('id', FILTER_VALIDATE_INT); $username = get_username($_SESSION['sess_user_id']); if ($id === false || $id === null || $id <= 0) { @@ -1738,6 +1788,7 @@ function saved_search_global(): string { if ($row === false) { return (string) json_encode(['error' => __('Saved search not found.', 'syslog')]); } + if ($row['user'] !== $username && !syslog_saved_search_admin()) { return (string) json_encode(['error' => __('You may only share your own saved searches.', 'syslog')]); } @@ -1755,7 +1806,7 @@ function saved_search_global(): string { /** * Apply a timespan or timeshift selection and store the resulting dates. * - * @param bool|string $shift_span The shift mode: 'span', 'shift', 'custom', or false for page navigation. + * @param bool|string $shift_span The shift mode: 'span', 'shift', 'custom', or false for page navigation. * @param string $session_prefix The session key prefix of the tab. * * @return void @@ -1847,7 +1898,7 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): // syslog and syslog_removed can legitimately gain independent operational // columns. Keep the combined viewer query on its stable display contract // instead of using SELECT *, which would make UNION depend on identical DDL. - $breach_sql = syslog_message_breach_sql(); + $breach_sql = syslog_message_breach_sql(); $message_columns = 'syslog.facility_id, syslog.priority_id, syslog.program_id, syslog.host_id, syslog.logtime, syslog.message, syslog.seq, ' . $breach_sql . ' AS threshold_breached'; $sql_where = ''; @@ -1908,7 +1959,6 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): AND '" . get_request_var('date2') . "'"; } - if (isset_request_var('id') && $current_tab == 'current') { $sql_where .= ($sql_where == '' ? 'WHERE ' : ' AND ') . 'sa.id=' . get_request_var('id'); @@ -1917,6 +1967,7 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): if (get_request_var('search_mode') === 'logical') { $predicate = !empty($GLOBALS['syslog_search_error']) ? '(1 = 0)' : syslog_logical_search_sql($GLOBALS['syslog_search_tree'] ?? null, $tab == 'syslog' ? 'message' : 'logmsg'); + if ($predicate !== '') { $sql_where .= ($sql_where == '' ? 'WHERE ' : ' AND ') . $predicate; } @@ -2096,7 +2147,7 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): $sql_limit"; } elseif (get_request_var('removal') == '1') { $branch_limit = isset_request_var('export') ? 10000 : (int) $rows * get_request_var('page'); - $query_sql = "SELECT * FROM ( + $query_sql = "SELECT * FROM ( SELECT $message_columns, `syslog_programs`.`program`, 'main' AS mtype FROM `$syslogdb_default`.`syslog` AS syslog LEFT JOIN `$syslogdb_default`.`syslog_programs` @@ -2141,9 +2192,9 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): $sql_limit"; } - //print $query_sql; + // print $query_sql; - return syslog_db_fetch_assoc($query_sql); + return syslog_db_fetch_assoc($query_sql) ?: []; } /** @@ -2177,7 +2228,7 @@ function syslog_filter(string $sql_where, string $tab): void { // Note: $sql_where is this function's message filter; the saved search // visibility clause is separate. - $saved_where = "`user` = ? OR is_global = 'on'"; + $saved_where = "`user` = ? OR is_global = 'on'"; $shared_searches = syslog_shared_item_ids('saved_search'); if (cacti_sizeof($shared_searches)) { @@ -2206,7 +2257,7 @@ function syslog_filter(string $sql_where, string $tab): void { + html_start_box(__('Syslog Message Filter %s', $filter_text, 'syslog'), '100%', false, 3, 'center', ''); ?> "; - print "'; print "'; print "'; @@ -2663,7 +2712,6 @@ function syslog_messages(string $tab = 'syslog'): void { print $nav; } - print '
'; syslog_syslog_legend(); print '
'; @@ -2689,7 +2737,7 @@ function syslog_messages(string $tab = 'syslog'): void { print $nav; - html_start_box('', '100%', '', '3', 'center', ''); + html_start_box('', '100%', false, 3, 'center', ''); html_header_sort($display_text, get_request_var('sort_column'), get_request_var('sort_direction')); @@ -2734,7 +2782,15 @@ function syslog_messages(string $tab = 'syslog'): void { ?>
' . html_escape(__('Rules processed', 'syslog')) . '' . html_escape(__('Last run', 'syslog')) . '' . html_escape(__('Total', 'syslog')) . '
' . html_escape($label) . '' . html_escape(syslog_status_format_count($status['last_' . $type . '_rules_processed'])) . '' . html_escape(syslog_status_format_count($status['total_' . $type . '_rules_processed'])) . '
' . __esc('Fired last run', 'syslog') . ': ' . html_escape(syslog_status_format_rule_activity($status['last_' . $type . '_rules_fired'])) . '
@@ -2229,39 +2280,39 @@ function syslog_filter(string $sql_where, string $tab): void {
' title=''>
'> @@ -2293,16 +2344,16 @@ function syslog_filter(string $sql_where, string $tab): void {
@@ -2311,16 +2362,16 @@ function syslog_filter(string $sql_where, string $tab): void { @@ -2469,7 +2520,7 @@ function syslog_messages(string $tab = 'syslog'): void { SELECT $digest_count_key AS totals FROM `$syslogdb_default`.`syslog` AS syslog $sql_where - UNION + UNION ALL SELECT $digest_count_key AS totals FROM `$syslogdb_default`.`syslog_removed` AS syslog $sql_where @@ -2491,7 +2542,7 @@ function syslog_messages(string $tab = 'syslog'): void { SELECT COUNT(*) AS totals FROM `$syslogdb_default`.`syslog` AS syslog $sql_where - UNION + UNION ALL SELECT COUNT(*) AS totals FROM `$syslogdb_default`.`syslog_removed` AS syslog $sql_where @@ -2525,12 +2576,12 @@ function syslog_messages(string $tab = 'syslog'): void { $grouping_enabled = isset_request_var('grouping') && get_request_var('grouping') == '1'; $display_text = [ - 'logtime' => [__('Date', 'syslog'), 'ASC'], - 'host_id' => [__('Device', 'syslog'), 'ASC'], - 'program' => [__('Program', 'syslog'), 'ASC'], - 'message' => [__('Message', 'syslog'), 'ASC'], - 'facility_id' => [__('Facility', 'syslog'), 'ASC'], - 'priority_id' => [__('Priority', 'syslog'), 'ASC'], + 'logtime' => [__('Date', 'syslog'), 'ASC'], + 'host_id' => [__('Device', 'syslog'), 'ASC'], + 'program' => [__('Program', 'syslog'), 'ASC'], + 'message' => [__('Message', 'syslog'), 'ASC'], + 'facility_id' => [__('Facility', 'syslog'), 'ASC'], + 'priority_id' => [__('Priority', 'syslog'), 'ASC'], 'threshold_breached' => ['display' => __('Breach', 'syslog'), 'sort' => 'ASC', 'align' => 'center', 'tip' => __('Threshold breached', 'syslog')]]; // Add count column if grouping is enabled @@ -2541,7 +2592,7 @@ function syslog_messages(string $tab = 'syslog'): void { print $nav; - html_start_box('', '100%', '', '3', 'center', ''); + html_start_box('', '100%', false, 3, 'center', ''); html_header_sort($display_text, get_request_var('sort_column'), get_request_var('sort_direction')); @@ -2561,7 +2612,7 @@ function syslog_messages(string $tab = 'syslog'): void { $hosts = array_rekey( syslog_db_fetch_assoc_prepared("SELECT host_id, host FROM `$syslogdb_default`.`syslog_hosts` - WHERE host_id IN (" . implode(', ', array_fill(0, cacti_sizeof($bind), '?')) . ")", + WHERE host_id IN (" . implode(', ', array_fill(0, cacti_sizeof($bind), '?')) . ')', $bind), 'host_id', 'host' ); @@ -2591,7 +2642,6 @@ function syslog_messages(string $tab = 'syslog'): void { syslog_row_color($sm['priority_id'], $sm['message']); - // Display grouped or individual messages if ($grouping_enabled && isset($sm['occurrence_count']) && $sm['occurrence_count'] > 1) { // Grouped message display with expand/collapse @@ -2634,7 +2684,6 @@ function syslog_messages(string $tab = 'syslog'): void { foreach ($detail_messages as $dm) { print "
- - - + + + + + +
' value=''>' value=''> '>'>
'>