From 3a35e2a259310507ed7dafbdd50eacce696a0c8e Mon Sep 17 00:00:00 2001 From: Eric Kapitanski Date: Tue, 30 Dec 2025 11:56:16 -0800 Subject: [PATCH 1/2] Add FreeBSD/OPNsense core Python code Core daemon and supporting modules for the OPNsense plugin port package: - lightscope_daemon.py: Main daemon with pflog monitoring - pflog_reader.py: FreeBSD pflog interface reader - honeypot.py: Optional honeypot services - uploader.py: Data upload to LightScope cloud --- security/lightscope/LICENSE | 25 + security/lightscope/Makefile | 8 + security/lightscope/README.md | 204 +++++++ security/lightscope/pkg-descr | 10 + security/lightscope/src/+POST_DEINSTALL | 10 + security/lightscope/src/+POST_INSTALL | 16 + .../src/etc/inc/plugins.inc.d/lightscope.inc | 158 ++++++ .../lightscope/src/etc/lightscope.conf.sample | 27 + .../lightscope/src/etc/rc.d/os-lightscope | 86 +++ .../Lightscope/Api/ServiceController.php | 49 ++ .../Lightscope/Api/SettingsController.php | 41 ++ .../Lightscope/Api/StatusController.php | 66 +++ .../OPNsense/Lightscope/IndexController.php | 45 ++ .../OPNsense/Lightscope/forms/settings.xml | 14 + .../models/OPNsense/Lightscope/ACL/ACL.xml | 9 + .../models/OPNsense/Lightscope/Lightscope.php | 14 + .../models/OPNsense/Lightscope/Lightscope.xml | 42 ++ .../models/OPNsense/Lightscope/Menu/Menu.xml | 5 + .../app/views/OPNsense/Lightscope/index.volt | 244 +++++++++ .../opnsense/scripts/lightscope/__init__.py | 1 + .../opnsense/scripts/lightscope/honeypot.py | 504 ++++++++++++++++++ .../scripts/lightscope/lightscope_daemon.py | 490 +++++++++++++++++ .../src/opnsense/scripts/lightscope/logs.py | 46 ++ .../scripts/lightscope/pflog_reader.py | 320 +++++++++++ .../scripts/lightscope/reconfigure.sh | 39 ++ .../src/opnsense/scripts/lightscope/status.py | 243 +++++++++ .../opnsense/scripts/lightscope/uploader.py | 234 ++++++++ .../conf/actions.d/actions_lightscope.conf | 30 ++ .../src/opnsense/www/js/widgets/Lightscope.js | 109 ++++ .../www/js/widgets/Metadata/Lightscope.xml | 20 + 30 files changed, 3109 insertions(+) create mode 100644 security/lightscope/LICENSE create mode 100644 security/lightscope/Makefile create mode 100644 security/lightscope/README.md create mode 100644 security/lightscope/pkg-descr create mode 100755 security/lightscope/src/+POST_DEINSTALL create mode 100755 security/lightscope/src/+POST_INSTALL create mode 100644 security/lightscope/src/etc/inc/plugins.inc.d/lightscope.inc create mode 100644 security/lightscope/src/etc/lightscope.conf.sample create mode 100755 security/lightscope/src/etc/rc.d/os-lightscope create mode 100644 security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/ServiceController.php create mode 100644 security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/SettingsController.php create mode 100644 security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/StatusController.php create mode 100644 security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/IndexController.php create mode 100644 security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/forms/settings.xml create mode 100644 security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/ACL/ACL.xml create mode 100644 security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Lightscope.php create mode 100644 security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Lightscope.xml create mode 100644 security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Menu/Menu.xml create mode 100644 security/lightscope/src/opnsense/mvc/app/views/OPNsense/Lightscope/index.volt create mode 100755 security/lightscope/src/opnsense/scripts/lightscope/__init__.py create mode 100755 security/lightscope/src/opnsense/scripts/lightscope/honeypot.py create mode 100755 security/lightscope/src/opnsense/scripts/lightscope/lightscope_daemon.py create mode 100755 security/lightscope/src/opnsense/scripts/lightscope/logs.py create mode 100755 security/lightscope/src/opnsense/scripts/lightscope/pflog_reader.py create mode 100755 security/lightscope/src/opnsense/scripts/lightscope/reconfigure.sh create mode 100755 security/lightscope/src/opnsense/scripts/lightscope/status.py create mode 100755 security/lightscope/src/opnsense/scripts/lightscope/uploader.py create mode 100644 security/lightscope/src/opnsense/service/conf/actions.d/actions_lightscope.conf create mode 100644 security/lightscope/src/opnsense/www/js/widgets/Lightscope.js create mode 100644 security/lightscope/src/opnsense/www/js/widgets/Metadata/Lightscope.xml diff --git a/security/lightscope/LICENSE b/security/lightscope/LICENSE new file mode 100644 index 0000000000..74f8f91833 --- /dev/null +++ b/security/lightscope/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2025, Eric Kapitanski +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/security/lightscope/Makefile b/security/lightscope/Makefile new file mode 100644 index 0000000000..e26e70235f --- /dev/null +++ b/security/lightscope/Makefile @@ -0,0 +1,8 @@ +PLUGIN_NAME= lightscope +PLUGIN_VERSION= 1.0 +PLUGIN_COMMENT= LightScope Cybersecurity Research Honeypot and Telescope +PLUGIN_MAINTAINER= e@alumni.usc.edu +PLUGIN_WWW= https://thelightscope.com +PLUGIN_DEPENDS= python311 + +.include "../../Mk/plugins.mk" diff --git a/security/lightscope/README.md b/security/lightscope/README.md new file mode 100644 index 0000000000..53c9687110 --- /dev/null +++ b/security/lightscope/README.md @@ -0,0 +1,204 @@ +# LightScope for OPNsense + +OPNsense plugin for LightScope network security monitoring. + +## Features + +- Captures blocked TCP SYN packets from pf firewall via pflog0 +- Configurable honeypot ports with PROXY protocol forwarding +- Uploads security telemetry to thelightscope.com for analysis +- Privacy-preserving IP randomization + +## Requirements + +- OPNsense 24.x or later (FreeBSD 14.x) +- pf firewall with logging enabled +- Python 3.11 + +## Installation + +### From Package + +```bash +pkg install /path/to/os-lightscope-1.0.pkg +``` + +### Building from Source + +```bash +# Clone into OPNsense plugins directory +cd /usr/plugins/security +git clone lightscope + +# Or copy files +cp -r /path/to/OPNsense/* /usr/plugins/security/lightscope/ + +# Build +cd /usr/plugins/security/lightscope +make package + +# Install +pkg install work/pkg/os-lightscope-1.0.pkg +``` + +## Configuration + +Configuration file: `/usr/local/etc/lightscope.conf` + +```ini +[Settings] +# Database identifier - auto-generated if empty +database = + +# IP randomization key - auto-generated if empty +randomization_key = + +# Comma-separated honeypot ports (leave empty to disable) +honeypot_ports = 8080,2323,8443,3389,5900 + +# Remote honeypot server +honeypot_server = 128.9.28.79 +honeypot_ssh_port = 12345 +honeypot_telnet_port = 12346 +``` + +## Usage + +### Start the Service + +```bash +# One-time start +service os-lightscope onestart + +# Enable at boot +sysrc lightscope_enable="YES" +service os-lightscope start +``` + +### Check Status + +```bash +# View running processes +ps aux | grep lightscope + +# Check honeypot ports +sockstat -l | grep -E "8080|2323|8443|3389|5900" +``` + +### View Logs + +Logs are output to stdout/stderr. When running via rc.d, check: +```bash +tail -f /var/log/messages | grep lightscope +``` + +## How It Works + +1. **pf firewall** blocks unwanted traffic and logs to pflog0 interface +2. **pflog_reader** captures packets from pflog0, parses TCP SYN packets +3. **packet_handler** processes packets and prepares for upload +4. **uploader** batches and sends data to thelightscope.com +5. **honeypot** listens on configured ports and forwards connections + +### Data Flow + +``` +pflog0 → pflog_reader → packet_handler → uploader → thelightscope.com + ↑ +honeypot ports → honeypot → honeypot_uploader ┘ +``` + +### Data Storage + +All data is kept in memory only. No disk writes for packet data. +- Queue max size: 100,000 items +- Batch size: 600 items (or 5 second idle flush) +- On upload failure: retry after 5 seconds + +## Testing + +```bash +# Load pf modules +kldload pf pflog + +# Create pflog interface +ifconfig pflog0 create + +# Enable pf with a test block rule +pfctl -e +echo 'block out log quick proto tcp from any to any port 9998' | pfctl -f - + +# Start service +service os-lightscope onestart + +# Generate blocked traffic +nc -w 1 8.8.8.8 9998 + +# Check logs for: +# "pflog_reader: sent X packets" +# "uploader: Sent X items" +``` + +## Troubleshooting + +### pflog_reader not capturing packets + +1. Verify pf is enabled: `pfctl -s info` +2. Verify pflog0 exists: `ifconfig pflog0` +3. Verify rules have `log` keyword: `pfctl -sr` +4. Test capture directly: `tcpdump -i pflog0 -c 5` + +### Service won't start + +1. Check python3 symlink exists: `ls -la /usr/local/bin/python3` +2. If missing: `ln -s /usr/local/bin/python3.11 /usr/local/bin/python3` + +### Packets captured but not uploaded + +1. Check network connectivity to thelightscope.com +2. Look for upload errors in logs + +## Dependencies + +Installed automatically via pkg: +- python311 +- py311-dpkt +- py311-requests +- py311-psutil +- py311-pypcap + +## Files + +``` +/usr/local/etc/lightscope.conf.sample - Sample configuration +/usr/local/etc/lightscope.conf - Active configuration (created on first run) +/usr/local/etc/rc.d/os-lightscope - Service script +/usr/local/opnsense/scripts/lightscope/ - Python scripts + ├── lightscope_daemon.py - Main daemon + ├── pflog_reader.py - Packet capture + ├── honeypot.py - Honeypot listener + └── uploader.py - Data upload +``` + +## Technical Notes + +### pflog Header Size + +FreeBSD 14.x uses a 72-byte pflog header (see `/usr/include/net/if_pflog.h`). +The IP packet starts at offset 72 in captured pflog packets. + +### Honeypot PROXY Protocol + +Honeypot connections are forwarded with PROXY protocol v1 header: +``` +PROXY TCP4 +``` + +## License + +BSD 2-Clause License - see LICENSE file + +## Support + +- Dashboard: https://thelightscope.com +- Issues: https://github.com/Thelightscope/thelightscope/issues diff --git a/security/lightscope/pkg-descr b/security/lightscope/pkg-descr new file mode 100644 index 0000000000..472078884c --- /dev/null +++ b/security/lightscope/pkg-descr @@ -0,0 +1,10 @@ +LightScope is a network security monitoring tool that detects unwanted +network traffic and provides honeypot functionality. + +Features: +- Captures and analyzes blocked TCP SYN packets via pflog +- Configurable honeypot ports with PROXY protocol forwarding +- Uploads security telemetry to thelightscope.com for analysis +- Privacy-preserving IP randomization + +WWW: https://thelightscope.com diff --git a/security/lightscope/src/+POST_DEINSTALL b/security/lightscope/src/+POST_DEINSTALL new file mode 100755 index 0000000000..07241b5dfe --- /dev/null +++ b/security/lightscope/src/+POST_DEINSTALL @@ -0,0 +1,10 @@ +#!/bin/sh + +# Stop LightScope service if running +/usr/local/etc/rc.d/os-lightscope onestop 2>/dev/null || true + +# Reload firewall rules to remove LightScope rules +# This ensures the block+log rule is removed immediately +/usr/local/sbin/configctl filter reload 2>/dev/null || true + +echo "LightScope has been uninstalled." diff --git a/security/lightscope/src/+POST_INSTALL b/security/lightscope/src/+POST_INSTALL new file mode 100755 index 0000000000..bf52d8e5bb --- /dev/null +++ b/security/lightscope/src/+POST_INSTALL @@ -0,0 +1,16 @@ +#!/bin/sh + +# Set executable permissions on scripts +chmod +x /usr/local/opnsense/scripts/lightscope/*.py 2>/dev/null +chmod +x /usr/local/opnsense/scripts/lightscope/*.sh 2>/dev/null +chmod +x /usr/local/etc/rc.d/os-lightscope 2>/dev/null + +# Install Python dependencies via pip if not already present +if ! /usr/local/bin/python3.11 -c "import dpkt" 2>/dev/null; then + echo "Installing LightScope Python dependencies..." + /usr/local/bin/python3.11 -m ensurepip --default-pip 2>/dev/null || true + /usr/local/bin/python3.11 -m pip install --quiet dpkt requests psutil pypcap +fi + +echo "LightScope installed successfully." +echo "Configure via Services -> LightScope in the web UI." diff --git a/security/lightscope/src/etc/inc/plugins.inc.d/lightscope.inc b/security/lightscope/src/etc/inc/plugins.inc.d/lightscope.inc new file mode 100644 index 0000000000..c88a4912e5 --- /dev/null +++ b/security/lightscope/src/etc/inc/plugins.inc.d/lightscope.inc @@ -0,0 +1,158 @@ + + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE. + */ + +function lightscope_enabled() +{ + $model = new \OPNsense\Lightscope\Lightscope(); + return (string)$model->general->enabled == '1'; +} + +function lightscope_services() +{ + $services = array(); + + if (lightscope_enabled()) { + $services[] = array( + 'description' => gettext('LightScope Cybersecurity Research Honeypot and Telescope'), + 'configd' => array( + 'restart' => array('lightscope restart'), + 'start' => array('lightscope start'), + 'stop' => array('lightscope stop'), + ), + 'name' => 'lightscope', + 'pidfile' => '/var/run/lightscope.pid' + ); + } + + return $services; +} + +function lightscope_syslog() +{ + $logfacilities = array(); + + $logfacilities['lightscope'] = array( + 'facility' => array('lightscope'), + ); + + return $logfacilities; +} + +function lightscope_firewall(\OPNsense\Firewall\Plugin $fw) +{ + // Always add block+log rule when plugin is installed - no config dependency + // This ensures pflog captures blocked TCP traffic for LightScope visibility + // Uses very high priority to run last, catching traffic that falls through + $fw->registerFilterRule( + 999999, // highest priority - run as late as possible + array( + 'type' => 'block', + 'from' => 'any', + 'to' => '(self)', + 'direction' => 'in', + 'protocol' => 'tcp', + 'descr' => 'LightScope: log blocked TCP', + 'log' => true, + '#ref' => 'lightscope_blocklog' + ), + null + ); + + // Honeypot rules depend on config - wrap in try/catch for resilience + try { + $model = new \OPNsense\Lightscope\Lightscope(); + + if ((string)$model->general->enabled != '1') { + return; + } + + $honeypot_ports = (string)$model->general->honeypot_ports; + + if (empty($honeypot_ports)) { + return; + } + + // Parse comma-separated ports + $ports = array_map('trim', explode(',', $honeypot_ports)); + $valid_ports = array(); + + foreach ($ports as $port) { + if (is_numeric($port) && $port >= 1 && $port <= 65535) { + $valid_ports[] = $port; + } + } + + if (empty($valid_ports)) { + return; + } + + // Create firewall rule to allow honeypot traffic + $fw->registerFilterRule( + 100000, // priority - run late so other rules take precedence + array( + 'from' => 'any', + 'to' => '(self)', + 'direction' => 'in', + 'protocol' => 'tcp', + 'to_port' => implode(' ', $valid_ports), + 'descr' => 'LightScope honeypot ports', + 'log' => true, + '#ref' => 'lightscope_honeypot' + ), + null + ); + } catch (Exception $e) { + // Model loading failed - log rule is still added, honeypot rules skipped + // This ensures basic functionality even if config is broken + } +} + +function lightscope_xmlrpc_sync() +{ + $result = array(); + $result['id'] = 'lightscope'; + $result['section'] = 'OPNsense.Lightscope'; + $result['description'] = gettext('LightScope Security Monitor'); + $result['services'] = array('lightscope'); + return array($result); +} + +function lightscope_configure() +{ + return array( + 'bootup' => array('lightscope_configure_do') + ); +} + +function lightscope_configure_do($verbose = false) +{ + if (lightscope_enabled()) { + if ($verbose) { + echo 'Starting LightScope...'; + } + configd_run('lightscope start'); + if ($verbose) { + echo "done.\n"; + } + } +} diff --git a/security/lightscope/src/etc/lightscope.conf.sample b/security/lightscope/src/etc/lightscope.conf.sample new file mode 100644 index 0000000000..fd02a09a20 --- /dev/null +++ b/security/lightscope/src/etc/lightscope.conf.sample @@ -0,0 +1,27 @@ +[Settings] +# LightScope Configuration for OPNsense +# +# Database identifier - auto-generated if empty +# This is used to identify your installation on thelightscope.com +database = + +# IP randomization key - auto-generated if empty +# Used to anonymize destination IPs in uploaded data +randomization_key = + +# Comma-separated honeypot ports +# These ports will be opened and forwarded to the remote honeypot server +# Leave empty to disable honeypot functionality +# Default: 8080,2323,8443,3389,5900 +honeypot_ports = 8080,2323,8443,3389,5900 + +# Remote honeypot server +# The server that receives forwarded honeypot connections +honeypot_server = 128.9.28.79 + +# Remote honeypot ports +# SSH connections (even local ports) forward to this port +honeypot_ssh_port = 12345 + +# TELNET connections (odd local ports) forward to this port +honeypot_telnet_port = 12346 diff --git a/security/lightscope/src/etc/rc.d/os-lightscope b/security/lightscope/src/etc/rc.d/os-lightscope new file mode 100755 index 0000000000..afb25798b4 --- /dev/null +++ b/security/lightscope/src/etc/rc.d/os-lightscope @@ -0,0 +1,86 @@ +#!/bin/sh +# +# PROVIDE: lightscope +# REQUIRE: DAEMON +# KEYWORD: shutdown +# +# Add the following lines to /etc/rc.conf to enable lightscope: +# +# lightscope_enable="YES" +# + +. /etc/rc.subr + +name="lightscope" +rcvar="${name}_enable" + +load_rc_config $name + +# Check OPNsense model for enabled state if not explicitly set +if [ -z "${lightscope_enable}" ]; then + if /usr/local/sbin/pluginctl -g OPNsense.Lightscope.general.enabled 2>/dev/null | grep -q "1"; then + lightscope_enable="YES" + else + lightscope_enable="NO" + fi +fi + +: ${lightscope_config:="/usr/local/etc/lightscope.conf"} + +pidfile="/var/run/${name}.pid" +procname="/usr/local/bin/python3" + +start_cmd="${name}_start" +stop_cmd="${name}_stop" +status_cmd="${name}_status" + +start_precmd="${name}_prestart" + +lightscope_start() +{ + echo "Starting ${name}." + /usr/sbin/daemon -c -f -p ${pidfile} -o /var/log/lightscope.log /usr/local/bin/python3 -u /usr/local/opnsense/scripts/lightscope/lightscope_daemon.py +} + +lightscope_stop() +{ + if [ -f ${pidfile} ]; then + echo "Stopping ${name}." + kill -TERM $(cat ${pidfile}) 2>/dev/null + rm -f ${pidfile} + else + echo "${name} is not running." + fi +} + +lightscope_status() +{ + if [ -f ${pidfile} ] && kill -0 $(cat ${pidfile}) 2>/dev/null; then + echo "${name} is running as pid $(cat ${pidfile})." + return 0 + else + echo "${name} is not running." + return 1 + fi +} + +lightscope_prestart() +{ + # Copy sample config if no config exists + if [ ! -f "${lightscope_config}" ]; then + if [ -f "${lightscope_config}.sample" ]; then + cp "${lightscope_config}.sample" "${lightscope_config}" + echo "Created ${lightscope_config} from sample" + fi + fi + + # Ensure pflog0 interface exists + if ! ifconfig pflog0 > /dev/null 2>&1; then + echo "Warning: pflog0 interface not found. Creating..." + ifconfig pflog0 create + fi + + return 0 +} + +run_rc_command "$1" diff --git a/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/ServiceController.php b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/ServiceController.php new file mode 100644 index 0000000000..9fee18647b --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/ServiceController.php @@ -0,0 +1,49 @@ + + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +namespace OPNsense\Lightscope\Api; + +use OPNsense\Base\ApiMutableServiceControllerBase; +use OPNsense\Core\Backend; + +class ServiceController extends ApiMutableServiceControllerBase +{ + protected static $internalServiceClass = '\OPNsense\Lightscope\Lightscope'; + protected static $internalServiceEnabled = 'general.enabled'; + protected static $internalServiceName = 'lightscope'; + + /** + * Override reconfigure to skip template generation (we don't use templates) + */ + public function reconfigureAction() + { + $backend = new Backend(); + $response = $backend->configdRun('lightscope reconfigure'); + return array("status" => "ok", "response" => $response); + } +} diff --git a/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/SettingsController.php b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/SettingsController.php new file mode 100644 index 0000000000..051f9f9dac --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/SettingsController.php @@ -0,0 +1,41 @@ + + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +namespace OPNsense\Lightscope\Api; + +use OPNsense\Base\ApiMutableModelControllerBase; + +/** + * Class SettingsController + * @package OPNsense\Lightscope\Api + */ +class SettingsController extends ApiMutableModelControllerBase +{ + protected static $internalModelClass = '\OPNsense\Lightscope\Lightscope'; + protected static $internalModelName = 'lightscope'; +} diff --git a/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/StatusController.php b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/StatusController.php new file mode 100644 index 0000000000..e3bb11d3fe --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/Api/StatusController.php @@ -0,0 +1,66 @@ + + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +namespace OPNsense\Lightscope\Api; + +use OPNsense\Base\ApiControllerBase; +use OPNsense\Core\Backend; + +class StatusController extends ApiControllerBase +{ + /** + * Get LightScope status including database name and dashboard URL + * @return array status information + */ + public function statusAction() + { + $response = json_decode(trim((new Backend())->configdRun('lightscope status')), true); + if ($response !== null) { + return $response; + } + return [ + 'status' => 'unknown', + 'database' => '', + 'dashboard_url' => '', + 'error' => 'Unable to determine LightScope status' + ]; + } + + /** + * Get LightScope service logs + * @return array log content + */ + public function logsAction() + { + $response = json_decode(trim((new Backend())->configdRun('lightscope logs')), true); + if ($response !== null) { + return $response; + } + return ['logs' => 'Unable to retrieve logs']; + } +} diff --git a/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/IndexController.php b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/IndexController.php new file mode 100644 index 0000000000..d03b18c61c --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/IndexController.php @@ -0,0 +1,45 @@ + + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +namespace OPNsense\Lightscope; + +/** + * Class IndexController + * @package OPNsense\Lightscope + */ +class IndexController extends \OPNsense\Base\IndexController +{ + /** + * Default index action + */ + public function indexAction() + { + $this->view->settings = $this->getForm("settings"); + $this->view->pick('OPNsense/Lightscope/index'); + } +} diff --git a/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/forms/settings.xml b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/forms/settings.xml new file mode 100644 index 0000000000..4667955b0c --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/controllers/OPNsense/Lightscope/forms/settings.xml @@ -0,0 +1,14 @@ +
+ + lightscope.general.enabled + + checkbox + Enable or disable the LightScope security monitoring service. + + + lightscope.general.honeypot_ports + + text + Comma-separated list of TCP ports to forward to the USC honeypot for analysis. You can view your honeypot data on the Full Dashboard. Example input: 8080,2323,8443,3389,5900 + +
diff --git a/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/ACL/ACL.xml b/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/ACL/ACL.xml new file mode 100644 index 0000000000..643696ac38 --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/ACL/ACL.xml @@ -0,0 +1,9 @@ + + + Services: LightScope + + ui/lightscope/* + api/lightscope/* + + + diff --git a/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Lightscope.php b/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Lightscope.php new file mode 100644 index 0000000000..01e137b295 --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Lightscope.php @@ -0,0 +1,14 @@ + + * All rights reserved. + */ + +namespace OPNsense\Lightscope; + +use OPNsense\Base\BaseModel; + +class Lightscope extends BaseModel +{ +} diff --git a/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Lightscope.xml b/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Lightscope.xml new file mode 100644 index 0000000000..d98eff1f2c --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Lightscope.xml @@ -0,0 +1,42 @@ + + //OPNsense/Lightscope + 1.0.0 + LightScope Cybersecurity Research Honeypot and Telescope + + + + 0 + Y + + + N + /^[a-z0-9_]{0,63}$/ + Database name must be lowercase alphanumeric with underscores, max 63 chars + + + N + /^[a-z0-9_]{0,63}$/ + + + 8080,2323,8443,3389,5900 + N + /^[0-9,\s]*$/ + Enter comma-separated port numbers (e.g., 8080,2323,8443) + + + 128.9.28.79 + N + + + 12345 + 1 + 65535 + + + 12346 + 1 + 65535 + + + + diff --git a/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Menu/Menu.xml b/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Menu/Menu.xml new file mode 100644 index 0000000000..70a6d39755 --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/models/OPNsense/Lightscope/Menu/Menu.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/security/lightscope/src/opnsense/mvc/app/views/OPNsense/Lightscope/index.volt b/security/lightscope/src/opnsense/mvc/app/views/OPNsense/Lightscope/index.volt new file mode 100644 index 0000000000..48bde8f5e7 --- /dev/null +++ b/security/lightscope/src/opnsense/mvc/app/views/OPNsense/Lightscope/index.volt @@ -0,0 +1,244 @@ +{# + # Copyright (c) 2025 Eric Kapitanski + # University of Southern California Information Sciences Institute + # All rights reserved. + #} + + + +
+
+

LightScope Cybersecurity Research Honeypot and Telescope

+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
{{ lang._('Service Status') }} + {{ lang._('Loading...') }} +
+
+
+ + + + +
+ + + {{ partial("layout_partials/base_form", ['fields': settings, 'id': 'frm_GeneralSettings']) }} + +
+ + Important: Make sure logging is enabled on your firewall block/reject rules or LightScope can't see that traffic! LightScope works by examining your firewall logs for dropped/rejected traffic as they are created. Logging accepted traffic is not required, as LightScope will ignore anything accepted anyway. You don't need to retain logs or can use small log sizes, but logging must be enabled on your explicit drop/reject rules. +
+ +
+ + +
+
+ + + + +
+
+ +
+ + +
+
+

+ {{ lang._('Service Logs') }} + +

+
Loading logs...
+
+
+
+
diff --git a/security/lightscope/src/opnsense/scripts/lightscope/__init__.py b/security/lightscope/src/opnsense/scripts/lightscope/__init__.py new file mode 100755 index 0000000000..3ecfb9d2de --- /dev/null +++ b/security/lightscope/src/opnsense/scripts/lightscope/__init__.py @@ -0,0 +1 @@ +# LightScope for OPNsense diff --git a/security/lightscope/src/opnsense/scripts/lightscope/honeypot.py b/security/lightscope/src/opnsense/scripts/lightscope/honeypot.py new file mode 100755 index 0000000000..50d753c705 --- /dev/null +++ b/security/lightscope/src/opnsense/scripts/lightscope/honeypot.py @@ -0,0 +1,504 @@ +#!/usr/local/bin/python3 +""" +honeypot.py - Honeypot listener with PROXY protocol forwarding + +This module opens sockets on configured ports and forwards connections +to a remote honeypot server using the PROXY protocol to preserve +the original attacker IP address. +""" + +import socket +import select +import threading +import time +import sys +import subprocess +import re + + +class HoneypotListener: + """ + Manages honeypot listener sockets and forwards connections + to remote honeypot server using PROXY protocol. + """ + + def __init__(self, config, upload_pipe, database): + """ + Initialize honeypot listener. + + Args: + config: Dict with honeypot configuration + upload_pipe: Pipe for sending honeypot connection data + database: Database identifier for PROXY header + """ + self.config = config + self.upload_pipe = upload_pipe + self.database = database + + self.honeypot_server = config.get('honeypot_server', '128.9.28.79') + self.ssh_port = int(config.get('honeypot_ssh_port', 12345)) + self.telnet_port = int(config.get('honeypot_telnet_port', 12346)) + + self.sockets = {} # socket -> port mapping + self.sockets_lock = threading.Lock() + self.running = False + + # Connection limiting + self.MAX_CONCURRENT_CONNECTIONS = 50 + self.connection_semaphore = threading.Semaphore(self.MAX_CONCURRENT_CONNECTIONS) + self.active_connections = 0 + self.connection_lock = threading.Lock() + + # Firewall monitoring + self.FIREWALL_CHECK_INTERVAL = 10 # seconds + + def parse_ports(self, ports_string): + """Parse comma-separated port string into list of integers.""" + if not ports_string: + return [] + + ports = [] + for p in ports_string.split(','): + p = p.strip() + if p.isdigit(): + port = int(p) + if 1 <= port <= 65535: + ports.append(port) + return ports + + def get_firewall_allowed_ports(self): + """ + Get list of ports that have PASS/ALLOW rules in the firewall. + These are ports where legitimate services may be running. + Returns a tuple of (set of ports, list of (start, end) range tuples). + """ + allowed_ports = set() + allowed_ranges = [] + + try: + result = subprocess.run( + ["pfctl", "-sr"], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + for line in result.stdout.split('\n'): + # Look for pass rules with port specifications + if line.startswith('pass') and 'proto tcp' in line: + # Match port ranges like "port 1:4343" or "port 80:443" + range_match = re.search(r'port\s*[=]?\s*(\d+):(\d+)', line) + if range_match: + start_port = int(range_match.group(1)) + end_port = int(range_match.group(2)) + allowed_ranges.append((start_port, end_port)) + continue # Don't also match as single port + + # Match single port patterns like "port = 22" or "port 22" + port_match = re.search(r'port\s*[=]?\s*(\d+)(?![\d:])', line) + if port_match: + allowed_ports.add(int(port_match.group(1))) + + # Match port lists in braces + brace_match = re.search(r'port\s*[=]?\s*\{([^}]+)\}', line) + if brace_match: + ports_str = brace_match.group(1) + for p in re.findall(r'\d+', ports_str): + allowed_ports.add(int(p)) + except Exception as e: + print(f"honeypot: Error checking firewall rules: {e}", flush=True) + + return allowed_ports, allowed_ranges + + def is_port_allowed_by_firewall(self, port, allowed_ports, allowed_ranges): + """Check if a port is allowed by firewall rules (including ranges).""" + if port in allowed_ports: + return True + for start, end in allowed_ranges: + if start <= port <= end: + return True + return False + + def is_port_in_use(self, port): + """ + Check if a port is already in use by another service. + Returns True if port is in use, False if available. + """ + try: + test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + test_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + test_sock.bind(("", port)) + test_sock.close() + return False # Port is available + except OSError: + return True # Port is in use + + def _firewall_monitor_thread(self): + """ + Monitor firewall rules and port usage every FIREWALL_CHECK_INTERVAL seconds. + If a new ALLOW rule is detected or port becomes in use, close that honeypot port. + """ + print("honeypot: Started firewall/port monitor (checking every 10s)", flush=True) + + while self.running: + time.sleep(self.FIREWALL_CHECK_INTERVAL) + if not self.running: + break + + try: + allowed_ports, allowed_ranges = self.get_firewall_allowed_ports() + + with self.sockets_lock: + sockets_to_close = [] + for sock, port in list(self.sockets.items()): + if self.is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): + print(f"honeypot: WARNING - Firewall ALLOW rule detected for port {port}, closing honeypot on this port", flush=True) + sockets_to_close.append((sock, port, "firewall conflict")) + + for sock, port, reason in sockets_to_close: + try: + sock.close() + except: + pass + del self.sockets[sock] + print(f"honeypot: Closed listener on port {port} due to {reason}", flush=True) + + except Exception as e: + print(f"honeypot: Error in firewall monitor: {e}", flush=True) + + def open_port(self, port): + """ + Try to bind and listen on a port. + + Returns: + (socket, port) on success, (None, None) on failure + """ + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", port)) + s.listen(10) + s.setblocking(False) + return s, port + except OSError as e: + if e.errno == 48: # EADDRINUSE on FreeBSD + print(f"honeypot: Port {port} already in use", flush=True) + elif e.errno == 13: # EACCES + print(f"honeypot: Permission denied for port {port}", flush=True) + else: + print(f"honeypot: Error binding port {port}: {e}", flush=True) + return None, None + except Exception as e: + print(f"honeypot: Unexpected error binding port {port}: {e}", flush=True) + return None, None + + def start(self, ports_string): + """ + Start honeypot listeners on configured ports. + + Args: + ports_string: Comma-separated list of ports + """ + ports = self.parse_ports(ports_string) + if not ports: + print("honeypot: No valid ports configured", flush=True) + return + + # Check for firewall conflicts and ports already in use + allowed_ports, allowed_ranges = self.get_firewall_allowed_ports() + safe_ports = [] + for port in ports: + if self.is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): + print(f"honeypot: WARNING - Port {port} has a firewall ALLOW rule, skipping (may conflict with legitimate service)", flush=True) + elif self.is_port_in_use(port): + print(f"honeypot: WARNING - Port {port} is already in use by another service, skipping", flush=True) + else: + safe_ports.append(port) + + if not safe_ports: + print("honeypot: No ports available (all have conflicts or are in use)", flush=True) + return + + print(f"honeypot: Opening ports: {safe_ports}", flush=True) + + for port in safe_ports: + s, actual_port = self.open_port(port) + if s and actual_port: + with self.sockets_lock: + self.sockets[s] = actual_port + print(f"honeypot: Listening on port {actual_port}", flush=True) + + if not self.sockets: + print("honeypot: Failed to open any ports", flush=True) + return + + self.running = True + + # Start firewall monitor thread + monitor_thread = threading.Thread(target=self._firewall_monitor_thread, daemon=True) + monitor_thread.start() + + self._run_accept_loop() + + def stop(self): + """Stop all honeypot listeners.""" + self.running = False + with self.sockets_lock: + for s in list(self.sockets.keys()): + try: + s.close() + except: + pass + self.sockets.clear() + print("honeypot: Stopped all listeners", flush=True) + + def get_open_ports(self): + """Return list of currently open honeypot ports.""" + with self.sockets_lock: + return list(self.sockets.values()) + + def _run_accept_loop(self): + """Main accept loop for honeypot connections.""" + print("honeypot: Starting accept loop", flush=True) + + while self.running: + with self.sockets_lock: + sock_list = list(self.sockets.keys()) + + if not sock_list: + time.sleep(1.0) + continue + + try: + # Wait for incoming connections + readable, _, _ = select.select(sock_list, [], [], 1.0) + + for sock in readable: + try: + self._handle_connection(sock) + except Exception as e: + print(f"honeypot: Error handling connection: {e}", flush=True) + + except Exception as e: + if self.running: + print(f"honeypot: Error in accept loop: {e}", flush=True) + time.sleep(1.0) + + def _handle_connection(self, listen_sock): + """Handle an incoming connection on a honeypot port.""" + try: + local_conn, addr = listen_sock.accept() + with self.sockets_lock: + port = self.sockets.get(listen_sock) + if port is None: + local_conn.close() + return + attacker_ip = addr[0] + attacker_port = addr[1] + + print(f"honeypot: Connection from {attacker_ip}:{attacker_port} to port {port}", flush=True) + + # Determine service type: even ports = SSH, odd ports = TELNET + if port % 2 == 0: + service = 'SSH' + remote_port = self.ssh_port + else: + service = 'TELNET' + remote_port = self.telnet_port + + # Check connection limit + if not self.connection_semaphore.acquire(blocking=False): + print(f"honeypot: Connection limit reached, rejecting {attacker_ip}", flush=True) + local_conn.close() + return + + with self.connection_lock: + self.active_connections += 1 + + # Start forwarding in a new thread + threading.Thread( + target=self._forward_connection, + args=(local_conn, attacker_ip, attacker_port, port, service, remote_port), + daemon=True + ).start() + + except Exception as e: + print(f"honeypot: Error accepting connection: {e}", flush=True) + + def _forward_connection(self, local_conn, attacker_ip, attacker_port, honeypot_port, service, remote_port): + """ + Forward a connection to the remote honeypot server using PROXY protocol. + """ + remote_conn = None + connection_released = False + + try: + # Connect to remote honeypot + remote_conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + remote_conn.settimeout(30.0) + + try: + remote_conn.connect((self.honeypot_server, remote_port)) + except Exception as e: + print(f"honeypot: Failed to connect to remote server: {e}", flush=True) + return + + # Send PROXY protocol header + # Format: PROXY TCP4 src_ip dest_ip src_port dest_port\r\n + proxy_header = f"PROXY TCP4 {attacker_ip} {self.database} {attacker_port} {honeypot_port}\r\n" + print(f"honeypot: Sending PROXY header: {proxy_header.strip()}", flush=True) + remote_conn.sendall(proxy_header.encode()) + + # Set timeouts for idle connections (3 minutes) + CONNECTION_TIMEOUT = 180 + local_conn.settimeout(CONNECTION_TIMEOUT) + remote_conn.settimeout(CONNECTION_TIMEOUT) + + # Send honeypot connection data for logging + self._send_connection_data(attacker_ip, attacker_port, honeypot_port, service) + + # Bidirectional forwarding + def forward(src, dst, direction): + try: + while True: + data = src.recv(4096) + if not data: + break + dst.sendall(data) + except Exception: + pass + finally: + try: + src.close() + except: + pass + try: + dst.close() + except: + pass + + # Start forwarding threads + t1 = threading.Thread(target=forward, args=(local_conn, remote_conn, "client->server")) + t2 = threading.Thread(target=forward, args=(remote_conn, local_conn, "server->client")) + t1.daemon = True + t2.daemon = True + t1.start() + t2.start() + + # Wait for both threads to complete + t1.join() + t2.join() + + print(f"honeypot: Connection from {attacker_ip} closed", flush=True) + + except Exception as e: + print(f"honeypot: Forwarding error: {e}", flush=True) + + finally: + # Clean up + for conn in (local_conn, remote_conn): + if conn: + try: + conn.close() + except: + pass + + # Release connection slot + if not connection_released: + self.connection_semaphore.release() + with self.connection_lock: + self.active_connections -= 1 + + def _send_connection_data(self, attacker_ip, attacker_port, honeypot_port, service): + """Send honeypot connection data to upload pipe.""" + try: + data = { + "db_name": self.database, + "system_time": str(time.time()), + "ip_version": "HP", + "ip_ihl": "HP", + "ip_tos": "HP", + "ip_len": "HP", + "ip_id": "HP", + "ip_flags": "HP", + "ip_frag": "HP", + "ip_ttl": "HP", + "ip_proto": "HP", + "ip_chksum": "HP", + "ip_src": attacker_ip, + "ip_dst_randomized": "HP", + "ip_options": "HP", + "tcp_sport": str(attacker_port), + "tcp_dport": str(honeypot_port), + "tcp_seq": 0, + "tcp_ack": "HP", + "tcp_dataofs": "HP", + "tcp_reserved": "HP", + "tcp_flags": "HP", + "tcp_window": "HP", + "tcp_chksum": "HP", + "tcp_urgptr": "HP", + "tcp_options": "HP", + "ext_dst_ip_country": "HP", + "type": "HP", + "ASN": "HP", + "domain": "HP", + "city": "HP", + "as_type": "HP", + "ip_dst_is_private": "HP", + "external_is_private": "HP", + "open_ports": "", + "previously_open_ports": "", + "interface": "honeypot", + "internal_ip_randomized": "HP", + "external_ip_randomized": "HP", + "System_info": "OPNsense", + "Release_info": "HP", + "Version_info": "HP", + "Machine_info": "HP", + "Total_Memory": "HP", + "processor": "HP", + "architecture": "HP", + "honeypot_status": "True", + "payload": service, + "ls_version": "opnsense-1.0" + } + + if self.upload_pipe: + self.upload_pipe.send(data) + + except Exception as e: + print(f"honeypot: Error sending connection data: {e}", flush=True) + + +def run_honeypot(config, upload_pipe, database): + """ + Main entry point for honeypot process. + + Args: + config: Configuration dict + upload_pipe: Pipe for sending connection data + database: Database identifier + """ + listener = HoneypotListener(config, upload_pipe, database) + ports = config.get('honeypot_ports', '') + listener.start(ports) + + +if __name__ == "__main__": + # Test mode + print("honeypot: Running in test mode") + + test_config = { + 'honeypot_ports': '8080,2323', + 'honeypot_server': '128.9.28.79', + 'honeypot_ssh_port': 12345, + 'honeypot_telnet_port': 12346 + } + + listener = HoneypotListener(test_config, None, "test_database") + try: + listener.start(test_config['honeypot_ports']) + except KeyboardInterrupt: + listener.stop() diff --git a/security/lightscope/src/opnsense/scripts/lightscope/lightscope_daemon.py b/security/lightscope/src/opnsense/scripts/lightscope/lightscope_daemon.py new file mode 100755 index 0000000000..e9dc55dc29 --- /dev/null +++ b/security/lightscope/src/opnsense/scripts/lightscope/lightscope_daemon.py @@ -0,0 +1,490 @@ +#!/usr/local/bin/python3 +""" +lightscope_daemon.py - Main LightScope daemon for OPNsense + +This is the main entry point that: +1. Reads configuration from OPNsense model or config file +2. Spawns the pflog reader process +3. Spawns the honeypot listener process +4. Spawns the uploader process +5. Processes captured packets and sends to uploader +""" + +import os +import sys +import signal +import time +import hashlib +import ipaddress +import random +import string +import configparser +import multiprocessing +import threading +from collections import deque + +# Add script directory to path for imports +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, script_dir) + +from pflog_reader import read_pflog +from honeypot import run_honeypot +from uploader import send_data, send_honeypot_data + +try: + import requests +except ImportError: + print("Error: requests not found", file=sys.stderr) + sys.exit(1) + +try: + import psutil +except ImportError: + psutil = None + +# Version +LS_VERSION = "opnsense-1.0" + +# Configuration paths +CONFIG_FILE = "/usr/local/etc/lightscope.conf" +CONFIG_FILE_SAMPLE = "/usr/local/etc/lightscope.conf.sample" + + +class LightScopeConfig: + """Configuration manager for LightScope.""" + + def __init__(self, config_file=CONFIG_FILE): + self.config_file = config_file + self.database = "" + self.randomization_key = "" + self.honeypot_ports = "8080,2323,8443,3389,5900" + self.honeypot_server = "128.9.28.79" + self.honeypot_ssh_port = 12345 + self.honeypot_telnet_port = 12346 + + self._load_or_create_config() + + def _generate_database_name(self): + """Generate a unique database name with OPNsense prefix.""" + import datetime + today = datetime.date.today().strftime("%Y%m%d") + rand_part = ''.join(random.choices(string.ascii_lowercase, k=44)) + return f"opn{today}_{rand_part}" + + def _generate_randomization_key(self): + """Generate a randomization key.""" + rand_part = ''.join(random.choices(string.ascii_lowercase, k=46)) + return f"randomization_key_{rand_part}" + + def _load_or_create_config(self): + """Load config from file or create with defaults.""" + config = configparser.ConfigParser() + + # Try to load existing config + if os.path.exists(self.config_file): + config.read(self.config_file) + else: + # Copy from sample if available + if os.path.exists(CONFIG_FILE_SAMPLE): + import shutil + shutil.copy(CONFIG_FILE_SAMPLE, self.config_file) + config.read(self.config_file) + + # Ensure Settings section exists + if 'Settings' not in config: + config.add_section('Settings') + + # Load or generate database name + self.database = config.get('Settings', 'database', fallback='').strip() + if not self.database: + self.database = self._generate_database_name() + config.set('Settings', 'database', self.database) + print(f"Generated new database name: {self.database}") + + # Load or generate randomization key + self.randomization_key = config.get('Settings', 'randomization_key', fallback='').strip() + if not self.randomization_key: + self.randomization_key = self._generate_randomization_key() + config.set('Settings', 'randomization_key', self.randomization_key) + + # Load honeypot settings + self.honeypot_ports = config.get('Settings', 'honeypot_ports', fallback=self.honeypot_ports) + self.honeypot_server = config.get('Settings', 'honeypot_server', fallback=self.honeypot_server) + self.honeypot_ssh_port = config.getint('Settings', 'honeypot_ssh_port', fallback=self.honeypot_ssh_port) + self.honeypot_telnet_port = config.getint('Settings', 'honeypot_telnet_port', fallback=self.honeypot_telnet_port) + + print(f"Loaded honeypot_ports from config: '{self.honeypot_ports}'") + + # Save config + try: + with open(self.config_file, 'w') as f: + config.write(f) + except Exception as e: + print(f"Warning: Could not save config: {e}") + + print(f"Database: {self.database}") + print(f"View reports at: https://thelightscope.com/light_table/{self.database}") + + def get_dict(self): + """Return config as dictionary.""" + return { + 'database': self.database, + 'randomization_key': self.randomization_key, + 'honeypot_ports': self.honeypot_ports, + 'honeypot_server': self.honeypot_server, + 'honeypot_ssh_port': self.honeypot_ssh_port, + 'honeypot_telnet_port': self.honeypot_telnet_port + } + + +def fetch_external_info(): + """Fetch external network information from thelightscope.com.""" + try: + resp = requests.get("https://thelightscope.com/ipinfo", timeout=10) + resp.raise_for_status() + data = resp.json() + + queried_ip = data.get("queried_ip") + asn_rec = data["results"].get("asn", {}).get("record", {}) + loc_rec = data["results"].get("location", {}).get("record", {}) + company_rec = data["results"].get("company", {}).get("record", {}) + + return { + "queried_ip": queried_ip, + "ASN": asn_rec.get("asn"), + "domain": asn_rec.get("domain"), + "city": loc_rec.get("city"), + "country": loc_rec.get("country"), + "as_type": company_rec.get("as_type"), + "type": asn_rec.get("type") + } + except Exception as e: + print(f"Warning: Could not fetch external info: {e}") + return { + "queried_ip": "0.0.0.0", + "ASN": "unknown", + "domain": "unknown", + "city": "unknown", + "country": "unknown", + "as_type": "unknown", + "type": "unknown" + } + + +def get_system_info(): + """Get system information.""" + import platform + info = { + "System": platform.system(), + "Release": platform.release(), + "Version": platform.version(), + "Machine": platform.machine(), + "processor": platform.processor(), + "architecture": platform.architecture()[0] + } + + if psutil: + info["Total_Memory"] = f"{psutil.virtual_memory().total / (1024 ** 3):.2f} GB" + else: + info["Total_Memory"] = "unknown" + + return info + + +def randomize_ip(ip_str, key): + """Randomize an IP address for privacy.""" + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return "unknown" + + def hash_segment(segment, k): + combined = f"{segment}-{k}" + h = hashlib.sha256(combined.encode()).hexdigest() + return int(h[:2], 16) % 256 + + orig_bytes = ip.packed + new_bytes = bytes(hash_segment(b, key) for b in orig_bytes) + + try: + rand_ip = ipaddress.IPv4Address(new_bytes) if ip.version == 4 else ipaddress.IPv6Address(new_bytes) + return str(rand_ip) + except: + return "unknown" + + +def check_ip_is_private(ip_str): + """Check if IP is private.""" + try: + ip_obj = ipaddress.ip_address(ip_str) + return "True" if ip_obj.is_private else "False" + except ValueError: + return "unknown" + + +class PacketProcessor: + """Processes packets from pflog and prepares them for upload.""" + + def __init__(self, config, external_info, system_info, upload_pipe): + self.config = config + self.external_info = external_info + self.system_info = system_info + self.upload_pipe = upload_pipe + self.packet_count = 0 + self.HEARTBEAT_INTERVAL = 15 * 60 # 15 minutes + + def process_batch(self, batch): + """Process a batch of packets from pflog.""" + for pkt in batch: + self.packet_count += 1 + self._prepare_and_send(pkt) + + def _prepare_and_send(self, pkt): + """Prepare packet data and send to uploader.""" + payload = { + "db_name": self.config['database'], + "system_time": str(pkt.packet_time), + "ip_version": pkt.ip_version, + "ip_ihl": pkt.ip_ihl, + "ip_tos": pkt.ip_tos, + "ip_len": pkt.ip_len, + "ip_id": pkt.ip_id, + "ip_flags": pkt.ip_flags if isinstance(pkt.ip_flags, str) else ",".join(str(v) for v in pkt.ip_flags), + "ip_frag": pkt.ip_frag, + "ip_ttl": pkt.ip_ttl, + "ip_proto": pkt.ip_proto, + "ip_chksum": pkt.ip_chksum, + "ip_src": pkt.ip_src, + "ip_dst_randomized": randomize_ip(pkt.ip_dst, self.config['randomization_key']), + "ip_options": pkt.ip_options if isinstance(pkt.ip_options, str) else ",".join(str(v) for v in pkt.ip_options), + "tcp_sport": pkt.tcp_sport, + "tcp_dport": pkt.tcp_dport, + "tcp_seq": pkt.tcp_seq, + "tcp_ack": pkt.tcp_ack, + "tcp_dataofs": pkt.tcp_dataofs, + "tcp_reserved": pkt.tcp_reserved, + "tcp_flags": pkt.tcp_flags, + "tcp_window": pkt.tcp_window, + "tcp_chksum": pkt.tcp_chksum, + "tcp_urgptr": pkt.tcp_urgptr, + "tcp_options": "", + "ext_dst_ip_country": self.external_info.get('country', 'unknown'), + "type": self.external_info.get('type', 'unknown'), + "ASN": self.external_info.get('ASN', 'unknown'), + "domain": self.external_info.get('domain', 'unknown'), + "city": self.external_info.get('city', 'unknown'), + "as_type": self.external_info.get('as_type', 'unknown'), + "ip_dst_is_private": check_ip_is_private(pkt.ip_dst), + "external_is_private": check_ip_is_private(self.external_info.get('queried_ip', '')), + "open_ports": "", + "previously_open_ports": "", + "interface": "pflog0", + "internal_ip_randomized": randomize_ip(pkt.ip_dst, self.config['randomization_key']), + "external_ip_randomized": randomize_ip(self.external_info.get('queried_ip', ''), self.config['randomization_key']), + "System_info": self.system_info.get('System', 'OPNsense'), + "Release_info": self.system_info.get('Release', 'unknown'), + "Version_info": self.system_info.get('Version', 'unknown'), + "Machine_info": self.system_info.get('Machine', 'unknown'), + "Total_Memory": self.system_info.get('Total_Memory', 'unknown'), + "processor": self.system_info.get('processor', 'unknown'), + "architecture": self.system_info.get('architecture', 'unknown'), + "honeypot_status": "False", + "payload": "N/A", + "ls_version": LS_VERSION + } + + try: + self.upload_pipe.send(payload) + except Exception as e: + print(f"Error sending packet data: {e}", flush=True) + + def _send_heartbeat(self): + """Send heartbeat message.""" + heartbeat = { + "db_name": "heartbeats", + "unwanted_db": self.config['database'], + "pkts_last_hb": self.packet_count, + "ext_dst_ip_country": self.external_info.get('country', 'unknown'), + "type": self.external_info.get('type', 'unknown'), + "ASN": self.external_info.get('ASN', 'unknown'), + "domain": self.external_info.get('domain', 'unknown'), + "city": self.external_info.get('city', 'unknown'), + "as_type": self.external_info.get('as_type', 'unknown'), + "external_is_private": check_ip_is_private(self.external_info.get('queried_ip', '')), + "open_ports": self.config['honeypot_ports'], + "previously_open_ports": "", + "interface": "pflog0", + "internal_ip_randomized": "", + "external_ip_randomized": randomize_ip(self.external_info.get('queried_ip', ''), self.config['randomization_key']), + "System_info": self.system_info.get('System', 'OPNsense'), + "Release_info": self.system_info.get('Release', 'unknown'), + "Version_info": self.system_info.get('Version', 'unknown'), + "Machine_info": self.system_info.get('Machine', 'unknown'), + "Total_Memory": self.system_info.get('Total_Memory', 'unknown'), + "processor": self.system_info.get('processor', 'unknown'), + "architecture": self.system_info.get('architecture', 'unknown'), + "open_honeypot_ports": self.config['honeypot_ports'], + "ls_version": LS_VERSION + } + + try: + self.upload_pipe.send(heartbeat) + self.packet_count = 0 + print("Sent heartbeat", flush=True) + except Exception as e: + print(f"Error sending heartbeat: {e}", flush=True) + + +def packet_handler(pflog_pipe, upload_pipe, config, external_info, system_info): + """ + Process packets from pflog reader and send to uploader. + + This runs in its own process. + """ + processor = PacketProcessor(config, external_info, system_info, upload_pipe) + + print("packet_handler: Started", flush=True) + + # Send initial heartbeat at startup + processor._send_heartbeat() + + # Start a background thread for periodic heartbeats + def heartbeat_loop(): + while True: + time.sleep(processor.HEARTBEAT_INTERVAL) + processor._send_heartbeat() + + hb_thread = threading.Thread(target=heartbeat_loop, daemon=True) + hb_thread.start() + + while True: + try: + batch = pflog_pipe.recv() + processor.process_batch(batch) + except (EOFError, OSError): + print("packet_handler: Pipe closed, exiting", flush=True) + break + except Exception as e: + print(f"packet_handler: Error: {e}", flush=True) + time.sleep(1) + + +def main(): + """Main entry point for LightScope daemon.""" + print(f"LightScope for OPNsense v{LS_VERSION}", flush=True) + print("Starting...", flush=True) + + # Write PID file + pid = os.getpid() + try: + with open("/var/run/lightscope.pid", "w") as f: + f.write(str(pid)) + except Exception as e: + print(f"Warning: Could not write PID file: {e}") + + # Load configuration + config = LightScopeConfig() + config_dict = config.get_dict() + + # Fetch external network info + print("Fetching external network information...", flush=True) + external_info = fetch_external_info() + print(f"External IP: {external_info.get('queried_ip', 'unknown')}", flush=True) + + # Get system info + system_info = get_system_info() + + # Create pipes + pflog_consumer, pflog_producer = multiprocessing.Pipe(duplex=False) + upload_consumer, upload_producer = multiprocessing.Pipe(duplex=False) + hp_upload_consumer, hp_upload_producer = multiprocessing.Pipe(duplex=False) + + # Start processes + processes = [] + + # 1. pflog reader + p_pflog = multiprocessing.Process( + target=read_pflog, + args=(pflog_producer,), + name="pflog_reader" + ) + p_pflog.start() + processes.append(p_pflog) + print("Started pflog reader", flush=True) + + # 2. Packet handler + p_handler = multiprocessing.Process( + target=packet_handler, + args=(pflog_consumer, upload_producer, config_dict, external_info, system_info), + name="packet_handler" + ) + p_handler.start() + processes.append(p_handler) + print("Started packet handler", flush=True) + + # 3. Data uploader + p_uploader = multiprocessing.Process( + target=send_data, + args=(upload_consumer,), + name="uploader" + ) + p_uploader.start() + processes.append(p_uploader) + print("Started uploader", flush=True) + + # 4. Honeypot uploader + p_hp_uploader = multiprocessing.Process( + target=send_honeypot_data, + args=(hp_upload_consumer,), + name="honeypot_uploader" + ) + p_hp_uploader.start() + processes.append(p_hp_uploader) + print("Started honeypot uploader", flush=True) + + # 5. Honeypot listener + if config_dict.get('honeypot_ports'): + p_honeypot = multiprocessing.Process( + target=run_honeypot, + args=(config_dict, hp_upload_producer, config_dict['database']), + name="honeypot" + ) + p_honeypot.start() + processes.append(p_honeypot) + print("Started honeypot listener", flush=True) + else: + print("Honeypot disabled (no ports configured)", flush=True) + + # Signal handler for clean shutdown + def shutdown(signum, frame): + print("\nShutting down...", flush=True) + for p in processes: + if p.is_alive(): + p.terminate() + for p in processes: + p.join(timeout=5) + try: + os.remove("/var/run/lightscope.pid") + except: + pass + sys.exit(0) + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + print("LightScope is running. Press Ctrl+C to stop.", flush=True) + + # Monitor processes + while True: + time.sleep(60) + + for p in processes: + if not p.is_alive(): + print(f"Process {p.name} died, restarting...", flush=True) + # For now, just exit and let the service manager restart us + shutdown(None, None) + + +if __name__ == "__main__": + multiprocessing.freeze_support() + main() diff --git a/security/lightscope/src/opnsense/scripts/lightscope/logs.py b/security/lightscope/src/opnsense/scripts/lightscope/logs.py new file mode 100755 index 0000000000..d2c5e56d18 --- /dev/null +++ b/security/lightscope/src/opnsense/scripts/lightscope/logs.py @@ -0,0 +1,46 @@ +#!/usr/local/bin/python3 +""" +logs.py - Returns LightScope logs as JSON +""" + +import json +import os +import subprocess + +LOG_FILE = "/var/log/lightscope.log" +MAX_LINES = 100 + +def get_logs(): + """Get recent LightScope logs.""" + logs = "" + + # Try reading from log file first + if os.path.exists(LOG_FILE): + try: + with open(LOG_FILE, 'r') as f: + lines = f.readlines() + logs = ''.join(lines[-MAX_LINES:]) + except Exception as e: + logs = f"Error reading log file: {e}" + + # If no log file, try syslog + if not logs: + try: + proc = subprocess.run( + ['grep', '-i', 'lightscope', '/var/log/system.log'], + capture_output=True, + text=True, + timeout=5 + ) + if proc.stdout: + lines = proc.stdout.strip().split('\n') + logs = '\n'.join(lines[-MAX_LINES:]) + else: + logs = "No logs found. Service may not have been started yet." + except Exception as e: + logs = f"Error reading syslog: {e}" + + return {"logs": logs} + +if __name__ == "__main__": + print(json.dumps(get_logs())) diff --git a/security/lightscope/src/opnsense/scripts/lightscope/pflog_reader.py b/security/lightscope/src/opnsense/scripts/lightscope/pflog_reader.py new file mode 100755 index 0000000000..7625c58981 --- /dev/null +++ b/security/lightscope/src/opnsense/scripts/lightscope/pflog_reader.py @@ -0,0 +1,320 @@ +#!/usr/local/bin/python3 +""" +pflog_reader.py - Captures blocked TCP SYN packets from pflog0 interface + +This module reads packets from the pflog0 interface (pf firewall log) +and extracts TCP SYN packets that were blocked by the firewall. +""" + +import struct +import socket +import datetime +import time +import sys +from collections import namedtuple, deque +import threading + +# Try to import pcap library (python-libpcap on FreeBSD) +try: + import pcap +except ImportError: + print("Error: pypcap not found. Install with: pkg install py311-pypcap", file=sys.stderr) + sys.exit(1) + +try: + import dpkt +except ImportError: + print("Error: dpkt not found. Install with: pkg install py311-dpkt", file=sys.stderr) + sys.exit(1) + + +# pflog header structure (FreeBSD) +# See /usr/include/net/if_pflog.h +# Structure size: 1+1+1+1+16+16+4+4+4+4+4+4+1+3+4+1+3 = 72 bytes +PFLOG_HDRLEN = 72 # FreeBSD pflog header length + +# pflog action values (from /usr/include/netpfil/pf/pf.h) +PF_PASS = 0 +PF_DROP = 1 + +# PacketInfo namedtuple - compatible with lightscope_core.py +PacketInfo = namedtuple("PacketInfo", [ + "packet_num", "proto", "packet_time", + "ip_version", "ip_ihl", "ip_tos", "ip_len", "ip_id", "ip_flags", "ip_frag", + "ip_ttl", "ip_proto", "ip_chksum", "ip_src", "ip_dst", "ip_options", + "tcp_sport", "tcp_dport", "tcp_seq", "tcp_ack", "tcp_dataofs", + "tcp_reserved", "tcp_flags", "tcp_window", "tcp_chksum", "tcp_urgptr", "tcp_options" +]) + + +def tcp_flags_to_str(flags_value): + """Convert dpkt TCP flags value to a comma-separated string.""" + flag_names = [] + if flags_value & dpkt.tcp.TH_FIN: + flag_names.append("FIN") + if flags_value & dpkt.tcp.TH_SYN: + flag_names.append("SYN") + if flags_value & dpkt.tcp.TH_RST: + flag_names.append("RST") + if flags_value & dpkt.tcp.TH_PUSH: + flag_names.append("PSH") + if flags_value & dpkt.tcp.TH_ACK: + flag_names.append("ACK") + if flags_value & dpkt.tcp.TH_URG: + flag_names.append("URG") + return ",".join(flag_names) if flag_names else "" + + +def parse_pflog_packet(buf, packet_num): + """ + Parse a pflog packet and extract TCP SYN information. + + pflog packets have a pflog header followed by the IP packet. + We only care about blocked TCP SYN packets. + + Returns PacketInfo namedtuple or None if not a blocked TCP SYN. + """ + if len(buf) < PFLOG_HDRLEN: + return None + + # Extract action from pflog header (byte offset 2) + # Only process blocked packets (PF_DROP) + action = buf[2] + if action != PF_DROP: + return None + + # Skip pflog header to get to IP packet + ip_data = buf[PFLOG_HDRLEN:] + + if len(ip_data) < 20: # Minimum IP header length + return None + + # Check IP version + ip_version = (ip_data[0] >> 4) & 0xF + + if ip_version == 4: + try: + ip = dpkt.ip.IP(ip_data) + except Exception: + return None + + # Only process TCP + if ip.p != dpkt.ip.IP_PROTO_TCP: + return None + + try: + tcp = ip.data + if not isinstance(tcp, dpkt.tcp.TCP): + tcp = dpkt.tcp.TCP(ip.data) + except Exception: + return None + + # Only capture SYN packets (SYN flag set, ACK flag not set) + if not (tcp.flags & dpkt.tcp.TH_SYN) or (tcp.flags & dpkt.tcp.TH_ACK): + return None + + # Extract IP flags + flags = [] + if ip.df: + flags.append("DF") + if ip.mf: + flags.append("MF") + ip_flags_str = ",".join(flags) if flags else "" + + ip_opts = ip.opts.hex() if ip.opts else "" + + return PacketInfo( + packet_num=packet_num, + proto="TCP", + packet_time=datetime.datetime.now().timestamp(), + ip_version=ip.v, + ip_ihl=ip.hl, + ip_tos=ip.tos, + ip_len=ip.len, + ip_id=ip.id, + ip_flags=ip_flags_str, + ip_frag=ip.offset >> 3, + ip_ttl=ip.ttl, + ip_proto=ip.p, + ip_chksum=ip.sum, + ip_src=socket.inet_ntoa(ip.src), + ip_dst=socket.inet_ntoa(ip.dst), + ip_options=ip_opts, + tcp_sport=tcp.sport, + tcp_dport=tcp.dport, + tcp_seq=tcp.seq, + tcp_ack=tcp.ack, + tcp_dataofs=tcp.off * 4, + tcp_reserved=0, + tcp_flags=tcp_flags_to_str(tcp.flags), + tcp_window=tcp.win, + tcp_chksum=tcp.sum, + tcp_urgptr=tcp.urp, + tcp_options=tcp.opts + ) + + elif ip_version == 6: + try: + ip6 = dpkt.ip6.IP6(ip_data) + except Exception: + return None + + # Only process TCP + if ip6.nxt != dpkt.ip.IP_PROTO_TCP: + return None + + try: + tcp = ip6.data + if not isinstance(tcp, dpkt.tcp.TCP): + tcp = dpkt.tcp.TCP(ip6.data) + except Exception: + return None + + # Only capture SYN packets + if not (tcp.flags & dpkt.tcp.TH_SYN) or (tcp.flags & dpkt.tcp.TH_ACK): + return None + + return PacketInfo( + packet_num=packet_num, + proto="TCP", + packet_time=datetime.datetime.now().timestamp(), + ip_version=6, + ip_ihl=None, + ip_tos=None, + ip_len=ip6.plen, + ip_id=None, + ip_flags="", + ip_frag=0, + ip_ttl=ip6.hlim, + ip_proto=ip6.nxt, + ip_chksum=None, + ip_src=socket.inet_ntop(socket.AF_INET6, ip6.src), + ip_dst=socket.inet_ntop(socket.AF_INET6, ip6.dst), + ip_options="", + tcp_sport=tcp.sport, + tcp_dport=tcp.dport, + tcp_seq=tcp.seq, + tcp_ack=tcp.ack, + tcp_dataofs=tcp.off * 4, + tcp_reserved=0, + tcp_flags=tcp_flags_to_str(tcp.flags), + tcp_window=tcp.win, + tcp_chksum=tcp.sum, + tcp_urgptr=tcp.urp, + tcp_options=tcp.opts + ) + + return None + + +def read_pflog(output_pipe, interface="pflog0"): + """ + Main pflog reader function. + + Captures packets from pflog0 interface, parses TCP SYNs, + and sends them to output_pipe in batches. + + Args: + output_pipe: multiprocessing.Pipe connection for sending packet batches + interface: Network interface to capture from (default: pflog0) + """ + BATCH_SIZE = 100 + IDLE_FLUSH_SECS = 1.0 + MAX_QUEUE_SIZE = 10000 + + send_deque = deque(maxlen=MAX_QUEUE_SIZE) + last_activity = time.monotonic() + + def sender_thread(): + """Thread to batch and send packets.""" + nonlocal last_activity + prior_time = time.monotonic() + packets_sent = 0 + + while True: + now = time.monotonic() + to_send = 0 + + # Log stats every second + if (now - prior_time) >= 1.0: + if packets_sent > 0: + print(f"pflog_reader: sent {packets_sent} packets, queue={len(send_deque)}", flush=True) + packets_sent = 0 + prior_time = now + + # Check if we should send a batch + if len(send_deque) >= BATCH_SIZE: + to_send = BATCH_SIZE + elif send_deque and (now - last_activity) >= IDLE_FLUSH_SECS: + to_send = len(send_deque) + else: + time.sleep(0.01) + continue + + # Build and send batch + batch = [send_deque.popleft() for _ in range(to_send)] + try: + output_pipe.send(batch) + packets_sent += len(batch) + except Exception as e: + print(f"pflog_reader: pipe send error: {e}", file=sys.stderr) + return + + # Start sender thread + threading.Thread(target=sender_thread, daemon=True).start() + + # Open pflog0 for capture + try: + # DLT_PFLOG = 117 on FreeBSD + sniffer = pcap.pcap( + name=interface, + snaplen=65535, + promisc=False, + immediate=True, + timeout_ms=100 + ) + # Filter for TCP only + sniffer.setfilter("tcp") + except Exception as e: + print(f"pflog_reader: Failed to open {interface}: {e}", file=sys.stderr) + sys.exit(1) + + print(f"pflog_reader: Capturing on {interface}", flush=True) + + packet_num = 0 + for ts, buf in sniffer: + packet_num += 1 + + try: + pkt_info = parse_pflog_packet(buf, packet_num) + if pkt_info: + send_deque.append(pkt_info) + last_activity = time.monotonic() + except Exception as e: + # Skip malformed packets + continue + + +if __name__ == "__main__": + # Test mode - print captured packets + print("pflog_reader: Running in test mode (printing to stdout)") + + try: + sniffer = pcap.pcap( + name="pflog0", + snaplen=65535, + promisc=False, + immediate=True, + timeout_ms=1000 + ) + sniffer.setfilter("tcp") + except Exception as e: + print(f"Failed to open pflog0: {e}") + sys.exit(1) + + packet_num = 0 + for ts, buf in sniffer: + packet_num += 1 + pkt_info = parse_pflog_packet(buf, packet_num) + if pkt_info: + print(f"SYN: {pkt_info.ip_src}:{pkt_info.tcp_sport} -> {pkt_info.ip_dst}:{pkt_info.tcp_dport}") diff --git a/security/lightscope/src/opnsense/scripts/lightscope/reconfigure.sh b/security/lightscope/src/opnsense/scripts/lightscope/reconfigure.sh new file mode 100755 index 0000000000..4505b703b2 --- /dev/null +++ b/security/lightscope/src/opnsense/scripts/lightscope/reconfigure.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# +# Reconfigure LightScope - regenerate config and restart service +# +# Copyright (c) 2025 Eric Kapitanski +# University of Southern California Information Sciences Institute +# + +CONFIG_FILE="/usr/local/etc/lightscope.conf" + +# Update honeypot_ports from OPNsense model (preserves database ID) +# Note: pluginctl returns empty string if not set, which is valid (no honeypot ports) +HONEYPOT_PORTS=$(/usr/local/sbin/pluginctl -g OPNsense.Lightscope.general.honeypot_ports 2>/dev/null) +if [ -f "$CONFIG_FILE" ]; then + # Check if line exists + if grep -q "^honeypot_ports" "$CONFIG_FILE"; then + sed -i '' "s/^honeypot_ports.*/honeypot_ports = $HONEYPOT_PORTS/" "$CONFIG_FILE" + else + # Add the line if it doesn't exist + echo "honeypot_ports = $HONEYPOT_PORTS" >> "$CONFIG_FILE" + fi + echo "Updated honeypot_ports to: $HONEYPOT_PORTS" +fi + +# Reload firewall rules (for honeypot ports) +/usr/local/etc/rc.filter_configure + +# Check if service should be enabled +ENABLED=$(/usr/local/sbin/pluginctl -g OPNsense.Lightscope.general.enabled 2>/dev/null) + +if [ "$ENABLED" = "1" ]; then + # Restart service if enabled + /usr/local/etc/rc.d/os-lightscope onerestart +else + # Stop service if disabled + /usr/local/etc/rc.d/os-lightscope onestop 2>/dev/null +fi + +exit 0 diff --git a/security/lightscope/src/opnsense/scripts/lightscope/status.py b/security/lightscope/src/opnsense/scripts/lightscope/status.py new file mode 100755 index 0000000000..c60acbfb55 --- /dev/null +++ b/security/lightscope/src/opnsense/scripts/lightscope/status.py @@ -0,0 +1,243 @@ +#!/usr/local/bin/python3 +""" +status.py - Returns LightScope status as JSON for API/widget +""" + +import json +import os +import re +import socket +import configparser +import subprocess + +CONFIG_FILE = "/usr/local/etc/lightscope.conf" +DASHBOARD_URL = "https://thelightscope.com/light_table" + + +def get_firewall_allowed_ports(): + """ + Get list of ports that have PASS/ALLOW rules in the firewall. + These are ports where legitimate services may be running. + Returns a set of individual ports AND a list of (start, end) tuples for ranges. + """ + allowed_ports = set() + allowed_ranges = [] + + try: + # Get active pf rules + result = subprocess.run( + ["pfctl", "-sr"], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + for line in result.stdout.split('\n'): + # Look for pass rules with port specifications + # Example: pass in on em0 proto tcp from any to any port = 22 + # Example: pass in on em0 proto tcp from any to any port 1:4343 + if line.startswith('pass') and 'proto tcp' in line: + # Match port ranges like "port 1:4343" or "port 80:443" + range_match = re.search(r'port\s*[=]?\s*(\d+):(\d+)', line) + if range_match: + start_port = int(range_match.group(1)) + end_port = int(range_match.group(2)) + allowed_ranges.append((start_port, end_port)) + continue # Don't also match as single port + + # Match single port patterns like "port = 22" or "port 22" + port_match = re.search(r'port\s*[=]?\s*(\d+)(?![\d:])', line) + if port_match: + allowed_ports.add(int(port_match.group(1))) + + # Match port lists in braces like "port { 22 80 443 }" + brace_match = re.search(r'port\s*[=]?\s*\{([^}]+)\}', line) + if brace_match: + ports_str = brace_match.group(1) + for p in re.findall(r'\d+', ports_str): + allowed_ports.add(int(p)) + except Exception as e: + pass + + return allowed_ports, allowed_ranges + + +def is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): + """Check if a port is allowed by firewall rules (including ranges).""" + if port in allowed_ports: + return True + for start, end in allowed_ranges: + if start <= port <= end: + return True + return False + + +def get_lightscope_pids(): + """Get all lightscope-related PIDs (main daemon and child processes).""" + pids = set() + try: + # Get all processes matching lightscope_daemon + result = subprocess.run( + ["pgrep", "-f", "lightscope_daemon"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + if line.strip(): + try: + pids.add(int(line.strip())) + except ValueError: + pass + except Exception: + pass + return pids + + +def is_port_in_use_by_other(port, lightscope_pids=None): + """ + Check if a port is in use by a service OTHER than lightscope. + Returns True if another service is using it, False if available or used by lightscope. + """ + if lightscope_pids is None: + lightscope_pids = set() + + try: + # Use sockstat to see what's listening on the port + result = subprocess.run( + ["sockstat", "-4", "-l", "-p", str(port)], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + # Skip header line, check remaining lines + for line in lines[1:]: + if line.strip(): + # sockstat output: USER COMMAND PID FD PROTO LOCAL FOREIGN + parts = line.split() + if len(parts) >= 3: + try: + sock_pid = int(parts[2]) + # Check if this is one of our lightscope processes + if sock_pid in lightscope_pids: + return False # It's our honeypot, not a conflict + except ValueError: + pass + # Something else is using the port + return True + + # No one listening - port is available + return False + + except Exception: + # Fall back to socket bind test + try: + test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + test_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + test_sock.settimeout(0.5) + test_sock.bind(("", port)) + test_sock.close() + return False + except OSError: + return True + + +def get_port_status(ports_string): + """ + Check status of honeypot ports against firewall rules and port usage. + Returns dict with port status info. + """ + port_status = {} + + if not ports_string: + return port_status + + allowed_ports, allowed_ranges = get_firewall_allowed_ports() + lightscope_pids = get_lightscope_pids() + + for p in ports_string.split(','): + p = p.strip() + if p.isdigit(): + port = int(p) + if 1 <= port <= 65535: + if is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): + # Port has a firewall ALLOW rule - potential conflict + port_status[port] = "firewall_conflict" + elif is_port_in_use_by_other(port, lightscope_pids): + # Port is already in use by another service (not lightscope) + port_status[port] = "in_use" + else: + # Port is safe for honeypot or already opened by lightscope + port_status[port] = "ok" + + return port_status + + +def get_status(): + """Get LightScope status information.""" + result = { + "status": "stopped", + "database": "", + "dashboard_url": "", + "honeypot_ports": "", + "port_status": {}, + "config_exists": False + } + + # Check if config exists + if os.path.exists(CONFIG_FILE): + result["config_exists"] = True + try: + config = configparser.ConfigParser() + config.read(CONFIG_FILE) + + database = config.get('Settings', 'database', fallback='').strip() + if database: + result["database"] = database + result["dashboard_url"] = f"{DASHBOARD_URL}/{database}" + + result["honeypot_ports"] = config.get('Settings', 'honeypot_ports', fallback='') + except Exception as e: + result["config_error"] = str(e) + + # Check if service is running via pid file + pidfile = "/var/run/lightscope.pid" + try: + if os.path.exists(pidfile): + with open(pidfile, 'r') as f: + pid = int(f.read().strip()) + # Check if process is actually running + os.kill(pid, 0) # Signal 0 just checks if process exists + result["status"] = "running" + else: + result["status"] = "stopped" + except (ProcessLookupError, ValueError, PermissionError): + result["status"] = "stopped" + except Exception: + result["status"] = "unknown" + + # Check process count + try: + proc = subprocess.run( + ["pgrep", "-f", "lightscope_daemon"], + capture_output=True, + text=True, + timeout=5 + ) + pids = proc.stdout.strip().split('\n') + result["process_count"] = len([p for p in pids if p]) + except Exception: + result["process_count"] = 0 + + # Check port status against firewall rules + result["port_status"] = get_port_status(result["honeypot_ports"]) + + return result + +if __name__ == "__main__": + print(json.dumps(get_status())) diff --git a/security/lightscope/src/opnsense/scripts/lightscope/uploader.py b/security/lightscope/src/opnsense/scripts/lightscope/uploader.py new file mode 100755 index 0000000000..04e8b39b0b --- /dev/null +++ b/security/lightscope/src/opnsense/scripts/lightscope/uploader.py @@ -0,0 +1,234 @@ +#!/usr/local/bin/python3 +""" +uploader.py - Data upload module for LightScope + +Handles batching and uploading packet data and heartbeats to thelightscope.com +""" + +import time +import threading +import sys +from collections import deque + +try: + import requests + from requests.adapters import HTTPAdapter +except ImportError: + print("Error: requests not found. Install with: pkg install py311-requests", file=sys.stderr) + sys.exit(1) + + +# API Configuration +DATA_URL = "https://thelightscope.com/log_mysql_data" +HEARTBEAT_URL = "https://thelightscope.com/heartbeat" +HEADERS = { + "Content-Type": "application/json", + "X-API-Key": "lightscopeAPIkey2025_please_dont_distribute_me_but_im_write_only_anyways" +} + +# Upload settings +BATCH_SIZE = 600 +IDLE_FLUSH_SEC = 5.0 +RETRY_BACKOFF = 5 +MAX_QUEUE_SIZE = 100000 + + +def send_data(consumer_pipe): + """ + Main data upload function. + + Receives data from consumer_pipe, batches it, and uploads to thelightscope.com. + Handles heartbeat messages separately. + + Args: + consumer_pipe: multiprocessing.Pipe connection to receive data from + """ + session = requests.Session() + adapter = HTTPAdapter(pool_connections=4, pool_maxsize=4) + session.mount("https://", adapter) + session.mount("http://", adapter) + + queue = deque(maxlen=MAX_QUEUE_SIZE) + last_activity = time.monotonic() + stop_event = threading.Event() + + def reader(): + """Thread to read from pipe and add to queue.""" + nonlocal last_activity + while not stop_event.is_set(): + try: + item = consumer_pipe.recv() + queue.append(item) + last_activity = time.monotonic() + except (EOFError, OSError): + stop_event.set() + break + except Exception as e: + print(f"uploader: Error receiving data: {e}", flush=True) + + # Drain any remaining data + while consumer_pipe.poll(0): + try: + queue.append(consumer_pipe.recv()) + except: + break + + # Start reader thread + reader_thread = threading.Thread(target=reader, daemon=True) + reader_thread.start() + + print("uploader: Started data upload service", flush=True) + + try: + while not stop_event.is_set() or queue: + # 1) Process heartbeats first + hb_count = 0 + n = len(queue) + for _ in range(n): + try: + item = queue.popleft() + except IndexError: + break + + if item.get("db_name") == "heartbeats": + hb_count += 1 + try: + resp = session.post( + HEARTBEAT_URL, + json=item, + headers=HEADERS, + timeout=10 + ) + if resp.status_code != 200: + print(f"uploader: Heartbeat rejected ({resp.status_code})", flush=True) + except requests.RequestException as e: + print(f"uploader: Heartbeat error: {e}", flush=True) + else: + # Put non-heartbeat items back + queue.append(item) + + if hb_count: + print(f"uploader: Sent {hb_count} heartbeat(s)", flush=True) + + # 2) Batch and send regular data + now = time.monotonic() + elapsed = now - last_activity + + if queue and (len(queue) >= BATCH_SIZE or elapsed >= IDLE_FLUSH_SEC): + to_send = min(len(queue), BATCH_SIZE) + batch = [queue.popleft() for _ in range(to_send)] + + try: + resp = session.post( + DATA_URL, + json={"batch": batch}, + headers=HEADERS, + timeout=10 + ) + if resp.status_code == 200: + print(f"uploader: Sent {to_send} items", flush=True) + else: + print(f"uploader: Upload rejected ({resp.status_code})", flush=True) + last_activity = time.monotonic() + + except requests.RequestException as e: + print(f"uploader: Upload error, will retry: {e}", flush=True) + # Put items back at front of queue + for item in reversed(batch): + queue.appendleft(item) + time.sleep(RETRY_BACKOFF) + else: + time.sleep(0.1) + + except KeyboardInterrupt: + print("uploader: Shutting down...", flush=True) + finally: + stop_event.set() + reader_thread.join(timeout=2) + print("uploader: Stopped", flush=True) + + +def send_honeypot_data(consumer_pipe): + """ + Honeypot-specific data upload function. + + Similar to send_data but with smaller batch sizes for honeypot data. + + Args: + consumer_pipe: multiprocessing.Pipe connection to receive honeypot data from + """ + session = requests.Session() + adapter = HTTPAdapter(pool_connections=2, pool_maxsize=2) + session.mount("https://", adapter) + session.mount("http://", adapter) + + queue = deque(maxlen=MAX_QUEUE_SIZE) + last_activity = time.monotonic() + stop_event = threading.Event() + + HONEYPOT_BATCH_SIZE = 100 + + def reader(): + nonlocal last_activity + while not stop_event.is_set(): + try: + item = consumer_pipe.recv() + queue.append(item) + last_activity = time.monotonic() + except (EOFError, OSError): + stop_event.set() + break + except Exception as e: + print(f"honeypot_uploader: Error receiving data: {e}", flush=True) + + while consumer_pipe.poll(0): + try: + queue.append(consumer_pipe.recv()) + except: + break + + reader_thread = threading.Thread(target=reader, daemon=True) + reader_thread.start() + + print("honeypot_uploader: Started", flush=True) + + try: + while not stop_event.is_set() or queue: + now = time.monotonic() + elapsed = now - last_activity + + if queue and (len(queue) >= HONEYPOT_BATCH_SIZE or elapsed >= IDLE_FLUSH_SEC): + to_send = min(len(queue), HONEYPOT_BATCH_SIZE) + batch = [queue.popleft() for _ in range(to_send)] + + try: + resp = session.post( + DATA_URL, + json={"batch": batch}, + headers=HEADERS, + timeout=10 + ) + if resp.status_code == 200: + print(f"honeypot_uploader: Sent {to_send} items", flush=True) + else: + print(f"honeypot_uploader: Upload rejected ({resp.status_code})", flush=True) + last_activity = time.monotonic() + + except requests.RequestException as e: + print(f"honeypot_uploader: Error, will retry: {e}", flush=True) + for item in reversed(batch): + queue.appendleft(item) + time.sleep(RETRY_BACKOFF) + else: + time.sleep(0.1) + + except KeyboardInterrupt: + pass + finally: + stop_event.set() + reader_thread.join(timeout=2) + print("honeypot_uploader: Stopped", flush=True) + + +if __name__ == "__main__": + print("uploader: This module should be run as part of lightscope_daemon.py") diff --git a/security/lightscope/src/opnsense/service/conf/actions.d/actions_lightscope.conf b/security/lightscope/src/opnsense/service/conf/actions.d/actions_lightscope.conf new file mode 100644 index 0000000000..6815958ba1 --- /dev/null +++ b/security/lightscope/src/opnsense/service/conf/actions.d/actions_lightscope.conf @@ -0,0 +1,30 @@ +[start] +command:/usr/local/etc/rc.d/os-lightscope onestart +type:script +message:starting lightscope service + +[stop] +command:/usr/local/etc/rc.d/os-lightscope onestop +type:script +message:stopping lightscope service + +[restart] +command:/usr/local/etc/rc.d/os-lightscope onerestart +type:script +message:restarting lightscope service + +[status] +command:/usr/local/opnsense/scripts/lightscope/status.py +type:script_output +message:requesting lightscope status + +[reconfigure] +command:/usr/local/opnsense/scripts/lightscope/reconfigure.sh +type:script +message:reconfiguring lightscope +description:Regenerate lightscope configuration + +[logs] +command:/usr/local/opnsense/scripts/lightscope/logs.py +type:script_output +message:retrieving lightscope logs diff --git a/security/lightscope/src/opnsense/www/js/widgets/Lightscope.js b/security/lightscope/src/opnsense/www/js/widgets/Lightscope.js new file mode 100644 index 0000000000..d1638735bf --- /dev/null +++ b/security/lightscope/src/opnsense/www/js/widgets/Lightscope.js @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 USC Information Sciences Institute + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +export default class Lightscope extends BaseTableWidget { + constructor() { + super(); + } + + getGridOptions() { + return { + sizeToContent: 300 + }; + } + + getMarkup() { + let $container = $('
'); + let $lightscopeTable = this.createTable('lightscopeStatusTable', { + headerPosition: 'left' + }); + $container.append($lightscopeTable); + return $container; + } + + async onWidgetTick() { + const data = await this.ajaxCall('/api/lightscope/status/status'); + + if (!data) { + this.displayError(this.translations.noData); + return; + } + + if (!this.dataChanged('lightscope-data', data)) { + return; + } + + let rows = []; + + // Status row + let statusText = data.status === 'running' + ? ` ${this.translations.running}` + : ` ${this.translations.stopped}`; + rows.push({ + column1: this.translations.status, + column2: statusText + }); + + // Dashboard link row + if (data.dashboard_url) { + rows.push({ + column1: this.translations.dashboard, + column2: ` + ${this.translations.viewReports} + ` + }); + } + + // Database ID row + if (data.database) { + let shortDb = data.database.length > 30 + ? data.database.substring(0, 30) + '...' + : data.database; + rows.push({ + column1: this.translations.databaseId, + column2: `${shortDb}` + }); + } + + // Honeypot ports row + if (data.honeypot_ports) { + rows.push({ + column1: this.translations.honeypotPorts, + column2: data.honeypot_ports + }); + } + + // Process count + if (data.process_count !== undefined) { + rows.push({ + column1: this.translations.processes, + column2: data.process_count + }); + } + + super.updateTable('lightscopeStatusTable', rows); + } +} diff --git a/security/lightscope/src/opnsense/www/js/widgets/Metadata/Lightscope.xml b/security/lightscope/src/opnsense/www/js/widgets/Metadata/Lightscope.xml new file mode 100644 index 0000000000..033d341906 --- /dev/null +++ b/security/lightscope/src/opnsense/www/js/widgets/Metadata/Lightscope.xml @@ -0,0 +1,20 @@ + + + Lightscope.js + + /api/lightscope/status/status + + + LightScope + Status + Running + Stopped + Dashboard + View Reports + Database ID + Honeypot Ports + Processes + Could not fetch LightScope status + + + From 86ccd7bb46cccfb80670e54ce686d9c65bb3fcde Mon Sep 17 00:00:00 2001 From: Eric Kapitanski Date: Wed, 31 Dec 2025 16:38:46 -0800 Subject: [PATCH 2/2] security/lightscope: Refactor to use separate port package - Remove core Python files (now in security/lightscope port) - Change PLUGIN_DEPENDS from python311 to lightscope - Update rc.d script to call /usr/local/bin/lightscope --- security/lightscope/Makefile | 2 +- .../lightscope/src/etc/lightscope.conf.sample | 27 - .../lightscope/src/etc/rc.d/os-lightscope | 4 +- .../opnsense/scripts/lightscope/__init__.py | 1 - .../opnsense/scripts/lightscope/honeypot.py | 504 ------------------ .../scripts/lightscope/lightscope_daemon.py | 490 ----------------- .../scripts/lightscope/pflog_reader.py | 320 ----------- .../opnsense/scripts/lightscope/uploader.py | 234 -------- 8 files changed, 3 insertions(+), 1579 deletions(-) delete mode 100644 security/lightscope/src/etc/lightscope.conf.sample delete mode 100755 security/lightscope/src/opnsense/scripts/lightscope/__init__.py delete mode 100755 security/lightscope/src/opnsense/scripts/lightscope/honeypot.py delete mode 100755 security/lightscope/src/opnsense/scripts/lightscope/lightscope_daemon.py delete mode 100755 security/lightscope/src/opnsense/scripts/lightscope/pflog_reader.py delete mode 100755 security/lightscope/src/opnsense/scripts/lightscope/uploader.py diff --git a/security/lightscope/Makefile b/security/lightscope/Makefile index e26e70235f..721b24598f 100644 --- a/security/lightscope/Makefile +++ b/security/lightscope/Makefile @@ -3,6 +3,6 @@ PLUGIN_VERSION= 1.0 PLUGIN_COMMENT= LightScope Cybersecurity Research Honeypot and Telescope PLUGIN_MAINTAINER= e@alumni.usc.edu PLUGIN_WWW= https://thelightscope.com -PLUGIN_DEPENDS= python311 +PLUGIN_DEPENDS= lightscope .include "../../Mk/plugins.mk" diff --git a/security/lightscope/src/etc/lightscope.conf.sample b/security/lightscope/src/etc/lightscope.conf.sample deleted file mode 100644 index fd02a09a20..0000000000 --- a/security/lightscope/src/etc/lightscope.conf.sample +++ /dev/null @@ -1,27 +0,0 @@ -[Settings] -# LightScope Configuration for OPNsense -# -# Database identifier - auto-generated if empty -# This is used to identify your installation on thelightscope.com -database = - -# IP randomization key - auto-generated if empty -# Used to anonymize destination IPs in uploaded data -randomization_key = - -# Comma-separated honeypot ports -# These ports will be opened and forwarded to the remote honeypot server -# Leave empty to disable honeypot functionality -# Default: 8080,2323,8443,3389,5900 -honeypot_ports = 8080,2323,8443,3389,5900 - -# Remote honeypot server -# The server that receives forwarded honeypot connections -honeypot_server = 128.9.28.79 - -# Remote honeypot ports -# SSH connections (even local ports) forward to this port -honeypot_ssh_port = 12345 - -# TELNET connections (odd local ports) forward to this port -honeypot_telnet_port = 12346 diff --git a/security/lightscope/src/etc/rc.d/os-lightscope b/security/lightscope/src/etc/rc.d/os-lightscope index afb25798b4..4761f56d66 100755 --- a/security/lightscope/src/etc/rc.d/os-lightscope +++ b/security/lightscope/src/etc/rc.d/os-lightscope @@ -28,7 +28,7 @@ fi : ${lightscope_config:="/usr/local/etc/lightscope.conf"} pidfile="/var/run/${name}.pid" -procname="/usr/local/bin/python3" +procname="/usr/local/bin/lightscope" start_cmd="${name}_start" stop_cmd="${name}_stop" @@ -39,7 +39,7 @@ start_precmd="${name}_prestart" lightscope_start() { echo "Starting ${name}." - /usr/sbin/daemon -c -f -p ${pidfile} -o /var/log/lightscope.log /usr/local/bin/python3 -u /usr/local/opnsense/scripts/lightscope/lightscope_daemon.py + /usr/sbin/daemon -c -f -p ${pidfile} -o /var/log/lightscope.log /usr/local/bin/lightscope } lightscope_stop() diff --git a/security/lightscope/src/opnsense/scripts/lightscope/__init__.py b/security/lightscope/src/opnsense/scripts/lightscope/__init__.py deleted file mode 100755 index 3ecfb9d2de..0000000000 --- a/security/lightscope/src/opnsense/scripts/lightscope/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# LightScope for OPNsense diff --git a/security/lightscope/src/opnsense/scripts/lightscope/honeypot.py b/security/lightscope/src/opnsense/scripts/lightscope/honeypot.py deleted file mode 100755 index 50d753c705..0000000000 --- a/security/lightscope/src/opnsense/scripts/lightscope/honeypot.py +++ /dev/null @@ -1,504 +0,0 @@ -#!/usr/local/bin/python3 -""" -honeypot.py - Honeypot listener with PROXY protocol forwarding - -This module opens sockets on configured ports and forwards connections -to a remote honeypot server using the PROXY protocol to preserve -the original attacker IP address. -""" - -import socket -import select -import threading -import time -import sys -import subprocess -import re - - -class HoneypotListener: - """ - Manages honeypot listener sockets and forwards connections - to remote honeypot server using PROXY protocol. - """ - - def __init__(self, config, upload_pipe, database): - """ - Initialize honeypot listener. - - Args: - config: Dict with honeypot configuration - upload_pipe: Pipe for sending honeypot connection data - database: Database identifier for PROXY header - """ - self.config = config - self.upload_pipe = upload_pipe - self.database = database - - self.honeypot_server = config.get('honeypot_server', '128.9.28.79') - self.ssh_port = int(config.get('honeypot_ssh_port', 12345)) - self.telnet_port = int(config.get('honeypot_telnet_port', 12346)) - - self.sockets = {} # socket -> port mapping - self.sockets_lock = threading.Lock() - self.running = False - - # Connection limiting - self.MAX_CONCURRENT_CONNECTIONS = 50 - self.connection_semaphore = threading.Semaphore(self.MAX_CONCURRENT_CONNECTIONS) - self.active_connections = 0 - self.connection_lock = threading.Lock() - - # Firewall monitoring - self.FIREWALL_CHECK_INTERVAL = 10 # seconds - - def parse_ports(self, ports_string): - """Parse comma-separated port string into list of integers.""" - if not ports_string: - return [] - - ports = [] - for p in ports_string.split(','): - p = p.strip() - if p.isdigit(): - port = int(p) - if 1 <= port <= 65535: - ports.append(port) - return ports - - def get_firewall_allowed_ports(self): - """ - Get list of ports that have PASS/ALLOW rules in the firewall. - These are ports where legitimate services may be running. - Returns a tuple of (set of ports, list of (start, end) range tuples). - """ - allowed_ports = set() - allowed_ranges = [] - - try: - result = subprocess.run( - ["pfctl", "-sr"], - capture_output=True, - text=True, - timeout=5 - ) - - if result.returncode == 0: - for line in result.stdout.split('\n'): - # Look for pass rules with port specifications - if line.startswith('pass') and 'proto tcp' in line: - # Match port ranges like "port 1:4343" or "port 80:443" - range_match = re.search(r'port\s*[=]?\s*(\d+):(\d+)', line) - if range_match: - start_port = int(range_match.group(1)) - end_port = int(range_match.group(2)) - allowed_ranges.append((start_port, end_port)) - continue # Don't also match as single port - - # Match single port patterns like "port = 22" or "port 22" - port_match = re.search(r'port\s*[=]?\s*(\d+)(?![\d:])', line) - if port_match: - allowed_ports.add(int(port_match.group(1))) - - # Match port lists in braces - brace_match = re.search(r'port\s*[=]?\s*\{([^}]+)\}', line) - if brace_match: - ports_str = brace_match.group(1) - for p in re.findall(r'\d+', ports_str): - allowed_ports.add(int(p)) - except Exception as e: - print(f"honeypot: Error checking firewall rules: {e}", flush=True) - - return allowed_ports, allowed_ranges - - def is_port_allowed_by_firewall(self, port, allowed_ports, allowed_ranges): - """Check if a port is allowed by firewall rules (including ranges).""" - if port in allowed_ports: - return True - for start, end in allowed_ranges: - if start <= port <= end: - return True - return False - - def is_port_in_use(self, port): - """ - Check if a port is already in use by another service. - Returns True if port is in use, False if available. - """ - try: - test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - test_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - test_sock.bind(("", port)) - test_sock.close() - return False # Port is available - except OSError: - return True # Port is in use - - def _firewall_monitor_thread(self): - """ - Monitor firewall rules and port usage every FIREWALL_CHECK_INTERVAL seconds. - If a new ALLOW rule is detected or port becomes in use, close that honeypot port. - """ - print("honeypot: Started firewall/port monitor (checking every 10s)", flush=True) - - while self.running: - time.sleep(self.FIREWALL_CHECK_INTERVAL) - if not self.running: - break - - try: - allowed_ports, allowed_ranges = self.get_firewall_allowed_ports() - - with self.sockets_lock: - sockets_to_close = [] - for sock, port in list(self.sockets.items()): - if self.is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): - print(f"honeypot: WARNING - Firewall ALLOW rule detected for port {port}, closing honeypot on this port", flush=True) - sockets_to_close.append((sock, port, "firewall conflict")) - - for sock, port, reason in sockets_to_close: - try: - sock.close() - except: - pass - del self.sockets[sock] - print(f"honeypot: Closed listener on port {port} due to {reason}", flush=True) - - except Exception as e: - print(f"honeypot: Error in firewall monitor: {e}", flush=True) - - def open_port(self, port): - """ - Try to bind and listen on a port. - - Returns: - (socket, port) on success, (None, None) on failure - """ - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - s.bind(("", port)) - s.listen(10) - s.setblocking(False) - return s, port - except OSError as e: - if e.errno == 48: # EADDRINUSE on FreeBSD - print(f"honeypot: Port {port} already in use", flush=True) - elif e.errno == 13: # EACCES - print(f"honeypot: Permission denied for port {port}", flush=True) - else: - print(f"honeypot: Error binding port {port}: {e}", flush=True) - return None, None - except Exception as e: - print(f"honeypot: Unexpected error binding port {port}: {e}", flush=True) - return None, None - - def start(self, ports_string): - """ - Start honeypot listeners on configured ports. - - Args: - ports_string: Comma-separated list of ports - """ - ports = self.parse_ports(ports_string) - if not ports: - print("honeypot: No valid ports configured", flush=True) - return - - # Check for firewall conflicts and ports already in use - allowed_ports, allowed_ranges = self.get_firewall_allowed_ports() - safe_ports = [] - for port in ports: - if self.is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): - print(f"honeypot: WARNING - Port {port} has a firewall ALLOW rule, skipping (may conflict with legitimate service)", flush=True) - elif self.is_port_in_use(port): - print(f"honeypot: WARNING - Port {port} is already in use by another service, skipping", flush=True) - else: - safe_ports.append(port) - - if not safe_ports: - print("honeypot: No ports available (all have conflicts or are in use)", flush=True) - return - - print(f"honeypot: Opening ports: {safe_ports}", flush=True) - - for port in safe_ports: - s, actual_port = self.open_port(port) - if s and actual_port: - with self.sockets_lock: - self.sockets[s] = actual_port - print(f"honeypot: Listening on port {actual_port}", flush=True) - - if not self.sockets: - print("honeypot: Failed to open any ports", flush=True) - return - - self.running = True - - # Start firewall monitor thread - monitor_thread = threading.Thread(target=self._firewall_monitor_thread, daemon=True) - monitor_thread.start() - - self._run_accept_loop() - - def stop(self): - """Stop all honeypot listeners.""" - self.running = False - with self.sockets_lock: - for s in list(self.sockets.keys()): - try: - s.close() - except: - pass - self.sockets.clear() - print("honeypot: Stopped all listeners", flush=True) - - def get_open_ports(self): - """Return list of currently open honeypot ports.""" - with self.sockets_lock: - return list(self.sockets.values()) - - def _run_accept_loop(self): - """Main accept loop for honeypot connections.""" - print("honeypot: Starting accept loop", flush=True) - - while self.running: - with self.sockets_lock: - sock_list = list(self.sockets.keys()) - - if not sock_list: - time.sleep(1.0) - continue - - try: - # Wait for incoming connections - readable, _, _ = select.select(sock_list, [], [], 1.0) - - for sock in readable: - try: - self._handle_connection(sock) - except Exception as e: - print(f"honeypot: Error handling connection: {e}", flush=True) - - except Exception as e: - if self.running: - print(f"honeypot: Error in accept loop: {e}", flush=True) - time.sleep(1.0) - - def _handle_connection(self, listen_sock): - """Handle an incoming connection on a honeypot port.""" - try: - local_conn, addr = listen_sock.accept() - with self.sockets_lock: - port = self.sockets.get(listen_sock) - if port is None: - local_conn.close() - return - attacker_ip = addr[0] - attacker_port = addr[1] - - print(f"honeypot: Connection from {attacker_ip}:{attacker_port} to port {port}", flush=True) - - # Determine service type: even ports = SSH, odd ports = TELNET - if port % 2 == 0: - service = 'SSH' - remote_port = self.ssh_port - else: - service = 'TELNET' - remote_port = self.telnet_port - - # Check connection limit - if not self.connection_semaphore.acquire(blocking=False): - print(f"honeypot: Connection limit reached, rejecting {attacker_ip}", flush=True) - local_conn.close() - return - - with self.connection_lock: - self.active_connections += 1 - - # Start forwarding in a new thread - threading.Thread( - target=self._forward_connection, - args=(local_conn, attacker_ip, attacker_port, port, service, remote_port), - daemon=True - ).start() - - except Exception as e: - print(f"honeypot: Error accepting connection: {e}", flush=True) - - def _forward_connection(self, local_conn, attacker_ip, attacker_port, honeypot_port, service, remote_port): - """ - Forward a connection to the remote honeypot server using PROXY protocol. - """ - remote_conn = None - connection_released = False - - try: - # Connect to remote honeypot - remote_conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - remote_conn.settimeout(30.0) - - try: - remote_conn.connect((self.honeypot_server, remote_port)) - except Exception as e: - print(f"honeypot: Failed to connect to remote server: {e}", flush=True) - return - - # Send PROXY protocol header - # Format: PROXY TCP4 src_ip dest_ip src_port dest_port\r\n - proxy_header = f"PROXY TCP4 {attacker_ip} {self.database} {attacker_port} {honeypot_port}\r\n" - print(f"honeypot: Sending PROXY header: {proxy_header.strip()}", flush=True) - remote_conn.sendall(proxy_header.encode()) - - # Set timeouts for idle connections (3 minutes) - CONNECTION_TIMEOUT = 180 - local_conn.settimeout(CONNECTION_TIMEOUT) - remote_conn.settimeout(CONNECTION_TIMEOUT) - - # Send honeypot connection data for logging - self._send_connection_data(attacker_ip, attacker_port, honeypot_port, service) - - # Bidirectional forwarding - def forward(src, dst, direction): - try: - while True: - data = src.recv(4096) - if not data: - break - dst.sendall(data) - except Exception: - pass - finally: - try: - src.close() - except: - pass - try: - dst.close() - except: - pass - - # Start forwarding threads - t1 = threading.Thread(target=forward, args=(local_conn, remote_conn, "client->server")) - t2 = threading.Thread(target=forward, args=(remote_conn, local_conn, "server->client")) - t1.daemon = True - t2.daemon = True - t1.start() - t2.start() - - # Wait for both threads to complete - t1.join() - t2.join() - - print(f"honeypot: Connection from {attacker_ip} closed", flush=True) - - except Exception as e: - print(f"honeypot: Forwarding error: {e}", flush=True) - - finally: - # Clean up - for conn in (local_conn, remote_conn): - if conn: - try: - conn.close() - except: - pass - - # Release connection slot - if not connection_released: - self.connection_semaphore.release() - with self.connection_lock: - self.active_connections -= 1 - - def _send_connection_data(self, attacker_ip, attacker_port, honeypot_port, service): - """Send honeypot connection data to upload pipe.""" - try: - data = { - "db_name": self.database, - "system_time": str(time.time()), - "ip_version": "HP", - "ip_ihl": "HP", - "ip_tos": "HP", - "ip_len": "HP", - "ip_id": "HP", - "ip_flags": "HP", - "ip_frag": "HP", - "ip_ttl": "HP", - "ip_proto": "HP", - "ip_chksum": "HP", - "ip_src": attacker_ip, - "ip_dst_randomized": "HP", - "ip_options": "HP", - "tcp_sport": str(attacker_port), - "tcp_dport": str(honeypot_port), - "tcp_seq": 0, - "tcp_ack": "HP", - "tcp_dataofs": "HP", - "tcp_reserved": "HP", - "tcp_flags": "HP", - "tcp_window": "HP", - "tcp_chksum": "HP", - "tcp_urgptr": "HP", - "tcp_options": "HP", - "ext_dst_ip_country": "HP", - "type": "HP", - "ASN": "HP", - "domain": "HP", - "city": "HP", - "as_type": "HP", - "ip_dst_is_private": "HP", - "external_is_private": "HP", - "open_ports": "", - "previously_open_ports": "", - "interface": "honeypot", - "internal_ip_randomized": "HP", - "external_ip_randomized": "HP", - "System_info": "OPNsense", - "Release_info": "HP", - "Version_info": "HP", - "Machine_info": "HP", - "Total_Memory": "HP", - "processor": "HP", - "architecture": "HP", - "honeypot_status": "True", - "payload": service, - "ls_version": "opnsense-1.0" - } - - if self.upload_pipe: - self.upload_pipe.send(data) - - except Exception as e: - print(f"honeypot: Error sending connection data: {e}", flush=True) - - -def run_honeypot(config, upload_pipe, database): - """ - Main entry point for honeypot process. - - Args: - config: Configuration dict - upload_pipe: Pipe for sending connection data - database: Database identifier - """ - listener = HoneypotListener(config, upload_pipe, database) - ports = config.get('honeypot_ports', '') - listener.start(ports) - - -if __name__ == "__main__": - # Test mode - print("honeypot: Running in test mode") - - test_config = { - 'honeypot_ports': '8080,2323', - 'honeypot_server': '128.9.28.79', - 'honeypot_ssh_port': 12345, - 'honeypot_telnet_port': 12346 - } - - listener = HoneypotListener(test_config, None, "test_database") - try: - listener.start(test_config['honeypot_ports']) - except KeyboardInterrupt: - listener.stop() diff --git a/security/lightscope/src/opnsense/scripts/lightscope/lightscope_daemon.py b/security/lightscope/src/opnsense/scripts/lightscope/lightscope_daemon.py deleted file mode 100755 index e9dc55dc29..0000000000 --- a/security/lightscope/src/opnsense/scripts/lightscope/lightscope_daemon.py +++ /dev/null @@ -1,490 +0,0 @@ -#!/usr/local/bin/python3 -""" -lightscope_daemon.py - Main LightScope daemon for OPNsense - -This is the main entry point that: -1. Reads configuration from OPNsense model or config file -2. Spawns the pflog reader process -3. Spawns the honeypot listener process -4. Spawns the uploader process -5. Processes captured packets and sends to uploader -""" - -import os -import sys -import signal -import time -import hashlib -import ipaddress -import random -import string -import configparser -import multiprocessing -import threading -from collections import deque - -# Add script directory to path for imports -script_dir = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, script_dir) - -from pflog_reader import read_pflog -from honeypot import run_honeypot -from uploader import send_data, send_honeypot_data - -try: - import requests -except ImportError: - print("Error: requests not found", file=sys.stderr) - sys.exit(1) - -try: - import psutil -except ImportError: - psutil = None - -# Version -LS_VERSION = "opnsense-1.0" - -# Configuration paths -CONFIG_FILE = "/usr/local/etc/lightscope.conf" -CONFIG_FILE_SAMPLE = "/usr/local/etc/lightscope.conf.sample" - - -class LightScopeConfig: - """Configuration manager for LightScope.""" - - def __init__(self, config_file=CONFIG_FILE): - self.config_file = config_file - self.database = "" - self.randomization_key = "" - self.honeypot_ports = "8080,2323,8443,3389,5900" - self.honeypot_server = "128.9.28.79" - self.honeypot_ssh_port = 12345 - self.honeypot_telnet_port = 12346 - - self._load_or_create_config() - - def _generate_database_name(self): - """Generate a unique database name with OPNsense prefix.""" - import datetime - today = datetime.date.today().strftime("%Y%m%d") - rand_part = ''.join(random.choices(string.ascii_lowercase, k=44)) - return f"opn{today}_{rand_part}" - - def _generate_randomization_key(self): - """Generate a randomization key.""" - rand_part = ''.join(random.choices(string.ascii_lowercase, k=46)) - return f"randomization_key_{rand_part}" - - def _load_or_create_config(self): - """Load config from file or create with defaults.""" - config = configparser.ConfigParser() - - # Try to load existing config - if os.path.exists(self.config_file): - config.read(self.config_file) - else: - # Copy from sample if available - if os.path.exists(CONFIG_FILE_SAMPLE): - import shutil - shutil.copy(CONFIG_FILE_SAMPLE, self.config_file) - config.read(self.config_file) - - # Ensure Settings section exists - if 'Settings' not in config: - config.add_section('Settings') - - # Load or generate database name - self.database = config.get('Settings', 'database', fallback='').strip() - if not self.database: - self.database = self._generate_database_name() - config.set('Settings', 'database', self.database) - print(f"Generated new database name: {self.database}") - - # Load or generate randomization key - self.randomization_key = config.get('Settings', 'randomization_key', fallback='').strip() - if not self.randomization_key: - self.randomization_key = self._generate_randomization_key() - config.set('Settings', 'randomization_key', self.randomization_key) - - # Load honeypot settings - self.honeypot_ports = config.get('Settings', 'honeypot_ports', fallback=self.honeypot_ports) - self.honeypot_server = config.get('Settings', 'honeypot_server', fallback=self.honeypot_server) - self.honeypot_ssh_port = config.getint('Settings', 'honeypot_ssh_port', fallback=self.honeypot_ssh_port) - self.honeypot_telnet_port = config.getint('Settings', 'honeypot_telnet_port', fallback=self.honeypot_telnet_port) - - print(f"Loaded honeypot_ports from config: '{self.honeypot_ports}'") - - # Save config - try: - with open(self.config_file, 'w') as f: - config.write(f) - except Exception as e: - print(f"Warning: Could not save config: {e}") - - print(f"Database: {self.database}") - print(f"View reports at: https://thelightscope.com/light_table/{self.database}") - - def get_dict(self): - """Return config as dictionary.""" - return { - 'database': self.database, - 'randomization_key': self.randomization_key, - 'honeypot_ports': self.honeypot_ports, - 'honeypot_server': self.honeypot_server, - 'honeypot_ssh_port': self.honeypot_ssh_port, - 'honeypot_telnet_port': self.honeypot_telnet_port - } - - -def fetch_external_info(): - """Fetch external network information from thelightscope.com.""" - try: - resp = requests.get("https://thelightscope.com/ipinfo", timeout=10) - resp.raise_for_status() - data = resp.json() - - queried_ip = data.get("queried_ip") - asn_rec = data["results"].get("asn", {}).get("record", {}) - loc_rec = data["results"].get("location", {}).get("record", {}) - company_rec = data["results"].get("company", {}).get("record", {}) - - return { - "queried_ip": queried_ip, - "ASN": asn_rec.get("asn"), - "domain": asn_rec.get("domain"), - "city": loc_rec.get("city"), - "country": loc_rec.get("country"), - "as_type": company_rec.get("as_type"), - "type": asn_rec.get("type") - } - except Exception as e: - print(f"Warning: Could not fetch external info: {e}") - return { - "queried_ip": "0.0.0.0", - "ASN": "unknown", - "domain": "unknown", - "city": "unknown", - "country": "unknown", - "as_type": "unknown", - "type": "unknown" - } - - -def get_system_info(): - """Get system information.""" - import platform - info = { - "System": platform.system(), - "Release": platform.release(), - "Version": platform.version(), - "Machine": platform.machine(), - "processor": platform.processor(), - "architecture": platform.architecture()[0] - } - - if psutil: - info["Total_Memory"] = f"{psutil.virtual_memory().total / (1024 ** 3):.2f} GB" - else: - info["Total_Memory"] = "unknown" - - return info - - -def randomize_ip(ip_str, key): - """Randomize an IP address for privacy.""" - try: - ip = ipaddress.ip_address(ip_str) - except ValueError: - return "unknown" - - def hash_segment(segment, k): - combined = f"{segment}-{k}" - h = hashlib.sha256(combined.encode()).hexdigest() - return int(h[:2], 16) % 256 - - orig_bytes = ip.packed - new_bytes = bytes(hash_segment(b, key) for b in orig_bytes) - - try: - rand_ip = ipaddress.IPv4Address(new_bytes) if ip.version == 4 else ipaddress.IPv6Address(new_bytes) - return str(rand_ip) - except: - return "unknown" - - -def check_ip_is_private(ip_str): - """Check if IP is private.""" - try: - ip_obj = ipaddress.ip_address(ip_str) - return "True" if ip_obj.is_private else "False" - except ValueError: - return "unknown" - - -class PacketProcessor: - """Processes packets from pflog and prepares them for upload.""" - - def __init__(self, config, external_info, system_info, upload_pipe): - self.config = config - self.external_info = external_info - self.system_info = system_info - self.upload_pipe = upload_pipe - self.packet_count = 0 - self.HEARTBEAT_INTERVAL = 15 * 60 # 15 minutes - - def process_batch(self, batch): - """Process a batch of packets from pflog.""" - for pkt in batch: - self.packet_count += 1 - self._prepare_and_send(pkt) - - def _prepare_and_send(self, pkt): - """Prepare packet data and send to uploader.""" - payload = { - "db_name": self.config['database'], - "system_time": str(pkt.packet_time), - "ip_version": pkt.ip_version, - "ip_ihl": pkt.ip_ihl, - "ip_tos": pkt.ip_tos, - "ip_len": pkt.ip_len, - "ip_id": pkt.ip_id, - "ip_flags": pkt.ip_flags if isinstance(pkt.ip_flags, str) else ",".join(str(v) for v in pkt.ip_flags), - "ip_frag": pkt.ip_frag, - "ip_ttl": pkt.ip_ttl, - "ip_proto": pkt.ip_proto, - "ip_chksum": pkt.ip_chksum, - "ip_src": pkt.ip_src, - "ip_dst_randomized": randomize_ip(pkt.ip_dst, self.config['randomization_key']), - "ip_options": pkt.ip_options if isinstance(pkt.ip_options, str) else ",".join(str(v) for v in pkt.ip_options), - "tcp_sport": pkt.tcp_sport, - "tcp_dport": pkt.tcp_dport, - "tcp_seq": pkt.tcp_seq, - "tcp_ack": pkt.tcp_ack, - "tcp_dataofs": pkt.tcp_dataofs, - "tcp_reserved": pkt.tcp_reserved, - "tcp_flags": pkt.tcp_flags, - "tcp_window": pkt.tcp_window, - "tcp_chksum": pkt.tcp_chksum, - "tcp_urgptr": pkt.tcp_urgptr, - "tcp_options": "", - "ext_dst_ip_country": self.external_info.get('country', 'unknown'), - "type": self.external_info.get('type', 'unknown'), - "ASN": self.external_info.get('ASN', 'unknown'), - "domain": self.external_info.get('domain', 'unknown'), - "city": self.external_info.get('city', 'unknown'), - "as_type": self.external_info.get('as_type', 'unknown'), - "ip_dst_is_private": check_ip_is_private(pkt.ip_dst), - "external_is_private": check_ip_is_private(self.external_info.get('queried_ip', '')), - "open_ports": "", - "previously_open_ports": "", - "interface": "pflog0", - "internal_ip_randomized": randomize_ip(pkt.ip_dst, self.config['randomization_key']), - "external_ip_randomized": randomize_ip(self.external_info.get('queried_ip', ''), self.config['randomization_key']), - "System_info": self.system_info.get('System', 'OPNsense'), - "Release_info": self.system_info.get('Release', 'unknown'), - "Version_info": self.system_info.get('Version', 'unknown'), - "Machine_info": self.system_info.get('Machine', 'unknown'), - "Total_Memory": self.system_info.get('Total_Memory', 'unknown'), - "processor": self.system_info.get('processor', 'unknown'), - "architecture": self.system_info.get('architecture', 'unknown'), - "honeypot_status": "False", - "payload": "N/A", - "ls_version": LS_VERSION - } - - try: - self.upload_pipe.send(payload) - except Exception as e: - print(f"Error sending packet data: {e}", flush=True) - - def _send_heartbeat(self): - """Send heartbeat message.""" - heartbeat = { - "db_name": "heartbeats", - "unwanted_db": self.config['database'], - "pkts_last_hb": self.packet_count, - "ext_dst_ip_country": self.external_info.get('country', 'unknown'), - "type": self.external_info.get('type', 'unknown'), - "ASN": self.external_info.get('ASN', 'unknown'), - "domain": self.external_info.get('domain', 'unknown'), - "city": self.external_info.get('city', 'unknown'), - "as_type": self.external_info.get('as_type', 'unknown'), - "external_is_private": check_ip_is_private(self.external_info.get('queried_ip', '')), - "open_ports": self.config['honeypot_ports'], - "previously_open_ports": "", - "interface": "pflog0", - "internal_ip_randomized": "", - "external_ip_randomized": randomize_ip(self.external_info.get('queried_ip', ''), self.config['randomization_key']), - "System_info": self.system_info.get('System', 'OPNsense'), - "Release_info": self.system_info.get('Release', 'unknown'), - "Version_info": self.system_info.get('Version', 'unknown'), - "Machine_info": self.system_info.get('Machine', 'unknown'), - "Total_Memory": self.system_info.get('Total_Memory', 'unknown'), - "processor": self.system_info.get('processor', 'unknown'), - "architecture": self.system_info.get('architecture', 'unknown'), - "open_honeypot_ports": self.config['honeypot_ports'], - "ls_version": LS_VERSION - } - - try: - self.upload_pipe.send(heartbeat) - self.packet_count = 0 - print("Sent heartbeat", flush=True) - except Exception as e: - print(f"Error sending heartbeat: {e}", flush=True) - - -def packet_handler(pflog_pipe, upload_pipe, config, external_info, system_info): - """ - Process packets from pflog reader and send to uploader. - - This runs in its own process. - """ - processor = PacketProcessor(config, external_info, system_info, upload_pipe) - - print("packet_handler: Started", flush=True) - - # Send initial heartbeat at startup - processor._send_heartbeat() - - # Start a background thread for periodic heartbeats - def heartbeat_loop(): - while True: - time.sleep(processor.HEARTBEAT_INTERVAL) - processor._send_heartbeat() - - hb_thread = threading.Thread(target=heartbeat_loop, daemon=True) - hb_thread.start() - - while True: - try: - batch = pflog_pipe.recv() - processor.process_batch(batch) - except (EOFError, OSError): - print("packet_handler: Pipe closed, exiting", flush=True) - break - except Exception as e: - print(f"packet_handler: Error: {e}", flush=True) - time.sleep(1) - - -def main(): - """Main entry point for LightScope daemon.""" - print(f"LightScope for OPNsense v{LS_VERSION}", flush=True) - print("Starting...", flush=True) - - # Write PID file - pid = os.getpid() - try: - with open("/var/run/lightscope.pid", "w") as f: - f.write(str(pid)) - except Exception as e: - print(f"Warning: Could not write PID file: {e}") - - # Load configuration - config = LightScopeConfig() - config_dict = config.get_dict() - - # Fetch external network info - print("Fetching external network information...", flush=True) - external_info = fetch_external_info() - print(f"External IP: {external_info.get('queried_ip', 'unknown')}", flush=True) - - # Get system info - system_info = get_system_info() - - # Create pipes - pflog_consumer, pflog_producer = multiprocessing.Pipe(duplex=False) - upload_consumer, upload_producer = multiprocessing.Pipe(duplex=False) - hp_upload_consumer, hp_upload_producer = multiprocessing.Pipe(duplex=False) - - # Start processes - processes = [] - - # 1. pflog reader - p_pflog = multiprocessing.Process( - target=read_pflog, - args=(pflog_producer,), - name="pflog_reader" - ) - p_pflog.start() - processes.append(p_pflog) - print("Started pflog reader", flush=True) - - # 2. Packet handler - p_handler = multiprocessing.Process( - target=packet_handler, - args=(pflog_consumer, upload_producer, config_dict, external_info, system_info), - name="packet_handler" - ) - p_handler.start() - processes.append(p_handler) - print("Started packet handler", flush=True) - - # 3. Data uploader - p_uploader = multiprocessing.Process( - target=send_data, - args=(upload_consumer,), - name="uploader" - ) - p_uploader.start() - processes.append(p_uploader) - print("Started uploader", flush=True) - - # 4. Honeypot uploader - p_hp_uploader = multiprocessing.Process( - target=send_honeypot_data, - args=(hp_upload_consumer,), - name="honeypot_uploader" - ) - p_hp_uploader.start() - processes.append(p_hp_uploader) - print("Started honeypot uploader", flush=True) - - # 5. Honeypot listener - if config_dict.get('honeypot_ports'): - p_honeypot = multiprocessing.Process( - target=run_honeypot, - args=(config_dict, hp_upload_producer, config_dict['database']), - name="honeypot" - ) - p_honeypot.start() - processes.append(p_honeypot) - print("Started honeypot listener", flush=True) - else: - print("Honeypot disabled (no ports configured)", flush=True) - - # Signal handler for clean shutdown - def shutdown(signum, frame): - print("\nShutting down...", flush=True) - for p in processes: - if p.is_alive(): - p.terminate() - for p in processes: - p.join(timeout=5) - try: - os.remove("/var/run/lightscope.pid") - except: - pass - sys.exit(0) - - signal.signal(signal.SIGTERM, shutdown) - signal.signal(signal.SIGINT, shutdown) - - print("LightScope is running. Press Ctrl+C to stop.", flush=True) - - # Monitor processes - while True: - time.sleep(60) - - for p in processes: - if not p.is_alive(): - print(f"Process {p.name} died, restarting...", flush=True) - # For now, just exit and let the service manager restart us - shutdown(None, None) - - -if __name__ == "__main__": - multiprocessing.freeze_support() - main() diff --git a/security/lightscope/src/opnsense/scripts/lightscope/pflog_reader.py b/security/lightscope/src/opnsense/scripts/lightscope/pflog_reader.py deleted file mode 100755 index 7625c58981..0000000000 --- a/security/lightscope/src/opnsense/scripts/lightscope/pflog_reader.py +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/local/bin/python3 -""" -pflog_reader.py - Captures blocked TCP SYN packets from pflog0 interface - -This module reads packets from the pflog0 interface (pf firewall log) -and extracts TCP SYN packets that were blocked by the firewall. -""" - -import struct -import socket -import datetime -import time -import sys -from collections import namedtuple, deque -import threading - -# Try to import pcap library (python-libpcap on FreeBSD) -try: - import pcap -except ImportError: - print("Error: pypcap not found. Install with: pkg install py311-pypcap", file=sys.stderr) - sys.exit(1) - -try: - import dpkt -except ImportError: - print("Error: dpkt not found. Install with: pkg install py311-dpkt", file=sys.stderr) - sys.exit(1) - - -# pflog header structure (FreeBSD) -# See /usr/include/net/if_pflog.h -# Structure size: 1+1+1+1+16+16+4+4+4+4+4+4+1+3+4+1+3 = 72 bytes -PFLOG_HDRLEN = 72 # FreeBSD pflog header length - -# pflog action values (from /usr/include/netpfil/pf/pf.h) -PF_PASS = 0 -PF_DROP = 1 - -# PacketInfo namedtuple - compatible with lightscope_core.py -PacketInfo = namedtuple("PacketInfo", [ - "packet_num", "proto", "packet_time", - "ip_version", "ip_ihl", "ip_tos", "ip_len", "ip_id", "ip_flags", "ip_frag", - "ip_ttl", "ip_proto", "ip_chksum", "ip_src", "ip_dst", "ip_options", - "tcp_sport", "tcp_dport", "tcp_seq", "tcp_ack", "tcp_dataofs", - "tcp_reserved", "tcp_flags", "tcp_window", "tcp_chksum", "tcp_urgptr", "tcp_options" -]) - - -def tcp_flags_to_str(flags_value): - """Convert dpkt TCP flags value to a comma-separated string.""" - flag_names = [] - if flags_value & dpkt.tcp.TH_FIN: - flag_names.append("FIN") - if flags_value & dpkt.tcp.TH_SYN: - flag_names.append("SYN") - if flags_value & dpkt.tcp.TH_RST: - flag_names.append("RST") - if flags_value & dpkt.tcp.TH_PUSH: - flag_names.append("PSH") - if flags_value & dpkt.tcp.TH_ACK: - flag_names.append("ACK") - if flags_value & dpkt.tcp.TH_URG: - flag_names.append("URG") - return ",".join(flag_names) if flag_names else "" - - -def parse_pflog_packet(buf, packet_num): - """ - Parse a pflog packet and extract TCP SYN information. - - pflog packets have a pflog header followed by the IP packet. - We only care about blocked TCP SYN packets. - - Returns PacketInfo namedtuple or None if not a blocked TCP SYN. - """ - if len(buf) < PFLOG_HDRLEN: - return None - - # Extract action from pflog header (byte offset 2) - # Only process blocked packets (PF_DROP) - action = buf[2] - if action != PF_DROP: - return None - - # Skip pflog header to get to IP packet - ip_data = buf[PFLOG_HDRLEN:] - - if len(ip_data) < 20: # Minimum IP header length - return None - - # Check IP version - ip_version = (ip_data[0] >> 4) & 0xF - - if ip_version == 4: - try: - ip = dpkt.ip.IP(ip_data) - except Exception: - return None - - # Only process TCP - if ip.p != dpkt.ip.IP_PROTO_TCP: - return None - - try: - tcp = ip.data - if not isinstance(tcp, dpkt.tcp.TCP): - tcp = dpkt.tcp.TCP(ip.data) - except Exception: - return None - - # Only capture SYN packets (SYN flag set, ACK flag not set) - if not (tcp.flags & dpkt.tcp.TH_SYN) or (tcp.flags & dpkt.tcp.TH_ACK): - return None - - # Extract IP flags - flags = [] - if ip.df: - flags.append("DF") - if ip.mf: - flags.append("MF") - ip_flags_str = ",".join(flags) if flags else "" - - ip_opts = ip.opts.hex() if ip.opts else "" - - return PacketInfo( - packet_num=packet_num, - proto="TCP", - packet_time=datetime.datetime.now().timestamp(), - ip_version=ip.v, - ip_ihl=ip.hl, - ip_tos=ip.tos, - ip_len=ip.len, - ip_id=ip.id, - ip_flags=ip_flags_str, - ip_frag=ip.offset >> 3, - ip_ttl=ip.ttl, - ip_proto=ip.p, - ip_chksum=ip.sum, - ip_src=socket.inet_ntoa(ip.src), - ip_dst=socket.inet_ntoa(ip.dst), - ip_options=ip_opts, - tcp_sport=tcp.sport, - tcp_dport=tcp.dport, - tcp_seq=tcp.seq, - tcp_ack=tcp.ack, - tcp_dataofs=tcp.off * 4, - tcp_reserved=0, - tcp_flags=tcp_flags_to_str(tcp.flags), - tcp_window=tcp.win, - tcp_chksum=tcp.sum, - tcp_urgptr=tcp.urp, - tcp_options=tcp.opts - ) - - elif ip_version == 6: - try: - ip6 = dpkt.ip6.IP6(ip_data) - except Exception: - return None - - # Only process TCP - if ip6.nxt != dpkt.ip.IP_PROTO_TCP: - return None - - try: - tcp = ip6.data - if not isinstance(tcp, dpkt.tcp.TCP): - tcp = dpkt.tcp.TCP(ip6.data) - except Exception: - return None - - # Only capture SYN packets - if not (tcp.flags & dpkt.tcp.TH_SYN) or (tcp.flags & dpkt.tcp.TH_ACK): - return None - - return PacketInfo( - packet_num=packet_num, - proto="TCP", - packet_time=datetime.datetime.now().timestamp(), - ip_version=6, - ip_ihl=None, - ip_tos=None, - ip_len=ip6.plen, - ip_id=None, - ip_flags="", - ip_frag=0, - ip_ttl=ip6.hlim, - ip_proto=ip6.nxt, - ip_chksum=None, - ip_src=socket.inet_ntop(socket.AF_INET6, ip6.src), - ip_dst=socket.inet_ntop(socket.AF_INET6, ip6.dst), - ip_options="", - tcp_sport=tcp.sport, - tcp_dport=tcp.dport, - tcp_seq=tcp.seq, - tcp_ack=tcp.ack, - tcp_dataofs=tcp.off * 4, - tcp_reserved=0, - tcp_flags=tcp_flags_to_str(tcp.flags), - tcp_window=tcp.win, - tcp_chksum=tcp.sum, - tcp_urgptr=tcp.urp, - tcp_options=tcp.opts - ) - - return None - - -def read_pflog(output_pipe, interface="pflog0"): - """ - Main pflog reader function. - - Captures packets from pflog0 interface, parses TCP SYNs, - and sends them to output_pipe in batches. - - Args: - output_pipe: multiprocessing.Pipe connection for sending packet batches - interface: Network interface to capture from (default: pflog0) - """ - BATCH_SIZE = 100 - IDLE_FLUSH_SECS = 1.0 - MAX_QUEUE_SIZE = 10000 - - send_deque = deque(maxlen=MAX_QUEUE_SIZE) - last_activity = time.monotonic() - - def sender_thread(): - """Thread to batch and send packets.""" - nonlocal last_activity - prior_time = time.monotonic() - packets_sent = 0 - - while True: - now = time.monotonic() - to_send = 0 - - # Log stats every second - if (now - prior_time) >= 1.0: - if packets_sent > 0: - print(f"pflog_reader: sent {packets_sent} packets, queue={len(send_deque)}", flush=True) - packets_sent = 0 - prior_time = now - - # Check if we should send a batch - if len(send_deque) >= BATCH_SIZE: - to_send = BATCH_SIZE - elif send_deque and (now - last_activity) >= IDLE_FLUSH_SECS: - to_send = len(send_deque) - else: - time.sleep(0.01) - continue - - # Build and send batch - batch = [send_deque.popleft() for _ in range(to_send)] - try: - output_pipe.send(batch) - packets_sent += len(batch) - except Exception as e: - print(f"pflog_reader: pipe send error: {e}", file=sys.stderr) - return - - # Start sender thread - threading.Thread(target=sender_thread, daemon=True).start() - - # Open pflog0 for capture - try: - # DLT_PFLOG = 117 on FreeBSD - sniffer = pcap.pcap( - name=interface, - snaplen=65535, - promisc=False, - immediate=True, - timeout_ms=100 - ) - # Filter for TCP only - sniffer.setfilter("tcp") - except Exception as e: - print(f"pflog_reader: Failed to open {interface}: {e}", file=sys.stderr) - sys.exit(1) - - print(f"pflog_reader: Capturing on {interface}", flush=True) - - packet_num = 0 - for ts, buf in sniffer: - packet_num += 1 - - try: - pkt_info = parse_pflog_packet(buf, packet_num) - if pkt_info: - send_deque.append(pkt_info) - last_activity = time.monotonic() - except Exception as e: - # Skip malformed packets - continue - - -if __name__ == "__main__": - # Test mode - print captured packets - print("pflog_reader: Running in test mode (printing to stdout)") - - try: - sniffer = pcap.pcap( - name="pflog0", - snaplen=65535, - promisc=False, - immediate=True, - timeout_ms=1000 - ) - sniffer.setfilter("tcp") - except Exception as e: - print(f"Failed to open pflog0: {e}") - sys.exit(1) - - packet_num = 0 - for ts, buf in sniffer: - packet_num += 1 - pkt_info = parse_pflog_packet(buf, packet_num) - if pkt_info: - print(f"SYN: {pkt_info.ip_src}:{pkt_info.tcp_sport} -> {pkt_info.ip_dst}:{pkt_info.tcp_dport}") diff --git a/security/lightscope/src/opnsense/scripts/lightscope/uploader.py b/security/lightscope/src/opnsense/scripts/lightscope/uploader.py deleted file mode 100755 index 04e8b39b0b..0000000000 --- a/security/lightscope/src/opnsense/scripts/lightscope/uploader.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/local/bin/python3 -""" -uploader.py - Data upload module for LightScope - -Handles batching and uploading packet data and heartbeats to thelightscope.com -""" - -import time -import threading -import sys -from collections import deque - -try: - import requests - from requests.adapters import HTTPAdapter -except ImportError: - print("Error: requests not found. Install with: pkg install py311-requests", file=sys.stderr) - sys.exit(1) - - -# API Configuration -DATA_URL = "https://thelightscope.com/log_mysql_data" -HEARTBEAT_URL = "https://thelightscope.com/heartbeat" -HEADERS = { - "Content-Type": "application/json", - "X-API-Key": "lightscopeAPIkey2025_please_dont_distribute_me_but_im_write_only_anyways" -} - -# Upload settings -BATCH_SIZE = 600 -IDLE_FLUSH_SEC = 5.0 -RETRY_BACKOFF = 5 -MAX_QUEUE_SIZE = 100000 - - -def send_data(consumer_pipe): - """ - Main data upload function. - - Receives data from consumer_pipe, batches it, and uploads to thelightscope.com. - Handles heartbeat messages separately. - - Args: - consumer_pipe: multiprocessing.Pipe connection to receive data from - """ - session = requests.Session() - adapter = HTTPAdapter(pool_connections=4, pool_maxsize=4) - session.mount("https://", adapter) - session.mount("http://", adapter) - - queue = deque(maxlen=MAX_QUEUE_SIZE) - last_activity = time.monotonic() - stop_event = threading.Event() - - def reader(): - """Thread to read from pipe and add to queue.""" - nonlocal last_activity - while not stop_event.is_set(): - try: - item = consumer_pipe.recv() - queue.append(item) - last_activity = time.monotonic() - except (EOFError, OSError): - stop_event.set() - break - except Exception as e: - print(f"uploader: Error receiving data: {e}", flush=True) - - # Drain any remaining data - while consumer_pipe.poll(0): - try: - queue.append(consumer_pipe.recv()) - except: - break - - # Start reader thread - reader_thread = threading.Thread(target=reader, daemon=True) - reader_thread.start() - - print("uploader: Started data upload service", flush=True) - - try: - while not stop_event.is_set() or queue: - # 1) Process heartbeats first - hb_count = 0 - n = len(queue) - for _ in range(n): - try: - item = queue.popleft() - except IndexError: - break - - if item.get("db_name") == "heartbeats": - hb_count += 1 - try: - resp = session.post( - HEARTBEAT_URL, - json=item, - headers=HEADERS, - timeout=10 - ) - if resp.status_code != 200: - print(f"uploader: Heartbeat rejected ({resp.status_code})", flush=True) - except requests.RequestException as e: - print(f"uploader: Heartbeat error: {e}", flush=True) - else: - # Put non-heartbeat items back - queue.append(item) - - if hb_count: - print(f"uploader: Sent {hb_count} heartbeat(s)", flush=True) - - # 2) Batch and send regular data - now = time.monotonic() - elapsed = now - last_activity - - if queue and (len(queue) >= BATCH_SIZE or elapsed >= IDLE_FLUSH_SEC): - to_send = min(len(queue), BATCH_SIZE) - batch = [queue.popleft() for _ in range(to_send)] - - try: - resp = session.post( - DATA_URL, - json={"batch": batch}, - headers=HEADERS, - timeout=10 - ) - if resp.status_code == 200: - print(f"uploader: Sent {to_send} items", flush=True) - else: - print(f"uploader: Upload rejected ({resp.status_code})", flush=True) - last_activity = time.monotonic() - - except requests.RequestException as e: - print(f"uploader: Upload error, will retry: {e}", flush=True) - # Put items back at front of queue - for item in reversed(batch): - queue.appendleft(item) - time.sleep(RETRY_BACKOFF) - else: - time.sleep(0.1) - - except KeyboardInterrupt: - print("uploader: Shutting down...", flush=True) - finally: - stop_event.set() - reader_thread.join(timeout=2) - print("uploader: Stopped", flush=True) - - -def send_honeypot_data(consumer_pipe): - """ - Honeypot-specific data upload function. - - Similar to send_data but with smaller batch sizes for honeypot data. - - Args: - consumer_pipe: multiprocessing.Pipe connection to receive honeypot data from - """ - session = requests.Session() - adapter = HTTPAdapter(pool_connections=2, pool_maxsize=2) - session.mount("https://", adapter) - session.mount("http://", adapter) - - queue = deque(maxlen=MAX_QUEUE_SIZE) - last_activity = time.monotonic() - stop_event = threading.Event() - - HONEYPOT_BATCH_SIZE = 100 - - def reader(): - nonlocal last_activity - while not stop_event.is_set(): - try: - item = consumer_pipe.recv() - queue.append(item) - last_activity = time.monotonic() - except (EOFError, OSError): - stop_event.set() - break - except Exception as e: - print(f"honeypot_uploader: Error receiving data: {e}", flush=True) - - while consumer_pipe.poll(0): - try: - queue.append(consumer_pipe.recv()) - except: - break - - reader_thread = threading.Thread(target=reader, daemon=True) - reader_thread.start() - - print("honeypot_uploader: Started", flush=True) - - try: - while not stop_event.is_set() or queue: - now = time.monotonic() - elapsed = now - last_activity - - if queue and (len(queue) >= HONEYPOT_BATCH_SIZE or elapsed >= IDLE_FLUSH_SEC): - to_send = min(len(queue), HONEYPOT_BATCH_SIZE) - batch = [queue.popleft() for _ in range(to_send)] - - try: - resp = session.post( - DATA_URL, - json={"batch": batch}, - headers=HEADERS, - timeout=10 - ) - if resp.status_code == 200: - print(f"honeypot_uploader: Sent {to_send} items", flush=True) - else: - print(f"honeypot_uploader: Upload rejected ({resp.status_code})", flush=True) - last_activity = time.monotonic() - - except requests.RequestException as e: - print(f"honeypot_uploader: Error, will retry: {e}", flush=True) - for item in reversed(batch): - queue.appendleft(item) - time.sleep(RETRY_BACKOFF) - else: - time.sleep(0.1) - - except KeyboardInterrupt: - pass - finally: - stop_event.set() - reader_thread.join(timeout=2) - print("honeypot_uploader: Stopped", flush=True) - - -if __name__ == "__main__": - print("uploader: This module should be run as part of lightscope_daemon.py")