diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec352d5..48f4e5d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ --- develop --- +* feature: Add optional Alarm Manager forwarding per alert rule, retain Syslog Alarming as the default and fallback, and preserve threshold episode recovery behavior. + * issue#317: Expire partitions by their actual UTC upper boundaries instead of partition count, preserving cutoff-spanning partitions and supporting legacy TO_DAYS layouts. * issue#317: Defer automatic partition reorganization when overflow estimates exceed 100,000 rows or 128 MiB, or cannot be read; preserve the safety partition and report the reason through maintenance health. diff --git a/README.md b/README.md index a0bfbe1d..218d8810 100644 --- a/README.md +++ b/README.md @@ -551,3 +551,5 @@ nesting levels. Invalid searches return no results and CSV export rejects them. ----------------------------------------------- Copyright (c) 2004-2026 - The Cacti Group, Inc. + +Optional [Alarm Manager forwarding](docs/ALARM_MANAGER.md) uses Cacti native in-process hooks with existing Syslog email as fallback. diff --git a/docs/ALARM_MANAGER.md b/docs/ALARM_MANAGER.md new file mode 100644 index 00000000..e2b5285c --- /dev/null +++ b/docs/ALARM_MANAGER.md @@ -0,0 +1,66 @@ +# Alarm Manager forwarding + +In **Console → Configuration → Settings → Syslog**, enable **Forward alarms to +Alarm Manager**. It is off by default. Install and enable `plugin_alarmmanager` +on each processing collector, with matching enabled routing rules and delivery +destinations. Cacti's normal settings replication carries this setting; Syslog's +existing rule replication continues unchanged. No HTTP endpoint or credentials +are needed by Syslog. + +In each Alert Rule, select **Alarm Destination → Syslog Alarming** (the default) +or **Alarm Manager**. The selector appears only when Alarm Manager is installed. +The global forwarding setting must also be enabled for Alarm Manager rules. +Their Email Options and Actions sections are hidden, and ticket, alert, and recovery commands never +run, including when forwarding is unavailable. Configured email remains the +fallback. Removing or disabling Alarm Manager retains that email fallback. + +Syslog checks Cacti's `api_plugin_installed()` and `api_plugin_is_enabled()` and +the enabled `alarmmanager_submit` hook registration, then calls +`api_plugin_hook_function()` with contract version 1. Only an unchanged event +returned with `accepted` or `already_accepted` and a positive integer +`event_row_id` suppresses the corresponding email/SMS email. `unhandled`, +`failed` (including invalid-event rejection), exceptions, and malformed results +retain normal Syslog email. Acceptance means committed ingestion and processing +job storage, not successful routing or external delivery. Syslog Alarming rules retain their existing commands and email. Alarm history, +maintenance and suppression checks remain active for both destinations. + +Events include the rule, source message IDs, message text (bounded to 16 KiB), +hosts, severity, timestamp, count, and processing collector. Syslog host IDs are +not Cacti device IDs; they are sent as external identities with a null device +ID. Event identity uses the rule, collector and sorted source sequence/time +pairs. Retrying the same batch sends the same event; separate occurrences have +separate stream keys. Keep the Cacti base URL, Syslog database name, collector ID +and collector timezone stable across retries. Failures are logged by rule and +safe reason code without message or provider exception contents. + +Windowed threshold events use their existing breach key to correlate raises +and recovery. Only accepted raises enable forwarding of their quiet-period +clear. The existing collector-owned recovery queue retains clear timestamps and +retries failed submissions. Alarm Manager recovery does not run commands. +Occurrence alarms and thresholds without breach state send occurrences +and never invent clears. Replicated history and foreign-owned breaches cannot +submit events. Alarm Manager owns its own native queue/history replication. + +Raise forwarding is synchronous at the existing notification boundary; this +change does not add a crash-durable raise outbox. The local recovery-ownership +write follows Alarm Manager's ingestion commit; a crash or failed local write +between them can leave an accepted raise without a forwarded clear (logged when +the write fails). Existing Syslog notification retry semantics otherwise apply. +Disabling forwarding, deleting/disabling/editing a rule, or changing its recovery +command can cancel queued recovery under the existing lifecycle rules. + +Local checks: + +```sh +node tests/regression/alarm_destination_ui_test.js +php tests/regression/alarmmanager_forward_test.php +SYSLOG_TEST_MYSQL_SOCKET=/path/to/disposable/mysql.sock php tests/regression/threshold_recovery_database_test.php +php tests/regression/threshold_alert_host_test.php +``` + +The forwarding tests use Alarm Manager's real event validator from the sibling +checkout (an alternate validator path can be passed as the first argument to +`alarmmanager_forward_test.php`). Ingestion and mail delivery are stubbed. The +MariaDB test creates and drops randomly named test databases and exercises real +Syslog recovery SQL and local fixture commands. Neither test verifies live +remote collectors, Alarm Manager ingestion commits, routing, or provider delivery. diff --git a/includes/alarmmanager.php b/includes/alarmmanager.php new file mode 100644 index 00000000..de067123 --- /dev/null +++ b/includes/alarmmanager.php @@ -0,0 +1,86 @@ + 0 && (int) $match['replication_source_poller_id'] !== $collector) { + throw new RuntimeException('foreign_collector'); + } + if (empty($match['seq']) || empty($match['logtime'])) { + throw new RuntimeException('source_identity_missing'); + } + } + usort($matches, static fn($a, $b) => (int) $a['seq'] <=> (int) $b['seq']); + $first = $matches[0]; + $identity = hash('sha256', json_encode([$alert['id'], $collector, array_map(static fn($m) => [$m['seq'], $m['logtime']], $matches)], JSON_THROW_ON_ERROR)); + $episode = $state['breach_key'] ?? $identity; + $transition = $recovered ? 'recovered' : ($state ? 'raised' : 'occurrence'); + $time = $recovered ? (new DateTimeImmutable('@' . $cleared_at)) : new DateTimeImmutable($first['logtime']); + $event = [ + 'event_id' => $recovered ? hash('sha256', $episode . ':clear') : $identity, + 'source' => 'syslog', + 'source_instance' => hash('sha256', read_config_option('base_url') . ':' . $syslogdb_default . ':' . $collector), + 'resource_id' => 'rule:' . $alert['id'], + // Independent episodes must not share a fixed-sequence stream. + 'alarm_key' => 'rule:' . $alert['id'] . ':' . $episode, + 'episode_id' => $episode, + 'sequence' => $recovered ? PHP_INT_MAX : (int) $first['seq'], + 'transition' => $transition, + 'severity' => $recovered ? 'clear' : ([0 => 'info', 1 => 'warning', 2 => 'critical'][(int) $alert['severity']] ?? 'warning'), + 'occurred_at' => $time->format('Y-m-d\TH:i:sP'), + 'collector_id' => $collector, + // Syslog host IDs are not Cacti device IDs. + 'device' => ['id' => null, 'hostname' => substr((string) $first['host'], 0, 255), 'external_id' => 'syslog:' . ($first['host_id'] ?? $first['host'])], + 'summary' => substr((string) $alert['name'], 0, 512), + 'details' => substr(implode("\n", array_map(static fn($m) => $m['host'] . ' ' . $m['logtime'] . ' #' . $m['seq'] . ' [priority ' . ($m['priority_id'] ?? '') . ', facility ' . ($m['facility_id'] ?? '') . ']: ' . $m['message'], $matches)), 0, 16384), + 'context' => ['rule_name' => substr((string) $alert['name'], 0, 512), 'rule_type' => (string) $alert['method'], 'metric_value' => (string) $count, 'threshold' => (string) ($alert['num'] ?? 1), 'source_notification_channels' => ['email']] + ]; + $envelope = ['contract_version' => 1, 'event' => $event]; + $result = api_plugin_hook_function('alarmmanager_submit', $envelope); + if (is_array($result) && in_array($result['status'] ?? null, ['accepted', 'already_accepted'], true) + && is_int($result['event_row_id'] ?? null) && $result['event_row_id'] > 0 + && ($result['event'] ?? null) === $event) { + if (!$recovered && $state) { + try { + if (!syslog_db_execute_prepared("UPDATE syslog_threshold_state SET recovery_context = JSON_SET(recovery_context, '$.alarmmanager_accepted', true) WHERE source_poller_id = ? AND breach_key = ?", [$collector, $state['breach_key']])) { + throw new RuntimeException('recovery_context_write_failed'); + } + } catch (Throwable $error) { + // Acceptance is already committed; a local failure must not resend email. + cacti_log('SYSLOG: Unable to retain Alarm Manager recovery ownership for rule ' . (int) $alert['id'], false, 'SYSLOG'); + } + } + return true; + } + throw new RuntimeException(is_array($result) && in_array($result['status'] ?? null, ['failed', 'rejected', 'unhandled'], true) ? 'submission_' . $result['status'] : 'submission_not_accepted'); + } catch (Throwable $error) { + // Never log provider exceptions or message contents. + $reason = in_array($error->getMessage(), ['plugin_unavailable', 'hook_unavailable', 'invalid_owner_or_event', 'foreign_collector', 'source_identity_missing', 'submission_not_accepted', 'submission_failed', 'submission_rejected', 'submission_unhandled'], true) ? $error->getMessage() : 'submission_exception'; + cacti_log('SYSLOG: Alarm Manager ' . $reason . ' for rule ' . (int) $alert['id'] . '; retaining Syslog notification', false, 'SYSLOG'); + return false; + } +} diff --git a/includes/functions.php b/includes/functions.php index bbbba4ce..3c442f70 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -22,6 +22,8 @@ +-------------------------------------------------------------------------+ */ +require_once(__DIR__ . '/alarmmanager.php'); + $syslog_query_builder = dirname(__DIR__) . '/lib/QueryBuilder.php'; if (file_exists($syslog_query_builder)) { @@ -4686,6 +4688,9 @@ function syslog_array2xml(array $array, string $tag = 'template'): string { * @return void */ function syslog_execute_ticket_command($alert, $hostlist, $error_message) { + if (syslog_alarmmanager_selected($alert)) { + return; + } $command = read_config_option('syslog_ticket_command'); if ($command != '') { @@ -4731,6 +4736,9 @@ function syslog_execute_ticket_command($alert, $hostlist, $error_message) { * @return bool True when the command exits successfully. */ function syslog_execute_alert_command($alert, $results, $hostname, string $event = 'breach'): bool { + if (syslog_alarmmanager_selected($alert)) { + return true; + } /* alert_replace_variables() escapes each substituted token (, * , , , , ) with * cacti_escapeshellarg(). The command template itself comes from admin @@ -4998,7 +5006,9 @@ function syslog_alert_window_seconds(array $alert): int { /** Hash the rule settings that define a threshold episode. */ function syslog_threshold_rule_hash(array $alert): string { - return sha1(json_encode(array_intersect_key($alert, array_flip(['type', 'message', 'level', 'num', 'threshold_window', 'threshold_unit', 'threshold_recovery_minutes', 'threshold_recovery_unit', 'recovery_command', 'date'])))); + $settings = array_intersect_key($alert, array_flip(['type', 'message', 'level', 'num', 'threshold_window', 'threshold_unit', 'threshold_recovery_minutes', 'threshold_recovery_unit', 'recovery_command', 'alarm_destination', 'date'])); + + return substr(hash('sha256', json_encode($settings)), 0, 40); } /** Recover quiet breaches without scanning or rewriting message history. */ @@ -5013,10 +5023,10 @@ function syslog_threshold_recover(?int $now = null): void { } $queries = [ ["INSERT INTO syslog_threshold_recovery_queue (source_poller_id, breach_key, alert_id, rule_date, context, available_at) - SELECT s.source_poller_id, s.breach_key, s.alert_id, s.rule_date, s.recovery_context, ? + SELECT s.source_poller_id, s.breach_key, s.alert_id, s.rule_date, JSON_SET(s.recovery_context, '$.cleared_at', s.expires_at), ? FROM syslog_threshold_state AS s JOIN syslog_alert AS a ON a.id = s.alert_id WHERE s.source_poller_id = ? AND s.expires_at <= ? AND a.enabled = 'on' AND a.method = 1 - AND s.rule_date = a.date AND s.recovery_context IS NOT NULL AND COALESCE(a.recovery_command, '') <> '' + AND s.rule_date = a.date AND s.recovery_context IS NOT NULL AND ((a.alarm_destination = 'syslog' AND COALESCE(a.recovery_command, '') <> '') OR (a.alarm_destination = 'alarmmanager' AND '" . (read_config_option('syslog_alarmmanager_enabled') === 'on' ? 'on' : '') . "' = 'on')) ON DUPLICATE KEY UPDATE breach_key = VALUES(breach_key)", [$now, $source, $now]], ["DELETE c FROM syslog_alert_counts AS c JOIN syslog_threshold_state AS s ON s.alert_id = c.alert_id AND s.scope_key = c.scope_key LEFT JOIN syslog_alert AS a ON a.id = s.alert_id WHERE s.source_poller_id = ? AND $ended", [$source, $now]], ["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]], @@ -5049,7 +5059,7 @@ function syslog_threshold_deliver_recoveries(?int $now = null): void { 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])) { + 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 ((a.alarm_destination = 'alarmmanager' OR COALESCE(a.recovery_command, '') = '') AND (a.alarm_destination <> 'alarmmanager' OR '" . (read_config_option('syslog_alarmmanager_enabled') === 'on' ? 'on' : '') . "' <> 'on')))", [$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]); @@ -5062,9 +5072,13 @@ function syslog_threshold_deliver_recoveries(?int $now = null): void { && 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'])) { + $forwarded = true; + if (syslog_alarmmanager_selected($context['alert']) && !empty($context['alarmmanager_accepted']) && isset($context['alarmmanager_matches'], $context['cleared_at']) && read_config_option('syslog_alarmmanager_enabled') === 'on') { + $forwarded = syslog_alarmmanager_forward($context['alert'], $context['alarmmanager_matches'], 0, true, (int) $context['cleared_at']); + } alert_setup_environment($context['alert'], $context['results'], $context['hostlist'], $context['hostname']); - - if (syslog_execute_alert_command($context['alert'], $context['results'], $context['hostname'], 'clear')) { + $command_done = trim($context['alert']['command']) === '' || syslog_execute_alert_command($context['alert'], $context['results'], $context['hostname'], 'clear'); + if ($command_done && $forwarded) { syslog_db_execute_prepared('DELETE FROM syslog_threshold_recovery_queue WHERE source_poller_id = ? AND breach_key = ?', [$source, $row['breach_key']]); continue; @@ -5416,11 +5430,12 @@ function syslog_mark_threshold_messages(array $alert, int $count, array $matches return true; } - if ($matches && !empty($alert['_threshold_state']) && trim($alert['recovery_command'] ?? '') !== '') { - $state = $alert['_threshold_state']; + if ($matches && !empty($alert['_threshold_state']) && (syslog_alarmmanager_selected($alert) ? read_config_option('syslog_alarmmanager_enabled') === 'on' : trim($alert['recovery_command'] ?? '') !== '')) { + $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'], 'alarm_destination' => $alert['alarm_destination'] ?? 'syslog', 'method' => $alert['method'], 'num' => $alert['num'], 'command' => $alert['recovery_command'] ?? '', '_threshold_state' => $state], + 'results' => ['priority_id' => $matches[0]['priority_id'], 'facility_id' => $matches[0]['facility_id'], 'message' => $matches[0]['message']], + 'alarmmanager_matches' => [$matches[0]], '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); @@ -5783,7 +5798,7 @@ function syslog_process_alert($alert, $sql, $params, $count, $hostname = '', $at /** * Send the Email notification */ - if ($alert['email'] != '' || $smsalert != '') { + if (!syslog_alarmmanager_forward($alert, $at, (int) $count) && ($alert['email'] != '' || $smsalert != '')) { syslog_sendemail(trim($alert['email']), $from, __esc('Event Alert - %s', $alert['name'], 'syslog'), $message, $smsalert); } @@ -5808,7 +5823,7 @@ function syslog_process_alert($alert, $sql, $params, $count, $hostname = '', $at * so a threshold alert emails out only when a recipient * is configured. */ - if ($alert['email'] != '') { + if (!syslog_alarmmanager_forward($alert, $at, (int) $count) && $alert['email'] != '') { syslog_sendemail(trim($alert['email']), $from, __esc('Event Alert - %s', $alert['name'], 'syslog'), $message, $smsalert); } diff --git a/includes/schema.php b/includes/schema.php index a415f6a6..a14beb2b 100644 --- a/includes/schema.php +++ b/includes/schema.php @@ -573,6 +573,7 @@ function syslog_setup_table_new(array $options, bool $repair = false): void { `maintenance_end` char(5) NOT NULL default '00:00', `maintenance_datetime_start` varchar(16) NOT NULL default '', `maintenance_datetime_end` varchar(16) NOT NULL default '', + `alarm_destination` varchar(16) NOT NULL default 'syslog', `open_ticket` CHAR(2) default '', `message` TEXT NOT NULL, `body` VARCHAR(8192) NOT NULL default '', @@ -914,6 +915,7 @@ function syslog_ensure_table_structures(): bool { ['name' => 'threshold_unit', 'type' => 'varchar(6)', 'NULL' => false, 'default' => 'minute', 'after' => 'threshold_window'], ['name' => 'threshold_recovery_minutes', 'type' => 'int(10)', 'unsigned' => true, 'NULL' => false, 'default' => '5', 'after' => 'threshold_unit'], ['name' => 'threshold_recovery_unit', 'type' => 'varchar(6)', 'NULL' => false, 'default' => 'minute', 'after' => 'threshold_recovery_minutes'], + ['name' => 'alarm_destination', 'type' => 'varchar(16)', 'NULL' => false, 'default' => 'syslog'], ['name' => 'recovery_command', 'type' => 'varchar(255)', 'NULL' => true] ], 'primary' => ['id']], false, true, $syslog_cnn) === false) { return false; diff --git a/includes/settings.php b/includes/settings.php index 724c4119..0f4a904c 100644 --- a/includes/settings.php +++ b/includes/settings.php @@ -72,6 +72,12 @@ function syslog_config_settings(): void { 'method' => 'checkbox', 'default' => 'on' ], + 'syslog_alarmmanager_enabled' => [ + 'friendly_name' => __('Forward alarms to Alarm Manager', 'syslog'), + 'description' => __('Allow rules set to Alarm Manager to forward alarms. Syslog email remains the fallback; command actions run only for Syslog Alarming rules.', 'syslog'), + 'method' => 'checkbox', + 'default' => '' + ], 'syslog_domains' => [ 'friendly_name' => __('Strip Domains', 'syslog'), 'description' => __('A comma delimited list of domains that you wish to remove from the syslog hostname, Examples would be \'mydomain.com, otherdomain.com\'', 'syslog'), diff --git a/js/alarm-destination.js b/js/alarm-destination.js new file mode 100644 index 00000000..230f838b --- /dev/null +++ b/js/alarm-destination.js @@ -0,0 +1,13 @@ +/** Keep native rule controls aligned with the selected notification destination. */ +function syslogAlertDestinationVisibility($, notifyExists) { + var threshold = $('#report_method').val() == '1'; + var syslogAlarming = $('#alarm_destination').val() !== 'alarmmanager'; + $('#row_num, #row_threshold_window, #row_threshold_recovery_minutes').toggle(threshold); + $('#row_spacer1, #row_open_ticket, #row_command, #row_header_email, #row_email, #row_body').toggle(syslogAlarming); + $('#row_notify').toggle(syslogAlarming && notifyExists); + $('#row_recovery_command').toggle(threshold && syslogAlarming); +} + +if (typeof module !== 'undefined') { + module.exports = syslogAlertDestinationVisibility; +} diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 917946ea..8304bd27 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,10 +8,10 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.2.32\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-16 14:29-0400\n" +"POT-Creation-Date: 2026-10-26 13:53-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Cacti Developers \n" -"Language-Team: Cacti Developers \n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -184,6 +184,15 @@ msgstr "" msgid "partition creation failed" msgstr "" +#: includes/functions.php +msgid "Automatic partition recovery deferred: dMaxValue size is unavailable; operator intervention required." +msgstr "" + +#: includes/functions.php +#, php-format +msgid "Automatic partition recovery deferred: dMaxValue has an estimated %s rows and %s bytes; limits are 100000 rows and 134217728 bytes. Operator intervention required." +msgstr "" + #: includes/functions.php msgid "Unknown rule type." msgstr "" @@ -633,6 +642,14 @@ msgstr "" msgid "If this checkbox is set, records will be transferred from the Syslog Incoming table to the main syslog table and Alerts and Reports will be enabled. Please keep in mind that if the system is disabled log entries will still accumulate into the Syslog Incoming table as this is defined by the rsyslog or syslog-ng process." msgstr "" +#: includes/settings.php +msgid "Forward alarms to Alarm Manager" +msgstr "" + +#: includes/settings.php +msgid "Allow rules set to Alarm Manager to forward alarms. Syslog email remains the fallback; command actions run only for Syslog Alarming rules." +msgstr "" + #: includes/settings.php msgid "Strip Domains" msgstr "" @@ -2354,6 +2371,10 @@ msgstr "" msgid "Export Syslog Alert Rule(s)" msgstr "" +#: syslog_alerts.php +msgid "Select Syslog Alarming or Alarm Manager." +msgstr "" + #: syslog_alerts.php msgid "Enter a whole number of minutes, hours, or days for the threshold window." msgstr "" @@ -2439,6 +2460,22 @@ msgstr "" msgid "Please describe this Alert." msgstr "" +#: syslog_alerts.php +msgid "Alarm Destination" +msgstr "" + +#: syslog_alerts.php +msgid "Choose Syslog Alarming or Alarm Manager. Alarm Manager also requires forwarding enabled in Syslog settings. Email is retained as fallback; command actions do not run for Alarm Manager rules." +msgstr "" + +#: syslog_alerts.php +msgid "Alarm Manager" +msgstr "" + +#: syslog_alerts.php +msgid "Syslog Alarming" +msgstr "" + #: syslog_alerts.php msgid "What is the Severity Level of this Alert?" msgstr "" @@ -2809,6 +2846,10 @@ msgstr "" msgid "Import rejected: the file is empty, invalid, or contains a different type of Syslog object." msgstr "" +#: syslog_alerts.php +msgid "Import rejected: invalid alarm destination." +msgstr "" + #: syslog_alerts.php syslog_removal.php syslog_reports.php msgid "Imported" msgstr "" @@ -3509,12 +3550,3 @@ msgstr "" #: syslog_saved_searches.php msgid "Choose a field, operator, and value. Use AND, OR, NOT, or Group to build the query. Changes apply to this shared template for all users." msgstr "" - -#: includes/functions.php -msgid "Automatic partition recovery deferred: dMaxValue size is unavailable; operator intervention required." -msgstr "" - -#: includes/functions.php -#, php-format -msgid "Automatic partition recovery deferred: dMaxValue has an estimated %s rows and %s bytes; limits are 100000 rows and 134217728 bytes. Operator intervention required." -msgstr "" diff --git a/phpunit.xml b/phpunit.xml index 69ef371f..585da6ea 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -29,6 +29,7 @@ includes/database.php includes/functions.php + includes/alarmmanager.php lib/QueryBuilder.php lib/syslog_dashboard.php lib/syslog_thresholds.php diff --git a/syslog_alerts.php b/syslog_alerts.php index 62d5bb4c..16d5626e 100644 --- a/syslog_alerts.php +++ b/syslog_alerts.php @@ -117,7 +117,7 @@ function form_save(): void { get_nfilter_request_var('maintenance_datetime_start'), get_nfilter_request_var('maintenance_datetime_end'), get_nfilter_request_var('threshold_window'), get_nfilter_request_var('threshold_unit'), get_nfilter_request_var('threshold_recovery_minutes'), get_nfilter_request_var('threshold_recovery_unit') ?: 'minute', - get_nfilter_request_var('recovery_command')); + get_nfilter_request_var('recovery_command'), get_nfilter_request_var('alarm_destination') ?: 'syslog'); if ((is_error_message()) || (get_filter_request_var('id') != get_filter_request_var('_id')) || $alertid === false) { header('Location: syslog_alerts.php?header=false&action=edit&id=' . (empty($alertid) ? get_filter_request_var('id') : $alertid)); @@ -340,7 +340,7 @@ function api_syslog_alert_save($id, $name, $method, $level, $num, $type, $messag $enabled, $severity, $command, $repeat_alert, $open_ticket, $notify = 0, $body = '', $cooldown_minutes = 0, $deduplication_minutes = 0, $maintenance_mode = 'inherit', $maintenance_days = '1,2,3,4,5', $maintenance_start = '00:00', $maintenance_end = '00:00', $maintenance_datetime_start = '', - $maintenance_datetime_end = '', $threshold_window = 0, $threshold_unit = 'minute', $threshold_recovery_minutes = 5, $threshold_recovery_unit = 'minute', $recovery_command = '') { + $maintenance_datetime_end = '', $threshold_window = 0, $threshold_unit = 'minute', $threshold_recovery_minutes = 5, $threshold_recovery_unit = 'minute', $recovery_command = '', $alarm_destination = 'syslog') { global $syslogdb_default; // get the username @@ -352,6 +352,12 @@ function api_syslog_alert_save($id, $name, $method, $level, $num, $type, $messag $save['id'] = ''; } + if (!in_array($alarm_destination, ['syslog', 'alarmmanager'], true)) { + raise_message('alarm_destination', __('Select Syslog Alarming or Alarm Manager.', 'syslog'), MESSAGE_LEVEL_ERROR); + return false; + } + $save['alarm_destination'] = $alarm_destination; + $save['hash'] = get_hash_syslog($save['id'], 'syslog_alert'); $save['name'] = form_input_validate($name, 'name', '', false, 3); @@ -588,7 +594,7 @@ function get_repeat_array(): array { * @return void */ function syslog_action_edit(): void { - global $message_types, $severities; + global $message_types, $severities, $config; global $syslogdb_default; // ================= input validation ================= @@ -713,6 +719,14 @@ function syslog_action_edit(): void { 'max_length' => '250', 'size' => 80 ], + 'alarm_destination' => [ + 'method' => api_plugin_installed('alarmmanager') ? 'drop_array' : 'hidden', + 'friendly_name' => __('Alarm Destination', 'syslog'), + 'description' => __('Choose Syslog Alarming or Alarm Manager. Alarm Manager also requires forwarding enabled in Syslog settings. Email is retained as fallback; command actions do not run for Alarm Manager rules.', 'syslog'), + 'value' => '|arg1:alarm_destination|', + 'array' => ['syslog' => __('Syslog Alarming', 'syslog'), 'alarmmanager' => __('Alarm Manager', 'syslog')], + 'default' => 'syslog' + ], 'severity' => [ 'method' => 'drop_array', 'friendly_name' => __('Severity', 'syslog'), @@ -968,6 +982,7 @@ function syslog_action_edit(): void { } ?> + '; +print ''; diff --git a/tests/Security/CsrfPurgeTest.php b/tests/Security/CsrfPurgeTest.php index 7f380429..62303abe 100644 --- a/tests/Security/CsrfPurgeTest.php +++ b/tests/Security/CsrfPurgeTest.php @@ -59,6 +59,7 @@ $staged = [ $root . '/setup.php' => $sandbox . '/setup.php', $root . '/includes/functions.php' => $sandbox . '/includes/functions.php', + $root . '/includes/alarmmanager.php' => $sandbox . '/includes/alarmmanager.php', $root . '/includes/schema.php' => $sandbox . '/includes/schema.php', $root . '/includes/processing.php' => $sandbox . '/includes/processing.php', $root . '/includes/settings.php' => $sandbox . '/includes/settings.php', diff --git a/tests/Unit/AlarmManagerForwardingTest.php b/tests/Unit/AlarmManagerForwardingTest.php new file mode 100644 index 00000000..5763d7f1 --- /dev/null +++ b/tests/Unit/AlarmManagerForwardingTest.php @@ -0,0 +1,189 @@ + $name === 'syslog_alarmmanager_enabled' ? 'on' : 'https://cacti.example/'); + test_override('api_plugin_installed', static fn() => true); + test_override('api_plugin_is_enabled', static fn() => true); + test_override('db_fetch_cell_prepared', static fn() => 1); + test_override('api_plugin_hook_function', static function ($hook, $envelope) { + expect($hook)->toBe('alarmmanager_submit'); + $GLOBALS['alarmmanager_test_events'][] = $envelope['event']; + return $envelope + ['status' => 'accepted', 'event_row_id' => 42]; + }); +}); + +function alarmmanager_test_rule(): array { + return ['id' => 7, 'name' => 'Rule', 'method' => 1, 'num' => 5, 'severity' => 2, 'alarm_destination' => 'alarmmanager']; +} + +function alarmmanager_test_matches(): array { + return [['seq' => 9, 'logtime' => '2026-10-26 12:00:00', 'host' => 'router', 'message' => 'fixture', 'priority_id' => 3, 'facility_id' => 1]]; +} + +it('retains Syslog Alarming for existing rules and the disabled master setting', function () { + $rule = alarmmanager_test_rule(); + unset($rule['alarm_destination']); + expect(syslog_alarmmanager_forward($rule, alarmmanager_test_matches(), 1))->toBeFalse(); + test_override('read_config_option', static fn() => ''); + expect(syslog_alarmmanager_forward(alarmmanager_test_rule(), alarmmanager_test_matches(), 1))->toBeFalse(); + expect($GLOBALS['alarmmanager_test_events'])->toBe([]); +}); + +it('keeps fallback when a native availability check fails', function ($api) { + test_override($api, static fn() => false); + expect(syslog_alarmmanager_forward(alarmmanager_test_rule(), alarmmanager_test_matches(), 1))->toBeFalse(); + expect($GLOBALS['alarmmanager_test_events'])->toBe([]); +})->with(['api_plugin_installed', 'api_plugin_is_enabled', 'db_fetch_cell_prepared']); + +it('rejects missing source identity and foreign collector ownership', function ($case) { + $rule = alarmmanager_test_rule(); + $matches = alarmmanager_test_matches(); + if ($case === 'collector') { $GLOBALS['config']['poller_id'] = 0; } + if ($case === 'breach') { $rule['_threshold_state'] = ['source_poller_id' => 1]; } + if ($case === 'replica') { $matches[0]['replication_source_poller_id'] = 1; } + if ($case === 'identity') { $matches[0]['seq'] = 0; } + if ($case === 'empty') { $matches = []; } + expect(syslog_alarmmanager_forward($rule, $matches, 1))->toBeFalse(); + expect($GLOBALS['alarmmanager_test_events'])->toBe([]); +})->with(['collector', 'breach', 'replica', 'identity', 'empty']); + +it('requires a complete explicit durable acceptance result', function ($status, $row, $expected) { + test_override('api_plugin_hook_function', static fn($hook, $envelope) => $envelope + ['status' => $status, 'event_row_id' => $row]); + expect(syslog_alarmmanager_forward(alarmmanager_test_rule(), alarmmanager_test_matches(), 1))->toBe($expected); +})->with([ + ['accepted', 42, true], ['already_accepted', 42, true], ['rejected', null, false], + ['failed', null, false], ['unhandled', null, false], ['accepted', '42', false], ['accepted', 0, false] +]); + +it('rejects truthy and altered hook results', function ($altered) { + test_override('api_plugin_hook_function', static function ($hook, $envelope) use ($altered) { + if (!$altered) { return true; } + $envelope['event']['summary'] = 'changed'; + return $envelope + ['status' => 'accepted', 'event_row_id' => 42]; + }); + expect(syslog_alarmmanager_forward(alarmmanager_test_rule(), alarmmanager_test_matches(), 1))->toBeFalse(); +})->with([false, true]); + +it('handles hook exceptions and invalid timestamps without logging sensitive contents', function ($bad_time) { + $matches = alarmmanager_test_matches(); + if ($bad_time) { $matches[0]['logtime'] = 'invalid timestamp'; } + test_override('api_plugin_hook_function', static fn() => throw new RuntimeException('sensitive provider token')); + $logs = []; + test_override('cacti_log', function ($message) use (&$logs) { $logs[] = $message; }); + expect(syslog_alarmmanager_forward(alarmmanager_test_rule(), $matches, 1))->toBeFalse(); + expect(implode(' ', $logs))->not->toContain('sensitive provider token'); +})->with([false, true]); + +it('keeps retry payloads stable and gives independent occurrences independent streams', function () { + $rule = alarmmanager_test_rule(); + $matches = alarmmanager_test_matches(); + expect(syslog_alarmmanager_forward($rule, $matches, 1))->toBeTrue(); + expect(syslog_alarmmanager_forward($rule, $matches, 1))->toBeTrue(); + $matches[0]['seq']++; + expect(syslog_alarmmanager_forward($rule, $matches, 1))->toBeTrue(); + [$first, $retry, $next] = $GLOBALS['alarmmanager_test_events']; + expect($retry)->toBe($first); + expect($first['event_id'])->not->toBe($next['event_id']); + expect($first['alarm_key'])->not->toBe($next['alarm_key']); +}); + +it('correlates threshold recovery and records acceptance without resending on local failure', function ($write) { + $rule = alarmmanager_test_rule(); + $rule['_threshold_state'] = ['source_poller_id' => 2, 'breach_key' => str_repeat('a', 40)]; + test_override('syslog_db_execute_prepared', static function () use ($write) { + if ($write === 'throws') { throw new RuntimeException('private database detail'); } + return $write; + }); + expect(syslog_alarmmanager_forward($rule, alarmmanager_test_matches(), 5))->toBeTrue(); + expect(syslog_alarmmanager_forward($rule, alarmmanager_test_matches(), 0, true, 1793010000))->toBeTrue(); + [$raise, $clear] = $GLOBALS['alarmmanager_test_events']; + expect($clear['episode_id'])->toBe($raise['episode_id']); + expect($clear['transition'])->toBe('recovered'); + expect($clear['sequence'])->toBeGreaterThan($raise['sequence']); +})->with([true, false, 'throws']); + +it('refuses a recovery without a breach and clear timestamp', function () { + expect(syslog_alarmmanager_forward(alarmmanager_test_rule(), alarmmanager_test_matches(), 0, true))->toBeFalse(); +}); + +it('does not execute hidden command actions for Alarm Manager rules', function () { + $rule = alarmmanager_test_rule() + ['command' => '/bin/false', 'open_ticket' => 'on']; + expect(syslog_execute_alert_command($rule, [], 'router'))->toBeTrue(); + syslog_execute_ticket_command($rule, [], 'failure'); + expect($GLOBALS['alarmmanager_test_events'])->toBe([]); +}); + +it('persists validated destinations through the existing page API', function ($destination) { + $save = syslog_test_page_function('syslog_alerts.php', 'api_syslog_alert_save'); + $_SESSION['sess_user_id'] = 1; + test_override('syslog_db_fetch_cell_prepared', static fn() => str_repeat('a', 32)); + test_override('syslog_db_fetch_assoc_prepared', static fn() => []); + $saved = []; + test_override('sql_save', function ($row) use (&$saved) { $saved = $row; return 7; }); + $args = [7, 'Rule', 1, 1, 5, 'messagec', 'error', '', '', 'on', 1, '', 0, '', 0, '', 0, 0, 'disabled', '1', '00:00', '00:00', '', '', 0, 'minute', 5, 'minute', '']; + if ($destination !== null) { $args[] = $destination; } + $result = $save(...$args); + if ($destination === 'invalid') { + expect($result)->toBeFalse(); + expect($saved)->toBe([]); + } else { + expect($result)->toBeNull(); + expect($saved['alarm_destination'])->toBe($destination ?? 'syslog'); + } +})->with(['syslog', 'alarmmanager', 'invalid', null]); + +it('uses a stable schema-width change fingerprint including the destination', function () { + $rule = alarmmanager_test_rule(); + $hash = syslog_threshold_rule_hash($rule); + expect(strlen($hash))->toBe(40); + expect(syslog_threshold_rule_hash($rule))->toBe($hash); + $rule['alarm_destination'] = 'syslog'; + expect(syslog_threshold_rule_hash($rule))->not->toBe($hash); +}); + +it('forwards a queued threshold recovery to Alarm Manager and completes the queue entry', function () { + $GLOBALS['severities'] = [2 => 'Warning']; + $GLOBALS['syslog_levels'] = [3 => 'Error']; + $GLOBALS['syslog_facilities'] = [1 => 'User']; + $rule = alarmmanager_test_rule() + ['message' => 'fixture', 'command' => '', 'severity' => 2, + '_threshold_state' => ['source_poller_id' => 2, 'breach_key' => str_repeat('a', 40)]]; + $context = json_encode([ + 'alert' => $rule, 'results' => alarmmanager_test_matches()[0], 'hostname' => 'router', 'hostlist' => ['router'], + 'alarmmanager_accepted' => true, 'alarmmanager_matches' => alarmmanager_test_matches(), 'cleared_at' => 1793010000 + ]); + $writes = []; + test_override('syslog_db_fetch_cell_prepared', static fn() => 1); + test_override('syslog_db_fetch_assoc_prepared', static fn() => [['breach_key' => str_repeat('a', 40), 'context' => $context, 'attempts' => 0]]); + test_override('syslog_db_execute_prepared', function ($sql) use (&$writes) { $writes[] = $sql; return true; }); + syslog_threshold_deliver_recoveries(1000); + expect($GLOBALS['alarmmanager_test_events'][0]['transition'])->toBe('recovered'); + expect(end($writes))->toStartWith('DELETE FROM syslog_threshold_recovery_queue WHERE'); +}); + +it('sends fallback email only when Alarm Manager does not accept a message alert', function ($status, $emails) { + $GLOBALS['severities'] = [2 => 'Warning']; + $GLOBALS['syslog_levels'] = [3 => 'Error']; + $GLOBALS['syslog_facilities'] = [1 => 'User']; + $GLOBALS['config']['base_path'] = dirname(__DIR__) . '/Fixtures/threshold_notifications'; + $sent = 0; + $rule = array_replace(alarmmanager_test_rule(), ['method' => 0, 'num' => 1, 'type' => 'messagec', 'message' => 'fixture', + 'repeat_alert' => 0, 'notify' => 0, 'email' => 'ops@example.com', 'body' => '', 'command' => '', 'open_ticket' => '', + 'maintenance_mode' => 'disabled', 'level' => 0, 'date' => 1]); + $matches = alarmmanager_test_matches(); + test_override('read_config_option', static fn($name) => match ($name) { + 'syslog_alarmmanager_enabled' => 'on', 'syslog_html' => '', 'poller_interval' => 300, default => '' + }); + test_override('api_plugin_hook_function', static fn($hook, $envelope) => $hook === 'alarmmanager_submit' ? $envelope + ['status' => $status, 'event_row_id' => 42] : $envelope); + test_override('syslog_db_execute_prepared', static fn() => true); + test_override('syslog_db_fetch_row_prepared', static fn() => []); + test_override('syslog_db_fetch_assoc_prepared', static fn() => []); + test_override('syslog_db_fetch_cell_prepared', static fn() => 1); + test_override('sql_save', static fn() => 1); + test_override('mailer', function () use (&$sent) { $sent++; }); + syslog_process_alert($rule, 'SELECT 1', [], 1, 'router', $matches); + expect($sent)->toBe($emails); +})->with([['accepted', 0], ['rejected', 1]]); diff --git a/tests/Unit/AlertThresholdWindowTest.php b/tests/Unit/AlertThresholdWindowTest.php index 0410bbb8..747a2241 100644 --- a/tests/Unit/AlertThresholdWindowTest.php +++ b/tests/Unit/AlertThresholdWindowTest.php @@ -83,5 +83,5 @@ function threshold_rule_fixture(): array { return true; }); expect(syslog_ensure_table_structures())->toBeTrue(); - expect($columns)->toBe(['threshold_window', 'threshold_unit', 'threshold_recovery_minutes', 'threshold_recovery_unit', 'recovery_command']); + expect($columns)->toBe(['threshold_window', 'threshold_unit', 'threshold_recovery_minutes', 'threshold_recovery_unit', 'alarm_destination', 'recovery_command']); }); diff --git a/tests/Unit/QueryBuilderGuardTest.php b/tests/Unit/QueryBuilderGuardTest.php index d65ee6a8..ce4b5222 100644 --- a/tests/Unit/QueryBuilderGuardTest.php +++ b/tests/Unit/QueryBuilderGuardTest.php @@ -36,6 +36,7 @@ mkdir($shim . '/lib', 0700, true); copy(dirname(__DIR__, 2) . '/includes/functions.php', $shim . '/includes/functions.php'); + copy(dirname(__DIR__, 2) . '/includes/alarmmanager.php', $shim . '/includes/alarmmanager.php'); file_put_contents($shim . '/run.php', 'and($report['normalize'])->toBe($document, 'The normalize guard must pass the raw document through.') ->and(is_file(dirname(__DIR__, 2) . '/coverage/clover-guard.xml'))->toBeTrue('The guard fragment must be written for the patch-coverage gate.'); } finally { - foreach (['includes/functions.php', 'run.php'] as $relative) { + foreach (['includes/functions.php', 'includes/alarmmanager.php', 'run.php'] as $relative) { $file = $shim . '/' . $relative; if (is_file($file)) { diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index c6587b12..e398de00 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -361,6 +361,18 @@ function api_plugin_hook($name) { } } +if (!function_exists('api_plugin_installed')) { + function api_plugin_installed($plugin) { + return test_call_override('api_plugin_installed', [$plugin], false); + } +} + +if (!function_exists('api_plugin_is_enabled')) { + function api_plugin_is_enabled($plugin) { + return test_call_override('api_plugin_is_enabled', [$plugin], false); + } +} + if (!function_exists('api_plugin_hook_function')) { function api_plugin_hook_function($name, $value = null) { return test_call_override('api_plugin_hook_function', [$name, $value], $value); diff --git a/tests/regression/alarm_destination_ui_test.js b/tests/regression/alarm_destination_ui_test.js new file mode 100644 index 00000000..5ea876a8 --- /dev/null +++ b/tests/regression/alarm_destination_ui_test.js @@ -0,0 +1,42 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const updateVisibility = require('../../js/alarm-destination.js'); +const path = require('node:path'); +const {execFileSync} = require('node:child_process'); +const root = path.resolve(__dirname, '../..'); +const source = fs.readFileSync(path.join(root, 'syslog_alerts.php'), 'utf8'); +let method, destination; +const visibility = {}; +const context = {notifyExists: true, $: selector => ({ + val: () => selector === '#report_method' ? method : destination, + toggle: visible => selector.split(', ').forEach(row => { visibility[row] = visible; }) +})}; +for (method of ['0', '1']) { + for (destination of ['syslog', 'alarmmanager']) { + updateVisibility(context.$, context.notifyExists); + const syslog = destination === 'syslog'; + assert.equal(visibility['#row_spacer1'], syslog); + assert.equal(visibility['#row_open_ticket'], syslog); + assert.equal(visibility['#row_command'], syslog); + assert.equal(visibility['#row_recovery_command'], syslog && method === '1'); + for (const row of ['#row_header_email', '#row_email', '#row_body', '#row_notify']) { + assert.equal(visibility[row], syslog); + } + assert.equal(visibility['#row_num'], method === '1'); + } +} +context.notifyExists = false; +destination = 'syslog'; +updateVisibility(context.$, context.notifyExists); +assert.equal(visibility['#row_notify'], false); +const fixture = path.join(root, 'tests/Fixtures/threshold_rule_commands.php'); +const cacti = process.env.CACTI_PATH || path.resolve(root, '../cacti'); +const installed = execFileSync('/usr/bin/php', [fixture, cacti, 'installed'], {encoding: 'utf8'}); +const missing = execFileSync('/usr/bin/php', [fixture, cacti, 'missing'], {encoding: 'utf8'}); +assert.match(installed, / 2]; +$syslogdb_default = 'syslog'; +$options = ['syslog_alarmmanager_enabled' => '', 'base_url' => 'https://cacti.example/']; +$installed = $enabled = $hook = true; +$response = 'accepted'; +$submitted = []; +$logs = []; +function read_config_option($name) { return $GLOBALS['options'][$name] ?? ''; } +function api_plugin_installed($name) { return $GLOBALS['installed']; } +function api_plugin_is_enabled($name) { return $GLOBALS['enabled']; } +function db_fetch_cell_prepared(...$args) { return $GLOBALS['hook'] ? 1 : 0; } +function syslog_db_execute_prepared(...$args) { return true; } +function cacti_log($message, ...$args) { $GLOBALS['logs'][] = $message; } +function api_plugin_hook_function($name, $envelope) { + if ($name !== 'alarmmanager_submit') { return $envelope; } + $GLOBALS['submitted'][] = $envelope; + alarmmanager_validate_event($envelope['event']); + $response = $GLOBALS['response']; + if ($response === 'throws') { throw new RuntimeException('secret message'); } + if (!is_string($response)) { return $response; } + $envelope['status'] = $response; + if (in_array($response, ['accepted', 'already_accepted'], true)) { $envelope['event_row_id'] = 42; } + return $envelope; +} +require dirname(__DIR__, 2) . '/includes/alarmmanager.php'; +$validator = $argv[1] ?? dirname(__DIR__, 3) . '/plugin_alarmmanager/includes/events.php'; +require $validator; +require dirname($validator) . '/lifecycle.php'; +$checks = 0; +function check($condition, $message) { $GLOBALS['checks']++; if (!$condition) { throw new RuntimeException($message); } } +$rule = ['alarm_destination' => 'alarmmanager', 'id' => 5, 'name' => 'Test rule', 'method' => 0, 'severity' => 2, 'num' => 1]; +$matches = [['seq' => 100, 'logtime' => '2026-10-26 10:00:00', 'host' => 'router.example', 'message' => 'sensitive test message', 'priority_id' => 3]]; +check(!syslog_alarmmanager_forward($rule, $matches, 1) && !$submitted, 'Disabled forwarding keeps email'); +$options['syslog_alarmmanager_enabled'] = 'on'; +$syslog_rule = $rule; +unset($syslog_rule['alarm_destination']); +check(!syslog_alarmmanager_forward($syslog_rule, $matches, 1) && !$submitted, 'Existing rules default to Syslog Alarming even when forwarding is enabled'); +foreach (['installed', 'enabled', 'hook'] as $availability) { + $GLOBALS[$availability] = false; + check(!syslog_alarmmanager_forward($rule, $matches, 1) && !$submitted, "$availability unavailable keeps email without calling hook"); + $GLOBALS[$availability] = true; +} +foreach (['throws', 'failed', 'rejected', 'unhandled', true, ['status' => 'accepted'], ['status' => 'accepted', 'event_row_id' => 42]] as $response) { + check(!syslog_alarmmanager_forward($rule, $matches, 1), 'Non-acceptance keeps email'); +} +$response = 'accepted'; +check(syslog_alarmmanager_forward($rule, $matches, 1), 'Committed acceptance suppresses email'); +$first = end($submitted)['event']; +check($first['severity'] === 'critical' && $first['transition'] === 'occurrence', 'Severity and occurrence mapping'); +$response = 'already_accepted'; +check(syslog_alarmmanager_forward($rule, $matches, 1), 'Durable duplicate suppresses email'); +check(end($submitted)['event'] === $first, 'Retry sends identical event'); +$response = 'accepted'; +$matches[0]['seq']++; +check(syslog_alarmmanager_forward($rule, $matches, 1), 'Independent occurrence accepted'); +$second = end($submitted)['event']; +check($second['event_id'] !== $first['event_id'] && $second['alarm_key'] !== $first['alarm_key'], 'Independent occurrence has distinct event and stream'); +$empty_state = ['last_sequence' => 0, 'episode_id' => '', 'active' => 0, 'acknowledged' => 0]; +check(alarmmanager_transition($empty_state, $first)[1] === 'occurrence' && alarmmanager_transition($empty_state, $second)[1] === 'occurrence', 'Real Alarm Manager lifecycle accepts each independent stream'); +$rule['method'] = 1; +$rule['_threshold_state'] = ['source_poller_id' => 2, 'breach_key' => str_repeat('a', 40)]; +check(syslog_alarmmanager_forward($rule, $matches, 5), 'Threshold raise accepted'); +$raised = end($submitted)['event']; +check(syslog_alarmmanager_forward($rule, $matches, 0, true, 1793010000), 'Recovery accepted'); +$clear = end($submitted)['event']; +check($clear['alarm_key'] === $raised['alarm_key'] && $clear['episode_id'] === $raised['episode_id'] && $clear['sequence'] > $raised['sequence'] && $clear['transition'] === 'recovered', 'Recovery correlates and advances sequence'); +[$active_state, $disposition] = alarmmanager_transition($empty_state, $raised); +check($disposition === 'applied' && alarmmanager_transition($active_state, $clear)[1] === 'applied', 'Real Alarm Manager lifecycle applies correlated raise and clear'); +check(syslog_alarmmanager_forward($rule, $matches, 0, true, 1793010000) && end($submitted)['event'] === $clear, 'Recovery retry is stable'); +$before = count($submitted); +$rule['_threshold_state']['source_poller_id'] = 1; +check(!syslog_alarmmanager_forward($rule, $matches, 5) && count($submitted) === $before, 'Foreign breach owner cannot submit'); +unset($rule['_threshold_state']); +$matches[0]['replication_source_poller_id'] = 1; +check(!syslog_alarmmanager_forward($rule, $matches, 1) && count($submitted) === $before, 'Replicated messages cannot resubmit'); +check(!str_contains(implode(' ', $logs), 'secret') && !str_contains(implode(' ', $logs), 'sensitive'), 'Diagnostics exclude message and exception contents'); +// Exercise the real notification call sites, with mail and Cacti persistence stubbed. +function __($text, ...$args) { return $text; } +function __esc($text, ...$args) { return $text; } +function html_escape($value) { return htmlspecialchars((string) $value); } +function cacti_sizeof($value) { return count($value); } +function cacti_escapeshellarg($value) { return escapeshellarg((string) $value); } +function clean_up_name($value) { return $value; } +function syslog_sql_save(...$args) { return 42; } +function mailer(...$args) { $GLOBALS['emails']++; } +require dirname(__DIR__, 2) . '/includes/functions.php'; +$fixture = sys_get_temp_dir() . '/syslog-am-' . bin2hex(random_bytes(6)); +mkdir($fixture . '/lib', 0700, true); +file_put_contents($fixture . '/lib/reports.php', ' 'Critical']; +$syslog_levels = [3 => 'Error']; +$syslog_facilities = [1 => 'User']; +$debug = false; +$emails = 0; +$rule += ['email' => 'fixture@example.invalid', 'notify' => 0, 'repeat_alert' => 0, 'body' => '', 'message' => 'test', 'command' => '', 'level' => 0, 'open_ticket' => '']; +$matches[0]['facility_id'] = 1; +unset($matches[0]['replication_source_poller_id']); +try { + foreach ([0, 1] as $method) { + $rule['method'] = $method; + foreach (['disabled', 'missing', 'plugin_disabled', 'no_hook', 'throws', 'failed', 'rejected', 'accepted', 'already_accepted'] as $case) { + $options['syslog_alarmmanager_enabled'] = $case === 'disabled' ? '' : 'on'; + $installed = $case !== 'missing'; + $enabled = $case !== 'plugin_disabled'; + $hook = $case !== 'no_hook'; + $response = $case; + $before = $emails; + syslog_process_alert($rule, '', [], 1, '', $matches); + check($emails === $before + (in_array($case, ['accepted', 'already_accepted'], true) ? 0 : 1), "Real email call site method $method: $case"); + } + } + $rule['alarm_destination'] = 'alarmmanager'; + $rule['command'] = '/bin/false'; + check(syslog_execute_alert_command($rule, ['message' => 'fixture', 'priority_id' => 3, 'facility_id' => 1], '') === true, 'Alarm Manager skips alert commands'); + check(syslog_execute_alert_command($rule, ['message' => 'fixture', 'priority_id' => 3, 'facility_id' => 1], '', 'clear') === true, 'Alarm Manager skips recovery commands'); + $rule['open_ticket'] = 'on'; + $options['syslog_ticket_command'] = '/bin/false'; + $before_logs = count($logs); + syslog_execute_ticket_command($rule, ['router.example'], 'ticket failure %s %s %s'); + check(count($logs) === $before_logs, 'Alarm Manager skips configured ticket commands'); + $rule['open_ticket'] = ''; + $before = $emails; + $rule['alarm_destination'] = 'syslog'; + $rule['command'] = ''; + $options['syslog_alarmmanager_enabled'] = 'on'; + $response = 'accepted'; + syslog_process_alert($rule, '', [], 1, '', $matches); + check($emails === $before + 1, 'Explicit Syslog Alarming retains its email when Alarm Manager is available'); +} finally { + unlink($fixture . '/lib/reports.php'); + rmdir($fixture . '/lib'); + rmdir($fixture); +} +echo "Alarm Manager forwarding: $checks checks passed.\n"; diff --git a/tests/regression/import_roundtrip_test.php b/tests/regression/import_roundtrip_test.php index 57559584..fad44b09 100644 --- a/tests/regression/import_roundtrip_test.php +++ b/tests/regression/import_roundtrip_test.php @@ -31,7 +31,7 @@ function load_function($file, $name) { } $fixtures = [ - 'syslog_alert' => ['name' => 'Alert', 'hash' => 'a', 'method' => '0', 'severity' => '1', 'message' => 'error'], + 'syslog_alert' => ['name' => 'Alert', 'hash' => 'a', 'alarm_destination' => 'alarmmanager', 'method' => '0', 'severity' => '1', 'message' => 'error'], 'syslog_remove' => ['name' => 'Removal', 'hash' => 'r', 'method' => 'trans', 'message' => 'noise'], 'syslog_saved_searches' => ['name' => 'Search', 'hash' => 's', 'search' => 'error AND router'], 'syslog_dashboards' => ['name' => 'Dashboard', 'hash' => 'd', 'panels' => []] @@ -43,6 +43,8 @@ function load_function($file, $name) { check((\syslog_parse_rule_import(json_encode([$fixture]), $target) !== false) === ($source === $target), "$source -> $target legacy JSON check"); } } +$roundtrip = \syslog_parse_rule_import(\syslog_rules_array2json('syslog_alert', [$fixtures['syslog_alert']]), 'syslog_alert'); +check(reset($roundtrip)['alarm_destination'] === 'alarmmanager', 'Alarm destination survives JSON export and import'); check(\syslog_parse_rule_import('{invalid', 'syslog_alert') === false, 'Malformed JSON rejected'); check(\syslog_parse_rule_import(json_encode(['templates' => [$fixtures['syslog_alert'], $fixtures['syslog_remove']]]), 'syslog_alert') === false, 'Mixed imports rejected before writes'); diff --git a/tests/regression/threshold_recovery_database_test.php b/tests/regression/threshold_recovery_database_test.php index 7d90918f..bf799218 100644 --- a/tests/regression/threshold_recovery_database_test.php +++ b/tests/regression/threshold_recovery_database_test.php @@ -24,6 +24,19 @@ function syslog_db_fetch_row_prepared($sql, $params = [], ...$args) { return sys function syslog_db_fetch_cell_prepared($sql, $params = [], ...$args) { $q = $GLOBALS['pdo']->prepare($sql); $q->execute($params); return $q->fetchColumn(); } function syslog_db_fetch_cell($sql) { return syslog_db_fetch_cell_prepared($sql); } function db_execute_prepared($sql, $params = [], ...$args) { return syslog_db_execute_prepared($sql, $params); } +function read_config_option($name) { return $GLOBALS['am_options'][$name] ?? ''; } +function api_plugin_installed($name) { return true; } +function api_plugin_is_enabled($name) { return true; } +function db_fetch_cell_prepared(...$args) { return 1; } +function api_plugin_hook_function($name, $envelope) { + $GLOBALS['am_events'][] = $envelope['event']; + alarmmanager_validate_event($envelope['event']); + $envelope['status'] = $GLOBALS['am_status']; + $envelope['event_row_id'] = 42; + return $envelope; +} +$am_validator = getenv('SYSLOG_TEST_ALARMMANAGER_VALIDATOR') ?: dirname(__DIR__, 3) . '/plugin_alarmmanager/includes/events.php'; +if (is_file($am_validator)) { require $am_validator; } function cacti_log(...$args) {} function cacti_sizeof($items) { return is_array($items) ? count($items) : 0; } function cacti_escapeshellarg($value) { return escapeshellarg((string) $value); } @@ -50,7 +63,7 @@ function add_test_messages($sequences) { $syslogdb_default = $database; syslog_create_alert_counts_table(); $syslogdb_default = $local_db; - $pdo->exec('CREATE TABLE syslog_alert (id INT PRIMARY KEY, enabled CHAR(2), method INT, date BIGINT, recovery_command VARCHAR(255))'); + $pdo->exec('CREATE TABLE syslog_alert (id INT PRIMARY KEY, enabled CHAR(2), method INT, date BIGINT, recovery_command VARCHAR(255), alarm_destination VARCHAR(16) NOT NULL DEFAULT \'syslog\')'); $pdo->exec("INSERT INTO syslog_alert (id,enabled,method,date) VALUES (1,'on',1,100),(2,'on',1,100)"); $pdo->exec('CREATE TABLE syslog_incoming (seq BIGINT PRIMARY KEY, status INT, threshold_breached TINYINT DEFAULT NULL)'); $pdo->exec('CREATE TABLE syslog (seq BIGINT AUTO_INCREMENT PRIMARY KEY, replication_source_poller_id INT, replication_source_event_id BIGINT, threshold_breached TINYINT DEFAULT NULL)'); @@ -236,6 +249,40 @@ function add_test_messages($sequences) { $pdo->exec('UPDATE syslog_alert SET date = 101'); syslog_threshold_deliver_recoveries($start + 360); check_recovery((int) $pdo->query('SELECT COUNT(*) FROM syslog_threshold_recovery_queue')->fetchColumn() === 0 && count(file($command_log)) === 5, 'Editing a rule cancels a pending clear before execution'); + if (is_file($am_validator)) { + // Real queue SQL and recovery commands; only the Alarm Manager ingestion boundary is stubbed. + $am_options = ['syslog_alarmmanager_enabled' => 'on', 'base_url' => 'https://fixture.invalid/']; + $am_status = 'accepted'; + $am_events = []; + $command = PHP_BINARY . ' ' . escapeshellarg(dirname(__DIR__) . '/Fixtures/threshold_command.php') . ' ' . escapeshellarg($command_log) . ' '; + $pdo->prepare("UPDATE syslog_alert SET enabled = 'on', method = 1, date = 100, recovery_command = ?")->execute([$command]); + $pdo->exec("UPDATE syslog_alert SET alarm_destination = 'alarmmanager'"); + $rule['alarm_destination'] = 'alarmmanager'; + $rule['date'] = 100; + $rule['recovery_command'] = $command; + $rule['command'] = $command; + $when = $start + 5000; + $matches = [['seq' => 900, 'logtime' => date('Y-m-d H:i:s', $when), 'host' => 'host-am', 'message' => 'fixture', 'priority_id' => 3, 'facility_id' => 1]]; + check_recovery(syslog_threshold_evaluate($rule, 2, 'host-am', $when) === 2, 'Open Alarm Manager threshold episode'); + check_recovery(syslog_mark_threshold_messages($rule, 2, $matches), 'Retain immutable recovery context'); + check_recovery(syslog_alarmmanager_forward($rule, $matches, 2), 'Accepted raise records recovery ownership'); + $raised = end($am_events); + syslog_threshold_recover($when + 60); + $am_status = 'failed'; + $commands_before = count(file($command_log)); + syslog_threshold_deliver_recoveries($when + 60); + check_recovery(count(file($command_log)) === $commands_before, 'Alarm Manager recovery does not run configured commands'); + $clear = end($am_events); + check_recovery($clear['transition'] === 'recovered' && $clear['episode_id'] === $raised['episode_id'], 'Queued recovery correlates to raise'); + check_recovery((int) syslog_db_fetch_cell_prepared('SELECT COUNT(*) FROM syslog_threshold_recovery_queue WHERE breach_key = ?', [$rule['_threshold_state']['breach_key']]) === 1, 'Failed clear remains queued'); + $am_status = 'already_accepted'; + syslog_threshold_deliver_recoveries($when + 120); + check_recovery(end($am_events) === $clear, 'Queued recovery retry retains identical event and time'); + check_recovery(count(file($command_log)) === $commands_before, 'Forwarding retry does not run hidden recovery command'); + check_recovery((int) syslog_db_fetch_cell_prepared('SELECT COUNT(*) FROM syslog_threshold_recovery_queue WHERE breach_key = ?', [$rule['_threshold_state']['breach_key']]) === 0, 'Durable duplicate completes queued recovery'); + } else { + print "SKIP: Alarm Manager recovery cases require its event validator.\n"; + } print "Threshold recovery MariaDB checks passed: $checks\n"; } finally { unlink($command_log);