diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml
index 4a634d0..befe032 100644
--- a/.github/workflows/plugin-ci-workflow.yml
+++ b/.github/workflows/plugin-ci-workflow.yml
@@ -184,6 +184,17 @@ jobs:
echo "Audit schema security columns are missing"
exit 1
fi
+
+ SYSLOG_TABLE_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se "
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = 'cacti'
+ AND table_name = 'audit_syslog_delivery';
+ ")
+ if [ "$SYSLOG_TABLE_COUNT" -ne 1 ]; then
+ echo "Audit Syslog delivery queue table is missing"
+ exit 1
+ fi
- name: Check PHP Syntax for Plugin
run: |
@@ -198,6 +209,8 @@ jobs:
cd ${{ github.workspace }}/cacti/plugins/audit
php tests/security_functions_test.php
php tests/controller_security_test.php
+ php tests/syslog_functions_test.php
+ php tests/syslog_queue_test.php
- name: Run Cacti Poller
run: |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aeb9096..d56164f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
--- develop ---
+* feature: Add standards-based remote Syslog delivery over UDP, TCP, and verified TLS
+* feature: Add RFC 5424 headers with RFC 5424, CEF, or compact JSON message formats
+* feature: Queue remote delivery in the poller with exponential backoff, dead-letter handling, health reporting, and audited admin actions
+* security: Restrict Syslog settings, tests, and dead-letter retries to Audit Log Admin with validated destinations and CSRF-protected POST actions
+* security: Preserve unfinished Syslog evidence during scheduled retention and manual purge
+* issue: Capture expected Syslog connection and write warnings without leaking PHP notices into the Cacti poller log
+* feature: Include redacted submitted, object, and detail data in CEF Syslog records
* feature: Verify user realm permission saves against the resulting database state
* security: Group Audit Log User and Audit Log Admin permissions under Audit Plugin
* feature: Add normalized compliance event identifiers, categories, actors, targets, outcomes, timing, and integrity metadata
diff --git a/INFO b/INFO
index 6510278..fe5f4b5 100644
--- a/INFO
+++ b/INFO
@@ -21,7 +21,7 @@
[info]
name = audit
-version = 1.4
+version = 1.5
longname = Audit Plugin for Cacti
author = The Cacti Group
email =
diff --git a/README.md b/README.md
index fd51efc..cbffe64 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,63 @@ events. External records are written only after request finalization, so SIEM
consumers receive the final request status instead of the earlier transient
`started` state. Consumers should deduplicate on `event_uuid`.
+## Remote Syslog
+
+Version 1.5 adds standards-based Remote Syslog as the plugin's only remote
+network transport. The plugin does not implement product-specific webhooks,
+Splunk HEC, or Microsoft Sentinel APIs. Splunk, Sentinel, and other SIEM
+products can ingest the existing audit file or receive Syslog through their
+normal collector or forwarder.
+
+Remote Syslog is configured under Configuration -> Settings -> Audit by a user
+with the Audit Log Admin permission. It is independent of the existing
+external-file option, so either destination or both destinations may be
+enabled.
+
+The available settings include:
+
+* Receiver hostname or IP address and an optional explicit port.
+* UDP, TCP, or TLS transport. A blank port uses 514 for UDP/TCP or 6514 for TLS.
+* RFC 5424 structured data, CEF, or compact JSON message payload.
+* Syslog facility, application name, stable Cacti node identity, and poller
+ identity.
+* Bounded connection/write timeout, poller batch size, retry attempts, and
+ exponential-backoff delays.
+* Maximum UDP record size and health-warning thresholds.
+* Optional TLS CA bundle and mutual-TLS client certificate/key paths.
+
+All records use an RFC 5424 header. TCP and TLS records use RFC 6587
+octet-count framing. TLS always verifies the receiver certificate and hostname;
+there is no setting to disable verification.
+
+CEF records include the same redacted submitted request data, captured object
+data, and event details available to JSON consumers in the `cs4`, `cs5`, and
+`cs6` custom string fields. The corresponding labels are `Submitted Data`,
+`Object Data`, and `Details`.
+
+UDP sends one complete event per datagram. If a formatted event is larger than
+the configured UDP maximum, it is moved to dead-letter instead of being
+truncated or split. TCP or TLS is recommended when events can be large or when
+transport reliability matters.
+
+Remote network I/O does not run in the originating web request. Finalized events
+are added to `audit_syslog_delivery` and sent in bounded batches by the Cacti
+poller. Transient connection and write errors use bounded exponential backoff.
+Permanent formatting/configuration errors and exhausted retries enter
+dead-letter state. Audit Log Admin can test the stored configuration and reset
+dead-letter events from the Audit Log page; both actions require CSRF-protected
+POST requests and are audited.
+
+A successful UDP, TCP, or TLS socket write is shown as `sent_unconfirmed`. This
+means the local operating system accepted the complete record; standard Syslog
+does not confirm that a receiver durably stored it. Every transmission retains
+the same `event_uuid`, which receivers should use as their deduplication key.
+
+The Audit Log page reports pending, retry, sent-unconfirmed, and dead-letter
+counts, oldest pending age, last attempt, last socket write, and the latest
+bounded error. Scheduled retention and manual purge preserve events with
+pending, retry, or dead-letter Syslog delivery.
+
The normalized fields distinguish request processing from the result of the
requested Cacti operation. `request_status=completed` means that PHP request
processing completed without a fatal error or an HTTP error response. It does
diff --git a/audit.php b/audit.php
index 19c26e8..8703ec0 100644
--- a/audit.php
+++ b/audit.php
@@ -31,6 +31,77 @@
case 'export':
audit_export_rows();
+ break;
+case 'syslog_test':
+ if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(405);
+ header('Allow: POST');
+ exit;
+ }
+
+ if (!audit_user_is_admin() || !csrf_check(false)) {
+ http_response_code(403);
+ exit;
+ }
+
+ $result = audit_syslog_test_delivery();
+ $success = $result['status'] === 'sent_unconfirmed';
+ audit_record_event('audit.syslog.test', array(
+ 'event_category' => 'audit',
+ 'severity' => $success ? 'notice' : 'warning',
+ 'action' => 'test',
+ 'target_type' => 'syslog_receiver',
+ 'operation_outcome' => $success ? 'success' : 'failure',
+ 'outcome_reason' => $success ? 'socket_write_completed' : ($result['error_code'] ?? 'delivery_failed'),
+ 'details' => array(
+ 'transport' => audit_syslog_config()['transport'],
+ 'status' => $result['status'],
+ 'error_code' => $result['error_code'] ?? ''
+ )
+ ));
+ $_SESSION['audit_message'] = $success
+ ? __('Syslog test record was written to the local socket. Receiver storage is unconfirmed.', 'audit')
+ : __('Syslog test failed: %s', html_escape($result['error'] ?? 'Unknown error'), 'audit');
+ raise_message('audit_message');
+
+ top_header();
+ audit_log();
+ bottom_footer();
+
+ break;
+case 'syslog_retry':
+ if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(405);
+ header('Allow: POST');
+ exit;
+ }
+
+ if (!audit_user_is_admin() || !csrf_check(false)) {
+ http_response_code(403);
+ exit;
+ }
+
+ $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW);
+ $post = is_array($post) ? $post : array();
+ $delivery_ids = isset($post['delivery_ids']) && is_array($post['delivery_ids'])
+ ? $post['delivery_ids']
+ : array();
+ $retried = audit_syslog_retry_dead_letters($delivery_ids);
+ audit_record_event('audit.syslog.dead_letter.retried', array(
+ 'event_category' => 'audit',
+ 'severity' => 'notice',
+ 'action' => 'retry',
+ 'target_type' => 'syslog_delivery',
+ 'operation_outcome' => 'success',
+ 'details' => array('row_count' => $retried)
+ ));
+ $_SESSION['audit_message'] = __('Reset %d dead-letter Syslog deliveries for retry.', $retried, 'audit');
+ raise_message('audit_message');
+
+ top_header();
+ audit_log();
+ bottom_footer();
+
break;
case 'purge':
if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -95,9 +166,29 @@ function audit_render_event_details($data) {
if ($data['outcome_reason'] != '') {
$output .= '
' . __('Outcome Reason:', 'audit') . ' ' . html_escape($data['outcome_reason']) . '';
}
- $output .= '
' . __('External Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . '';
+ $output .= '
' . __('External File Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . '';
if ($data['external_error'] != '') {
- $output .= '
' . __('External Error:', 'audit') . ' ' . html_escape($data['external_error']) . '';
+ $output .= '
' . __('External File Error:', 'audit') . ' ' . html_escape($data['external_error']) . '';
+ }
+
+ if (db_table_exists('audit_syslog_delivery')) {
+ $syslog = db_fetch_row_prepared('SELECT state, attempts, last_attempt, sent_time, last_error
+ FROM audit_syslog_delivery
+ WHERE audit_id = ?
+ ORDER BY id DESC
+ LIMIT 1',
+ array($data['id']));
+
+ if (cacti_sizeof($syslog)) {
+ $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($syslog['state']) . '';
+ $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . (int) $syslog['attempts'] . '';
+ if ($syslog['sent_time'] != '') {
+ $output .= '
' . __('Syslog Socket Write:', 'audit') . ' ' . html_escape($syslog['sent_time']) . '';
+ }
+ if ($syslog['last_error'] != '') {
+ $output .= '
' . __('Syslog Error:', 'audit') . ' ' . html_escape($syslog['last_error']) . '';
+ }
+ }
}
$output .= '
';
@@ -164,16 +255,37 @@ function audit_render_value($value) {
}
function audit_purge() {
- db_execute('TRUNCATE TABLE audit_log');
+ $protected = db_fetch_cell("SELECT COUNT(*)
+ FROM audit_log
+ WHERE EXISTS (
+ SELECT 1
+ FROM audit_syslog_delivery
+ WHERE audit_syslog_delivery.audit_id = audit_log.id
+ AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter')
+ )");
+
+ db_execute("DELETE FROM audit_log
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM audit_syslog_delivery
+ WHERE audit_syslog_delivery.audit_id = audit_log.id
+ AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter')
+ )");
+ $purged = db_affected_rows();
audit_record_event('audit.log.purged', array(
'event_category' => 'audit',
'severity' => 'warning',
'action' => 'purge',
- 'target_type' => 'audit_log'
+ 'target_type' => 'audit_log',
+ 'details' => array(
+ 'purged_count' => $purged,
+ 'protected_count' => (int) $protected
+ )
));
- $_SESSION['audit_message'] = __('Audit Log Purged by %s', get_username($_SESSION['sess_user_id']), 'audit');
+ $_SESSION['audit_message'] = __('Audit Log purge by %s removed %d events and protected %d events with unfinished Syslog delivery.',
+ get_username($_SESSION['sess_user_id']), $purged, (int) $protected, 'audit');
cacti_log('NOTE: Audit Log Purged by ' . get_username($_SESSION['sess_user_id']), false, 'WEBUI');
@@ -342,6 +454,10 @@ function audit_log() {
$rows = get_request_var('rows');
}
+ if (audit_user_is_admin()) {
+ audit_render_syslog_health();
+ }
+
html_start_box(__('Audit Log', 'audit'), '100%', '', '3', 'center', '');
?>
@@ -499,10 +615,10 @@ function audit_log() {
'tip' => __('Request processing state; completion does not guarantee that every operation succeeded.', 'audit')
),
'external_status' => array(
- 'display' => __('External Delivery', 'audit'),
+ 'display' => __('External File Delivery', 'audit'),
'align' => 'left',
'sort' => 'ASC',
- 'tip' => __('Delivery state for the optional external audit log.', 'audit')
+ 'tip' => __('Delivery state for the optional external audit log file. Remote Syslog health is shown separately.', 'audit')
),
'user_agent' => array(
'display' => __('User Agent', 'audit'),
@@ -567,3 +683,47 @@ function audit_log() {
= $config['dead_letter_warning'] ||
+ $health['oldest_pending_seconds'] >= $config['pending_age_warning']
+ );
+
+ html_start_box(__('Remote Syslog Delivery', 'audit'), '100%', '', '3', 'center', '');
+
+ print "";
+ print '| ' . __('Status', 'audit') . ' | ';
+ print '' . html_escape(!$enabled ? __('Disabled', 'audit') : ($unhealthy ? __('Unhealthy', 'audit') : __('Healthy', 'audit'))) . ' | ';
+ print '' . __('Pending', 'audit') . ' | ' . (int) $health['pending'] . ' | ';
+ print '' . __('Retry', 'audit') . ' | ' . (int) $health['retry'] . ' | ';
+ print '' . __('Dead-letter', 'audit') . ' | ' . (int) $health['dead_letter'] . ' | ';
+ print '' . __('Sent (Unconfirmed)', 'audit') . ' | ' . (int) $health['sent_unconfirmed'] . ' | ';
+ print '
';
+
+ print "";
+ print '| ' . __('Oldest Pending', 'audit') . ' | ' . (int) $health['oldest_pending_seconds'] . ' ' . html_escape(__('seconds', 'audit')) . ' | ';
+ print '' . __('Last Attempt', 'audit') . ' | ' . html_escape($health['last_attempt'] ?? __('Never', 'audit')) . ' | ';
+ print '' . __('Last Socket Write', 'audit') . ' | ' . html_escape($health['last_sent'] ?? __('Never', 'audit')) . ' | ';
+ print '';
+ print " ';
+ if ($health['dead_letter'] > 0) {
+ print "';
+ }
+ print ' |
';
+
+ if ($enabled && !$config['valid']) {
+ print "| " . __esc('Configuration Error:', 'audit') . ' ' .
+ html_escape(implode(', ', $config['errors'])) . ' |
';
+ } elseif ($health['last_error'] !== null) {
+ print "| " . __esc('Last Error:', 'audit') . ' ' .
+ html_escape($health['last_error']) . ' |
';
+ }
+
+ html_end_box();
+}
diff --git a/audit_functions.php b/audit_functions.php
index 0d56410..90e7eef 100644
--- a/audit_functions.php
+++ b/audit_functions.php
@@ -1,5 +1,7 @@
$value) {
+ if (strpos((string) $name, 'audit_syslog_') === 0) {
+ $has_syslog_fields = true;
+ break;
+ }
+ }
+
+ if (!$has_syslog_fields) {
+ return;
+ }
+
+ if (!audit_user_is_admin()) {
+ audit_record_event('audit.syslog.configuration.denied', array(
+ 'event_category' => 'audit',
+ 'severity' => 'warning',
+ 'action' => 'save',
+ 'target_type' => 'syslog_configuration',
+ 'operation_outcome' => 'failure',
+ 'outcome_reason' => 'audit_admin_required'
+ ));
+ http_response_code(403);
+ exit;
+ }
+
+ $names = array(
+ 'receiver', 'port', 'transport', 'format', 'facility', 'application',
+ 'node_id', 'timeout', 'udp_max_size', 'retry_base', 'retry_max',
+ 'max_attempts', 'batch_size', 'pending_age_warning',
+ 'dead_letter_warning', 'tls_ca_file', 'tls_client_cert',
+ 'tls_client_key'
+ );
+ $overrides = array();
+
+ foreach ($names as $name) {
+ $setting = 'audit_syslog_' . $name;
+ $overrides[$name] = isset($post[$setting]) && is_scalar($post[$setting])
+ ? (string) $post[$setting]
+ : '';
+ }
+
+ $config = audit_syslog_config($overrides);
+ $enabling = isset($post['audit_syslog_enabled']) && $post['audit_syslog_enabled'] === 'on';
+ $configuring = trim($overrides['receiver']) !== '';
+
+ if (($enabling || $configuring) && !$config['valid']) {
+ audit_record_event('audit.syslog.configuration.rejected', array(
+ 'event_category' => 'audit',
+ 'severity' => 'warning',
+ 'action' => 'save',
+ 'target_type' => 'syslog_configuration',
+ 'operation_outcome' => 'failure',
+ 'outcome_reason' => 'configuration_invalid',
+ 'details' => array('errors' => $config['errors'])
+ ));
+
+ raise_message(
+ 'audit_syslog_configuration',
+ __('Remote Syslog settings were not saved: %s', implode(', ', $config['errors']), 'audit'),
+ MESSAGE_LEVEL_ERROR
+ );
+ header('Location: settings.php?tab=audit');
+ exit;
+ }
+}
+
function audit_config_insert() {
global $action, $config;
+ audit_enforce_syslog_settings_request();
+
if (audit_log_valid_event()) {
$started_at = microtime(true);
/* prepare post */
diff --git a/audit_syslog.php b/audit_syslog.php
new file mode 100644
index 0000000..f73f406
--- /dev/null
+++ b/audit_syslog.php
@@ -0,0 +1,899 @@
+ $maximum) {
+ $errors[] = $name . '_out_of_range';
+ return $default;
+ }
+
+ return $value;
+}
+
+function audit_syslog_valid_receiver($receiver) {
+ if ($receiver === '' || strlen($receiver) > 253 ||
+ preg_match('/[[:cntrl:][:space:]\\/@]/', $receiver) ||
+ strpos($receiver, '://') !== false) {
+ return false;
+ }
+
+ if (filter_var($receiver, FILTER_VALIDATE_IP) !== false) {
+ return true;
+ }
+
+ if (substr($receiver, -1) === '.') {
+ $receiver = substr($receiver, 0, -1);
+ }
+
+ if ($receiver === '' || strlen($receiver) > 253) {
+ return false;
+ }
+
+ $labels = explode('.', $receiver);
+ foreach ($labels as $label) {
+ if ($label === '' || strlen($label) > 63 ||
+ !preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i', $label)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function audit_syslog_valid_header_value($value, $maximum) {
+ return $value !== '' && strlen($value) <= $maximum &&
+ !preg_match('/[^\\x21-\\x7e]|[\\[\\]="]/', $value);
+}
+
+function audit_syslog_validate_optional_file($path, $name, &$errors) {
+ if ($path === '') {
+ return '';
+ }
+
+ if ($path[0] !== '/' || !is_file($path) || is_link($path) || !is_readable($path)) {
+ $errors[] = $name . '_invalid';
+ }
+
+ return $path;
+}
+
+function audit_syslog_config($overrides = array()) {
+ $defaults = array(
+ 'receiver' => '',
+ 'port' => '',
+ 'transport' => 'udp',
+ 'format' => 'json',
+ 'facility' => 'local0',
+ 'application' => 'cacti-audit',
+ 'node_id' => php_uname('n'),
+ 'timeout' => '5',
+ 'udp_max_size' => '8192',
+ 'retry_base' => '30',
+ 'retry_max' => '3600',
+ 'max_attempts' => '10',
+ 'batch_size' => '100',
+ 'pending_age_warning' => '900',
+ 'dead_letter_warning' => '1',
+ 'tls_ca_file' => '',
+ 'tls_client_cert' => '',
+ 'tls_client_key' => ''
+ );
+ $values = array();
+
+ foreach ($defaults as $name => $default) {
+ $setting = 'audit_syslog_' . $name;
+ $values[$name] = array_key_exists($name, $overrides)
+ ? $overrides[$name]
+ : audit_syslog_read_setting($setting, $default);
+ }
+
+ $errors = array();
+ $receiver = trim((string) $values['receiver']);
+ if (!audit_syslog_valid_receiver($receiver)) {
+ $errors[] = 'receiver_invalid';
+ }
+
+ $transport = strtolower((string) $values['transport']);
+ if (!in_array($transport, array('udp', 'tcp', 'tls'), true)) {
+ $errors[] = 'transport_invalid';
+ $transport = 'udp';
+ }
+
+ $format = strtolower((string) $values['format']);
+ if (!in_array($format, array('rfc5424', 'cef', 'json'), true)) {
+ $errors[] = 'format_invalid';
+ $format = 'json';
+ }
+
+ $facility_map = audit_syslog_facilities();
+ $facility = strtolower((string) $values['facility']);
+ if (!isset($facility_map[$facility])) {
+ $errors[] = 'facility_invalid';
+ $facility = 'local0';
+ }
+
+ $application = trim((string) $values['application']);
+ if (!audit_syslog_valid_header_value($application, 48)) {
+ $errors[] = 'application_invalid';
+ $application = 'cacti-audit';
+ }
+
+ $node_id = trim((string) $values['node_id']);
+ if (!audit_syslog_valid_header_value($node_id, 255)) {
+ $errors[] = 'node_id_invalid';
+ $node_id = 'cacti';
+ }
+
+ $port = trim((string) $values['port']) === ''
+ ? ($transport === 'tls' ? 6514 : 514)
+ : audit_syslog_bounded_integer($values['port'], $transport === 'tls' ? 6514 : 514, 1, 65535, $errors, 'port');
+ $timeout = audit_syslog_bounded_integer($values['timeout'], 5, 1, 30, $errors, 'timeout');
+ $udp_max_size = audit_syslog_bounded_integer($values['udp_max_size'], 8192, 512, 65507, $errors, 'udp_max_size');
+ $retry_base = audit_syslog_bounded_integer($values['retry_base'], 30, 1, 3600, $errors, 'retry_base');
+ $retry_max = audit_syslog_bounded_integer($values['retry_max'], 3600, 1, 86400, $errors, 'retry_max');
+ $max_attempts = audit_syslog_bounded_integer($values['max_attempts'], 10, 1, 100, $errors, 'max_attempts');
+ $batch_size = audit_syslog_bounded_integer($values['batch_size'], 100, 1, 1000, $errors, 'batch_size');
+ $pending_age_warning = audit_syslog_bounded_integer($values['pending_age_warning'], 900, 60, 604800, $errors, 'pending_age_warning');
+ $dead_letter_warning = audit_syslog_bounded_integer($values['dead_letter_warning'], 1, 1, 1000000, $errors, 'dead_letter_warning');
+
+ if ($retry_max < $retry_base) {
+ $errors[] = 'retry_max_less_than_base';
+ $retry_max = $retry_base;
+ }
+
+ $tls_ca_file = trim((string) $values['tls_ca_file']);
+ $tls_client_cert = trim((string) $values['tls_client_cert']);
+ $tls_client_key = trim((string) $values['tls_client_key']);
+
+ if ($transport === 'tls') {
+ audit_syslog_validate_optional_file($tls_ca_file, 'tls_ca_file', $errors);
+ audit_syslog_validate_optional_file($tls_client_cert, 'tls_client_cert', $errors);
+ audit_syslog_validate_optional_file($tls_client_key, 'tls_client_key', $errors);
+
+ if (($tls_client_cert === '') !== ($tls_client_key === '')) {
+ $errors[] = 'tls_client_identity_incomplete';
+ }
+ }
+
+ $config = array(
+ 'receiver' => $receiver,
+ 'port' => $port,
+ 'transport' => $transport,
+ 'format' => $format,
+ 'facility' => $facility,
+ 'application' => $application,
+ 'node_id' => $node_id,
+ 'timeout' => $timeout,
+ 'udp_max_size' => $udp_max_size,
+ 'retry_base' => $retry_base,
+ 'retry_max' => $retry_max,
+ 'max_attempts' => $max_attempts,
+ 'batch_size' => $batch_size,
+ 'pending_age_warning' => $pending_age_warning,
+ 'dead_letter_warning' => $dead_letter_warning,
+ 'tls_ca_file' => $tls_ca_file,
+ 'tls_client_cert' => $tls_client_cert,
+ 'tls_client_key' => $tls_client_key,
+ 'poller_id' => defined('POLLER_ID') ? (string) POLLER_ID : '',
+ 'tls_verify_peer' => true,
+ 'tls_verify_peer_name' => true,
+ 'errors' => array_values(array_unique($errors))
+ );
+ $config['valid'] = empty($config['errors']);
+ $config['fingerprint'] = audit_syslog_destination_fingerprint($config);
+
+ return $config;
+}
+
+function audit_syslog_destination_fingerprint($config) {
+ $identity = array(
+ 'receiver' => $config['receiver'],
+ 'port' => (int) $config['port'],
+ 'transport' => $config['transport'],
+ 'format' => $config['format'],
+ 'facility' => $config['facility'],
+ 'application' => $config['application'],
+ 'node_id' => $config['node_id'],
+ 'tls_ca_file' => $config['tls_ca_file'],
+ 'tls_client_cert' => $config['tls_client_cert']
+ );
+
+ return hash('sha256', audit_json_encode($identity, JSON_UNESCAPED_SLASHES));
+}
+
+function audit_syslog_facilities() {
+ return array(
+ 'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3,
+ 'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7,
+ 'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11,
+ 'ntp' => 12, 'audit' => 13, 'alert' => 14, 'clock' => 15,
+ 'local0' => 16, 'local1' => 17, 'local2' => 18, 'local3' => 19,
+ 'local4' => 20, 'local5' => 21, 'local6' => 22, 'local7' => 23
+ );
+}
+
+function audit_syslog_severity_code($severity) {
+ $map = array(
+ 'emergency' => 0, 'emerg' => 0, 'alert' => 1, 'critical' => 2,
+ 'crit' => 2, 'error' => 3, 'err' => 3, 'warning' => 4,
+ 'warn' => 4, 'notice' => 5, 'info' => 6, 'debug' => 7
+ );
+ $severity = strtolower((string) $severity);
+
+ return isset($map[$severity]) ? $map[$severity] : 6;
+}
+
+function audit_syslog_header_token($value, $maximum, $fallback) {
+ $value = preg_replace('/[^\\x21-\\x3c\\x3e-\\x5a\\x5e-\\x7e]/', '_', (string) $value);
+ $value = substr($value, 0, $maximum);
+
+ return $value === '' ? $fallback : $value;
+}
+
+function audit_syslog_structured_value($value) {
+ $value = preg_replace('/[\\x00-\\x1f\\x7f]/', ' ', (string) $value);
+
+ return str_replace(array('\\', '"', ']'), array('\\\\', '\\"', '\\]'), $value);
+}
+
+function audit_syslog_timestamp($value) {
+ $value = (string) $value;
+ if (preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]([0-9]{2}:[0-9]{2}:[0-9]{2})(\\.[0-9]{1,6})?/', $value, $matches)) {
+ return $matches[1] . 'T' . $matches[2] . (isset($matches[3]) ? $matches[3] : '') . 'Z';
+ }
+
+ return gmdate('Y-m-d\\TH:i:s\\Z');
+}
+
+function audit_syslog_normalized_data($event, $config) {
+ $data = audit_external_event_data($event);
+ $data['node_id'] = $config['node_id'];
+ $data['poller_id'] = $config['poller_id'] !== '' ? $config['poller_id'] : null;
+
+ return $data;
+}
+
+function audit_syslog_cef_escape_header($value) {
+ return str_replace(array('\\', '|', "\r", "\n"), array('\\\\', '\\|', ' ', ' '), (string) $value);
+}
+
+function audit_syslog_cef_escape_extension($value) {
+ return str_replace(
+ array('\\', '=', "\r", "\n"),
+ array('\\\\', '\\=', '\\r', '\\n'),
+ (string) $value
+ );
+}
+
+function audit_syslog_cef_severity($severity) {
+ $map = array(
+ 'emergency' => 10, 'emerg' => 10, 'alert' => 10,
+ 'critical' => 9, 'crit' => 9, 'error' => 8, 'err' => 8,
+ 'warning' => 6, 'warn' => 6, 'notice' => 5,
+ 'info' => 3, 'debug' => 1
+ );
+ $severity = strtolower((string) $severity);
+
+ return isset($map[$severity]) ? $map[$severity] : 3;
+}
+
+function audit_syslog_cef_event_field($value) {
+ if (is_string($value) && $value !== '') {
+ $decoded = audit_json_decode($value, $error);
+ if ($error === null) {
+ $value = $decoded;
+ }
+ }
+
+ if (is_array($value)) {
+ return audit_json_encode(
+ audit_redact_sensitive_data($value),
+ JSON_UNESCAPED_SLASHES
+ );
+ }
+
+ if ($value === null) {
+ return '';
+ }
+
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ }
+
+ return audit_redact_sensitive_value((string) $value);
+}
+
+function audit_syslog_cef_payload($event, $config) {
+ $severity = audit_syslog_cef_severity($event['severity'] ?? 'info');
+ $header = array(
+ 'CEF:0',
+ 'Cacti',
+ 'Audit Plugin',
+ '1.5',
+ $event['event_type'] ?? 'cacti.audit',
+ $event['action'] ?? 'audit',
+ $severity
+ );
+ $extension = array(
+ 'externalId' => $event['event_uuid'] ?? '',
+ 'suser' => $event['user_id'] ?? '',
+ 'src' => $event['ip_address'] ?? '',
+ 'act' => $event['action'] ?? '',
+ 'outcome' => $event['operation_outcome'] ?? '',
+ 'cs1Label' => 'Correlation ID',
+ 'cs1' => $event['correlation_id'] ?? '',
+ 'cs2Label' => 'Target',
+ 'cs2' => trim(($event['target_type'] ?? '') . ':' . ($event['target_id'] ?? ''), ':'),
+ 'cs3Label' => 'Node ID',
+ 'cs3' => $config['node_id'],
+ 'cn1Label' => 'Poller ID',
+ 'cn1' => $config['poller_id'],
+ 'cs4Label' => 'Submitted Data',
+ 'cs4' => audit_syslog_cef_event_field($event['post'] ?? ''),
+ 'cs5Label' => 'Object Data',
+ 'cs5' => audit_syslog_cef_event_field($event['object_data'] ?? ''),
+ 'cs6Label' => 'Details',
+ 'cs6' => audit_syslog_cef_event_field($event['details'] ?? '')
+ );
+ $encoded_header = array_map('audit_syslog_cef_escape_header', $header);
+ $encoded_extension = array();
+
+ foreach ($extension as $name => $value) {
+ $encoded_extension[] = $name . '=' . audit_syslog_cef_escape_extension($value);
+ }
+
+ return implode('|', $encoded_header) . '|' . implode(' ', $encoded_extension);
+}
+
+function audit_syslog_message_payload($event, $config) {
+ if ($config['format'] === 'cef') {
+ return audit_syslog_cef_payload($event, $config);
+ }
+
+ if ($config['format'] === 'json') {
+ return audit_json_encode(audit_syslog_normalized_data($event, $config), JSON_UNESCAPED_SLASHES);
+ }
+
+ return 'Audit event ' . (string) ($event['event_uuid'] ?? '');
+}
+
+function audit_syslog_record($event, $config) {
+ if (empty($config['valid'])) {
+ return array(
+ 'status' => 'failed',
+ 'permanent' => true,
+ 'error_code' => 'configuration_invalid',
+ 'error' => implode(',', $config['errors']),
+ 'record' => ''
+ );
+ }
+
+ $facilities = audit_syslog_facilities();
+ $priority = ($facilities[$config['facility']] * 8) +
+ audit_syslog_severity_code($event['severity'] ?? 'info');
+ $timestamp = audit_syslog_timestamp($event['event_time'] ?? '');
+ $hostname = audit_syslog_header_token($config['node_id'], 255, 'cacti');
+ $application = audit_syslog_header_token($config['application'], 48, 'cacti-audit');
+ $poller_id = $config['poller_id'] !== '' ? $config['poller_id'] : '-';
+ $process = audit_syslog_header_token($poller_id, 128, '-');
+ $message_id = audit_syslog_header_token($event['event_type'] ?? 'cacti.audit', 32, 'cacti.audit');
+ $structured_values = array(
+ 'eventUuid' => $event['event_uuid'] ?? '',
+ 'correlationId' => $event['correlation_id'] ?? '',
+ 'nodeId' => $config['node_id'],
+ 'pollerId' => $config['poller_id'],
+ 'outcome' => $event['operation_outcome'] ?? '',
+ 'targetType' => $event['target_type'] ?? '',
+ 'targetId' => $event['target_id'] ?? '',
+ 'integrityHash' => $event['integrity_hash'] ?? ''
+ );
+ $structured = array();
+
+ foreach ($structured_values as $name => $value) {
+ $structured[] = $name . '="' . audit_syslog_structured_value($value) . '"';
+ }
+
+ $record = '<' . $priority . '>1 ' . $timestamp . ' ' . $hostname . ' ' .
+ $application . ' ' . $process . ' ' . $message_id .
+ ' [cactiAudit@23925 ' . implode(' ', $structured) . '] ' .
+ audit_syslog_message_payload($event, $config);
+
+ if (strlen($record) > 262144) {
+ return array(
+ 'status' => 'failed',
+ 'permanent' => true,
+ 'error_code' => 'message_too_large',
+ 'error' => 'The formatted Syslog record exceeds 262144 bytes.',
+ 'record' => ''
+ );
+ }
+
+ if ($config['transport'] === 'udp' && strlen($record) > $config['udp_max_size']) {
+ return array(
+ 'status' => 'failed',
+ 'permanent' => true,
+ 'error_code' => 'udp_message_too_large',
+ 'error' => 'The formatted Syslog record exceeds the configured UDP maximum.',
+ 'record' => ''
+ );
+ }
+
+ return array(
+ 'status' => 'ready',
+ 'permanent' => false,
+ 'error_code' => '',
+ 'error' => '',
+ 'record' => $record
+ );
+}
+
+function audit_syslog_frame($record, $transport) {
+ if ($transport === 'tcp' || $transport === 'tls') {
+ return strlen($record) . ' ' . $record;
+ }
+
+ return $record;
+}
+
+function audit_syslog_socket_target($config) {
+ $receiver = $config['receiver'];
+ if (filter_var($receiver, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
+ $receiver = '[' . $receiver . ']';
+ }
+
+ $scheme = $config['transport'] === 'tls' ? 'tls' : $config['transport'];
+
+ return $scheme . '://' . $receiver . ':' . $config['port'];
+}
+
+function audit_syslog_stream_operation($operation, &$warning = null) {
+ $warning = '';
+ $handler = function($severity, $message) use (&$warning) {
+ $warning = audit_syslog_bounded_error($message);
+ return true;
+ };
+
+ set_error_handler($handler);
+
+ try {
+ return call_user_func($operation);
+ } catch (Throwable $exception) {
+ $warning = audit_syslog_bounded_error($exception->getMessage());
+ return false;
+ } finally {
+ restore_error_handler();
+ }
+}
+
+function audit_syslog_open_socket($config) {
+ $context_options = array();
+
+ if ($config['transport'] === 'tls') {
+ $context_options['ssl'] = array(
+ 'verify_peer' => true,
+ 'verify_peer_name' => true,
+ 'allow_self_signed' => false,
+ 'peer_name' => $config['receiver'],
+ 'SNI_enabled' => true,
+ 'disable_compression' => true
+ );
+
+ if ($config['tls_ca_file'] !== '') {
+ $context_options['ssl']['cafile'] = $config['tls_ca_file'];
+ }
+
+ if ($config['tls_client_cert'] !== '') {
+ $context_options['ssl']['local_cert'] = $config['tls_client_cert'];
+ $context_options['ssl']['local_pk'] = $config['tls_client_key'];
+ }
+ }
+
+ $context = stream_context_create($context_options);
+ $error_number = 0;
+ $error_message = '';
+ $flags = STREAM_CLIENT_CONNECT;
+ $stream_warning = '';
+ $socket = audit_syslog_stream_operation(function() use (
+ $config,
+ &$error_number,
+ &$error_message,
+ $flags,
+ $context
+ ) {
+ return stream_socket_client(
+ audit_syslog_socket_target($config),
+ $error_number,
+ $error_message,
+ $config['timeout'],
+ $flags,
+ $context
+ );
+ }, $stream_warning);
+
+ if ($socket === false) {
+ $error = $error_message !== ''
+ ? $error_message
+ : ($stream_warning !== '' ? $stream_warning : 'Unable to connect to Syslog receiver.');
+
+ return array(
+ 'socket' => null,
+ 'error_code' => 'connection_failed',
+ 'error' => audit_syslog_bounded_error($error)
+ );
+ }
+
+ stream_set_timeout($socket, $config['timeout']);
+ stream_set_blocking($socket, true);
+
+ return array('socket' => $socket, 'error_code' => '', 'error' => '');
+}
+
+function audit_syslog_bounded_error($error) {
+ $error = preg_replace('/[\\x00-\\x1f\\x7f]+/', ' ', (string) $error);
+
+ return substr(trim($error), 0, 1024);
+}
+
+function audit_syslog_fwrite($socket, $message, &$warning = null) {
+ return audit_syslog_stream_operation(function() use ($socket, $message) {
+ return fwrite($socket, $message);
+ }, $warning);
+}
+
+function audit_syslog_write($socket, $message, $transport) {
+ if (!is_resource($socket)) {
+ return array('status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.');
+ }
+
+ if ($transport === 'udp') {
+ $stream_warning = '';
+ $written = audit_syslog_fwrite($socket, $message, $stream_warning);
+ if ($written !== strlen($message)) {
+ $error = $stream_warning !== ''
+ ? $stream_warning
+ : 'Unable to write the complete Syslog datagram.';
+
+ return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => $error);
+ }
+ } else {
+ $length = strlen($message);
+ $offset = 0;
+
+ while ($offset < $length) {
+ $stream_warning = '';
+ $written = audit_syslog_fwrite($socket, substr($message, $offset), $stream_warning);
+ if ($written === false || $written === 0) {
+ $metadata = stream_get_meta_data($socket);
+ $error = !empty($metadata['timed_out'])
+ ? 'Syslog write timed out.'
+ : ($stream_warning !== '' ? $stream_warning : 'Unable to write the complete Syslog record.');
+
+ return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => $error);
+ }
+
+ $offset += $written;
+ }
+ }
+
+ return array('status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '');
+}
+
+function audit_syslog_send_event($event, $config, &$socket = null) {
+ $formatted = audit_syslog_record($event, $config);
+ if ($formatted['status'] !== 'ready') {
+ return $formatted;
+ }
+
+ if (!is_resource($socket)) {
+ $opened = audit_syslog_open_socket($config);
+ if (!is_resource($opened['socket'])) {
+ return array(
+ 'status' => 'failed',
+ 'permanent' => false,
+ 'error_code' => $opened['error_code'],
+ 'error' => $opened['error']
+ );
+ }
+
+ $socket = $opened['socket'];
+ }
+
+ $message = audit_syslog_frame($formatted['record'], $config['transport']);
+ $result = audit_syslog_write($socket, $message, $config['transport']);
+ $result['permanent'] = false;
+
+ if ($result['status'] !== 'sent_unconfirmed' && is_resource($socket)) {
+ fclose($socket);
+ $socket = null;
+ }
+
+ return $result;
+}
+
+function audit_enqueue_syslog_event($audit_id) {
+ if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) {
+ return;
+ }
+
+ $event = db_fetch_row_prepared('SELECT id, event_uuid, request_status
+ FROM audit_log
+ WHERE id = ?',
+ array($audit_id));
+
+ if (!cacti_sizeof($event) || $event['request_status'] === 'started' || $event['event_uuid'] === '') {
+ return;
+ }
+
+ $config = audit_syslog_config();
+ $state = $config['valid'] ? 'pending' : 'dead_letter';
+ $error = $config['valid'] ? null : audit_syslog_bounded_error('configuration_invalid: ' . implode(',', $config['errors']));
+
+ db_execute_prepared('INSERT IGNORE INTO audit_syslog_delivery (
+ audit_id, event_uuid, destination_fingerprint, node_id, poller_id, state, attempts,
+ next_attempt, last_error, created_time, updated_time
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, UTC_TIMESTAMP(6), ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))',
+ array(
+ $event['id'], $event['event_uuid'], $config['fingerprint'],
+ $config['node_id'], $config['poller_id'], $state, $error
+ ));
+}
+
+function audit_syslog_delivery_config($config, $delivery) {
+ if (isset($delivery['delivery_node_id']) && $delivery['delivery_node_id'] !== '') {
+ $config['node_id'] = $delivery['delivery_node_id'];
+ }
+
+ if (array_key_exists('delivery_poller_id', $delivery)) {
+ $config['poller_id'] = (string) $delivery['delivery_poller_id'];
+ }
+
+ $config['fingerprint'] = audit_syslog_destination_fingerprint($config);
+
+ return $config;
+}
+
+function audit_syslog_retry_delay($attempt, $config) {
+ $exponent = min(max(0, (int) $attempt - 1), 30);
+ $delay = $config['retry_base'] * pow(2, $exponent);
+
+ return (int) min($config['retry_max'], $delay);
+}
+
+function audit_syslog_update_delivery($delivery, $result, $config) {
+ $attempts = (int) $delivery['attempts'] + 1;
+ $error = isset($result['error']) ? audit_syslog_bounded_error($result['error']) : '';
+
+ if ($result['status'] === 'sent_unconfirmed') {
+ db_execute_prepared("UPDATE audit_syslog_delivery
+ SET state = 'sent_unconfirmed',
+ destination_fingerprint = ?,
+ attempts = ?,
+ last_attempt = UTC_TIMESTAMP(6),
+ sent_time = UTC_TIMESTAMP(6),
+ last_error = NULL,
+ updated_time = UTC_TIMESTAMP(6)
+ WHERE id = ?",
+ array($config['fingerprint'], $attempts, $delivery['delivery_id']));
+ return;
+ }
+
+ $permanent = !empty($result['permanent']) || $attempts >= $config['max_attempts'];
+ $state = $permanent ? 'dead_letter' : 'retry';
+ $delay = $permanent ? 0 : audit_syslog_retry_delay($attempts, $config);
+ $error_code = isset($result['error_code']) ? $result['error_code'] : 'delivery_failed';
+ $stored_error = audit_syslog_bounded_error($error_code . ': ' . $error);
+
+ db_execute_prepared('UPDATE audit_syslog_delivery
+ SET state = ?,
+ destination_fingerprint = ?,
+ attempts = ?,
+ next_attempt = CASE WHEN ? = 0 THEN next_attempt ELSE DATE_ADD(UTC_TIMESTAMP(6), INTERVAL ? SECOND) END,
+ last_attempt = UTC_TIMESTAMP(6),
+ last_error = ?,
+ updated_time = UTC_TIMESTAMP(6)
+ WHERE id = ?',
+ array($state, $config['fingerprint'], $attempts, $delay, $delay, $stored_error, $delivery['delivery_id']));
+}
+
+function audit_process_syslog_queue() {
+ if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) {
+ return;
+ }
+
+ $config = audit_syslog_config();
+ if (!$config['valid']) {
+ audit_syslog_check_health($config);
+ return;
+ }
+
+ $batch_size = (int) $config['batch_size'];
+ $deliveries = db_fetch_assoc_prepared("SELECT
+ d.id AS delivery_id, d.attempts, d.event_uuid AS delivery_event_uuid,
+ d.node_id AS delivery_node_id, d.poller_id AS delivery_poller_id,
+ a.*
+ FROM audit_syslog_delivery AS d
+ INNER JOIN audit_log AS a
+ ON a.id = d.audit_id
+ WHERE d.state IN ('pending', 'retry')
+ AND d.next_attempt <= UTC_TIMESTAMP(6)
+ AND a.request_status <> 'started'
+ ORDER BY d.next_attempt, d.id
+ LIMIT " . $batch_size,
+ array());
+ $socket = null;
+
+ foreach ($deliveries as $delivery) {
+ $delivery_config = audit_syslog_delivery_config($config, $delivery);
+ $result = audit_syslog_send_event($delivery, $delivery_config, $socket);
+ audit_syslog_update_delivery($delivery, $result, $delivery_config);
+
+ if ($result['status'] !== 'sent_unconfirmed' && empty($result['permanent'])) {
+ break;
+ }
+ }
+
+ if (is_resource($socket)) {
+ fclose($socket);
+ }
+
+ audit_syslog_check_health($config);
+}
+
+function audit_syslog_health() {
+ if (!db_table_exists('audit_syslog_delivery')) {
+ return array(
+ 'pending' => 0, 'retry' => 0, 'sent_unconfirmed' => 0,
+ 'dead_letter' => 0, 'oldest_pending_seconds' => 0,
+ 'last_attempt' => null, 'last_sent' => null, 'last_error' => null
+ );
+ }
+
+ $row = db_fetch_row("SELECT
+ SUM(state = 'pending') AS pending,
+ SUM(state = 'retry') AS retry,
+ SUM(state = 'sent_unconfirmed') AS sent_unconfirmed,
+ SUM(state = 'dead_letter') AS dead_letter,
+ COALESCE(MAX(CASE WHEN state IN ('pending', 'retry')
+ THEN TIMESTAMPDIFF(SECOND, created_time, UTC_TIMESTAMP(6)) ELSE 0 END), 0) AS oldest_pending_seconds,
+ MAX(last_attempt) AS last_attempt,
+ MAX(sent_time) AS last_sent
+ FROM audit_syslog_delivery");
+ $last_error = db_fetch_cell("SELECT last_error
+ FROM audit_syslog_delivery
+ WHERE last_error IS NOT NULL
+ AND last_error <> ''
+ ORDER BY last_attempt DESC, id DESC
+ LIMIT 1");
+
+ return array(
+ 'pending' => (int) ($row['pending'] ?? 0),
+ 'retry' => (int) ($row['retry'] ?? 0),
+ 'sent_unconfirmed' => (int) ($row['sent_unconfirmed'] ?? 0),
+ 'dead_letter' => (int) ($row['dead_letter'] ?? 0),
+ 'oldest_pending_seconds' => (int) ($row['oldest_pending_seconds'] ?? 0),
+ 'last_attempt' => $row['last_attempt'] ?? null,
+ 'last_sent' => $row['last_sent'] ?? null,
+ 'last_error' => $last_error !== false && $last_error !== '' ? $last_error : null
+ );
+}
+
+function audit_syslog_check_health($config = null) {
+ if (!audit_syslog_enabled()) {
+ return;
+ }
+
+ $config = $config === null ? audit_syslog_config() : $config;
+ $health = audit_syslog_health();
+ $unhealthy = !$config['valid'] ||
+ $health['dead_letter'] >= $config['dead_letter_warning'] ||
+ $health['oldest_pending_seconds'] >= $config['pending_age_warning'];
+ $state = $unhealthy ? 'unhealthy' : 'healthy';
+ $previous = read_config_option('audit_syslog_health_state');
+
+ if ($previous !== $state) {
+ $message = $unhealthy
+ ? 'WARNING: Audit Syslog delivery is unhealthy.'
+ : 'NOTICE: Audit Syslog delivery has recovered.';
+ cacti_log($message, false, 'AUDIT');
+ set_config_option('audit_syslog_health_state', $state);
+ }
+}
+
+function audit_syslog_retry_dead_letters($delivery_ids = array()) {
+ if (!db_table_exists('audit_syslog_delivery')) {
+ return 0;
+ }
+
+ $normalized_ids = array();
+ foreach ($delivery_ids as $delivery_id) {
+ $delivery_id = (int) $delivery_id;
+ if ($delivery_id > 0) {
+ $normalized_ids[] = $delivery_id;
+ }
+ }
+ $delivery_ids = array_values(array_unique($normalized_ids));
+ if (cacti_sizeof($delivery_ids) > 1000) {
+ $delivery_ids = array_slice($delivery_ids, 0, 1000);
+ }
+
+ if (cacti_sizeof($delivery_ids)) {
+ $placeholders = implode(',', array_fill(0, cacti_sizeof($delivery_ids), '?'));
+ db_execute_prepared("UPDATE audit_syslog_delivery
+ SET state = 'pending',
+ attempts = 0,
+ next_attempt = UTC_TIMESTAMP(6),
+ last_error = NULL,
+ updated_time = UTC_TIMESTAMP(6)
+ WHERE state = 'dead_letter'
+ AND id IN ($placeholders)",
+ $delivery_ids);
+ } else {
+ db_execute("UPDATE audit_syslog_delivery
+ SET state = 'pending',
+ attempts = 0,
+ next_attempt = UTC_TIMESTAMP(6),
+ last_error = NULL,
+ updated_time = UTC_TIMESTAMP(6)
+ WHERE state = 'dead_letter'");
+ }
+
+ return db_affected_rows();
+}
+
+function audit_syslog_test_delivery() {
+ $config = audit_syslog_config();
+ $event = array(
+ 'event_uuid' => audit_uuid_v4(),
+ 'correlation_id' => audit_request_correlation_id(),
+ 'event_type' => 'audit.syslog.test',
+ 'event_category' => 'audit',
+ 'severity' => 'notice',
+ 'user_id' => $_SESSION['sess_user_id'] ?? 0,
+ 'action' => 'test',
+ 'request_status' => 'completed',
+ 'operation_outcome' => 'success',
+ 'target_type' => 'syslog_receiver',
+ 'target_id' => $config['receiver'],
+ 'ip_address' => function_exists('get_client_addr') ? get_client_addr() : '',
+ 'event_time' => audit_utc_time(),
+ 'details' => audit_json_encode(array('test' => true))
+ );
+ $socket = null;
+ $result = audit_syslog_send_event($event, $config, $socket);
+
+ if (is_resource($socket)) {
+ fclose($socket);
+ }
+
+ return $result;
+}
diff --git a/js/functions.js b/js/functions.js
index 41bf752..96b9a78 100644
--- a/js/functions.js
+++ b/js/functions.js
@@ -117,6 +117,22 @@ $(function() {
});
});
+ $('#syslog_test').click(function() {
+ loadPageUsingPost('audit.php?action=syslog_test&header=false', {
+ __csrf_magic: csrfMagicToken
+ });
+ });
+
+ $('#syslog_retry').click(function() {
+ if (!window.confirm($(this).data('confirm'))) {
+ return;
+ }
+
+ loadPageUsingPost('audit.php?action=syslog_retry&header=false', {
+ __csrf_magic: csrfMagicToken
+ });
+ });
+
$('#export').click(function() {
document.location = 'audit.php?action=export' +
'&filter='+encodeURIComponent($('#filter').val())+
diff --git a/setup.php b/setup.php
index 4c1be32..7d74976 100644
--- a/setup.php
+++ b/setup.php
@@ -101,6 +101,7 @@ function audit_remove_deprecated_realms() {
}
function plugin_audit_uninstall() {
+ db_execute('DROP TABLE IF EXISTS audit_syslog_delivery');
db_execute('DROP TABLE IF EXISTS audit_log');
return true;
}
@@ -160,6 +161,7 @@ function audit_check_upgrade() {
db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data");
db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status');
audit_upgrade_event_schema();
+ audit_setup_syslog_table();
audit_setup_realms();
audit_remove_deprecated_realms();
@@ -234,6 +236,7 @@ function audit_replicate_out($data) {
function audit_poller_bottom() {
audit_retry_external_logs();
+ audit_process_syslog_queue();
$last_check = read_config_option('audit_last_check');
$now = gmdate('Y-m-d');
@@ -244,7 +247,15 @@ function audit_poller_bottom() {
if ($retention > 0) {
$cutoff = audit_retention_cutoff($retention);
- db_execute_prepared('DELETE FROM audit_log WHERE event_time < ?', array($cutoff->format('Y-m-d H:i:s')));
+ db_execute_prepared("DELETE FROM audit_log
+ WHERE event_time < ?
+ AND NOT EXISTS (
+ SELECT 1
+ FROM audit_syslog_delivery
+ WHERE audit_syslog_delivery.audit_id = audit_log.id
+ AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter')
+ )",
+ array($cutoff->format('Y-m-d H:i:s')));
$rows = db_affected_rows();
cacti_log('NOTE: Purged ' . $rows . ' Audit Log Records from Cacti', false, 'POLLER');
}
@@ -304,9 +315,46 @@ function audit_setup_table() {
ENGINE=InnoDB
COMMENT='Audit Log for all GUI activities'");
+ audit_setup_syslog_table();
+
return true;
}
+function audit_setup_syslog_table() {
+ db_execute("CREATE TABLE IF NOT EXISTS `audit_syslog_delivery` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `audit_id` bigint(20) unsigned NOT NULL,
+ `event_uuid` char(36) NOT NULL,
+ `destination_fingerprint` char(64) NOT NULL,
+ `node_id` varchar(255) NOT NULL,
+ `poller_id` varchar(64) DEFAULT NULL,
+ `state` varchar(20) NOT NULL DEFAULT 'pending',
+ `attempts` int unsigned NOT NULL DEFAULT 0,
+ `next_attempt` datetime(6) NOT NULL,
+ `last_attempt` datetime(6) DEFAULT NULL,
+ `sent_time` datetime(6) DEFAULT NULL,
+ `last_error` varchar(1024) DEFAULT NULL,
+ `created_time` datetime(6) NOT NULL,
+ `updated_time` datetime(6) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `audit_destination` (`audit_id`, `destination_fingerprint`),
+ KEY `event_uuid` (`event_uuid`),
+ KEY `state_next_attempt` (`state`, `next_attempt`),
+ KEY `destination_state` (`destination_fingerprint`, `state`),
+ CONSTRAINT `fk_audit_syslog_event`
+ FOREIGN KEY (`audit_id`) REFERENCES `audit_log` (`id`)
+ ON DELETE CASCADE)
+ ENGINE=InnoDB
+ COMMENT='Remote Syslog delivery queue for audit events'");
+
+ db_execute("ALTER TABLE audit_syslog_delivery
+ ADD COLUMN IF NOT EXISTS node_id varchar(255) NOT NULL DEFAULT 'cacti'
+ AFTER destination_fingerprint");
+ db_execute("ALTER TABLE audit_syslog_delivery
+ ADD COLUMN IF NOT EXISTS poller_id varchar(64) DEFAULT NULL
+ AFTER node_id");
+}
+
function audit_upgrade_event_schema($rcnn_id = false) {
$remote = $rcnn_id !== false;
$args = $remote ? array(true, $rcnn_id) : array();
@@ -488,6 +536,182 @@ function audit_config_settings() {
),
);
+ if (php_sapi_name() === 'cli' || audit_user_is_admin()) {
+ $facility_options = array();
+ foreach (audit_syslog_facilities() as $facility => $code) {
+ $facility_options[$facility] = strtoupper($facility) . ' (' . $code . ')';
+ }
+
+ $syslog = array(
+ 'audit_syslog_header' => array(
+ 'friendly_name' => __('Remote Syslog', 'audit'),
+ 'method' => 'spacer'
+ ),
+ 'audit_syslog_enabled' => array(
+ 'friendly_name' => __('Enable Remote Syslog', 'audit'),
+ 'description' => __('Queue finalized audit events for remote Syslog delivery. The existing external file output remains independent.', 'audit'),
+ 'method' => 'checkbox',
+ 'default' => 'off'
+ ),
+ 'audit_syslog_receiver' => array(
+ 'friendly_name' => __('Syslog Receiver', 'audit'),
+ 'description' => __('Enter a receiver hostname or IP address without a URI scheme, path, or credentials.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '',
+ 'max_length' => '253',
+ 'size' => '60'
+ ),
+ 'audit_syslog_port' => array(
+ 'friendly_name' => __('Syslog Port', 'audit'),
+ 'description' => __('Enter 1-65535, or leave blank to use 514 for UDP/TCP and 6514 for TLS.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '',
+ 'max_length' => '5',
+ 'size' => '8'
+ ),
+ 'audit_syslog_transport' => array(
+ 'friendly_name' => __('Syslog Transport', 'audit'),
+ 'description' => __('UDP sends one datagram without acknowledgement. TCP and TLS use RFC 6587 octet-count framing.', 'audit'),
+ 'method' => 'drop_array',
+ 'default' => 'udp',
+ 'array' => array(
+ 'udp' => __('UDP', 'audit'),
+ 'tcp' => __('TCP', 'audit'),
+ 'tls' => __('TLS', 'audit')
+ )
+ ),
+ 'audit_syslog_format' => array(
+ 'friendly_name' => __('Syslog Payload Format', 'audit'),
+ 'description' => __('All formats use an RFC 5424 header. Select RFC 5424 structured data, CEF, or compact JSON for the message.', 'audit'),
+ 'method' => 'drop_array',
+ 'default' => 'json',
+ 'array' => array(
+ 'rfc5424' => __('RFC 5424', 'audit'),
+ 'cef' => __('CEF', 'audit'),
+ 'json' => __('JSON', 'audit')
+ )
+ ),
+ 'audit_syslog_facility' => array(
+ 'friendly_name' => __('Syslog Facility', 'audit'),
+ 'description' => __('Select the facility used to calculate the RFC 5424 priority.', 'audit'),
+ 'method' => 'drop_array',
+ 'default' => 'local0',
+ 'array' => $facility_options
+ ),
+ 'audit_syslog_application' => array(
+ 'friendly_name' => __('Syslog Application Name', 'audit'),
+ 'description' => __('RFC 5424 APP-NAME. Printable non-space ASCII, up to 48 characters.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => 'cacti-audit',
+ 'max_length' => '48',
+ 'size' => '30'
+ ),
+ 'audit_syslog_node_id' => array(
+ 'friendly_name' => __('Audit Node Identity', 'audit'),
+ 'description' => __('Stable RFC 5424 hostname identity for this Cacti node. Do not use a value that changes on restart.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => php_uname('n'),
+ 'max_length' => '255',
+ 'size' => '60'
+ ),
+ 'audit_syslog_timeout' => array(
+ 'friendly_name' => __('Connection and Write Timeout', 'audit'),
+ 'description' => __('Timeout in seconds, from 1 through 30.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '5',
+ 'max_length' => '2',
+ 'size' => '8'
+ ),
+ 'audit_syslog_udp_max_size' => array(
+ 'friendly_name' => __('Maximum UDP Record Size', 'audit'),
+ 'description' => __('Records larger than this byte limit are dead-lettered and are never truncated or split. TCP or TLS is recommended for large events.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '8192',
+ 'max_length' => '5',
+ 'size' => '10'
+ ),
+ 'audit_syslog_tls_header' => array(
+ 'friendly_name' => __('Syslog TLS', 'audit'),
+ 'method' => 'spacer'
+ ),
+ 'audit_syslog_tls_ca_file' => array(
+ 'friendly_name' => __('TLS CA File', 'audit'),
+ 'description' => __('Optional PEM CA bundle. Peer and hostname verification are always enabled.', 'audit'),
+ 'method' => 'filepath',
+ 'default' => '',
+ 'max_length' => '255'
+ ),
+ 'audit_syslog_tls_client_cert' => array(
+ 'friendly_name' => __('TLS Client Certificate', 'audit'),
+ 'description' => __('Optional PEM client certificate. A client key must also be configured.', 'audit'),
+ 'method' => 'filepath',
+ 'default' => '',
+ 'max_length' => '255'
+ ),
+ 'audit_syslog_tls_client_key' => array(
+ 'friendly_name' => __('TLS Client Private Key', 'audit'),
+ 'description' => __('Optional readable PEM private-key path. The key contents are never stored in audit events.', 'audit'),
+ 'method' => 'filepath',
+ 'default' => '',
+ 'max_length' => '255'
+ ),
+ 'audit_syslog_delivery_header' => array(
+ 'friendly_name' => __('Syslog Delivery Queue', 'audit'),
+ 'method' => 'spacer'
+ ),
+ 'audit_syslog_retry_base' => array(
+ 'friendly_name' => __('Retry Base Delay', 'audit'),
+ 'description' => __('Initial retry delay in seconds, from 1 through 3600.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '30',
+ 'max_length' => '4',
+ 'size' => '8'
+ ),
+ 'audit_syslog_retry_max' => array(
+ 'friendly_name' => __('Maximum Retry Delay', 'audit'),
+ 'description' => __('Maximum retry delay in seconds, from the base delay through 86400.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '3600',
+ 'max_length' => '5',
+ 'size' => '8'
+ ),
+ 'audit_syslog_max_attempts' => array(
+ 'friendly_name' => __('Maximum Delivery Attempts', 'audit'),
+ 'description' => __('Move a record to dead-letter after this many attempts, from 1 through 100.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '10',
+ 'max_length' => '3',
+ 'size' => '8'
+ ),
+ 'audit_syslog_batch_size' => array(
+ 'friendly_name' => __('Poller Batch Size', 'audit'),
+ 'description' => __('Maximum due records processed per poller cycle, from 1 through 1000.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '100',
+ 'max_length' => '4',
+ 'size' => '8'
+ ),
+ 'audit_syslog_pending_age_warning' => array(
+ 'friendly_name' => __('Pending Age Warning', 'audit'),
+ 'description' => __('Show an unhealthy warning when the oldest queued record reaches this age in seconds.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '900',
+ 'max_length' => '6',
+ 'size' => '10'
+ ),
+ 'audit_syslog_dead_letter_warning' => array(
+ 'friendly_name' => __('Dead-letter Warning Count', 'audit'),
+ 'description' => __('Show an unhealthy warning when this many records are dead-lettered.', 'audit'),
+ 'method' => 'textbox',
+ 'default' => '1',
+ 'max_length' => '7',
+ 'size' => '10'
+ )
+ );
+
+ $temp = array_merge($temp, $syslog);
+ }
+
$tabs['audit'] = __('Audit', 'audit');
if (isset($settings['audit'])) {
diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php
index d8bb161..bd8d5e1 100644
--- a/tests/Security/Php74CompatibilityTest.php
+++ b/tests/Security/Php74CompatibilityTest.php
@@ -16,6 +16,7 @@
$files = array(
'audit.php',
'audit_functions.php',
+ 'audit_syslog.php',
'setup.php',
);
diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php
index ba9f023..fbdc78d 100644
--- a/tests/Security/PreparedStatementConsistencyTest.php
+++ b/tests/Security/PreparedStatementConsistencyTest.php
@@ -17,6 +17,7 @@
$targetFiles = array(
'audit.php',
'audit_functions.php',
+ 'audit_syslog.php',
'setup.php',
);
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 0aa22f2..b701e11 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -76,6 +76,18 @@ function db_column_exists($table, $column) {
}
}
+if (!function_exists('db_table_exists')) {
+ function db_table_exists($table) {
+ return true;
+ }
+}
+
+if (!function_exists('db_affected_rows')) {
+ function db_affected_rows() {
+ return 0;
+ }
+}
+
if (!function_exists('api_plugin_db_add_column')) {
function api_plugin_db_add_column($plugin, $table, $data) {
return true;
diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php
index 890b682..2f445ae 100644
--- a/tests/controller_security_test.php
+++ b/tests/controller_security_test.php
@@ -12,7 +12,11 @@
'html_escape($data',
"__('Outcome Reason:', 'audit')",
"header('Content-Type: text/csv; charset=UTF-8')",
- "fputcsv("
+ "fputcsv(",
+ "case 'syslog_test':",
+ "case 'syslog_retry':",
+ 'audit_syslog_test_delivery()',
+ 'audit_syslog_retry_dead_letters($delivery_ids)'
);
foreach ($required_controller_guards as $guard) {
@@ -35,10 +39,14 @@
'ADD COLUMN IF NOT EXISTS external_error',
'SHOW CREATE TABLE $table',
'audit_retry_external_logs()',
+ 'audit_process_syslog_queue()',
'logout_pre_session_destroy',
'event_uuid char(36)',
'operation_outcome',
- 'external_attempts'
+ 'external_attempts',
+ 'CREATE TABLE IF NOT EXISTS `audit_syslog_delivery`',
+ 'DROP TABLE IF EXISTS audit_syslog_delivery',
+ "auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'))"
);
foreach ($required_schema_fragments as $fragment) {
@@ -77,6 +85,17 @@
exit(1);
}
+if (substr_count($controller, 'csrf_check(false)') < 3) {
+ fwrite(STDERR, 'Purge, Syslog test, and Syslog retry actions must each validate CSRF.' . PHP_EOL);
+ exit(1);
+}
+
+if (strpos($functions, 'audit_enforce_syslog_settings_request()') === false ||
+ strpos($functions, "'audit.syslog.configuration.denied'") === false) {
+ fwrite(STDERR, 'Remote Syslog settings must enforce Audit Log Admin on save.' . PHP_EOL);
+ exit(1);
+}
+
if (strpos($javascript, "loadPageNoHeader('audit.php?action=purge") !== false) {
fwrite(STDERR, 'Purge must not use the legacy GET request path.' . PHP_EOL);
exit(1);
@@ -87,4 +106,10 @@
exit(1);
}
+if (strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_test") === false ||
+ strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_retry") === false) {
+ fwrite(STDERR, 'Syslog administration actions must use POST request paths.' . PHP_EOL);
+ exit(1);
+}
+
print "Controller security checks passed.\n";
diff --git a/tests/syslog_functions_test.php b/tests/syslog_functions_test.php
new file mode 100644
index 0000000..0adf595
--- /dev/null
+++ b/tests/syslog_functions_test.php
@@ -0,0 +1,446 @@
+ '127.0.0.1',
+ 'port' => '514',
+ 'transport' => 'udp',
+ 'format' => 'json',
+ 'facility' => 'local0',
+ 'application' => 'cacti-audit',
+ 'node_id' => 'cacti-node-1',
+ 'timeout' => '1',
+ 'udp_max_size' => '8192',
+ 'retry_base' => '5',
+ 'retry_max' => '60',
+ 'max_attempts' => '5',
+ 'batch_size' => '10',
+ 'pending_age_warning' => '900',
+ 'dead_letter_warning' => '1',
+ 'tls_ca_file' => '',
+ 'tls_client_cert' => '',
+ 'tls_client_key' => ''
+ );
+
+ return audit_syslog_config(array_merge($values, $overrides));
+}
+
+function audit_syslog_test_event() {
+ return array(
+ 'id' => 42,
+ 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca',
+ 'correlation_id' => '48f486c3-90d0-45a2-a50d-3b31e571aa91',
+ 'event_type' => 'cacti.user_admin.save',
+ 'event_category' => 'identity_access',
+ 'severity' => 'info',
+ 'actor_type' => 'user',
+ 'page' => 'user_admin.php',
+ 'user_id' => 1,
+ 'action' => 'save',
+ 'request_status' => 'completed',
+ 'operation_outcome' => 'success',
+ 'target_type' => 'user',
+ 'target_id' => '4',
+ 'ip_address' => '192.0.2.10',
+ 'event_time' => '2026-07-24 15:16:17.123456',
+ 'post' => '{"id":4,"description":"new value","password":"must-not-leak"}',
+ 'object_data' => '[{"id":4,"description":"old value"}]',
+ 'details' => '{"test":true}',
+ 'integrity_hash' => str_repeat('a', 64)
+ );
+}
+
+function audit_syslog_test_udp_server(&$port, &$error, &$error_message) {
+ for ($attempt = 0; $attempt < 20; $attempt++) {
+ $port = random_int(20000, 50000);
+ $server = @stream_socket_server(
+ 'udp://127.0.0.1:' . $port,
+ $error,
+ $error_message,
+ STREAM_SERVER_BIND
+ );
+
+ if (is_resource($server)) {
+ return $server;
+ }
+ }
+
+ return false;
+}
+
+function audit_syslog_test_tls_material() {
+ $directory = sys_get_temp_dir() . '/audit-syslog-' . bin2hex(random_bytes(8));
+ if (!mkdir($directory, 0700)) {
+ return false;
+ }
+
+ $config_path = $directory . '/openssl.cnf';
+ $config_text = "[req]\n" .
+ "distinguished_name=req_dn\n" .
+ "req_extensions=v3_req\n" .
+ "prompt=no\n" .
+ "[req_dn]\n" .
+ "CN=127.0.0.1\n" .
+ "[v3_req]\n" .
+ "subjectAltName=IP:127.0.0.1\n" .
+ "keyUsage=digitalSignature,keyEncipherment\n" .
+ "extendedKeyUsage=serverAuth\n";
+ file_put_contents($config_path, $config_text);
+
+ $options = array(
+ 'config' => $config_path,
+ 'digest_alg' => 'sha256',
+ 'private_key_bits' => 2048,
+ 'private_key_type' => OPENSSL_KEYTYPE_RSA,
+ 'req_extensions' => 'v3_req',
+ 'x509_extensions' => 'v3_req'
+ );
+ $key = openssl_pkey_new($options);
+ $csr = $key === false ? false : openssl_csr_new(array('commonName' => '127.0.0.1'), $key, $options);
+ $certificate = $csr === false ? false : openssl_csr_sign($csr, null, $key, 1, $options);
+
+ if ($certificate === false ||
+ !openssl_x509_export($certificate, $certificate_pem) ||
+ !openssl_pkey_export($key, $key_pem, null, $options)) {
+ return false;
+ }
+
+ $ca_path = $directory . '/ca.pem';
+ $server_path = $directory . '/server.pem';
+ $result_path = $directory . '/received.log';
+ file_put_contents($ca_path, $certificate_pem);
+ file_put_contents($server_path, $certificate_pem . $key_pem);
+ chmod($server_path, 0600);
+
+ return array(
+ 'directory' => $directory,
+ 'config' => $config_path,
+ 'ca' => $ca_path,
+ 'server' => $server_path,
+ 'result' => $result_path
+ );
+}
+
+function audit_syslog_test_tls_server($material, &$port) {
+ $context = stream_context_create(array(
+ 'ssl' => array(
+ 'local_cert' => $material['server'],
+ 'verify_peer' => false,
+ 'allow_self_signed' => true
+ )
+ ));
+ $error = 0;
+ $error_message = '';
+ $server = stream_socket_server(
+ 'tls://127.0.0.1:0',
+ $error,
+ $error_message,
+ STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
+ $context
+ );
+
+ if (!is_resource($server)) {
+ return false;
+ }
+
+ $name = stream_socket_get_name($server, false);
+ $port = (int) substr($name, strrpos($name, ':') + 1);
+
+ return $server;
+}
+
+function audit_syslog_test_remove_tls_material($material) {
+ foreach (array('result', 'server', 'ca', 'config') as $name) {
+ if (is_file($material[$name])) {
+ unlink($material[$name]);
+ }
+ }
+
+ rmdir($material['directory']);
+}
+
+$config = audit_syslog_test_config();
+audit_syslog_test_assert($config['valid'], 'A valid Syslog configuration must be accepted.');
+audit_syslog_test_assert($config['tls_verify_peer'] === true, 'TLS peer verification must always be enabled.');
+audit_syslog_test_assert($config['tls_verify_peer_name'] === true, 'TLS hostname verification must always be enabled.');
+audit_syslog_test_assert(
+ audit_syslog_test_config(array('transport' => 'udp', 'port' => ''))['port'] === 514,
+ 'A blank UDP port must use the standard default of 514.'
+);
+audit_syslog_test_assert(
+ audit_syslog_test_config(array('transport' => 'tls', 'port' => ''))['port'] === 6514,
+ 'A blank TLS port must use the standard default of 6514.'
+);
+
+$outer_warning_called = false;
+$captured_warning = '';
+set_error_handler(function() use (&$outer_warning_called) {
+ $outer_warning_called = true;
+ return true;
+});
+$warning_result = audit_syslog_stream_operation(function() {
+ trigger_error("expected stream warning\nwith control text", E_USER_WARNING);
+ return false;
+}, $captured_warning);
+restore_error_handler();
+audit_syslog_test_assert($warning_result === false && !$outer_warning_called,
+ 'Expected stream warnings must not leak into Cacti global error handling.');
+audit_syslog_test_assert($captured_warning === 'expected stream warning with control text',
+ 'Captured stream warnings must be normalized into a bounded one-line delivery error.');
+
+$invalid = audit_syslog_test_config(array('receiver' => 'udp://user:secret@example.test'));
+audit_syslog_test_assert(!$invalid['valid'], 'Receiver URIs and embedded credentials must be rejected.');
+
+$event = audit_syslog_test_event();
+$formatted = audit_syslog_record($event, $config);
+audit_syslog_test_assert($formatted['status'] === 'ready', 'A valid event must produce a Syslog record.');
+audit_syslog_test_assert(strpos($formatted['record'], '<134>1 2026-07-24T15:16:17.123456Z cacti-node-1 cacti-audit 7 cacti.user_admin.save ') === 0,
+ 'RFC 5424 headers must contain the calculated priority, UTC timestamp, node, app, poller, and message ID.');
+audit_syslog_test_assert(strpos($formatted['record'], 'eventUuid="32e0a97d-d9e8-4abc-8f41-2bbbc50793ca"') !== false,
+ 'RFC 5424 structured data must contain the event UUID.');
+audit_syslog_test_assert(strpos($formatted['record'], '[cactiAudit@23925 ') !== false,
+ 'RFC 5424 enterprise structured data must use the IANA-registered Cacti private enterprise number.');
+audit_syslog_test_assert(strpos($formatted['record'], '"node_id":"cacti-node-1"') !== false,
+ 'JSON payloads must contain stable node identity.');
+
+$frame = audit_syslog_frame($formatted['record'], 'tcp');
+audit_syslog_test_assert($frame === strlen($formatted['record']) . ' ' . $formatted['record'],
+ 'TCP and TLS must use RFC 6587 octet-count framing.');
+audit_syslog_test_assert(audit_syslog_frame($formatted['record'], 'udp') === $formatted['record'],
+ 'UDP must send one unframed RFC 5424 record per datagram.');
+
+$cef_config = audit_syslog_test_config(array('format' => 'cef'));
+$cef = audit_syslog_record($event, $cef_config);
+audit_syslog_test_assert(strpos($cef['record'], 'CEF:0|Cacti|Audit Plugin|1.5|cacti.user_admin.save|save|3|') !== false,
+ 'CEF payloads must contain normalized vendor, product, event, action, and severity fields.');
+audit_syslog_test_assert(strpos($cef['record'], 'externalId=32e0a97d-d9e8-4abc-8f41-2bbbc50793ca') !== false,
+ 'CEF payloads must expose the stable event UUID for deduplication.');
+audit_syslog_test_assert(strpos($cef['record'], 'cs4Label=Submitted Data cs4={"id":4,"description":"new value","password":"[REDACTED]"}') !== false,
+ 'CEF payloads must expose redacted submitted data for investigation.');
+audit_syslog_test_assert(strpos($cef['record'], 'cs5Label=Object Data cs5=[{"id":4,"description":"old value"}]') !== false,
+ 'CEF payloads must expose the stored object data available to JSON consumers.');
+audit_syslog_test_assert(strpos($cef['record'], 'cs6Label=Details cs6={"test":true}') !== false,
+ 'CEF payloads must expose normalized event details.');
+audit_syslog_test_assert(strpos($cef['record'], 'must-not-leak') === false,
+ 'CEF payloads must defensively redact sensitive submitted fields.');
+
+$rfc_config = audit_syslog_test_config(array('format' => 'rfc5424'));
+$rfc = audit_syslog_record($event, $rfc_config);
+audit_syslog_test_assert(strpos($rfc['record'], 'Audit event 32e0a97d-d9e8-4abc-8f41-2bbbc50793ca') !== false,
+ 'RFC 5424-only payloads must remain identifiable.');
+
+$escaped_event = $event;
+$escaped_event['target_id'] = "value\\\"]\nnext";
+$escaped = audit_syslog_record($escaped_event, $config);
+audit_syslog_test_assert(strpos($escaped['record'], 'targetId="value\\\\\\"\\] next"') !== false,
+ 'RFC 5424 structured data must escape backslashes, quotes, brackets, and control characters.');
+
+$small_udp = audit_syslog_test_config(array('udp_max_size' => '512'));
+$large_event = $event;
+$large_event['details'] = audit_json_encode(array('value' => str_repeat('x', 1024)));
+$oversized = audit_syslog_record($large_event, $small_udp);
+audit_syslog_test_assert($oversized['error_code'] === 'udp_message_too_large' && $oversized['permanent'],
+ 'Oversized UDP events must fail permanently without truncation or splitting.');
+
+$again = audit_syslog_record($event, $config);
+audit_syslog_test_assert($formatted['record'] === $again['record'],
+ 'Repeated formatting of one event must preserve receiver deduplication identity.');
+
+$udp_error = 0;
+$udp_error_message = '';
+$udp_port = 0;
+$udp_server = audit_syslog_test_udp_server($udp_port, $udp_error, $udp_error_message);
+audit_syslog_test_assert(is_resource($udp_server), 'The UDP integration-test receiver must start.');
+$udp_config = audit_syslog_test_config(array('port' => (string) $udp_port));
+$udp_socket = null;
+$udp_result = audit_syslog_send_event($event, $udp_config, $udp_socket);
+audit_syslog_test_assert($udp_result['status'] === 'sent_unconfirmed',
+ 'A complete UDP socket write must be recorded as sent_unconfirmed.');
+stream_set_timeout($udp_server, 1);
+$udp_received = fread($udp_server, 65535);
+audit_syslog_test_assert($udp_received === audit_syslog_record($event, $udp_config)['record'],
+ 'UDP transport must deliver exactly one complete untruncated record.');
+if (is_resource($udp_socket)) {
+ fclose($udp_socket);
+}
+fclose($udp_server);
+
+$tcp_error = 0;
+$tcp_error_message = '';
+$tcp_server = stream_socket_server('tcp://127.0.0.1:0', $tcp_error, $tcp_error_message);
+audit_syslog_test_assert(is_resource($tcp_server), 'The TCP integration-test receiver must start.');
+$tcp_name = stream_socket_get_name($tcp_server, false);
+$tcp_port = (int) substr($tcp_name, strrpos($tcp_name, ':') + 1);
+$tcp_config = audit_syslog_test_config(array('transport' => 'tcp', 'port' => (string) $tcp_port));
+$tcp_socket = null;
+$tcp_result = audit_syslog_send_event($event, $tcp_config, $tcp_socket);
+audit_syslog_test_assert($tcp_result['status'] === 'sent_unconfirmed',
+ 'A complete TCP socket write must be recorded as sent_unconfirmed.');
+$tcp_peer = stream_socket_accept($tcp_server, 1);
+audit_syslog_test_assert(is_resource($tcp_peer), 'The TCP integration-test receiver must accept the connection.');
+stream_set_timeout($tcp_peer, 1);
+$tcp_received = fread($tcp_peer, 262144);
+$expected_tcp = audit_syslog_frame(audit_syslog_record($event, $tcp_config)['record'], 'tcp');
+audit_syslog_test_assert($tcp_received === $expected_tcp,
+ 'TCP transport must deliver one complete RFC 6587 octet-counted record.');
+fclose($tcp_peer);
+if (is_resource($tcp_socket)) {
+ fclose($tcp_socket);
+}
+fclose($tcp_server);
+
+if (function_exists('stream_socket_pair') && function_exists('pcntl_fork') &&
+ extension_loaded('sockets') && function_exists('socket_import_stream')) {
+ $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+ audit_syslog_test_assert(is_array($pair), 'The partial-write test must create a local stream pair.');
+ $native_socket = socket_import_stream($pair[0]);
+ socket_set_option($native_socket, SOL_SOCKET, SO_SNDBUF, 1024);
+ stream_set_timeout($pair[0], 2);
+ $partial_payload = str_repeat('p', 262144);
+ $partial_pid = pcntl_fork();
+ audit_syslog_test_assert($partial_pid >= 0, 'The partial-write test must fork a local reader.');
+
+ if ($partial_pid === 0) {
+ fclose($pair[0]);
+ $received_length = 0;
+ while ($received_length < strlen($partial_payload)) {
+ $chunk = fread($pair[1], 8192);
+ if ($chunk === false || $chunk === '') {
+ break;
+ }
+ $received_length += strlen($chunk);
+ }
+ fclose($pair[1]);
+ exit($received_length === strlen($partial_payload) ? 0 : 1);
+ }
+
+ fclose($pair[1]);
+ $partial_result = audit_syslog_write($pair[0], $partial_payload, 'tcp');
+ fclose($pair[0]);
+ pcntl_waitpid($partial_pid, $partial_status);
+ audit_syslog_test_assert($partial_result['status'] === 'sent_unconfirmed' &&
+ pcntl_wexitstatus($partial_status) === 0,
+ 'TCP writes must loop until a record is complete when the socket accepts partial chunks.');
+
+ $timeout_pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+ $timeout_socket = socket_import_stream($timeout_pair[0]);
+ socket_set_option($timeout_socket, SOL_SOCKET, SO_SNDBUF, 1024);
+ stream_set_timeout($timeout_pair[0], 0, 100000);
+ $timeout_result = audit_syslog_write($timeout_pair[0], str_repeat('t', 4194304), 'tcp');
+ audit_syslog_test_assert($timeout_result['status'] === 'failed' &&
+ $timeout_result['error_code'] === 'write_failed',
+ 'A blocked stream must fail with a bounded write timeout instead of hanging indefinitely.');
+ fclose($timeout_pair[0]);
+ fclose($timeout_pair[1]);
+}
+
+if (extension_loaded('openssl') && function_exists('pcntl_fork')) {
+ $tls_material = audit_syslog_test_tls_material();
+ audit_syslog_test_assert(is_array($tls_material), 'The TLS integration test must create temporary certificate material.');
+ $tls_port = 0;
+ $tls_server = audit_syslog_test_tls_server($tls_material, $tls_port);
+ audit_syslog_test_assert(is_resource($tls_server), 'The TLS integration-test receiver must start.');
+ $tls_pid = pcntl_fork();
+ audit_syslog_test_assert($tls_pid >= 0, 'The TLS integration test must fork a local receiver.');
+
+ if ($tls_pid === 0) {
+ $tls_peer = @stream_socket_accept($tls_server, 5);
+ if (is_resource($tls_peer)) {
+ stream_set_timeout($tls_peer, 2);
+ $tls_received = fread($tls_peer, 262144);
+ file_put_contents($tls_material['result'], $tls_received);
+ fclose($tls_peer);
+ exit(0);
+ }
+
+ exit(1);
+ }
+
+ fclose($tls_server);
+ $tls_config = audit_syslog_test_config(array(
+ 'transport' => 'tls',
+ 'port' => (string) $tls_port,
+ 'tls_ca_file' => $tls_material['ca']
+ ));
+ $tls_socket = null;
+ $tls_result = audit_syslog_send_event($event, $tls_config, $tls_socket);
+ if (is_resource($tls_socket)) {
+ fclose($tls_socket);
+ }
+ pcntl_waitpid($tls_pid, $tls_status);
+ audit_syslog_test_assert($tls_result['status'] === 'sent_unconfirmed',
+ 'A verified TLS socket write must be recorded as sent_unconfirmed.');
+ audit_syslog_test_assert(pcntl_wexitstatus($tls_status) === 0,
+ 'The verified TLS receiver must accept the connection.');
+ $tls_received = is_file($tls_material['result']) ? file_get_contents($tls_material['result']) : '';
+ $expected_tls = audit_syslog_frame(audit_syslog_record($event, $tls_config)['record'], 'tls');
+ audit_syslog_test_assert($tls_received === $expected_tls,
+ 'TLS transport must deliver one complete RFC 6587 octet-counted record.');
+
+ $untrusted_port = 0;
+ $untrusted_server = audit_syslog_test_tls_server($tls_material, $untrusted_port);
+ audit_syslog_test_assert(is_resource($untrusted_server), 'The untrusted TLS test receiver must start.');
+ $untrusted_pid = pcntl_fork();
+ audit_syslog_test_assert($untrusted_pid >= 0, 'The untrusted TLS test must fork a local receiver.');
+
+ if ($untrusted_pid === 0) {
+ $untrusted_peer = @stream_socket_accept($untrusted_server, 5);
+ if (is_resource($untrusted_peer)) {
+ fclose($untrusted_peer);
+ }
+ exit(0);
+ }
+
+ fclose($untrusted_server);
+ $untrusted_config = audit_syslog_test_config(array(
+ 'transport' => 'tls',
+ 'port' => (string) $untrusted_port,
+ 'tls_ca_file' => ''
+ ));
+ $untrusted_socket = null;
+ $untrusted_result = audit_syslog_send_event($event, $untrusted_config, $untrusted_socket);
+ if (is_resource($untrusted_socket)) {
+ fclose($untrusted_socket);
+ }
+ pcntl_waitpid($untrusted_pid, $untrusted_status);
+ audit_syslog_test_assert($untrusted_result['status'] === 'failed' &&
+ $untrusted_result['error_code'] === 'connection_failed',
+ 'TLS delivery must fail when the receiver certificate is not trusted.');
+
+ audit_syslog_test_remove_tls_material($tls_material);
+}
+
+$outage_error = 0;
+$outage_message = '';
+$outage_server = stream_socket_server('tcp://127.0.0.1:0', $outage_error, $outage_message);
+$outage_name = stream_socket_get_name($outage_server, false);
+$outage_port = (int) substr($outage_name, strrpos($outage_name, ':') + 1);
+fclose($outage_server);
+$outage_config = audit_syslog_test_config(array('transport' => 'tcp', 'port' => (string) $outage_port));
+$outage_socket = null;
+$outage = audit_syslog_send_event($event, $outage_config, $outage_socket);
+audit_syslog_test_assert($outage['status'] === 'failed' && $outage['error_code'] === 'connection_failed',
+ 'An unavailable receiver must return a bounded transient connection failure.');
+
+$invalid_tls = audit_syslog_test_config(array(
+ 'transport' => 'tls',
+ 'port' => '6514',
+ 'tls_ca_file' => '/does/not/exist'
+));
+audit_syslog_test_assert(!$invalid_tls['valid'] && in_array('tls_ca_file_invalid', $invalid_tls['errors'], true),
+ 'An invalid TLS trust path must be rejected before a connection is attempted.');
+
+print "Syslog helper and transport tests passed.\n";
diff --git a/tests/syslog_queue_test.php b/tests/syslog_queue_test.php
new file mode 100644
index 0000000..775f161
--- /dev/null
+++ b/tests/syslog_queue_test.php
@@ -0,0 +1,155 @@
+ 'on',
+ 'audit_syslog_receiver' => '127.0.0.1',
+ 'audit_syslog_port' => '514',
+ 'audit_syslog_transport' => 'udp',
+ 'audit_syslog_format' => 'json',
+ 'audit_syslog_facility' => 'local0',
+ 'audit_syslog_application' => 'cacti-audit',
+ 'audit_syslog_node_id' => 'queue-test-node',
+ 'audit_syslog_timeout' => '5',
+ 'audit_syslog_udp_max_size' => '8192',
+ 'audit_syslog_retry_base' => '5',
+ 'audit_syslog_retry_max' => '60',
+ 'audit_syslog_max_attempts' => '5',
+ 'audit_syslog_batch_size' => '10',
+ 'audit_syslog_pending_age_warning' => '900',
+ 'audit_syslog_dead_letter_warning' => '1',
+ 'audit_syslog_tls_ca_file' => '',
+ 'audit_syslog_tls_client_cert' => '',
+ 'audit_syslog_tls_client_key' => ''
+);
+$audit_queue_calls = array();
+$audit_queue_affected_rows = 0;
+
+function read_config_option($name) {
+ global $audit_queue_settings;
+
+ return isset($audit_queue_settings[$name]) ? $audit_queue_settings[$name] : '';
+}
+
+function db_table_exists($table) {
+ return $table === 'audit_syslog_delivery';
+}
+
+function db_fetch_row_prepared($sql, $params = array()) {
+ return array(
+ 'id' => (int) $params[0],
+ 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca',
+ 'request_status' => 'completed'
+ );
+}
+
+function db_execute_prepared($sql, $params = array()) {
+ global $audit_queue_calls;
+
+ $audit_queue_calls[] = array('sql' => $sql, 'params' => $params);
+ return true;
+}
+
+function db_execute($sql) {
+ global $audit_queue_calls;
+
+ $audit_queue_calls[] = array('sql' => $sql, 'params' => array());
+ return true;
+}
+
+function db_affected_rows() {
+ global $audit_queue_affected_rows;
+
+ return $audit_queue_affected_rows;
+}
+
+function cacti_sizeof($value) {
+ return is_array($value) ? count($value) : 0;
+}
+
+require_once dirname(__DIR__) . '/audit_functions.php';
+
+function audit_queue_assert($condition, $message) {
+ if (!$condition) {
+ fwrite(STDERR, $message . PHP_EOL);
+ exit(1);
+ }
+}
+
+audit_enqueue_syslog_event(42);
+audit_queue_assert(count($audit_queue_calls) === 1, 'Finalization must enqueue one Syslog delivery row.');
+audit_queue_assert(strpos($audit_queue_calls[0]['sql'], 'INSERT IGNORE INTO audit_syslog_delivery') !== false,
+ 'Queue insertion must be idempotent for one event and destination.');
+audit_queue_assert($audit_queue_calls[0]['params'][0] === 42, 'Queue insertion must retain the audit event ID.');
+audit_queue_assert($audit_queue_calls[0]['params'][1] === '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca',
+ 'Queue insertion must retain the stable event UUID.');
+audit_queue_assert($audit_queue_calls[0]['params'][3] === 'queue-test-node',
+ 'Queue insertion must snapshot the stable node identity.');
+audit_queue_assert($audit_queue_calls[0]['params'][5] === 'pending',
+ 'A valid enabled destination must enqueue in pending state.');
+
+$config = audit_syslog_config();
+$retry_identity = audit_syslog_delivery_config($config, array(
+ 'delivery_node_id' => 'original-node',
+ 'delivery_poller_id' => '3'
+));
+audit_queue_assert($retry_identity['node_id'] === 'original-node' && $retry_identity['poller_id'] === '3',
+ 'Retries must use the node and poller identity captured when the event was queued.');
+
+audit_queue_assert(audit_syslog_retry_delay(1, $config) === 5, 'The first retry must use the base delay.');
+audit_queue_assert(audit_syslog_retry_delay(2, $config) === 10, 'Retry delay must increase exponentially.');
+audit_queue_assert(audit_syslog_retry_delay(10, $config) === 60, 'Retry delay must be capped by the configured maximum.');
+
+$audit_queue_calls = array();
+$delivery = array('delivery_id' => 9, 'attempts' => 0);
+$failure = array(
+ 'status' => 'failed',
+ 'permanent' => false,
+ 'error_code' => 'connection_failed',
+ 'error' => "receiver unavailable\nwith control text"
+);
+audit_syslog_update_delivery($delivery, $failure, $config);
+audit_queue_assert($audit_queue_calls[0]['params'][0] === 'retry',
+ 'A transient failure before maximum attempts must enter retry state.');
+audit_queue_assert($audit_queue_calls[0]['params'][2] === 1, 'A failed delivery must increment attempts.');
+audit_queue_assert($audit_queue_calls[0]['params'][3] === 5 && $audit_queue_calls[0]['params'][4] === 5,
+ 'A transient failure must schedule the bounded exponential delay.');
+audit_queue_assert(strpos($audit_queue_calls[0]['params'][5], "\n") === false,
+ 'Stored delivery errors must be bounded to one safe line.');
+
+$audit_queue_calls = array();
+$delivery['attempts'] = 4;
+audit_syslog_update_delivery($delivery, $failure, $config);
+audit_queue_assert($audit_queue_calls[0]['params'][0] === 'dead_letter',
+ 'The configured final failed attempt must enter dead-letter state.');
+
+$audit_queue_calls = array();
+$permanent = $failure;
+$permanent['permanent'] = true;
+$permanent['error_code'] = 'udp_message_too_large';
+$delivery['attempts'] = 0;
+audit_syslog_update_delivery($delivery, $permanent, $config);
+audit_queue_assert($audit_queue_calls[0]['params'][0] === 'dead_letter',
+ 'A permanent formatting failure must enter dead-letter immediately.');
+
+$audit_queue_calls = array();
+$success = array('status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '', 'permanent' => false);
+audit_syslog_update_delivery($delivery, $success, $config);
+audit_queue_assert(strpos($audit_queue_calls[0]['sql'], "state = 'sent_unconfirmed'") !== false,
+ 'A complete socket write must enter sent_unconfirmed state.');
+
+$audit_queue_calls = array();
+$audit_queue_affected_rows = 2;
+$retried = audit_syslog_retry_dead_letters(array('2', 2, 0, -1, '7'));
+audit_queue_assert($retried === 2, 'Manual retry must report the affected row count.');
+audit_queue_assert($audit_queue_calls[0]['params'] === array(2, 7),
+ 'Manual retry must accept only unique positive selected delivery IDs.');
+audit_queue_assert(strpos($audit_queue_calls[0]['sql'], "WHERE state = 'dead_letter'") !== false,
+ 'Manual retry must never reset a non-dead-letter delivery.');
+
+$syslog_source = file_get_contents(dirname(__DIR__) . '/audit_syslog.php');
+audit_queue_assert(
+ strpos($syslog_source, "\$result['status'] !== 'sent_unconfirmed' && empty(\$result['permanent'])") !== false,
+ 'Poller batches must stop after one transient receiver failure to bound outage latency.'
+);
+
+print "Syslog queue tests passed.\n";