From 4945845dd2b2c4a660cfdddb6ee1ea9e01c87476 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 16 Oct 2026 09:19:27 -0400 Subject: [PATCH 01/15] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5ce1e289..fb03a808 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ phpstan-stubs.php graphify-out/** locales/po/*.po locales/LC_MESSAGES/*.mo +.claude-artifacts/** \ No newline at end of file From f51fcd7fc816d8ca8f3b6c0c012c58d5a5b32544 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 16 Oct 2026 09:51:48 -0400 Subject: [PATCH 02/15] fix Host autocomplete joins --- syslog.php | 148 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 123 insertions(+), 25 deletions(-) diff --git a/syslog.php b/syslog.php index ee500be7..3c66f45d 100644 --- a/syslog.php +++ b/syslog.php @@ -212,41 +212,139 @@ function get_ajax_hosts(): string { $term = '%' . get_nfilter_request_var('term') . '%'; - if (syslog_db_table_exists('host', false)) { - $hosts = syslog_db_fetch_assoc_prepared("SELECT DISTINCT sh.host_id, sh.host, h.id - FROM `$syslogdb_default`.`syslog_hosts` AS sh - LEFT JOIN host AS h - ON sh.host = h.hostname - OR sh.host = h.description - OR sh.host LIKE substring_index(h.hostname, '.', 1) - OR sh.host LIKE substring_index(h.description, '.', 1) - WHERE sh.host LIKE ? - OR h.description LIKE ? - ORDER BY host - LIMIT $ac_rows", - [$term, $term]); - } else { - $hosts = syslog_db_fetch_assoc_prepared("SELECT DISTINCT sh.host_id, sh.host, '0' AS id - FROM `$syslogdb_default`.`syslog_hosts` AS sh - WHERE sh.host LIKE ? - ORDER BY host + $has_host_table = syslog_db_table_exists('host', false); + + // 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, " . + ($has_host_table ? 'NULL' : "'0'") . " AS id + FROM `$syslogdb_default`.`syslog_hosts` AS sh + WHERE sh.host LIKE ? + ORDER BY sh.host + LIMIT $ac_rows", + [$term]); + + $candidates = []; + + foreach ($hosts as $row) { + $candidates[$row['host_id']] = $row; + } + + if ($has_host_table) { + // Step 2: resolve device ids in bounded steps. Never join + // syslog_hosts to the Cacti host table inside one query; on large + // installs (tens of thousands of devices) the historical four-way OR + // join forces every candidate to probe the entire device table and + // the autocomplete exceeds 60 seconds per keystroke. The two-phase + // queries below stay under ~150 ms on the same data volume. + + // Step 2a: devices whose description matches the term contribute + // additional syslog hosts. One bounded scan of the device table. + $devices = syslog_db_fetch_assoc_prepared("SELECT h.id, h.hostname, h.description + FROM host AS h + WHERE h.description LIKE ? + ORDER BY h.description, h.id LIMIT $ac_rows", [$term]); + + // Names a syslog host can carry to match one of these devices: its + // exact hostname, its exact description, or the first dot segment of + // either (the historical substring_index() matching semantics). + $labels = []; + + foreach ($devices as $device) { + foreach ([$device['hostname'], $device['description']] as $value) { + if ($value === null || $value === '') { + continue; + } + + $labels[$value] = true; + + $dot = strpos($value, '.'); + + $labels[$dot === false ? $value : substr($value, 0, $dot)] = true; + } + } + + // Step 2b: reverse direction, exact primary key probes only. + if ($labels !== []) { + $bind = array_keys($labels); + + $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), '?')) . ")", + $bind); + + foreach ($extra_hosts as $extra_host) { + if (!isset($candidates[$extra_host['host_id']])) { + $candidates[$extra_host['host_id']] = $extra_host; + } + } + } + + $candidates = array_values($candidates); + + usort($candidates, function ($a, $b) { + return strcasecmp($a['host'], $b['host']) ?: strcmp($a['host'], $b['host']); + }); + + $candidates = array_slice($candidates, 0, $ac_rows); + + // Step 2c: device id resolution for the surviving candidates, one + // index probe or scan per branch, most specific match first so the + // reported device is deterministic. + $bind = []; + + foreach ($candidates as $candidate) { + $bind[$candidate['host']] = true; + } + + if ($bind !== []) { + $bind = array_keys($bind); + + $marks = implode(', ', array_fill(0, cacti_sizeof($bind), '?')); + + $resolutions = [ + "SELECT h.id, h.hostname AS name FROM host AS h WHERE h.hostname IN ($marks)", + "SELECT h.id, h.description AS name FROM host AS h WHERE h.description IN ($marks)", + "SELECT h.id, SUBSTRING_INDEX(h.hostname, '.', 1) AS name FROM host AS h WHERE SUBSTRING_INDEX(h.hostname, '.', 1) IN ($marks)", + "SELECT h.id, SUBSTRING_INDEX(h.description, '.', 1) AS name FROM host AS h WHERE SUBSTRING_INDEX(h.description, '.', 1) IN ($marks)" + ]; + + $device_ids = []; + + foreach ($resolutions as $sql) { + $resolved = syslog_db_fetch_assoc_prepared($sql, $bind); + + foreach ($resolved as $resolved_row) { + if (!isset($device_ids[$resolved_row['name']])) { + $device_ids[$resolved_row['name']] = $resolved_row['id']; + } + } + } + + foreach ($candidates as $key => $candidate) { + if (isset($device_ids[$candidate['host']])) { + $candidates[$key]['id'] = $device_ids[$candidate['host']]; + } + } + } } - if (cacti_sizeof($hosts)) { + if (cacti_sizeof($candidates)) { $rhosts = []; - foreach ($hosts as $host) { - if (!empty($host['id'])) { - $class = get_device_leaf_class($host['id']); + foreach ($candidates as $candidate) { + if (!empty($candidate['id'])) { + $class = get_device_leaf_class($candidate['id']); } else { $class = 'deviceUp'; } - $rhosts[$host['host_id']] = [ - 'host' => $host['host'], - 'host_id' => $host['id'], + $rhosts[$candidate['host_id']] = [ + 'host' => $candidate['host'], + 'host_id' => $candidate['id'], 'class' => $class ]; } From 5c6578170aaae21950a557295ba121078193fab8 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 16 Oct 2026 10:57:59 -0400 Subject: [PATCH 03/15] Fix alert staging scalability and add message-digest grouping - syslog_process_alerts(): compile all rules to condition-only SQL and evaluate every rule's count in one consolidated SUM(CASE) scan of syslog_incoming (one scan per cycle regardless of rule count); rules below threshold or with zero counts skip their record fetches - syslog_device_rule_sql(): qualify the correlated host column so the mute filter was not a tautology (dr.host = dr.host silently matched every staged record and suppressed all alerts) - Add message_digest char(32) to syslog/syslog_removed (fresh installs via CREATE, existing via syslog_ensure_message_digest_columns()), backfill bounded per poller run via syslog_backfill_message_digest(); archive, move, and replication paths write MD5(message) at ingest - Viewer: grouped queries and nav counts key on the digest with an IF(digest = '', MD5(message), digest) exactness fallback, degrading to the old message GROUP BY when the column does not exist yet - Bounded per-render host-name fetch (host_id IN instead of full table) - tests: MessageDigestGroupingTest source-shape coverage --- includes/functions.php | 318 ++++++++++++++++++-- includes/schema.php | 31 +- syslog.php | 64 +++- syslog_process.php | 7 + tests/Unit/AlertThresholdWindowTest.php | 2 + tests/Unit/MessageDigestGroupingTest.php | 45 +++ tests/Unit/MessageThresholdBreachTest.php | 4 +- tests/Unit/ThresholdRecoveryCommandTest.php | 2 + tests/bootstrap-unit.php | 6 + 9 files changed, 435 insertions(+), 44 deletions(-) create mode 100644 tests/Unit/MessageDigestGroupingTest.php diff --git a/includes/functions.php b/includes/functions.php index 80e7c546..0cff0cf5 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -3701,8 +3701,8 @@ function syslog_remove_items($table, $max_seq) { } $move_failed = !syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_removed` - (logtime, priority_id, facility_id, program_id, host_id, message, threshold_breached, replication_source_poller_id, replication_source_event_id) - SELECT si.logtime, si.priority_id, si.facility_id, sp.program_id, sh.host_id, si.message, si.threshold_breached, {$config['poller_id']}, si.seq + (logtime, priority_id, facility_id, program_id, host_id, message, message_digest, threshold_breached, replication_source_poller_id, replication_source_event_id) + SELECT si.logtime, si.priority_id, si.facility_id, sp.program_id, sh.host_id, si.message, MD5(si.message), si.threshold_breached, {$config['poller_id']}, si.seq FROM `$syslogdb_default`.`syslog_incoming` AS si INNER JOIN `$syslogdb_default`.`syslog_hosts` AS sh ON sh.host = si.host @@ -3710,8 +3710,8 @@ function syslog_remove_items($table, $max_seq) { ON sp.program = si.program $insert_where", $insert_params); } else { $move_failed = !syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_removed` - (logtime, priority_id, facility_id, program_id, host_id, message, threshold_breached, replication_source_poller_id, replication_source_event_id) - SELECT si.logtime, si.priority_id, si.facility_id, sp.program_id, sh.host_id, si.message, si.threshold_breached, {$config['poller_id']}, si.seq + (logtime, priority_id, facility_id, program_id, host_id, message, message_digest, threshold_breached, replication_source_poller_id, replication_source_event_id) + SELECT si.logtime, si.priority_id, si.facility_id, sp.program_id, sh.host_id, si.message, MD5(si.message), si.threshold_breached, {$config['poller_id']}, si.seq FROM `$syslogdb_default`.`syslog_incoming` AS si INNER JOIN `$syslogdb_default`.`syslog_hosts` AS sh ON sh.host = si.host @@ -3720,8 +3720,8 @@ function syslog_remove_items($table, $max_seq) { } } else { $move_failed = !syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog_removed` - (logtime, priority_id, facility_id, program_id, host_id, message, threshold_breached, replication_source_poller_id, replication_source_event_id) - SELECT logtime, priority_id, facility_id, program_id, host_id, message, threshold_breached, replication_source_poller_id, replication_source_event_id + (logtime, priority_id, facility_id, program_id, host_id, message, message_digest, threshold_breached, replication_source_poller_id, replication_source_event_id) + SELECT logtime, priority_id, facility_id, program_id, host_id, message, MD5(message), threshold_breached, replication_source_poller_id, replication_source_event_id FROM `$syslogdb_default`.`syslog` $sql_where", $params); } @@ -4368,8 +4368,8 @@ function syslog_manage_items($from_table, $to_table) { } if (!syslog_db_execute("INSERT INTO `$syslogdb_default`.`$to_table` - (facility_id, priority_id, host_id, logtime, message, threshold_breached, replication_source_poller_id, replication_source_event_id) - (SELECT facility_id, priority_id, host_id, logtime, message, threshold_breached, replication_source_poller_id, replication_source_event_id + (facility_id, priority_id, host_id, logtime, message, message_digest, threshold_breached, replication_source_poller_id, replication_source_event_id) + (SELECT facility_id, priority_id, host_id, logtime, message, MD5(message), threshold_breached, replication_source_poller_id, replication_source_event_id FROM `$syslogdb_default`.`$from_table` WHERE seq IN (" . $all_seq . '))')) { syslog_db_execute('ROLLBACK'); @@ -4772,6 +4772,9 @@ function syslog_device_rule_sql(bool $maintenance_active): array { $host_field = $syslog_incoming_config['hostField'] ?? 'host'; $priority_field = $syslog_incoming_config['priorityField'] ?? 'priority_id'; $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 > ?))"; $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)"; @@ -5024,9 +5027,71 @@ function syslog_process_alerts($max_seq) { syslog_status_increment('total_alert_rules_processed', $syslog_alerts); if (cacti_sizeof($alerts)) { + // 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 = []; + $all_params = []; + foreach ($alerts as $alert) { + $sql_data = syslog_get_alert_sql($alert, $max_seq, true, true); + + if (!cacti_sizeof($sql_data) || $sql_data['sql'] == '') { + syslog_debug(sprintf('Error - Unable to determine SQL for Alert \'%s\'', $alert['name'])); + + $compiled[] = null; + + continue; + } + $maintenance_active = syslog_alert_maintenance_is_active($alert); - $sql = ''; + $device_rule = syslog_device_rule_sql($maintenance_active); + $index = cacti_sizeof($compiled); + + $compiled[$index] = [ + 'alert' => $alert, + 'cond' => $sql_data['sql'], + 'cond_params' => $sql_data['params'], + 'device_sql' => $device_rule['sql'], + 'device_params' => $device_rule['params'] + ]; + + $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']); + } + + if (cacti_sizeof($sums)) { + $count_sql = + "SELECT\n\t\t\t\t" . implode(",\n\t\t\t\t", $sums) . "\n" . + "\t\t\t\tFROM `$syslogdb_default`.`syslog_incoming`\n" . + "\t\t\t\tWHERE `status` = 1\n\t\t\t\tAND `seq` <= ?"; + + $all_params[] = $max_seq; + + $rows = syslog_db_fetch_assoc_prepared($count_sql, $all_params); + $row = isset($rows[0]) ? $rows[0] : []; + + foreach ($compiled as $index => $rule) { + if ($rule === null) { + continue; + } + + $counts[$index] = isset($row["m$index"]) ? (int)$row["m$index"] : 0; + } + } + + // Phase 2 - only rules whose consolidated count can trigger fetch + // their matching records; the rest skip their staging scan entirely. + foreach ($compiled as $index => $rule) { + if ($rule === null) { + continue; + } + + $alert = $rule['alert']; + $count = isset($counts[$index]) ? $counts[$index] : 0; + $sql = ''; $params = []; // we roll up statistics depending on the level @@ -5036,19 +5101,32 @@ function syslog_process_alerts($max_seq) { $groupBy = ''; } - $sql_data = syslog_get_alert_sql($alert, $max_seq); + // Windowed thresholds accumulate across polls, so a batch below + // the per-cycle threshold still contributes to the window and + // may complete a breach in progress; only legacy per-cycle + // rules may skip their evaluation entirely. + if ($alert['level'] != 1 && $alert['method'] == '1' + && (int) ($alert['threshold_window'] ?? 0) == 0 + && $count < $alert['num']) { + syslog_debug(sprintf('Skipping - Alert \'%s\' below threshold (%d of %d)', $alert['name'], $count, $alert['num'])); - if (!cacti_sizeof($sql_data)) { - syslog_debug(sprintf('Error - Unable to determine SQL for Alert \'%s\'', $alert['name'])); + continue; + } + if ($alert['level'] == 1 && $count == 0) { + continue; + } + + $sql_data = syslog_get_alert_sql($alert, $max_seq); + + if (!cacti_sizeof($sql_data) || $sql_data['sql'] == '') { continue; } $sql = $sql_data['sql']; $params = $sql_data['params']; - $device_rule = syslog_device_rule_sql($maintenance_active); - $sql .= $device_rule['sql']; - $params = array_merge($params, $device_rule['params']); + $sql .= $rule['device_sql']; + $params = array_merge($params, $rule['device_params']); if ($sql != '') { if ($alert['level'] == '1') { @@ -5072,9 +5150,14 @@ function syslog_process_alerts($max_seq) { } } } elseif ($alert['method'] == '1') { - $th_sql = str_replace('*', 'COUNT(*)', $sql); - $count = syslog_db_fetch_cell_prepared($th_sql . $groupBy, $params); - $count = syslog_threshold_evaluate($alert, (int) $count); + // the consolidated Phase 1 count is the same number the + // per-rule COUNT(*) scan would return; keep it instead + // of scanning the staging table a second time + $count = isset($counts[$index]) ? (int) $counts[$index] : (int) syslog_db_fetch_cell_prepared(str_replace('*', 'COUNT(*)', $sql) . $groupBy, $params); + // 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); $triggered = $count > 0 ? syslog_process_alert($alert, $sql, $params, $count) : 0; $syslog_alarms += $triggered; @@ -5082,8 +5165,10 @@ function syslog_process_alerts($max_seq) { $fired[$alert['name']] = ($fired[$alert['name']] ?? 0) + $triggered; } } else { - $count = 0; - $triggered = syslog_process_alert($alert, $sql, $params, $count); + // method 0 fires on any match; skip the matching-record + // fetch when the consolidated count already proved zero + $at = $count > 0 ? null : []; + $triggered = syslog_process_alert($alert, $sql, $params, 0, '', $at); $syslog_alarms += $triggered; if ($triggered > 0) { @@ -5155,10 +5240,13 @@ function syslog_mark_threshold_messages(array $alert, int $count, array $matches * 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 */ -function syslog_process_alert($alert, $sql, $params, $count, $hostname = '') { +function syslog_process_alert($alert, $sql, $params, $count, $hostname = '', $at = null) { global $config, $severities, $syslog_levels; if (!empty($alert['_threshold_state']) && !$alert['_threshold_state']['new_breach'] && (int) $alert['repeat_alert'] <= 0) { @@ -5229,7 +5317,11 @@ function syslog_process_alert($alert, $sql, $params, $count, $hostname = '') { * process the alert now. */ if (($alert['method'] == '1' && ($count >= $alert['num'] || !empty($alert['_threshold_state']))) || $alert['method'] == '0') { - $at = syslog_db_fetch_assoc_prepared($sql, $params); + if ($at === null) { + $at = syslog_db_fetch_assoc_prepared($sql, $params); + } + // an empty $at means the caller already knows there are no matches + if (!syslog_mark_threshold_messages($alert, (int) $count, $at ?: [])) { return 0; } @@ -5521,10 +5613,13 @@ function syslog_process_alert($alert, $sql, $params, $count, $hostname = '') { * * @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 * * @return array The SQL and the prepared array for the SQL */ -function syslog_get_alert_sql(&$alert, $max_seq, $processing_boundary = true) { +function syslog_get_alert_sql(&$alert, $max_seq, $processing_boundary = true, $condition_only = false) { global $syslogdb_default, $syslog_incoming_config; if (defined('SYSLOG_CONFIG')) { @@ -5543,6 +5638,51 @@ function syslog_get_alert_sql(&$alert, $max_seq, $processing_boundary = true) { $params = []; $sql = ''; + if ($condition_only) { + // Bare WHERE condition and its params, without the processing + // boundary. Lets the caller evaluate many rules in one staging + // scan and apply `status`/`seq` gating a single time. + switch ($alert['type']) { + case 'filter': + if (!class_exists('Cacti\\Syslog\\QueryBuilder')) { + return []; + } + $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', '=', '!=']], + 'facility_id' => ['column' => '`' . $syslog_incoming_config['facilityField'] . '`', 'operators' => ['=', '!='], 'type' => 'integer'], + 'priority_id' => ['column' => '`' . $syslog_incoming_config['priorityField'] . '`', 'operators' => ['=', '!='], 'type' => 'integer'] + ]; + + try { + $filter = \Cacti\Syslog\QueryBuilder::compile($alert['message'], $fields); + } catch (InvalidArgumentException $error) { + $GLOBALS['syslog_rule_filter_error'] = $error->getMessage(); + + return []; + } + + return ['sql' => "({$filter['sql']})", 'params' => $filter['params']]; + case 'facility': + return ['sql' => "`{$syslog_incoming_config['facilityField']}` = ?", 'params' => [$alert['message']]]; + case 'messageb': + return ['sql' => "`{$syslog_incoming_config['textField']}` LIKE ?", 'params' => [$alert['message'] . '%']]; + case 'messagec': + return ['sql' => "`{$syslog_incoming_config['textField']}` LIKE ?", 'params' => ['%' . $alert['message'] . '%']]; + case 'messagee': + return ['sql' => "`{$syslog_incoming_config['textField']}` LIKE ?", 'params' => ['%' . $alert['message']]]; + case 'host': + return ['sql' => "`{$syslog_incoming_config['hostField']}` = ?", 'params' => [$alert['message']]]; + case 'program': + return ['sql' => "`{$syslog_incoming_config['programField']}` = ?", 'params' => [$alert['message']]]; + case 'sql': + return ['sql' => "({$alert['message']})", 'params' => []]; + default: + return []; + } + } + if ($alert['type'] == 'filter') { if (!class_exists('Cacti\\Syslog\\QueryBuilder')) { $GLOBALS['syslog_rule_filter_error'] = 'The structured query builder is unavailable.'; @@ -5779,6 +5919,113 @@ function syslog_check_cacti_hosts($host, $max_seq, $seq_start = 0, $seq_end = 0) * * @return void */ +/** + * syslog_message_digest_ready - whether both archived message tables carry + * the digest grouping column. Checked once per request and used by the + * grouped viewer and its row counts to fall back to the message column + * while the schema upgrade has not run yet. + * + * @param string $table Optional single table to check + * + * @return bool + */ +function syslog_message_digest_ready(string $table = ''): bool { + static $ready_cache = []; + + $tables = $table != '' ? [$table] : ['syslog', 'syslog_removed']; + + foreach ($tables as $check_table) { + if (!isset($ready_cache[$check_table])) { + $ready_cache[$check_table] = syslog_db_table_exists($check_table, false) + && syslog_db_column_exists($check_table, 'message_digest', false); + } + } + + foreach ($tables as $check_table) { + if (!$ready_cache[$check_table]) { + return false; + } + } + + return true; +} + +/** + * syslog_backfill_message_digest - fill the digest column for rows stored + * before the column existed. + * + * 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. + * + * @param int $rows_per_run Maximum rows to digest per call + * + * @return int Rows still awaiting a digest + */ +function syslog_backfill_message_digest(int $rows_per_run = 50000): int { + global $syslogdb_default; + + $remaining = 0; + + foreach (['syslog', 'syslog_removed'] as $table) { + if (!syslog_db_table_exists($table, false) + || !syslog_db_column_exists($table, 'message_digest', false)) { + continue; + } + + $max_seq = (int) syslog_db_fetch_cell("SELECT COALESCE(MAX(seq), 0) FROM `$syslogdb_default`.`$table`"); + + if ($max_seq == 0) { + continue; + } + + $option = 'syslog_digest_backfill_seq_' . $table; + $cursor = (int) read_config_option($option, true); + $done = (int) read_config_option($option . '_done', true); + + // 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; + } + + $rows_remaining = (int) syslog_db_fetch_cell("SELECT COUNT(seq) FROM `$syslogdb_default`.`$table` WHERE `message_digest` = ''"); + + if ($rows_remaining == 0) { + set_config_option($option . '_done', (string) $max_seq); + continue; + } + + // 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); + + while ($left_to_digest > 0 && $cursor < $max_seq) { + $stop = min($cursor + $chunk, $max_seq); + + syslog_db_execute_prepared("UPDATE `$syslogdb_default`.`$table` + SET `message_digest` = MD5(`message`) + WHERE `seq` > ? AND `seq` <= ? AND `message_digest` = ''", [$cursor, $stop]); + + $left_to_digest -= ($stop - $cursor); + $cursor = $stop; + } + + 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; + + if ($rows_remaining == 0) { + set_config_option($option . '_done', (string) $max_seq); + } + } + + return $remaining; +} + function syslog_update_reference_tables($max_seq) { syslog_debug('-------------------------------------------------------------------------------------'); syslog_debug('Updating Reference Tables from New Syslog Records'); @@ -6437,13 +6684,28 @@ function syslog_replication_accept_central(array $event, $central, string $datab } $table = $event['disposition']; // validated fixed identifiers, never caller SQL + // The central database may run an older plugin schema, so the digest + // column is only written when that table already carries it. The + // threshold flag and replication identity are newer required columns + // and must never be dropped on the digest path. + $digest_col = db_column_exists($table, 'message_digest', false, $central) ? ', message_digest' : ''; + $digest_val = $digest_col === '' ? '' : ', ?'; + + $params = [$event['logtime_epoch'], $event['priority_id'], $event['facility_id'], $event['message'], + isset($event['threshold_breached']) ? (int) (bool) $event['threshold_breached'] : null, + $event['source_poller_id'], $event['source_event_id'], $event['program'], $event['host']]; + + if ($digest_val !== '') { + array_splice($params, 4, 0, [md5($event['message'])]); + } + return db_execute_prepared("INSERT INTO `$database`.`$table` - (logtime, priority_id, facility_id, program_id, host_id, message, threshold_breached, replication_source_poller_id, replication_source_event_id) - SELECT FROM_UNIXTIME(?), ?, ?, sp.program_id, sh.host_id, ?, ?, ?, ? + (logtime, priority_id, facility_id, program_id, host_id, message$digest_col, threshold_breached, replication_source_poller_id, replication_source_event_id) + SELECT FROM_UNIXTIME(?), ?, ?, sp.program_id, sh.host_id, ?$digest_val, ?, ?, ? FROM `$database`.`syslog_programs` AS sp INNER JOIN `$database`.`syslog_hosts` AS sh WHERE sp.program = ? AND sh.host = ?", - [$event['logtime_epoch'], $event['priority_id'], $event['facility_id'], $event['message'], isset($event['threshold_breached']) ? (int) (bool) $event['threshold_breached'] : null, $event['source_poller_id'], $event['source_event_id'], $event['program'], $event['host']], true, $central); + $params, true, $central); } /** @@ -6598,8 +6860,8 @@ function syslog_incoming_to_syslog($max_seq, $seq_start = 0, $seq_end = 0) { $archived = true; } else { $archived = syslog_db_execute_prepared("INSERT INTO `$syslogdb_default`.`syslog` - (logtime, priority_id, facility_id, program_id, host_id, message, threshold_breached, replication_source_poller_id, replication_source_event_id) - SELECT logtime, priority_id, facility_id, program_id, host_id, message, threshold_breached, replication_source_poller_id, replication_source_event_id + (logtime, priority_id, facility_id, program_id, host_id, message, message_digest, threshold_breached, replication_source_poller_id, replication_source_event_id) + SELECT logtime, priority_id, facility_id, program_id, host_id, message, MD5(message), si.threshold_breached, ? AS replication_source_poller_id, si.seq AS replication_source_event_id FROM ( SELECT logtime, priority_id, facility_id, sp.program_id, sh.host_id, message, si.threshold_breached, ? AS replication_source_poller_id, si.seq AS replication_source_event_id diff --git a/includes/schema.php b/includes/schema.php index 19c218f3..1c367453 100644 --- a/includes/schema.php +++ b/includes/schema.php @@ -80,7 +80,34 @@ function syslog_ensure_replication_history_columns(): void { ADD KEY `replication_source` (`replication_source_poller_id`, `replication_source_event_id`)"); } -/** Ensure remote replication transactions use transactional tables. */ +/** + * Add the digest grouping key to the archived message tables. + * + * Grouped views and their row counts key their results by this 32 byte + * digest of the message instead of the full TEXT column, which keeps the + * temporary tables used for grouping small. Rows stored before the + * column existed carry the empty default and fall back to computing + * MD5(message) per row until the bounded backfill reaches them, so the + * queries stay correct at every point of the migration. + * + * @return void + */ +function syslog_ensure_message_digest_columns(): void { + global $syslogdb_default; + + foreach (['syslog', 'syslog_removed'] as $table) { + if (syslog_db_table_exists($table, false) + && !syslog_db_column_exists($table, 'message_digest', false)) { + syslog_db_add_column($table, [ + 'name' => 'message_digest', + 'type' => 'char(32)', + 'NULL' => false, + 'default' => '', + 'after' => 'message' + ], false); + } + } +} function syslog_ensure_replication_storage_engine(): void { global $config, $syslogdb_default; @@ -362,6 +389,7 @@ function syslog_create_partitioned_syslog_table($engine = 'InnoDB', $days = 30, host_id int(10) unsigned default NULL, logtime timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', message TEXT NOT NULL, + message_digest char(32) NOT NULL default '', threshold_breached tinyint(1) unsigned DEFAULT NULL, seq bigint unsigned NOT NULL auto_increment, replication_source_poller_id int(10) unsigned default NULL, @@ -897,6 +925,7 @@ function syslog_ensure_table_structures(): bool { syslog_ensure_share_tables(); syslog_ensure_dashboard_tables(); } + syslog_ensure_message_digest_columns(); $tables = array_merge($tables, [ 'syslog_saved_searches', 'syslog_saved_searches_perm', 'syslog_dashboards', 'syslog_dashboards_perm', 'syslog_dashboard_panels' diff --git a/syslog.php b/syslog.php index 3c66f45d..e1c10b5e 100644 --- a/syslog.php +++ b/syslog.php @@ -2013,6 +2013,17 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): $grouping_enabled = isset_request_var('grouping') && get_request_var('grouping') == '1'; if ($grouping_enabled) { + // Group by a stored 32 byte digest of the message instead of the + // full TEXT column; the temporary tables used for grouping shrink + // accordingly. Rows stored before the digest column existed fall + // back to hashing the message at query time, and the digest group + // key is not used at all while the schema upgrade has not run. All + // grouped queries below expose their source table under the + // 'syslog' alias. + $message_group_key = syslog_message_digest_ready() + ? "GROUP BY syslog.host_id, IF(syslog.message_digest = '', MD5(syslog.message), syslog.message_digest), syslog.program_id, syslog.facility_id, syslog.priority_id" + : 'GROUP BY syslog.host_id, syslog.message, syslog.program_id, syslog.facility_id, syslog.priority_id'; + if (get_request_var('removal') == '-1') { $query_sql = "SELECT syslog.host_id, syslog.message, syslog.program_id, syslog.facility_id, syslog.priority_id, syslog_programs.program, @@ -2024,7 +2035,7 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): LEFT JOIN `$syslogdb_default`.`syslog_programs` ON syslog.program_id=syslog_programs.program_id $sql_where - GROUP BY syslog.host_id, syslog.message, syslog.program_id, syslog.facility_id, syslog.priority_id + $message_group_key $sql_order $sql_limit"; } elseif (get_request_var('removal') == '1') { @@ -2041,7 +2052,7 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): LEFT JOIN `$syslogdb_default`.`syslog_programs` ON syslog.program_id=syslog_programs.program_id $sql_where - GROUP BY syslog.host_id, syslog.message, syslog.program_id, syslog.facility_id, syslog.priority_id + $message_group_key ) UNION ( SELECT syslog.host_id, syslog.message, syslog.program_id, syslog.facility_id, syslog.priority_id, syslog_programs.program, @@ -2053,7 +2064,7 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): LEFT JOIN `$syslogdb_default`.`syslog_programs` ON syslog.program_id=syslog_programs.program_id $sql_where - GROUP BY syslog.host_id, syslog.message, syslog.program_id, syslog.facility_id, syslog.priority_id + $message_group_key ) ) AS grouped_results $sql_order @@ -2069,7 +2080,7 @@ function get_syslog_messages(string &$sql_where, int|string $rows, string $tab): LEFT JOIN `$syslogdb_default`.`syslog_programs` AS syslog_programs ON syslog.program_id = syslog_programs.program_id $sql_where - GROUP BY syslog.host_id, syslog.message, syslog.program_id, syslog.facility_id, syslog.priority_id + $message_group_key $sql_order $sql_limit"; } @@ -2444,24 +2455,31 @@ function syslog_messages(string $tab = 'syslog'): void { $grouping_enabled = isset_request_var('grouping') && get_request_var('grouping') == '1'; if ($grouping_enabled) { - // When grouping, count distinct groups instead of individual rows + // When grouping, count distinct groups instead of individual rows. + // Keyed by the stored message digest like the grouped view above, + // so the DISTINCT temporary table never has to compare full + // message text; empty digests fall back to hashing per row. + $digest_count_key = syslog_message_digest_ready() + ? "COUNT(DISTINCT CONCAT(host_id, '|', IF(message_digest = '', MD5(message), message_digest), '|', program_id, '|', facility_id, '|', priority_id))" + : "COUNT(DISTINCT CONCAT(host_id, '|', message, '|', program_id, '|', facility_id, '|', priority_id))"; + if (get_request_var('removal') == 1) { $total_rows = syslog_db_fetch_cell("SELECT SUM(totals) FROM ( - SELECT COUNT(DISTINCT CONCAT(host_id, '|', message, '|', program_id, '|', facility_id, '|', priority_id)) AS totals + SELECT $digest_count_key AS totals FROM `$syslogdb_default`.`syslog` AS syslog $sql_where UNION - SELECT COUNT(DISTINCT CONCAT(host_id, '|', message, '|', program_id, '|', facility_id, '|', priority_id)) AS totals + SELECT $digest_count_key AS totals FROM `$syslogdb_default`.`syslog_removed` AS syslog $sql_where ) AS rowcount"); } elseif (get_request_var('removal') == -1) { - $total_rows = syslog_db_fetch_cell("SELECT COUNT(DISTINCT CONCAT(host_id, '|', message, '|', program_id, '|', facility_id, '|', priority_id)) + $total_rows = syslog_db_fetch_cell("SELECT $digest_count_key FROM `$syslogdb_default`.`syslog` AS syslog $sql_where"); } else { - $total_rows = syslog_db_fetch_cell("SELECT COUNT(DISTINCT CONCAT(host_id, '|', message, '|', program_id, '|', facility_id, '|', priority_id)) + $total_rows = syslog_db_fetch_cell("SELECT $digest_count_key FROM `$syslogdb_default`.`syslog_removed` AS syslog $sql_where"); } @@ -2527,11 +2545,29 @@ function syslog_messages(string $tab = 'syslog'): void { html_header_sort($display_text, get_request_var('sort_column'), get_request_var('sort_direction')); - $hosts = array_rekey( - syslog_db_fetch_assoc("SELECT host_id, host - FROM `$syslogdb_default`.`syslog_hosts`"), - 'host_id', 'host' - ); + // Only the host names for the rows actually shown; never fetch the + // entire syslog_hosts table into memory on every render. + $host_ids = []; + + foreach ($syslog_messages as $sm) { + $host_ids[$sm['host_id']] = true; + } + + unset($host_ids['']); + + if (cacti_sizeof($host_ids)) { + $bind = array_keys($host_ids); + + $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), '?')) . ")", + $bind), + 'host_id', 'host' + ); + } else { + $hosts = []; + } $facilities = array_rekey( syslog_db_fetch_assoc("SELECT facility_id, facility diff --git a/syslog_process.php b/syslog_process.php index f718014e..0f50c2ef 100644 --- a/syslog_process.php +++ b/syslog_process.php @@ -33,6 +33,13 @@ syslog_connect(); +// Add the message digest grouping columns and backfill a bounded slice of +// pre-upgrade rows each cycle. The archival statements write digests from +// the first run after the columns exist, so only the stored backlog is left. +require_once(__DIR__ . '/includes/schema.php'); +syslog_ensure_message_digest_columns(); +syslog_backfill_message_digest(); + /** * Let it run for an hour if it has to, to clear up any big * bursts of incoming syslog events diff --git a/tests/Unit/AlertThresholdWindowTest.php b/tests/Unit/AlertThresholdWindowTest.php index 7e415e84..0410bbb8 100644 --- a/tests/Unit/AlertThresholdWindowTest.php +++ b/tests/Unit/AlertThresholdWindowTest.php @@ -74,6 +74,8 @@ function threshold_rule_fixture(): array { syslog_load_plugin_source('includes/schema.php'); $columns = []; test_override('syslog_db_table_exists', static fn() => true); + // the digest migration hooks run inside the same repair pass + test_override('syslog_db_column_exists', static fn() => true); test_override('db_update_table', function ($table, $definition) use (&$columns) { if ($table === 'syslog_alert') { $columns = array_column($definition['columns'], 'name'); diff --git a/tests/Unit/MessageDigestGroupingTest.php b/tests/Unit/MessageDigestGroupingTest.php new file mode 100644 index 00000000..63247288 --- /dev/null +++ b/tests/Unit/MessageDigestGroupingTest.php @@ -0,0 +1,45 @@ +toContain("message_digest char(32) NOT NULL default ''") + ->toContain('function syslog_ensure_message_digest_columns(): void') + ->toContain("['syslog', 'syslog_removed'] as \$table") + ->toContain('syslog_ensure_message_digest_columns();'); +}); + +it('keys grouped viewer queries and counts by the digest, with a hash fallback', function () { + $syslog = plugin_test_read_source('syslog.php'); + + expect($syslog)->toContain('syslog_message_digest_ready()') + ->toContain('IF(syslog.message_digest = \'\', MD5(syslog.message), syslog.message_digest)') + ->toContain("IF(message_digest = '', MD5(message), message_digest)"); + + // the plain message group key survives only as the fallback branch of the + // ternary, never inside a query string + expect(substr_count($syslog, 'GROUP BY syslog.host_id, syslog.message'))->toBe(1); + expect($syslog)->toContain("\n\t\t\t\t: 'GROUP BY syslog.host_id, syslog.message"); +}); + +it('writes digests at every archived message entry point', function () { + $functions = plugin_test_read_source('includes/functions.php'); + $process = plugin_test_read_source('syslog_process.php'); + + expect($functions)->toContain('message, message_digest, threshold_breached') + ->toContain(', message_digest') + ->toContain('MD5(message)')->toContain('MD5(si.message)') + ->toContain("db_column_exists(\$table, 'message_digest', false, \$central)") + ->toContain('function syslog_backfill_message_digest') + ->toContain('function syslog_message_digest_ready'); + + expect($process)->toContain('syslog_ensure_message_digest_columns();') + ->toContain('syslog_backfill_message_digest();'); +}); \ No newline at end of file diff --git a/tests/Unit/MessageThresholdBreachTest.php b/tests/Unit/MessageThresholdBreachTest.php index 58f0ab97..3928636b 100644 --- a/tests/Unit/MessageThresholdBreachTest.php +++ b/tests/Unit/MessageThresholdBreachTest.php @@ -40,6 +40,8 @@ syslog_load_plugin_source('includes/schema.php'); $columns = []; test_override('syslog_db_table_exists', static fn() => true); + // the digest migration hooks run inside the same repair pass + test_override('syslog_db_column_exists', static fn() => true); test_override('db_update_table', function ($table) { // The general table reconciler may drop an omitted primary key. expect($table)->not->toBeIn(['syslog', 'syslog_removed', 'syslog_incoming', 'syslog_replication_output']); @@ -85,7 +87,7 @@ test_override('syslog_db_execute_prepared', function ($sql) use (&$statements) { $statements[] = $sql; return true; }); expect(syslog_incoming_to_syslog(100)['success'])->toBeTrue(); $archive = implode("\n", $statements); - expect($archive)->toContain('message, threshold_breached')->toContain('si.threshold_breached'); + expect($archive)->toContain('message, message_digest, threshold_breached')->toContain('si.threshold_breached'); $statements = []; $GLOBALS['config']['poller_id'] = 2; test_override('read_config_option', static fn($name) => $name === 'syslog_remote_enabled' ? 'on' : ''); diff --git a/tests/Unit/ThresholdRecoveryCommandTest.php b/tests/Unit/ThresholdRecoveryCommandTest.php index 21bae301..dcc67853 100644 --- a/tests/Unit/ThresholdRecoveryCommandTest.php +++ b/tests/Unit/ThresholdRecoveryCommandTest.php @@ -115,6 +115,8 @@ function threshold_command_context(string $command): array { it('repairs recovery storage without replacing historical message tables', function ($failed_table) { syslog_load_plugin_source('includes/schema.php'); test_override('syslog_db_table_exists', static fn() => true); + // the digest migration hooks run inside the same repair pass + test_override('syslog_db_column_exists', static fn() => true); test_override('syslog_db_add_column', static fn($table, $column) => !($table === $failed_table && $column['name'] === 'recovery_context')); expect(syslog_ensure_table_structures())->toBe($failed_table === 'none'); })->with(['syslog_threshold_state', 'none']); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index f4f5a267..868c8229 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -247,6 +247,12 @@ function syslog_db_add_column($table, $column, $log = true) { } } +if (!function_exists('syslog_db_column_exists')) { + function syslog_db_column_exists($table, $column, $log = true) { + return test_call_override('syslog_db_column_exists', [$table, $column, $log], db_column_exists($table, $column)); + } +} + if (!function_exists('syslog_db_table_exists')) { function syslog_db_table_exists($table, $log = true) { return test_call_override('syslog_db_table_exists', [$table, $log], db_table_exists($table)); From e99f49b7e11e987aca233cfb6ef8a629c6f14840 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 16 Oct 2026 11:55:18 -0400 Subject: [PATCH 04/15] Linting --- cli/syslog_schema.php | 16 +- includes/database.php | 28 +- includes/functions.php | 698 ++++++++++++++++-------- includes/installer.php | 10 +- includes/navigation.php | 4 +- includes/processing.php | 4 +- includes/schema.php | 33 +- includes/settings.php | 30 +- includes/utilities.php | 7 +- lib/QueryBuilder.php | 25 +- lib/syslog_dashboard.php | 98 ++-- lib/syslog_thresholds.php | 15 +- setup.php | 125 ++++- syslog.php | 238 ++++---- syslog_alerts.php | 211 +++---- syslog_batch_transfer.php | 2 +- syslog_dashboards.php | 61 ++- syslog_device_rules.php | 146 +++-- syslog_process.php | 10 +- syslog_recovery.php | 2 +- syslog_removal.php | 49 +- syslog_reports.php | 36 +- syslog_saved_searches.php | 59 +- tests/Security/CsrfPurgeTest.php | 14 + tests/Unit/DefaultDateReappliedTest.php | 4 +- tests/Unit/PipelineHealthTest.php | 17 + tests/Unit/RepeatIntervalTest.php | 18 + 27 files changed, 1241 insertions(+), 719 deletions(-) create mode 100644 tests/Unit/RepeatIntervalTest.php 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/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 0cff0cf5..42d1284c 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,69 @@ 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 +1859,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 +1874,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 +1981,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 +2124,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 +2181,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 +2302,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 +2461,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 +2650,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 +2725,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 +2737,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 +2891,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 +2940,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 +2948,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 +2956,12 @@ 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 +3046,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 +3054,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 +3064,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 +3100,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 +3115,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 +3153,7 @@ function syslog_removal_filter_normalize($json, $table) { } $rows = []; + foreach ($ids as $index => $id) { $rows[] = [ 'join' => $index === 0 ? 'AND' : 'OR', @@ -3108,9 +3208,10 @@ function syslog_removal_filter_normalize($json, $table) { * 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 */ @@ -3210,7 +3311,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 +3322,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 +3637,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 +3660,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 +3787,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 +3799,7 @@ function syslog_remove_items($table, $max_seq) { // for this joined INSERT separately. if ($insert_where == '') { syslog_db_execute('ROLLBACK'); + continue; } @@ -3728,6 +3831,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 +3845,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 +3870,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 +3885,7 @@ function syslog_remove_items($table, $max_seq) { } } } - } + } syslog_status_set('last_delete_rules_fired', syslog_status_rule_activity_json($fired)); @@ -3794,7 +3900,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 +3994,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 +4016,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 +4090,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 +4128,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 +4221,6 @@ function syslog_export($tab) { fputcsv($fp, $line, ',', '"', ''); } - } fclose($fp); @@ -4170,13 +4287,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 +4481,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 +4492,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 +4503,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 +4511,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 +4686,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 +4720,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 +4728,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 +4748,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 +4756,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 +4767,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 +4796,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 +4841,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 +4860,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 +4877,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 +4902,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 +4922,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 +4931,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 +4948,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 +4966,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 +4983,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 +5028,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 +5124,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 +5194,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 +5222,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 +5253,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 +5289,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 +5305,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 +5321,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 +5331,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 +5344,7 @@ function syslog_process_alerts($max_seq) { } $activity = []; + foreach ($fired as $name => $count) { $activity[] = ['name' => $name, 'count' => $count]; } @@ -5196,13 +5361,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 +5376,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 +5423,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 +5686,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 +5784,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 +5802,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; @@ -5648,9 +5822,9 @@ function syslog_get_alert_sql(&$alert, $max_seq, $processing_boundary = true, $c return []; } $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'] ]; @@ -5690,9 +5864,9 @@ function syslog_get_alert_sql(&$alert, $max_seq, $processing_boundary = true, $c return []; } $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'] ]; @@ -5791,9 +5965,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; @@ -5994,13 +6168,14 @@ function syslog_backfill_message_digest(int $rows_per_run = 50000): int { if ($rows_remaining == 0) { set_config_option($option . '_done', (string) $max_seq); + continue; } // 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); + $chunk = max($rows_per_run, 10000); while ($left_to_digest > 0 && $cursor < $max_seq) { $stop = min($cursor + $chunk, $max_seq); @@ -6237,6 +6412,7 @@ function syslog_replication_cleanup_local_history(array $events): bool { $clauses = []; $params = []; + foreach ($events as $event) { if (($event['disposition'] ?? '') !== 'syslog') { continue; @@ -6255,9 +6431,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 */ @@ -6289,6 +6465,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]; } @@ -6303,17 +6480,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'] ?? '', ]; } @@ -6380,6 +6558,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; } @@ -6445,6 +6624,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'; } @@ -6461,17 +6641,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') { @@ -6501,7 +6684,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; @@ -6518,8 +6701,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 @@ -6532,12 +6716,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]); } @@ -6563,34 +6749,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); } @@ -6610,23 +6805,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; } @@ -6642,15 +6842,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; } @@ -6723,6 +6924,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'); @@ -6738,18 +6940,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); @@ -6757,6 +6962,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; } @@ -6766,6 +6972,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; } } @@ -6775,17 +6982,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']; @@ -6795,14 +7005,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; } @@ -6845,7 +7058,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'); @@ -6856,6 +7069,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 { @@ -7257,14 +7471,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 */ @@ -7442,10 +7656,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 ''; } @@ -7456,24 +7671,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 +888,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..7a2b576e 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'); @@ -82,7 +82,7 @@ function syslog_poller_bottom(): void { 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'); 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..3cb5f1cf 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; @@ -817,7 +817,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) { @@ -1016,8 +1016,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); @@ -1062,11 +1062,11 @@ function syslog_dashboard_panel_save() { 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)) { @@ -1190,7 +1190,7 @@ function syslog_dashboard_panel_move(array $panel, int $dashboard_id, int $direc global $syslogdb_default; $panels = syslog_dashboard_panels($dashboard_id); - $ids = []; + $ids = []; foreach ($panels as $row) { $ids[] = (int) $row['id']; @@ -1225,7 +1225,7 @@ function syslog_dashboard_panel_reposition(array $panel, int $dashboard_id, int global $syslogdb_default; $panels = syslog_dashboard_panels($dashboard_id); - $ids = []; + $ids = []; foreach ($panels as $row) { if ((int) $row['id'] !== (int) $panel['id']) { @@ -1271,6 +1271,7 @@ function syslog_dashboard(): void { foreach ($dashboards as $dashboard) { if ($dashboard['user'] === $username) { $dashboard_id = (int) $dashboard['id']; + break; } } @@ -1280,6 +1281,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 +1330,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 +1387,7 @@ function syslog_dashboard(): void {
@@ -1403,37 +1405,37 @@ function syslog_dashboard(): void {
@@ -1480,7 +1482,7 @@ function syslog_dashboard(): void { print ''; } } - ?> + ?> '> @@ -1520,7 +1522,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/setup.php b/setup.php index ae120e31..eccddc7e 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' : ''; @@ -228,8 +229,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 +241,7 @@ function plugin_syslog_check_config(): bool { require_once(__DIR__ . '/includes/settings.php'); syslog_upgrade_hook_module_paths(); + return syslog_check_upgrade(); } @@ -273,10 +276,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']]; @@ -409,7 +414,6 @@ function syslog_connect(): bool { $connected = false; } } - } return $connected; @@ -421,16 +425,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 +455,6 @@ function syslog_upgrade_saved_search_realm(): void { } $template = $admin; } - } /** @@ -463,6 +468,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 +491,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 +534,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 +571,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 +588,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 +604,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 +695,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 +706,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 +722,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 +747,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 +762,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 +778,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 +799,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 +826,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 +848,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 +904,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 +932,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 +1076,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 +1087,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 +1102,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 +1133,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 +1143,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 +1168,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..7396bbf0 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,20 @@ 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 +726,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 +851,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 +866,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 +918,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 +933,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 +954,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 +967,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 +1008,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 +1022,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 +1032,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 +1061,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 +1090,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 +1106,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 +1118,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 +1151,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 +1159,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 +1250,7 @@ function syslog_status(): void { print '
'; print ''; print ''; + foreach ($page_refresh_interval as $seconds => $display_text) { print ''; } @@ -1242,6 +1272,7 @@ function syslog_status(): void { print '
'; print '
'; + foreach ($layout['order'] as $key) { print syslog_status_render_card($key, !empty($layout['expanded'][$key])); } @@ -1253,11 +1284,12 @@ function syslog_status(): void { print '
'; $catalog = []; + foreach (syslog_status_available_cards() as $key) { $catalog[$key] = $meta[$key]['title']; } - print ""; + print "'; } /** @@ -1423,7 +1455,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 +1491,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 +1514,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 +1539,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 +1579,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 +1588,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 +1602,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 +1629,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 +1714,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 +1771,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 +1786,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 +1804,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 +1896,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 +1957,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 +1965,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 +2145,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,7 +2190,7 @@ 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); } @@ -2177,7 +2226,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 +2255,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 +2710,6 @@ function syslog_messages(string $tab = 'syslog'): void { print $nav; } - print '
'; syslog_syslog_legend(); print '
'; @@ -2689,7 +2735,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 +2780,9 @@ 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 +2278,39 @@ function syslog_filter(string $sql_where, string $tab): void {
' title=''>
'> @@ -2293,16 +2342,16 @@ function syslog_filter(string $sql_where, string $tab): void {
@@ -2311,16 +2360,16 @@ function syslog_filter(string $sql_where, string $tab): void { @@ -2525,12 +2574,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 +2590,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 +2610,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 +2640,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 +2682,6 @@ function syslog_messages(string $tab = 'syslog'): void { foreach ($detail_messages as $dm) { print "
- - + +
' value=''> '>'>
'> '; + print "'; } /** @@ -2780,7 +2782,13 @@ function syslog_messages(string $tab = 'syslog'): void { ?>
- - + + + - +
' value=''>' value=''> '>'>
'>
@@ -288,10 +295,13 @@ function apply(clear) { var query = clear ? '' : new URLSearchParams({filter: fi } if (get_request_var('enabled') !== '-1') { - $sql_where .= ($sql_where === '' ? 'WHERE ' : ' AND ') . 'enabled ' . (get_request_var('enabled') == '1' ? "= 'on'" : "<> 'on'"); + $enabled = (get_request_var('enabled') == '1' ? "= 'on'" : "<> 'on'"); + $sql_where .= ($sql_where === '' ? 'WHERE ' : ' AND ') . 'enabled ' . $enabled; } - $rows = get_request_var('rows') == '-1' ? (int) read_config_option('num_rows_table') : (int) get_request_var('rows'); - $rows = max(1, $rows); + $rows = get_request_var('rows') == '-1' + ? (int) read_config_option('num_rows_table') + : (int) get_request_var('rows'); + $rows = max(1, $rows); $total_rows = (int) syslog_db_fetch_cell_prepared("SELECT COUNT(*) FROM `$syslogdb_default`.`syslog_device_rule` $sql_where", $sql_params); $page = max(1, (int) get_request_var('page')); $offset = ($page - 1) * $rows;