From 27bd359a360166eae91d23b603bfb405f2d5b5f8 Mon Sep 17 00:00:00 2001 From: Ad Schellevis Date: Sat, 11 Oct 2025 09:07:04 +0200 Subject: [PATCH] security/q-feeds-connector - add initial version (ref: https://forum.opnsense.org/index.php?topic=49123.0) --- security/q-feeds-connector/+POST_INSTALL.post | 3 + security/q-feeds-connector/Makefile | 6 + security/q-feeds-connector/README.md | 48 +++++ security/q-feeds-connector/pkg-descr | 8 + .../src/etc/inc/plugins.inc.d/qfeeds.inc | 35 ++++ .../src/etc/rc.syshook.d/start/99-qfeeds | 4 + .../QFeeds/Api/SettingsController.php | 117 ++++++++++++ .../OPNsense/QFeeds/IndexController.php | 40 ++++ .../OPNsense/QFeeds/forms/settings.xml | 12 ++ .../OPNsense/System/Status/QfeedsStatus.php | 61 ++++++ .../Firewall/DynamicAliases/QfeedsAliases.php | 56 ++++++ .../app/models/OPNsense/QFeeds/ACL/ACL.xml | 9 + .../app/models/OPNsense/QFeeds/Connector.php | 38 ++++ .../app/models/OPNsense/QFeeds/Connector.xml | 10 + .../app/models/OPNsense/QFeeds/Menu/Menu.xml | 9 + .../mvc/app/views/OPNsense/QFeeds/index.volt | 136 +++++++++++++ .../opnsense/scripts/qfeeds/lib/__init__.py | 179 ++++++++++++++++++ .../src/opnsense/scripts/qfeeds/lib/api.py | 72 +++++++ .../src/opnsense/scripts/qfeeds/lib/file.py | 53 ++++++ .../src/opnsense/scripts/qfeeds/lib/log.py | 77 ++++++++ .../src/opnsense/scripts/qfeeds/qfeedsctl.py | 64 +++++++ .../conf/actions.d/actions_qfeeds.conf | 40 ++++ .../templates/OPNsense/QFeeds/+TARGETS | 1 + .../templates/OPNsense/QFeeds/qfeeds.conf | 4 + .../src/opnsense/www/img/QFeeds.png | Bin 0 -> 16073 bytes .../www/js/widgets/Metadata/QFeeds.xml | 20 ++ .../src/opnsense/www/js/widgets/QFeeds.js | 96 ++++++++++ 27 files changed, 1198 insertions(+) create mode 100755 security/q-feeds-connector/+POST_INSTALL.post create mode 100644 security/q-feeds-connector/Makefile create mode 100644 security/q-feeds-connector/README.md create mode 100644 security/q-feeds-connector/pkg-descr create mode 100644 security/q-feeds-connector/src/etc/inc/plugins.inc.d/qfeeds.inc create mode 100755 security/q-feeds-connector/src/etc/rc.syshook.d/start/99-qfeeds create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/controllers/OPNsense/QFeeds/Api/SettingsController.php create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/controllers/OPNsense/QFeeds/IndexController.php create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/controllers/OPNsense/QFeeds/forms/settings.xml create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/library/OPNsense/System/Status/QfeedsStatus.php create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/QfeedsAliases.php create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/ACL/ACL.xml create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/Connector.php create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/Connector.xml create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/Menu/Menu.xml create mode 100644 security/q-feeds-connector/src/opnsense/mvc/app/views/OPNsense/QFeeds/index.volt create mode 100644 security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/__init__.py create mode 100644 security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/api.py create mode 100644 security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/file.py create mode 100644 security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/log.py create mode 100755 security/q-feeds-connector/src/opnsense/scripts/qfeeds/qfeedsctl.py create mode 100644 security/q-feeds-connector/src/opnsense/service/conf/actions.d/actions_qfeeds.conf create mode 100644 security/q-feeds-connector/src/opnsense/service/templates/OPNsense/QFeeds/+TARGETS create mode 100644 security/q-feeds-connector/src/opnsense/service/templates/OPNsense/QFeeds/qfeeds.conf create mode 100644 security/q-feeds-connector/src/opnsense/www/img/QFeeds.png create mode 100644 security/q-feeds-connector/src/opnsense/www/js/widgets/Metadata/QFeeds.xml create mode 100644 security/q-feeds-connector/src/opnsense/www/js/widgets/QFeeds.js diff --git a/security/q-feeds-connector/+POST_INSTALL.post b/security/q-feeds-connector/+POST_INSTALL.post new file mode 100755 index 000000000..47481ad32 --- /dev/null +++ b/security/q-feeds-connector/+POST_INSTALL.post @@ -0,0 +1,3 @@ +#!/bin/sh + +/usr/local/sbin/pluginctl -s cron restart diff --git a/security/q-feeds-connector/Makefile b/security/q-feeds-connector/Makefile new file mode 100644 index 000000000..dffd163a2 --- /dev/null +++ b/security/q-feeds-connector/Makefile @@ -0,0 +1,6 @@ +PLUGIN_NAME= q-feeds-connector +PLUGIN_VERSION= 1.0 +PLUGIN_COMMENT= Connector for Q-Feeds threat intel +PLUGIN_MAINTAINER= devel@qfeeds.com + +.include "../../Mk/plugins.mk" diff --git a/security/q-feeds-connector/README.md b/security/q-feeds-connector/README.md new file mode 100644 index 000000000..b18fb8cc5 --- /dev/null +++ b/security/q-feeds-connector/README.md @@ -0,0 +1,48 @@ +# plugin to connect to QFeeds threat platform + +Register for a token at : https://qfeeds.com/opnsense/ + +# command line control + +Settings are persisted in `/usr/local/etc/qfeeds.conf`, using the following format: + +``` +[api] +key=tip_xxxxxxxx +``` + +Our commandline tool contains all actions used by the UI, which is practical for debuggging. + +``` +usage: qfeedsctl.py [-h] [--target_dir TARGET_DIR] [-f] [-v] [{fetch_index,fetch,show_index,firewall_load,update,stats} ...] + +positional arguments: + {fetch_index,fetch,show_index,firewall_load,update,stats} + +options: + -h, --help show this help message and exit + --target_dir TARGET_DIR + -f forced (auto index) + -v verbose output +``` + +The index is the driver for most actions, which is a json encoded file in `/var/db/qfeeds-tables/index.json`. + +Actions supported: + +* fetch_index --> download the index file +* fetch --> download the lists +* firewall_load --> collect ip lists into pre-defined firewall tables +* update [sleep when almost time] --> run fetch_index --> fetch --> firewall_load (to be used by cron) +* show_index --> dumps the index +* stats --> dumps feed information +* logs --> dumps firewall log information for aliases offered by Q-Feeds + + +Example usage: + +``` +/usr/local/opnsense/scripts/qfeeds/qfeedsctl.py update +``` + +**Fetch index and update lists when updated remotely** diff --git a/security/q-feeds-connector/pkg-descr b/security/q-feeds-connector/pkg-descr new file mode 100644 index 000000000..c6c57300e --- /dev/null +++ b/security/q-feeds-connector/pkg-descr @@ -0,0 +1,8 @@ +Connector for Q-Feeds threat intel + +Plugin Changelog +================ + +1.0 + +* Intial release version diff --git a/security/q-feeds-connector/src/etc/inc/plugins.inc.d/qfeeds.inc b/security/q-feeds-connector/src/etc/inc/plugins.inc.d/qfeeds.inc new file mode 100644 index 000000000..c2fb7a2a6 --- /dev/null +++ b/security/q-feeds-connector/src/etc/inc/plugins.inc.d/qfeeds.inc @@ -0,0 +1,35 @@ +configdRun('template reload OPNsense/QFeeds')); + if (strtolower($res) != 'ok') { + throw new UserException(sprintf(gettext("Unable to update settings (%s)"), $res)); + } + $res = trim($backend->configdRun('qfeeds reconfigure')); + if (strpos($res, 'EXIT OK') === false) { + throw new UserException($res); + } + return ['status' => 'ok', 'output' => $res]; + } + + public function searchFeedsAction() + { + $records = []; + $data = json_decode((new Backend())->configdRun('qfeeds info') ?? '[]', true); + if (!empty($data) && !empty($data['feeds'])) { + $records = $data['feeds']; + foreach ($records as &$record) { + $record['licensed'] = $record['licensed'] ? '1' : '0'; + } + } + return $this->searchRecordsetBase($records); + } + + public function searchEventsAction() + { + $records = []; + $ifnames = []; + foreach (Config::getInstance()->object()->interfaces->children() as $key => $node) { + if (!empty((string)$node->if)) { + $ifnames[(string)$node->if] = (string)($node->descr ?? strtoupper($key)); + } + } + $data = json_decode((new Backend())->configdRun('qfeeds logs') ?? '[]', true); + if (!empty($data) && !empty($data['rows'])) { + foreach ($data['rows'] as $row) { + $records[] = [ + 'timestamp' => $row[0], + 'interface' => $ifnames[$row[1]] ?? $row[1], + 'direction' => $row[2], + 'source' => $row[3], + 'destination' => $row[4], + ]; + } + } + return $this->searchRecordsetBase($records); + } + + public function statsAction() + { + $stats = json_decode((new Backend())->configdRun('qfeeds stats'), true); + if (!empty($stats) && !empty($stats['feeds'])) { + $info = json_decode((new Backend())->configdRun('qfeeds info'), true); + if (!empty($info) && !empty($info['feeds'])) { + $feeds = []; + foreach ($info['feeds'] as $feed) { + $feeds[$feed['feed_type']] = $feed; + } + foreach ($stats['feeds'] as &$feed) { + if (isset($feeds[$feed['name']])) { + $tmp = $feeds[$feed['name']]; + $feed['updated_at'] = $tmp['updated_at']; + $feed['next_update'] = $tmp['next_update']; + $feed['licensed'] = $tmp['licensed']; + } + } + } + } + return $stats; + } +} \ No newline at end of file diff --git a/security/q-feeds-connector/src/opnsense/mvc/app/controllers/OPNsense/QFeeds/IndexController.php b/security/q-feeds-connector/src/opnsense/mvc/app/controllers/OPNsense/QFeeds/IndexController.php new file mode 100644 index 000000000..aec86b9a3 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/mvc/app/controllers/OPNsense/QFeeds/IndexController.php @@ -0,0 +1,40 @@ +view->formSettings = $this->getForm("settings"); + $this->view->pick('OPNsense/QFeeds/index'); + } +} \ No newline at end of file diff --git a/security/q-feeds-connector/src/opnsense/mvc/app/controllers/OPNsense/QFeeds/forms/settings.xml b/security/q-feeds-connector/src/opnsense/mvc/app/controllers/OPNsense/QFeeds/forms/settings.xml new file mode 100644 index 000000000..e27658d30 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/mvc/app/controllers/OPNsense/QFeeds/forms/settings.xml @@ -0,0 +1,12 @@ +
+ + header + + + + connect.general.apikey + + text + click here]]> + +
\ No newline at end of file diff --git a/security/q-feeds-connector/src/opnsense/mvc/app/library/OPNsense/System/Status/QfeedsStatus.php b/security/q-feeds-connector/src/opnsense/mvc/app/library/OPNsense/System/Status/QfeedsStatus.php new file mode 100644 index 000000000..5477729d6 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/mvc/app/library/OPNsense/System/Status/QfeedsStatus.php @@ -0,0 +1,61 @@ +internalPriority = 2; + $this->internalPersistent = true; + $this->internalIsBanner = true; + $this->internalTitle = gettext('QFeeds'); + $this->internalScope = [ + '/ui/q_feeds/' + ]; + } + + public function collectStatus() + { + $cnf = Config::getInstance()->object(); + if (!empty($cnf->system->maximumtableentries) && $cnf->system->maximumtableentries >= 2000000) { + return; + } + $this->internalStatus = SystemStatusCode::ERROR; + $this->internalMessage = gettext( + 'QFeeds requires additional memory to be reserved for aliases. ' . + 'Please increase `Firewall Maximum Table Entries` in `Firewall: Settings: Advanced` to at least' . + ' 2 million items.' + ); + } +} diff --git a/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/QfeedsAliases.php b/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/QfeedsAliases.php new file mode 100644 index 000000000..26eca705d --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/Firewall/DynamicAliases/QfeedsAliases.php @@ -0,0 +1,56 @@ +configdRun('qfeeds index') ?? '', true) ?? []; + if (is_array($payload) && !empty($payload['feeds'])) { + foreach ($payload['feeds'] as $feed) { + if ($feed['type'] == 'ip' && !empty($feed['licensed'])) { + $name = '__qfeeds_'. $feed['feed_type']; + $result[$name] = [ + 'enabled' => '1', + 'counters' => '1', + 'name' => $name, + 'type' => 'external', + 'description' => $feed['feed_type'], + 'content' => '' + ]; + } + } + } + return $result; + } +} diff --git a/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/ACL/ACL.xml b/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/ACL/ACL.xml new file mode 100644 index 000000000..3c58e042a --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/ACL/ACL.xml @@ -0,0 +1,9 @@ + + + Services: QFeeds + + ui/q_feeds/* + api/q_feeds/* + + + \ No newline at end of file diff --git a/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/Connector.php b/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/Connector.php new file mode 100644 index 000000000..eca13f960 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/Connector.php @@ -0,0 +1,38 @@ + + //OPNsense/QFeedsConnector + 1.0.0 + QFeeds connector + + + + + + \ No newline at end of file diff --git a/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/Menu/Menu.xml b/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/Menu/Menu.xml new file mode 100644 index 000000000..a91edf926 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/mvc/app/models/OPNsense/QFeeds/Menu/Menu.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/security/q-feeds-connector/src/opnsense/mvc/app/views/OPNsense/QFeeds/index.volt b/security/q-feeds-connector/src/opnsense/mvc/app/views/OPNsense/QFeeds/index.volt new file mode 100644 index 000000000..08dc8e4a5 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/mvc/app/views/OPNsense/QFeeds/index.volt @@ -0,0 +1,136 @@ +{# + +OPNsense® is Copyright © 2025 by Deciso B.V. +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. + +#} + + + + +
+
+ {{ partial("layout_partials/base_form",['fields':formSettings,'id':'frm_settings'])}} +
+
+ + + + + + + + + + + + +
{{ lang._('Description') }}{{ lang._('Type') }}{{ lang._('Updated at') }}{{ lang._('Next update') }}{{ lang._('Licensed') }}
+
+
+ + + + + + + + + + + + + +
{{ lang._('Timestamp') }}{{ lang._('Interface') }}{{ lang._('Direction') }}{{ lang._('Source') }}{{ lang._('Destination') }}
+
{{ lang._('Collected events from the firewall log for QFeed aliases') }}
+
+
+ +
+
+
+
+ +

+
+
+
\ No newline at end of file diff --git a/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/__init__.py b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/__init__.py new file mode 100644 index 000000000..bf4c528aa --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/__init__.py @@ -0,0 +1,179 @@ +""" + Copyright (c) 2025 Deciso B.V. + 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. +""" +import os +import subprocess +import time +import ujson +from datetime import datetime +from lib.api import Api +from lib.log import PFLogCrawler +from lib.file import LockedFile + + +class QFeedsActions: + def __init__(self, target_dir, forced=False): + self._target_dir = target_dir + self._forced = forced + + @classmethod + def list_actions(cls): + return [ + 'fetch_index', + 'fetch', + 'show_index', + 'firewall_load', + 'update', + 'stats', + 'logs' + ] + + @property + def index_file(self): + return "%s/index.json" % self._target_dir + + @property + def index(self): + if not os.path.exists(self.index_file) and self._forced: + # require index file to get feeds + list(self.fetch_index()) + elif not os.path.exists(self.index_file): + return {} + data = ujson.load(open(self.index_file)) or {} + if type(data) is dict: + for feed in data.get('feeds', []): + feed['local_filename'] = "%s/%s.txt" % (self._target_dir, feed['feed_type']) + feed['updated_at_dt'] = datetime.fromisoformat(feed['updated_at']).timestamp() + feed['next_update_dt'] = datetime.fromisoformat(feed['next_update']).timestamp() + + return data + + def _file_stat(self, filename): + if not os.path.exists(filename): + return 0 + return os.stat(filename).st_mtime + + def fetch_index(self): + if not os.path.isdir(self._target_dir): + os.makedirs(self._target_dir) + with LockedFile(self.index_file) as f: + payload = Api().licenses() + f.truncate() + f.write(ujson.dumps(payload)) + yield 'downloaded index to %s' % f.filename + + def show_index(self): + yield ujson.dumps(self.index) + + def fetch(self): + for feed in self.index.get('feeds', []): + if feed['licensed'] and feed['updated_at_dt'] != self._file_stat(feed['local_filename']): + with LockedFile(feed['local_filename']) as f: + counter = 0 + for entry in Api().fetch(feed['feed_type']): + if counter == 0: + f.truncate() + f.write("%s\n" % entry) + counter += 1 + os.utime(feed['local_filename'], (feed['updated_at_dt'], feed['updated_at_dt'])) + yield "downloaded %d entries into %s [%s]" % (counter, feed['local_filename'], feed['updated_at']) + elif feed['licensed']: + yield "skipped %s [%s]" % (feed['local_filename'], feed['updated_at']) + + def firewall_load(self): + for feed in self.index.get('feeds', []): + if feed['licensed'] and os.path.exists(feed['local_filename']) and feed['type'] == 'ip': + table_name = '__qfeeds_%s' % feed['feed_type'] + sp = subprocess.run( + ['/sbin/pfctl', '-t', table_name, '-T', 'replace', '-f', feed['local_filename']], + capture_output=True, + text=True + ) + yield 'load feed %s [%s]' % (feed['feed_type'], sp.stderr.strip().replace("\n", " ")) + + def update(self): + update_sleep = 99999 + try: + index_payload = self.index + except TypeError: + # when the index can't be parsed, assume we have none while updating + index_payload = {} + do_update = len(index_payload.get('feeds', [])) == 0 + for feed in index_payload.get('feeds', []): + update_sleep = min(feed['next_update_dt'] - time.time(), update_sleep) + if feed['licensed'] and update_sleep <= 300: # 5 minute cron interval + do_update = True + if do_update: + if 0 < update_sleep <= 300: + time.sleep(update_sleep) + for action in ['fetch_index', 'fetch', 'firewall_load']: + yield from getattr(self, action)() + + def stats(self): + result = {'feeds': []} + for feed in self.index.get('feeds', []): + if feed['licensed'] and os.path.exists(feed['local_filename']) and feed['type'] == 'ip': + table_name = '__qfeeds_%s' % feed['feed_type'] + sp = subprocess.Popen( + ['/sbin/pfctl', '-t', table_name, '-vT', 'show'], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True + ) + record = { + 'name': feed['feed_type'], + 'total_entries': 0, + 'packets_blocked': 0, + 'bytes_blocked': 0, + 'addresses_blocked': 0 + } + while (line := sp.stdout.readline()): + if line.startswith(' '): + record['total_entries'] += 1 + elif 'Packets:' in line and 'Packets: 0 ' not in line: + parts = line.split() + if parts[3].isdigit() and parts[5].isdigit() and parts[0].lower().find('block') > 0: + record['packets_blocked'] += int(parts[3]) + record['bytes_blocked'] += int(parts[5]) + record['addresses_blocked'] += 1 + + result['feeds'].append(record) + result['totals'] = { + 'entries': sum(r['total_entries'] for r in result['feeds']), + # assumes no overlaps in datafeeds + 'addresses_blocked': sum(r['addresses_blocked'] for r in result['feeds']), + 'packets_blocked': sum(r['packets_blocked'] for r in result['feeds']), + 'bytes_blocked': sum(r['bytes_blocked'] for r in result['feeds']), + } + + yield ujson.dumps(result) + + def logs(self): + feeds = [] + for feed in self.index.get('feeds', []): + if feed['type'] == 'ip': + feeds.append('__qfeeds_%s' % feed['feed_type']) + + yield ujson.dumps({'rows': PFLogCrawler(feeds).find()}) diff --git a/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/api.py b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/api.py new file mode 100644 index 000000000..3892c371f --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/api.py @@ -0,0 +1,72 @@ +""" + Copyright (c) 2025 Deciso B.V. + 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. +""" +import os +import requests +from configparser import ConfigParser + + +class QFeedsConfig: + api_key = None + + def __init__(self): + config_filename = '/usr/local/etc/qfeeds.conf' + if os.path.isfile(config_filename): + cnf = ConfigParser() + cnf.read(config_filename) + if cnf.has_section('api') and cnf.has_option('api', 'key'): + self.api_key = cnf.get('api', 'key') + + +class Api: + def __init__(self): + self.api_key = QFeedsConfig().api_key + + def licenses(self): + r = requests.get( + url='https://api.qfeeds.com/licenses.php', + auth=('api_token', self.api_key), + timeout=60, + headers={'User-Agent': 'Q-Feeds_OPNsense'} + ) + r.raise_for_status() + return r.json() + + def fetch(self, feed): + r = requests.get( + url='https://api.qfeeds.com/api.php', + params={'feed_type': feed}, + auth=('api_token', self.api_key), + headers={'User-Agent': 'Q-Feeds_OPNsense'}, + stream=True, + timeout=60 + ) + r.raise_for_status() + for line in r.raw: + entry = line.decode().strip() + if entry: + yield entry + + diff --git a/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/file.py b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/file.py new file mode 100644 index 000000000..53d6b3ea6 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/file.py @@ -0,0 +1,53 @@ +""" + Copyright (c) 2025 Deciso B.V. + 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. +""" +import fcntl + + +class LockedFile: + def __init__(self, filename): + self._filename = filename + self._fh = None + + def __enter__(self): + self._fh = open(self._filename, 'a+') + fcntl.flock(self._fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + return self + + def __exit__(self, ex_type, ex_value, traceback): + if self._fh: + self._fh.close() + + def truncate(self): + self._fh.seek(0) + self._fh.truncate() + + def write(self, data): + self._fh.write(data) + + @property + def filename(self): + return self._filename + diff --git a/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/log.py b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/log.py new file mode 100644 index 000000000..fed49e8e4 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/lib/log.py @@ -0,0 +1,77 @@ +""" + Copyright (c) 2025 Deciso B.V. + 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. +""" + +import glob +import time +import subprocess +import ipaddress + +def is_ip_address(value): + try: + ipaddress.ip_address(value) + return True + except ValueError: + return False + + +class PFLogCrawler: + def __init__(self, table_names:list=[]): + self._table_names = table_names + self._rule_ids = [] + self._collect_rule_ids() + + def _collect_rule_ids(self): + self._rule_ids = [] + sp = subprocess.run(['/sbin/pfctl', '-sr'], capture_output=True, text=True) + for line in sp.stdout.split("\n"): + for table in self._table_names: + if line.find("<%s>" % table) > 0: + self._rule_ids.append(line.split()[-1].strip('"')) + + @staticmethod + def _parse_log_line(line): + # quick scan for datetime, interface, direction, source, dest + parts = line.split() + fw_line = parts[-1].split(',') # strip syslog + return [parts[1], fw_line[4], fw_line[7]] + [x for x in fw_line if is_ip_address(x)] + + def find(self, max_time=60, max_results=50000): + result = [] + start_time = time.time() + rows_processed = 0 + for filename in sorted(glob.glob("/var/log/filter/filter_*.log"), reverse=True): + with open(filename) as f_in: + for idx, line in enumerate(f_in): + for rule_id in self._rule_ids: + if rule_id in line: + result.append(self._parse_log_line(line)) + rows_processed +=1 + continue + if (idx % 100000 == 0 and time.time() - start_time > max_time) or rows_processed >= max_results: + return result + + return result + diff --git a/security/q-feeds-connector/src/opnsense/scripts/qfeeds/qfeedsctl.py b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/qfeedsctl.py new file mode 100755 index 000000000..737ec65e8 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/scripts/qfeeds/qfeedsctl.py @@ -0,0 +1,64 @@ +#!/usr/local/bin/python3 + +""" + Copyright (c) 2025 Deciso B.V. + 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. +""" + +import argparse +import sys +import ujson +from requests.exceptions import HTTPError, Timeout +from lib import QFeedsActions + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--target_dir', default='/var/db/qfeeds-tables') + parser.add_argument('-f', help='forced (auto index)' , default=False, action='store_true') + parser.add_argument('-v', help='verbose output' , default=False, action='store_true') + parser.add_argument("action", choices=QFeedsActions.list_actions(), nargs='*') + args = parser.parse_args() + if args.v: + # verbose mode + import http.client as http_client + http_client.HTTPConnection.debuglevel = 1 + try: + actions = QFeedsActions(args.target_dir, args.f) + for action in args.action: + for msg in getattr(actions, action)(): + print(msg) + except HTTPError as exc: + print('exit with HTTPError %d (%s)' % (exc.response.status_code, exc.response.text)) + sys.exit(-1) + except Timeout as exc: + print('timeout reaching api endpoint') + sys.exit(-1) + except IOError as e: + print("output filename locked or missing") + sys.exit(-1) + except ujson.JSONDecodeError: + print("JSON decode error") + sys.exit(-1) + diff --git a/security/q-feeds-connector/src/opnsense/service/conf/actions.d/actions_qfeeds.conf b/security/q-feeds-connector/src/opnsense/service/conf/actions.d/actions_qfeeds.conf new file mode 100644 index 000000000..b693285e2 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/service/conf/actions.d/actions_qfeeds.conf @@ -0,0 +1,40 @@ +[reconfigure] +command:/usr/local/opnsense/scripts/qfeeds/qfeedsctl.py fetch_index fetch firewall_load && echo 'EXIT OK' +parameters: +type:script_output +message:reconfigure QFeeds +errors:no + +[update] +command:/usr/local/opnsense/scripts/qfeeds/qfeedsctl.py update +parameters: +type:script_output +message:update QFeeds +errors:no + +[info] +command:/usr/local/opnsense/scripts/qfeeds/qfeedsctl.py show_index +parameters: +type:script_output +message:fetch QFeeds info + +[stats] +command:/usr/local/opnsense/scripts/qfeeds/qfeedsctl.py stats +parameters: +type:script_output +cache_ttl: 3600 +message:return Qfeeds local stats + +[logs] +command:/usr/local/opnsense/scripts/qfeeds/qfeedsctl.py logs +parameters: +type:script_output +cache_ttl: 300 +message:return Qfeeds log data + +[index] +command:cat /var/db/qfeeds-tables/index.json +parameters: +type:script_output +message:return raw QFeeds index file +cache_ttl: 60 diff --git a/security/q-feeds-connector/src/opnsense/service/templates/OPNsense/QFeeds/+TARGETS b/security/q-feeds-connector/src/opnsense/service/templates/OPNsense/QFeeds/+TARGETS new file mode 100644 index 000000000..da2b51f08 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/service/templates/OPNsense/QFeeds/+TARGETS @@ -0,0 +1 @@ +qfeeds.conf:/usr/local/etc/qfeeds.conf diff --git a/security/q-feeds-connector/src/opnsense/service/templates/OPNsense/QFeeds/qfeeds.conf b/security/q-feeds-connector/src/opnsense/service/templates/OPNsense/QFeeds/qfeeds.conf new file mode 100644 index 000000000..be8460cb8 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/service/templates/OPNsense/QFeeds/qfeeds.conf @@ -0,0 +1,4 @@ +{% if not helpers.empty('OPNsense.QFeedsConnector.general.apikey') %} +[api] +key={{OPNsense.QFeedsConnector.general.apikey}} +{% endif %} diff --git a/security/q-feeds-connector/src/opnsense/www/img/QFeeds.png b/security/q-feeds-connector/src/opnsense/www/img/QFeeds.png new file mode 100644 index 0000000000000000000000000000000000000000..5dd5e38a5c1a42a058f702592aea61e0afadcf69 GIT binary patch literal 16073 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 literal 0 HcmV?d00001 diff --git a/security/q-feeds-connector/src/opnsense/www/js/widgets/Metadata/QFeeds.xml b/security/q-feeds-connector/src/opnsense/www/js/widgets/Metadata/QFeeds.xml new file mode 100644 index 000000000..05f357452 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/www/js/widgets/Metadata/QFeeds.xml @@ -0,0 +1,20 @@ + + + QFeeds.js + + /api/q_feeds/settings/stats + + + Q-Feeds Threat Protection + Unable to contact information feed. + Installed Feeds + Database + Size + Blocked + Updated + Next + Licensed + Unlicensed + + + \ No newline at end of file diff --git a/security/q-feeds-connector/src/opnsense/www/js/widgets/QFeeds.js b/security/q-feeds-connector/src/opnsense/www/js/widgets/QFeeds.js new file mode 100644 index 000000000..6a9bb4be4 --- /dev/null +++ b/security/q-feeds-connector/src/opnsense/www/js/widgets/QFeeds.js @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2025 Deciso B.V. + * 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 QFeeds extends BaseTableWidget { + constructor() { + super(); + } + + getMarkup() { + let $container = $('
'); + let $sysinfotable = this.createTable('qfeeds-table', { + headerPosition: 'left', + }); + $container.append($sysinfotable); + return $container; + } + + async onWidgetTick() { + return; + } + + async onMarkupRendered() { + let header = $("div.widget.widget-qfeeds").find('.widget-header'); + let title = $('#qfeeds-title'); + let divider = $("div.widget.widget-qfeeds").find('.panel-divider'); + header.css({ + 'background-image': 'URL("/ui/img/QFeeds.png")', + 'background-size': 'auto 50px', + 'background-position': 'center left', + 'margin-top': '0px', + 'mix-blend-mode': 'difference', + 'background-repeat': 'no-repeat' + }); + title.empty(); + title.css({ + 'height': '70px' + }) + divider.hide(); + $("#qfeeds-table").css({ + 'margin-top': '0px', + 'margin-bottom': '5px', + }); + + const data = await this.ajaxCall('/api/q_feeds/settings/stats'); + if (!data.feeds.length) { + $('#qfeeds-table').html(`${this.translations.no_feed}`); + return; + } + let rows = []; + let feeds = []; + for (let feed of data.feeds) { + feeds.push( + ` ${feed.name}`, + `
  ${this.translations.last_update}: ${feed.updated_at}
`, + `
  ${this.translations.next_update}: ${feed.next_update}
` + ); + if (feed.licensed) { + feeds.push(`
  ${this.translations.licensed}
`); + } else { + feeds.push(`
  ${this.translations.unlicensed}
`); + } + } + rows.push([[this.translations.installed_feeds], feeds]); + let db = [ + `
${this.translations.size}: ${data.totals.entries.toLocaleString()}
`, + `
${this.translations.blocked}: ${data.totals.addresses_blocked.toLocaleString()}
` + + ]; + rows.push([[this.translations.database], db]); + + super.updateTable('qfeeds-table', rows); + } +}