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
+ #}
+
+
+
+
+
+
+
+
+
+
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