diff --git a/sysutils/os-splunk-hec/Makefile b/sysutils/os-splunk-hec/Makefile new file mode 100644 index 0000000000..2a6d8a92f4 --- /dev/null +++ b/sysutils/os-splunk-hec/Makefile @@ -0,0 +1,12 @@ +PLUGIN_NAME= splunk-hec +PLUGIN_VERSION= 1.0.0 +PLUGIN_COMMENT= Splunk HEC log exporter for OPNsense +PLUGIN_MAINTAINER= you@example.com +PLUGIN_WWW= https://github.com/pvols79/os-splunk-hec +PLUGIN_LICENSES= BSD2CLAUSE + +PLUGIN_ARCH_DEFAULT?= any + +PLUGINSDIR= /usr/local/src/opnsense-plugins + +.include "../opnsense-plugins/Mk/plugins.mk" diff --git a/sysutils/os-splunk-hec/pkg-descr b/sysutils/os-splunk-hec/pkg-descr new file mode 100644 index 0000000000..917e1c48b4 --- /dev/null +++ b/sysutils/os-splunk-hec/pkg-descr @@ -0,0 +1,8 @@ +Splunk HTTP Event Collector (HEC) log exporter for OPNsense. + +Forwards OPNsense system and firewall logs to a Splunk instance +via the HTTP Event Collector API. Supports configurable log sources, +on-disk payload caching with retry, and inode-based log rotation +detection. + +WWW: https://github.com/pvols79/os-splunk-hec diff --git a/sysutils/os-splunk-hec/src/etc/rc.d/splunk_hec b/sysutils/os-splunk-hec/src/etc/rc.d/splunk_hec new file mode 100644 index 0000000000..125d1eb8fe --- /dev/null +++ b/sysutils/os-splunk-hec/src/etc/rc.d/splunk_hec @@ -0,0 +1,37 @@ +#!/bin/sh +# +# OPNsense Splunk HEC Exporter — rc.d service script +# +# PROVIDE: splunk_hec +# REQUIRE: NETWORKING configd +# KEYWORD: shutdown + +. /etc/rc.subr + +name="splunk_hec" +rcvar="splunk_hec_enable" +pidfile="/var/run/${name}.pid" + +# Use FreeBSD's daemon utility to properly detach the process. +# We set procname so rc.subr correctly maps the PID file to the PHP process. +command="/usr/sbin/daemon" +procname="/usr/local/bin/php" +command_args="-f -p ${pidfile} /usr/local/bin/php /usr/local/opnsense/scripts/OPNsense/SplunkHEC/Exporter.php" + +load_rc_config $name + +# Force YES because Exporter.php has its own internal check for $cfg['enabled'] +# and exits gracefully if disabled. This bypasses the need for OPNsense rc.conf.d templates. +splunk_hec_enable="YES" + +splunk_hec_stop() +{ + if [ -f "${pidfile}" ]; then + kill $(cat "${pidfile}") >/dev/null 2>&1 || true + rm -f "${pidfile}" + fi + pkill -f "Exporter.php" >/dev/null 2>&1 || true +} +stop_cmd="splunk_hec_stop" + +run_rc_command "$1" diff --git a/sysutils/os-splunk-hec/src/opnsense/mvc/app/controllers/OPNsense/SplunkHEC/Api/ServiceController.php b/sysutils/os-splunk-hec/src/opnsense/mvc/app/controllers/OPNsense/SplunkHEC/Api/ServiceController.php new file mode 100644 index 0000000000..f5709ee365 --- /dev/null +++ b/sysutils/os-splunk-hec/src/opnsense/mvc/app/controllers/OPNsense/SplunkHEC/Api/ServiceController.php @@ -0,0 +1,198 @@ +request->isGet()) { + $mdl = $this->getModel(); + $nodes = $mdl->getNodes(); + $result['general'] = $nodes['general']; + $result['logs'] = $nodes['logs']; + } + return $result; + } + + /** + * POST /api/splunkhec/service/set + * + * Persist settings submitted from the UI. Save only — does NOT restart + * the daemon. The separate reconfigureAction() does that, called after + * this by SimpleActionButton's data-endpoint lifecycle. + * + * Note: setBase() in OPNsense 26.7+ is UUID-based (grid rows only). + * For flat settings pages we use setNodes() + validate() directly. + */ + public function setAction() + { + $result = ['result' => 'failed']; + + if ($this->request->isPost()) { + $mdl = $this->getModel(); + $post = $this->request->getPost(); + + $mdl->setNodes($post); + + $valMsgs = $mdl->validate(); + if (count($valMsgs) > 0) { + $result['validations'] = $valMsgs; + } else { + $mdl->serializeToConfig(); + Config::getInstance()->save(); + $this->writeIniConfig($mdl); + + // Restart daemon as part of save — avoids a separate + // reconfigure API call from the frontend (which has auth issues). + try { + $backend = new Backend(); + $backend->configdRun('splunk_hec restart'); + } catch (\Exception $e) { + syslog(LOG_WARNING, 'SplunkHEC: restart failed: ' . $e->getMessage()); + } + + $result['result'] = 'saved'; + } + } + + return $result; + } + + /** + * POST /api/splunkhec/service/reconfigure + * + * Apply the saved configuration by restarting the daemon. + * Called by SimpleActionButton after setAction() succeeds. + * This is the endpoint that controls the spinner lifecycle. + */ + public function reconfigureAction() + { + $result = ['result' => 'failed']; + + if ($this->request->isPost()) { + try { + $backend = new Backend(); + $backend->configdRun('splunk_hec restart'); + $result['result'] = 'ok'; + } catch (\Exception $e) { + syslog(LOG_WARNING, 'SplunkHEC: reconfigure failed: ' . $e->getMessage()); + } + } + + return $result; + } + + /** + * GET /api/splunkhec/service/status + */ + public function statusAction() + { + $backend = new Backend(); + $response = trim($backend->configdRun('splunk_hec status')); + return ['status' => ($response === 'running') ? 'running' : 'stopped']; + } + + /** + * Serialize current model values into the INI file consumed by Exporter.php. + * Written to /var/etc/splunk_hec.conf on every successful save. + */ + private function writeIniConfig($mdl) + { + $g = $mdl->general; + $l = $mdl->logs; + + $ini = "; Auto-generated by OPNsense SplunkHEC plugin — do not edit.\n"; + $ini .= "[splunk_hec]\n"; + $ini .= 'enabled = ' . (string)$g->enabled . "\n"; + $ini .= 'use_gzip = ' . (string)$g->use_gzip . "\n"; + $ini .= 'enable_telemetry = ' . (string)$g->enable_telemetry . "\n"; + $ini .= 'verify_ssl = ' . (string)$g->verify_ssl . "\n"; + $ini .= 'token = ' . (string)$g->token . "\n"; + $ini .= 'endpoint = ' . (string)$g->endpoint . "\n"; + $ini .= 'cache_size = ' . (string)$g->cache_size . "\n"; + $ini .= 'cache_time = ' . (string)$g->cache_time . "\n"; + $ini .= "\n[logs]\n"; + $ini .= 'system = ' . (string)$l->system . "\n"; + $ini .= 'filter = ' . (string)$l->filter . "\n"; + $ini .= 'audit = ' . (string)$l->audit . "\n"; + $ini .= 'dhcpd = ' . (string)$l->dhcpd . "\n"; + $ini .= 'lighttpd = ' . (string)$l->lighttpd . "\n"; + $ini .= 'ntpd = ' . (string)$l->ntpd . "\n"; + $ini .= 'openvpn = ' . (string)$l->openvpn . "\n"; + $ini .= 'routing = ' . (string)$l->routing . "\n"; + $ini .= 'suricata = ' . (string)$l->suricata . "\n"; + $ini .= 'unbound = ' . (string)$l->unbound . "\n"; + $ini .= 'kea = ' . (string)$l->kea . "\n"; + $ini .= 'dnsmasq = ' . (string)$l->dnsmasq . "\n"; + $ini .= 'wireguard = ' . (string)$l->wireguard . "\n"; + $ini .= 'suricata_eve = ' . (string)$l->suricata_eve . "\n"; + $ini .= 'portalauth = ' . (string)$l->portalauth . "\n"; + $ini .= 'crowdsec = ' . (string)$l->crowdsec . "\n"; + $ini .= 'elasticsearch = ' . (string)$l->elasticsearch . "\n"; + $ini .= 'zenarmor = ' . (string)$l->zenarmor . "\n"; + + @mkdir('/var/etc', 0755, true); + file_put_contents('/var/etc/splunk_hec.conf', $ini); + } + + /** + * Check which log files exist on the firewall + * @return array + */ + public function checklogsAction(): array + { + $paths = [ + 'system' => '/var/log/system/latest.log', + 'filter' => '/var/log/filter/latest.log', + 'audit' => '/var/log/audit/latest.log', + 'dhcpd' => '/var/log/dhcpd/latest.log', + 'kea' => '/var/log/kea/latest.log', + 'dnsmasq' => '/var/log/dnsmasq/latest.log', + 'lighttpd' => '/var/log/lighttpd/latest.log', + 'ntpd' => '/var/log/ntpd/latest.log', + 'openvpn' => '/var/log/openvpn/latest.log', + 'wireguard' => '/var/log/wireguard/latest.log', + 'routing' => '/var/log/routing/latest.log', + 'suricata' => '/var/log/suricata/latest.log', + 'suricata_eve' => '/var/log/suricata/eve.json', + 'unbound' => '/var/log/unbound/latest.log', + 'portalauth' => '/var/log/portalauth/latest.log', + 'crowdsec' => '/var/log/crowdsec/latest.log', + 'elasticsearch' => '/var/log/elasticsearch/latest.log', + 'zenarmor' => '/usr/local/zenarmor/output/active/temp' + ]; + + $result = []; + foreach ($paths as $key => $path) { + $result[$key] = file_exists($path); + } + + return $result; + } +} diff --git a/sysutils/os-splunk-hec/src/opnsense/mvc/app/controllers/OPNsense/SplunkHEC/GeneralController.php b/sysutils/os-splunk-hec/src/opnsense/mvc/app/controllers/OPNsense/SplunkHEC/GeneralController.php new file mode 100644 index 0000000000..4422e8bec9 --- /dev/null +++ b/sysutils/os-splunk-hec/src/opnsense/mvc/app/controllers/OPNsense/SplunkHEC/GeneralController.php @@ -0,0 +1,18 @@ +view->pick('OPNsense/SplunkHEC/index'); + } +} diff --git a/sysutils/os-splunk-hec/src/opnsense/mvc/app/models/OPNsense/SplunkHEC/ACL/ACL.xml b/sysutils/os-splunk-hec/src/opnsense/mvc/app/models/OPNsense/SplunkHEC/ACL/ACL.xml new file mode 100644 index 0000000000..fb2ec8fcfe --- /dev/null +++ b/sysutils/os-splunk-hec/src/opnsense/mvc/app/models/OPNsense/SplunkHEC/ACL/ACL.xml @@ -0,0 +1,10 @@ + + + + Splunk HEC + + /api/splunkhec/* + /ui/splunkhec/* + + + diff --git a/sysutils/os-splunk-hec/src/opnsense/mvc/app/models/OPNsense/SplunkHEC/Menu/Menu.xml b/sysutils/os-splunk-hec/src/opnsense/mvc/app/models/OPNsense/SplunkHEC/Menu/Menu.xml new file mode 100644 index 0000000000..5d14bf163c --- /dev/null +++ b/sysutils/os-splunk-hec/src/opnsense/mvc/app/models/OPNsense/SplunkHEC/Menu/Menu.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/sysutils/os-splunk-hec/src/opnsense/mvc/app/models/OPNsense/SplunkHEC/SplunkHEC.php b/sysutils/os-splunk-hec/src/opnsense/mvc/app/models/OPNsense/SplunkHEC/SplunkHEC.php new file mode 100644 index 0000000000..60b70323e9 --- /dev/null +++ b/sysutils/os-splunk-hec/src/opnsense/mvc/app/models/OPNsense/SplunkHEC/SplunkHEC.php @@ -0,0 +1,16 @@ + + + //OPNsense/SplunkHEC + 1.1.0 + Splunk HEC Log Exporter settings + + + + 0 + Y + + + N + /^[a-fA-F0-9\-]*$/ + Please enter a valid HEC token (UUID format). + + + 1 + Y + + + 0 + Y + + + 1 + Y + + + N + Please enter a valid URL (e.g. https://splunk.example.com:8088/services/collector). + + + 100 + 1 + 10000 + Y + Cache size must be between 1 and 10000 MB. + + + 24 + 1 + 720 + Y + Cache retention must be between 1 and 720 hours. + + + + + + 1 + Y + + + + 1 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + 0 + Y + + + + diff --git a/sysutils/os-splunk-hec/src/opnsense/mvc/app/views/OPNsense/SplunkHEC/index.volt b/sysutils/os-splunk-hec/src/opnsense/mvc/app/views/OPNsense/SplunkHEC/index.volt new file mode 100644 index 0000000000..cf2cfb6b82 --- /dev/null +++ b/sysutils/os-splunk-hec/src/opnsense/mvc/app/views/OPNsense/SplunkHEC/index.volt @@ -0,0 +1,329 @@ +{# + # Copyright (C) 2026 pvols79 + # All rights reserved. + # SPDX-License-Identifier: BSD-2-Clause + #} + + + +
+
+
+
+
+ + {# General Settings #} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ lang._('General') }}
+ + + + + {{ lang._('Enable or disable the Splunk HEC log forwarder daemon.') }} + +
+ + + + + {{ lang._('Disable only if using self-signed Splunk certificates with hostname mismatches (e.g. SplunkServerDefaultCert).') }} + +
+ + + + + {{ lang._('Compress payload batches before sending. Drastically reduces bandwidth usage.') }} + +
+ + + + + {{ lang._('Sample CPU, Memory, Disk, and Firewall state metrics every 60 seconds and ship them as a JSON event.') }} + +
+ + + + + {{ lang._('Full URL to the Splunk HTTP Event Collector.') }} + +
+ + + + + {{ lang._('Authentication token (UUID) for the Splunk HEC input.') }} + +
+ + + + + {{ lang._('Maximum on-disk cache size before old payloads are purged.') }} + +
+ + + + + {{ lang._('Maximum age of cached payloads before purging.') }} + +
+ + {# Log Sources #} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ lang._('Log Source') }}{{ lang._('Enable') }}{{ lang._('Path') }}{{ lang._('Splunk Sourcetype') }}
{{ lang._('System Log') }}/var/log/system/latest.logopnsense:syslog
{{ lang._('Firewall Filter Log') }}/var/log/filter/latest.logopnsense:filterlog
{{ lang._('Config Audit / GUI') }}/var/log/audit/latest.logopnsense:audit
{{ lang._('DHCP (IPv4/IPv6)') }}/var/log/dhcpd/latest.logopnsense:dhcpd
{{ lang._('DHCP (Kea)') }}/var/log/kea/latest.logopnsense:kea
{{ lang._('DNS/DHCP (Dnsmasq)') }}/var/log/dnsmasq/latest.logopnsense:dnsmasq
{{ lang._('Web GUI (Lighttpd)') }}/var/log/lighttpd/latest.logopnsense:lighttpd
{{ lang._('NTP Daemon') }}/var/log/ntpd/latest.logopnsense:ntpd
{{ lang._('VPN - OpenVPN') }}/var/log/openvpn/latest.logopnsense:openvpn
{{ lang._('VPN - WireGuard') }}/var/log/wireguard/latest.logopnsense:wireguard
{{ lang._('Routing') }}/var/log/routing/latest.logopnsense:routing
{{ lang._('Suricata (Syslog)') }}/var/log/suricata/latest.logopnsense:suricata
{{ lang._('Suricata (EVE JSON)') }}/var/log/suricata/eve.jsonopnsense:suricata:eve
{{ lang._('Unbound DNS') }}/var/log/unbound/latest.logopnsense:unbound
{{ lang._('Captive Portal') }}/var/log/portalauth/latest.logopnsense:portalauth
{{ lang._('CrowdSec') }}/var/log/crowdsec/latest.logopnsense:crowdsec
{{ lang._('Elasticsearch (Zenarmor Engine)') }}/var/log/elasticsearch/latest.logopnsense:elasticsearch
+ {{ lang._('Zenarmor (Traffic & Alerts)') }} +
+ + Experimental best-effort parser. Not affiliated with Zenarmor or Sunny Valley Networks. + +
/usr/local/zenarmor/.../*.ipdropnsense:zenarmor:*
+ +
+
+
+
+
+ +
+
+ +
+
diff --git a/sysutils/os-splunk-hec/src/opnsense/scripts/OPNsense/SplunkHEC/Exporter.php b/sysutils/os-splunk-hec/src/opnsense/scripts/OPNsense/SplunkHEC/Exporter.php new file mode 100644 index 0000000000..c34e3e1a21 --- /dev/null +++ b/sysutils/os-splunk-hec/src/opnsense/scripts/OPNsense/SplunkHEC/Exporter.php @@ -0,0 +1,433 @@ +#!/usr/local/bin/php + true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => $verifySsl, + CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0, + ]); + $response = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if ($code === 0) { + $err = curl_error($ch); + echo "ERROR cURL failure: {$err}\n"; + hec_log("ERROR cURL failure: {$err}"); + } elseif ($code !== 200) { + echo "ERROR Splunk API returned HTTP {$code}. Response: {$response}\n"; + hec_log("ERROR Splunk API returned HTTP {$code}. Response: {$response}"); + } + + curl_close($ch); + return $code; +} + +function cache_payload(string $payload): void +{ + @file_put_contents(CACHE_PATH, $payload . "\n", FILE_APPEND | LOCK_EX); +} + +function flush_cache(string $endpoint, string $token, int $maxSizeMB, int $maxAgeHours, bool $verifySsl, bool $useGzip): int +{ + if (!is_file(CACHE_PATH) || filesize(CACHE_PATH) === 0) return 0; + + $mtime = filemtime(CACHE_PATH); + if ($mtime !== false && (time() - $mtime) > ($maxAgeHours * 3600)) { + hec_log('INFO Cache expired (>' . $maxAgeHours . ' h) — purging.'); + @unlink(CACHE_PATH); + return 0; + } + + if (filesize(CACHE_PATH) > $maxSizeMB * 1024 * 1024) { + hec_log('WARN Cache exceeds ' . $maxSizeMB . ' MB — purging.'); + @unlink(CACHE_PATH); + return 0; + } + + $lines = file(CACHE_PATH, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $failed = []; + $ok = 0; + + $batch = ''; + $batchCount = 0; + + foreach ($lines as $line) { + $batch .= $line . "\n"; + $batchCount++; + + if ($batchCount >= 500) { + $code = hec_post($endpoint, $token, $batch, $verifySsl, $useGzip); + if ($code === 200) { + $ok += $batchCount; + } else { + $failed[] = trim($batch); + } + $batch = ''; + $batchCount = 0; + } + } + + if ($batchCount > 0) { + $code = hec_post($endpoint, $token, $batch, $verifySsl, $useGzip); + if ($code === 200) { + $ok += $batchCount; + } else { + $failed[] = trim($batch); + } + } + + if (count($failed) > 0) { + file_put_contents(CACHE_PATH, implode("\n", $failed) . "\n", LOCK_EX); + } else { + @unlink(CACHE_PATH); + } + + if ($ok > 0) hec_log('INFO Flushed ' . $ok . ' cached payload(s).'); + return $ok; +} + +// --------------------------------------------------------------------------- +// Telemetry Gathering +// --------------------------------------------------------------------------- + +function gather_telemetry(): string +{ + $load = sys_getloadavg() ?: [0, 0, 0]; + + $diskTotal = disk_total_space('/') ?: 0; + $diskFree = disk_free_space('/') ?: 0; + $diskUsedPct = $diskTotal > 0 ? round((($diskTotal - $diskFree) / $diskTotal) * 100, 2) : 0; + + $pagesize = (int)(shell_exec('/sbin/sysctl -n hw.pagesize') ?? 0); + $memFreePages = (int)(shell_exec('/sbin/sysctl -n vm.stats.vm.v_free_count') ?? 0); + $memTotal = (int)(shell_exec('/sbin/sysctl -n hw.physmem') ?? 0); + $memUsed = max(0, $memTotal - ($memFreePages * $pagesize)); + + $pfStats = shell_exec('/sbin/pfctl -si 2>/dev/null') ?? ''; + preg_match('/current entries\s+(\d+)/', $pfStats, $mStates); + $pfCurrent = isset($mStates[1]) ? (int)$mStates[1] : 0; + + $pfLimits = shell_exec('/sbin/pfctl -sm 2>/dev/null') ?? ''; + preg_match('/states\s+hard limit\s+(\d+)/', $pfLimits, $mMaxStates); + $pfMax = isset($mMaxStates[1]) ? (int)$mMaxStates[1] : 0; + + $boottimeStr = shell_exec('/sbin/sysctl -n kern.boottime') ?? ''; + preg_match('/sec = (\d+)/', $boottimeStr, $mBoot); + $uptime = isset($mBoot[1]) ? (time() - (int)$mBoot[1]) : 0; + + $event = [ + 'cpu_load_1m' => $load[0] ?? 0, + 'cpu_load_5m' => $load[1] ?? 0, + 'cpu_load_15m' => $load[2] ?? 0, + 'mem_total_bytes' => $memTotal, + 'mem_used_bytes' => $memUsed, + 'disk_root_used_pct' => $diskUsedPct, + 'pf_states_current' => $pfCurrent, + 'pf_states_max' => $pfMax, + 'uptime_seconds' => $uptime + ]; + + return json_encode([ + 'time' => time(), + 'host' => gethostname(), + 'source' => 'opnsense:system', + 'sourcetype' => 'opnsense:telemetry:system', + 'event' => $event + ], JSON_UNESCAPED_SLASHES) . "\n"; +} + +// --------------------------------------------------------------------------- +// Daemon Loop +// --------------------------------------------------------------------------- + +echo "INFO Exporter daemon started.\n"; +hec_log('INFO Exporter daemon started.'); + +while (true) { + echo "DEBUG: Reading INI file...\n"; + $ini = @parse_ini_file(CONF_PATH, true); + if (!$ini) { + echo "DEBUG: Failed to read INI. Sleeping 10s...\n"; + sleep(10); + continue; + } + + $cfg = $ini['splunk_hec'] ?? []; + if (($cfg['enabled'] ?? '0') !== '1') { + echo "INFO Service disabled — exiting.\n"; + hec_log('INFO Service disabled — exiting.'); + exit(0); + } + + $token = $cfg['token'] ?? ''; + $endpoint = $cfg['endpoint'] ?? ''; + $verifySsl = (($cfg['verify_ssl'] ?? '1') === '1'); + $useGzip = (($cfg['use_gzip'] ?? '1') === '1'); + $enableTelemetry = (($cfg['enable_telemetry'] ?? '0') === '1'); + + if ($token === '' || $endpoint === '') { + echo "DEBUG: Token or Endpoint missing. Sleeping 10s...\n"; + sleep(10); + continue; + } + + $maxSizeMB = max(1, (int)($cfg['cache_size'] ?? 100)); + $maxAgeHrs = max(1, (int)($cfg['cache_time'] ?? 24)); + + // Map the boolean settings from the UI to log paths and Splunk sourcetypes + $logsCfg = $ini['logs'] ?? []; + $sources = []; + + if (($logsCfg['system'] ?? '0') === '1') $sources['/var/log/system/latest.log'] = 'opnsense:syslog'; + if (($logsCfg['filter'] ?? '0') === '1') $sources['/var/log/filter/latest.log'] = 'opnsense:filterlog'; + if (($logsCfg['audit'] ?? '0') === '1') $sources['/var/log/audit/latest.log'] = 'opnsense:audit'; + if (($logsCfg['dhcpd'] ?? '0') === '1') $sources['/var/log/dhcpd/latest.log'] = 'opnsense:dhcpd'; + if (($logsCfg['lighttpd'] ?? '0') === '1') $sources['/var/log/lighttpd/latest.log'] = 'opnsense:lighttpd'; + if (($logsCfg['ntpd'] ?? '0') === '1') $sources['/var/log/ntpd/latest.log'] = 'opnsense:ntpd'; + if (($logsCfg['openvpn'] ?? '0') === '1') $sources['/var/log/openvpn/latest.log'] = 'opnsense:openvpn'; + if (($logsCfg['routing'] ?? '0') === '1') $sources['/var/log/routing/latest.log'] = 'opnsense:routing'; + if (($logsCfg['suricata'] ?? '0') === '1') $sources['/var/log/suricata/latest.log'] = 'opnsense:suricata'; + if (($logsCfg['suricata_eve'] ?? '0') === '1') $sources['/var/log/suricata/eve.json'] = 'opnsense:suricata:eve'; + if (($logsCfg['unbound'] ?? '0') === '1') $sources['/var/log/unbound/latest.log'] = 'opnsense:unbound'; + if (($logsCfg['kea'] ?? '0') === '1') $sources['/var/log/kea/latest.log'] = 'opnsense:kea'; + if (($logsCfg['dnsmasq'] ?? '0') === '1') $sources['/var/log/dnsmasq/latest.log'] = 'opnsense:dnsmasq'; + if (($logsCfg['wireguard'] ?? '0') === '1') $sources['/var/log/wireguard/latest.log'] = 'opnsense:wireguard'; + if (($logsCfg['portalauth'] ?? '0') === '1') $sources['/var/log/portalauth/latest.log'] = 'opnsense:portalauth'; + if (($logsCfg['crowdsec'] ?? '0') === '1') $sources['/var/log/crowdsec/latest.log'] = 'opnsense:crowdsec'; + if (($logsCfg['elasticsearch'] ?? '0') === '1') $sources['/var/log/elasticsearch/latest.log'] = 'opnsense:elasticsearch'; + + // Zenarmor rapidly rotating IPDR spools + if (($logsCfg['zenarmor'] ?? '0') === '1') { + $ipdrFiles = glob('/usr/local/zenarmor/output/active/temp/*.ipdr'); + if (is_array($ipdrFiles)) { + foreach ($ipdrFiles as $ipdr) { + if (strpos($ipdr, '_alert_') !== false) $sources[$ipdr] = 'opnsense:zenarmor:alert'; + elseif (strpos($ipdr, '_dns_') !== false) $sources[$ipdr] = 'opnsense:zenarmor:dns'; + elseif (strpos($ipdr, '_http_') !== false) $sources[$ipdr] = 'opnsense:zenarmor:http'; + elseif (strpos($ipdr, '_tls_') !== false) $sources[$ipdr] = 'opnsense:zenarmor:tls'; + elseif (strpos($ipdr, '_conn_') !== false) $sources[$ipdr] = 'opnsense:zenarmor:conn'; + else $sources[$ipdr] = 'opnsense:zenarmor:traffic'; + } + } + } + + if (empty($sources)) { + echo "DEBUG: No log sources enabled. Sleeping 10s...\n"; + sleep(10); + continue; + } + + echo "DEBUG: Flushing cache if any...\n"; + flush_cache($endpoint, $token, $maxSizeMB, $maxAgeHrs, $verifySsl, $useGzip); + + echo "DEBUG: Loading state...\n"; + $state = load_state(); + + // Telemetry sampling (every 60 seconds) + if ($enableTelemetry) { + $lastTelemetryTime = $state['telemetry_time'] ?? 0; + if ((time() - $lastTelemetryTime) >= 60) { + echo "DEBUG: Gathering system telemetry...\n"; + $telemetryJson = gather_telemetry(); + $code = hec_post($endpoint, $token, $telemetryJson, $verifySsl, $useGzip); + if ($code !== 200) { + cache_payload($telemetryJson); + } else { + echo "INFO Forwarded system telemetry.\n"; + } + $state['telemetry_time'] = time(); + } + } + + $anyActivity = false; + + foreach ($sources as $logFile => $sourcetype) { + if (!is_readable($logFile)) { + echo "DEBUG: Log file not readable: {$logFile}\n"; + continue; + } + + $currentInode = fileinode($logFile); + $prev = $state[$logFile] ?? null; + + if ($prev !== null && (int)$prev['inode'] !== $currentInode) { + echo "INFO Log rotated: {$logFile}\n"; + hec_log('INFO Log rotated: ' . $logFile); + $prev = null; + } + + $offset = ($prev !== null) ? (int)$prev['offset'] : 0; + $fileSize = filesize($logFile); + + if ($offset > $fileSize) { + echo "INFO File truncated: {$logFile}\n"; + hec_log('INFO File truncated: ' . $logFile); + $offset = 0; + } + + if ($offset < $fileSize) { + echo "DEBUG: Reading new lines from {$logFile}...\n"; + $fh = fopen($logFile, 'rb'); + if ($fh !== false) { + fseek($fh, $offset); + $lineCount = 0; + $batchCount = 0; + $processedCount = 0; + $payloadBatch = ''; + $maxLinesPerSlice = 5000; + + while (($line = fgets($fh)) !== false) { + $processedCount++; + $line = rtrim($line, "\r\n"); + if ($line === '' || $line === '{"index":{}}') continue; + + // Support sending structured JSON (like eve.json) directly instead of as escaped strings + $eventData = $line; + if (str_starts_with($line, '{') && str_ends_with($line, '}')) { + $decoded = @json_decode($line, true); + if ($decoded !== null) { + $eventData = $decoded; + } + } + + $payloadBatch .= json_encode([ + 'time' => time(), + 'host' => gethostname(), + 'source' => $logFile, + 'sourcetype' => $sourcetype, + 'event' => $eventData, + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) . "\n"; + + $batchCount++; + + // Send every 500 lines to avoid massive memory use or timeouts + if ($batchCount >= 500) { + $code = hec_post($endpoint, $token, $payloadBatch, $verifySsl, $useGzip); + if ($code === 200) { + $lineCount += $batchCount; + echo "."; // Progress indicator for massive files + } else { + cache_payload($payloadBatch); + echo "x"; + } + $payloadBatch = ''; + $batchCount = 0; + } + + // Yield to other log files if we've processed a huge chunk + if ($processedCount >= $maxLinesPerSlice) { + break; + } + } + + // Send remaining batch + if ($batchCount > 0) { + $code = hec_post($endpoint, $token, $payloadBatch, $verifySsl, $useGzip); + if ($code === 200) { + $lineCount += $batchCount; + echo "."; + } else { + cache_payload($payloadBatch); + echo "x"; + } + } + + if ($lineCount >= 500) echo "\n"; + + $newOffset = ftell($fh); + fclose($fh); + + $state[$logFile] = [ + 'inode' => $currentInode, + 'offset' => $newOffset, + ]; + + if ($lineCount > 0) { + $anyActivity = true; + $msg = "INFO {$logFile}: forwarded {$lineCount} line(s)."; + echo $msg . "\n"; + hec_log($msg); + } + } + } else { + echo "DEBUG: No new lines in {$logFile}.\n"; + } + } + + // Garbage collect deleted files (like ephemeral Zenarmor IPDRs) from state + foreach (array_keys($state) as $cachedFile) { + if ($cachedFile === 'telemetry_time') continue; + if (!file_exists($cachedFile)) { + unset($state[$cachedFile]); + } + } + + save_state($state); + + if ($anyActivity) { + // If we are actively chewing through backlogs, yield CPU briefly but skip the 10s sleep + usleep(100000); // 100ms + } else { + echo "DEBUG: Sleeping for 10 seconds...\n"; + sleep(10); + } +} diff --git a/sysutils/os-splunk-hec/src/opnsense/service/conf/actions.d/actions_splunk_hec.conf b/sysutils/os-splunk-hec/src/opnsense/service/conf/actions.d/actions_splunk_hec.conf new file mode 100644 index 0000000000..bcb681f9c5 --- /dev/null +++ b/sysutils/os-splunk-hec/src/opnsense/service/conf/actions.d/actions_splunk_hec.conf @@ -0,0 +1,23 @@ +[restart] +command:/usr/local/etc/rc.d/splunk_hec restart +parameters: +type:script +message:Restarting Splunk HEC exporter + +[start] +command:/usr/local/etc/rc.d/splunk_hec start +parameters: +type:script +message:Starting Splunk HEC exporter + +[stop] +command:/usr/local/etc/rc.d/splunk_hec stop +parameters: +type:script +message:Stopping Splunk HEC exporter + +[status] +command:/usr/local/etc/rc.d/splunk_hec status +parameters: +type:script_output +message:Requesting Splunk HEC exporter status