diff --git a/www/OPNProxy/+POST_DEINSTALL.post b/www/OPNProxy/+POST_DEINSTALL.post new file mode 100644 index 000000000..5630e0114 --- /dev/null +++ b/www/OPNProxy/+POST_DEINSTALL.post @@ -0,0 +1,2 @@ +#!/bin/sh +rm /usr/local/etc/squid/auth/10-opnproxy-ext.auth.conf diff --git a/www/OPNProxy/Makefile b/www/OPNProxy/Makefile new file mode 100644 index 000000000..3cc481e38 --- /dev/null +++ b/www/OPNProxy/Makefile @@ -0,0 +1,10 @@ +PLUGIN_NAME= OPNProxy +PLUGIN_VERSION= 1.0.5 +PLUGIN_COMMENT= OPNsense proxy additions +PLUGIN_DEPENDS= os-redis${PLUGIN_PKGSUFFIX} \ + os-squid${PLUGIN_PKGSUFFIX} \ + py${PLUGIN_PYTHON}-redis +PLUGIN_MAINTAINER= ad@opnsense.org +PLUGIN_TIER= 2 + +.include "../../Mk/plugins.mk" diff --git a/www/OPNProxy/pkg-descr b/www/OPNProxy/pkg-descr new file mode 100644 index 000000000..c731c51c9 --- /dev/null +++ b/www/OPNProxy/pkg-descr @@ -0,0 +1,10 @@ +OPNsense proxy additions to support more fine grained access management + +1.0.5 + +* Prepare for community release + +1.0.4: + +* Remove ident support as by default it is denied anyway nowadays + diff --git a/www/OPNProxy/src/etc/inc/plugins.inc.d/opnproxy.inc b/www/OPNProxy/src/etc/inc/plugins.inc.d/opnproxy.inc new file mode 100644 index 000000000..96f00a995 --- /dev/null +++ b/www/OPNProxy/src/etc/inc/plugins.inc.d/opnproxy.inc @@ -0,0 +1,50 @@ + ['opnproxy_user_changed:2'], + 'webproxy' => ['opnproxy_webproxy:2'], + ); +} + + +function opnproxy_user_changed($verbose = false, $username = '') +{ + exec("/usr/local/opnsense/scripts/OPNProxy/redis_sync_users.py " . escapeshellarg($username)); +} + + +function opnproxy_webproxy($verbose = false, $action = null) +{ + $response = configd_run('template reload Deciso/Proxy'); + if ($verbose) { + printf("template reload Deciso/Proxy: %s\n", trim($response)); + } +} diff --git a/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/AclController.php b/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/AclController.php new file mode 100644 index 000000000..c0be1fa08 --- /dev/null +++ b/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/AclController.php @@ -0,0 +1,40 @@ +view->pick('Deciso/Proxy/acl'); + $this->view->formDialogDefaultPolicy = $this->getForm("dialogDefaultPolicy"); + $this->view->formDialogCustomPolicy = $this->getForm("dialogCustomPolicy"); + } +} diff --git a/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/Api/AclController.php b/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/Api/AclController.php new file mode 100644 index 000000000..80a7563ff --- /dev/null +++ b/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/Api/AclController.php @@ -0,0 +1,131 @@ +searchBase("policies.policy", array('enabled', 'description', 'action'), "description"); + } + + public function setPolicyAction($uuid) + { + return $this->setBase("policy", "policies.policy", $uuid); + } + + public function addPolicyAction() + { + return $this->addBase("policy", "policies.policy"); + } + + public function getPolicyAction($uuid = null) + { + return $this->getBase("policy", "policies.policy", $uuid); + } + + public function delPolicyAction($uuid) + { + return $this->delBase("policies.policy", $uuid); + } + + public function togglePolicyAction($uuid, $enabled = null) + { + return $this->toggleBase("policies.policy", $uuid, $enabled); + } + public function searchCustomPolicyAction() + { + return $this->searchBase("custom_policies.policy", array('enabled', 'description', 'action'), "description"); + } + + public function setCustomPolicyAction($uuid) + { + return $this->setBase("custom_policy", "custom_policies.policy", $uuid); + } + + public function addCustomPolicyAction() + { + return $this->addBase("custom_policy", "custom_policies.policy"); + } + + public function getCustomPolicyAction($uuid = null) + { + return $this->getBase("custom_policy", "custom_policies.policy", $uuid); + } + + public function delCustomPolicyAction($uuid) + { + return $this->delBase("custom_policies.policy", $uuid); + } + + public function toggleCustomPolicyAction($uuid, $enabled = null) + { + return $this->toggleBase("custom_policies.policy", $uuid, $enabled); + } + + public function applyAction() + { + if ($this->request->isPost()) { + $this->sessionClose(); + $backend = new Backend(); + $backend->configdRun('template reload Deciso/Proxy'); + $backend->configdRun('opnproxy sync_users'); + return array("status" => trim($backend->configdRun('opnproxy apply_policies'))); + } else { + return array("status" => "error"); + } + } + + public function testAction() + { + if ($this->request->isPost() && $this->request->hasPost('uri')) { + $src = $this->request->getPost('src', 'striptags', ''); + $src = !empty($src) ? $src : "-"; + $user = $this->request->getPost('user', null, ''); + $user = !empty($user) ? $user : "-"; + $this->sessionClose(); + $backend = new Backend(); + $response = $backend->configdpRun('opnproxy user test', [ + $user, $this->request->getPost('uri'), $src + ]); + $respose = json_decode($response, true); + if (!empty($response)) { + return $respose; + } + } + return array("status" => "error"); + } +} diff --git a/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/forms/dialogCustomPolicy.xml b/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/forms/dialogCustomPolicy.xml new file mode 100644 index 000000000..f7ad32c89 --- /dev/null +++ b/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/forms/dialogCustomPolicy.xml @@ -0,0 +1,40 @@ +
+ + custom_policy.enabled + + checkbox + Enable this item + + + custom_policy.applies_on + + select_multiple + ACL applies on selected users and groups. Users are prefixed with *, best use groups to structure policies + + + custom_policy.source_net + + select_multiple + + true + source ip or network, examples 10.0.0.0/24, 10.0.0.1 + + + custom_policy.action + + dropdown + Action to perform. + + + custom_policy.content + + textbox + List of domains and path entries, prefix with . to include subdomains (e.g. .com to block all .com domains). To match all use * + true + + + custom_policy.description + + text + +
diff --git a/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/forms/dialogDefaultPolicy.xml b/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/forms/dialogDefaultPolicy.xml new file mode 100644 index 000000000..275b6b657 --- /dev/null +++ b/www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/forms/dialogDefaultPolicy.xml @@ -0,0 +1,39 @@ +
+ + policy.enabled + + checkbox + Enable this item + + + policy.applies_on + + select_multiple + ACL applies on selected users and groups. Users are prefixed with *, best use groups to structure policies + + + policy.source_net + + select_multiple + + true + source ip or network, examples 10.0.0.0/24, 10.0.0.1 + + + policy.action + + dropdown + Action to perform. + + + policy.content + + select_multiple + List of standard categories + + + policy.description + + text + +
diff --git a/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/ACL.php b/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/ACL.php new file mode 100644 index 000000000..3f5bfa84f --- /dev/null +++ b/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/ACL.php @@ -0,0 +1,35 @@ + + //Deciso/Proxy/ACL + 1.0.0 + + OPNsense central management / Proxy module + + + + + + 1 + Y + + + Y + deny + + Deny + Allow + + + + Y + Y + + adult + aggressive + astrology + audio-video + bank + bitcoin + blog + celebrity + chat + child + cleaning + cooking + cryptojacking + dangerous_material + dating + ddos + doh + download + drugs + educational_games + filehosting + financial + forums + gambling + games + hacking + jobsearch + lingerie + malware + manga + marketingware + mixed_adult + mobile-phone + phishing + press + advertisements + radio + redirector + remote-control + sexual_education + shopping + shortener + social_networks + sports + stalkerware + translation + update + vpn + warez + webmail + + + + Y + N + You need to select at least one user or group for who this list applies + + + N + N + , + Y + + + Y + /^([\t\n\v\f\r 0-9a-zA-Z.\-,_\x{00A0}-\x{FFFF}]){1,255}$/u + Description should be a string between 1 and 255 characters + + + + + + + 1 + Y + + + Y + deny + + Deny + Allow + + + + Y + + + Y + N + You need to select at least one user or group for who this list applies + + + N + N + , + Y + + + Y + /^([\t\n\v\f\r 0-9a-zA-Z.\-,_\x{00A0}-\x{FFFF}]){1,255}$/u + Description should be a string between 1 and 255 characters + + + + + diff --git a/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/FieldTypes/CustomPolicyField.php b/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/FieldTypes/CustomPolicyField.php new file mode 100644 index 000000000..15637602a --- /dev/null +++ b/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/FieldTypes/CustomPolicyField.php @@ -0,0 +1,94 @@ +separatorchar, trim($data)) as $value) { + yield $value; + } + } + + /** + * retrieve field validators for this field type + * @return array + */ + public function getValidators() + { + $validators = parent::getValidators(); + if ($this->internalValue != null) { + $validators[] = new CallbackValidator(["callback" => function ($data) { + $messages = array(); + foreach ($this->getItems($data) as $item) { + $parts = explode("/", $item, 2); + $domain = substr($parts[0], 0, 1) == "." ? substr($parts[0], 1) : $parts[0]; + if ($item == "*") { + // explicit wildcard + continue; + } elseif ( + filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false && + filter_var($domain, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6) === false + ) { + $messages[] = sprintf( + gettext('Entry "%s" does not contain a valid domain or address.'), + $item + ); + } elseif (filter_var("https://{$domain}", FILTER_VALIDATE_URL) === false) { + $messages[] = sprintf( + gettext('Entry "%s" does not contain a valid path.'), + $item + ); + continue; + } + } + return $messages; + } + ]); + } + return $validators; + } +} diff --git a/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/FieldTypes/UserGroupField.php b/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/FieldTypes/UserGroupField.php new file mode 100644 index 000000000..b50ce3219 --- /dev/null +++ b/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/FieldTypes/UserGroupField.php @@ -0,0 +1,76 @@ +optionSetId(); + if (!isset(self::$internalCacheOptionList[$setid])) { + self::$internalCacheOptionList[$setid] = array(); + } + if (empty(self::$internalCacheOptionList[$setid])) { + $cnf = Config::getInstance()->object(); + foreach (['group', 'user'] as $topic) { + if (!empty($cnf->system->$topic)) { + foreach ($cnf->system->$topic as $node) { + $prefix = $topic == "user" ? "*" : ""; + $tp = $topic == "user" ? "u" : "g"; + self::$internalCacheOptionList[$setid][$tp . ":" . $node->name] = $prefix . $node->name; + } + } + } + ksort(self::$internalCacheOptionList[$setid]); + } + $this->internalOptionList = self::$internalCacheOptionList[$setid]; + } +} diff --git a/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/Menu/Menu.xml b/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/Menu/Menu.xml new file mode 100644 index 000000000..54400fe64 --- /dev/null +++ b/www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/Menu/Menu.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/www/OPNProxy/src/opnsense/mvc/app/views/Deciso/Proxy/acl.volt b/www/OPNProxy/src/opnsense/mvc/app/views/Deciso/Proxy/acl.volt new file mode 100644 index 000000000..9d25467fd --- /dev/null +++ b/www/OPNProxy/src/opnsense/mvc/app/views/Deciso/Proxy/acl.volt @@ -0,0 +1,191 @@ + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
{{ lang._('ID') }}{{ lang._('Enabled') }}{{ lang._('Description') }}{{ lang._('Action') }}{{ lang._('Commands') }}
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + +
{{ lang._('ID') }}{{ lang._('Enabled') }}{{ lang._('Description') }}{{ lang._('Action') }}{{ lang._('Commands') }}
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ lang._('Property') }}{{ lang._('Value') }}
{{ lang._('Username') }}
{{ lang._('Source') }}
{{ lang._('Uri') }}
+ +
+
+
+ +
+
+
+ +
+ +

+
+
+ + +{{ partial("layout_partials/base_dialog",['fields':formDialogDefaultPolicy,'id':'DialogDefaultPolicy','label':lang._('Edit List')])}} +{{ partial("layout_partials/base_dialog",['fields':formDialogCustomPolicy,'id':'DialogCustomPolicy','label':lang._('Edit List')])}} diff --git a/www/OPNProxy/src/opnsense/scripts/OPNProxy/download_cleanse_ut1.py b/www/OPNProxy/src/opnsense/scripts/OPNProxy/download_cleanse_ut1.py new file mode 100755 index 000000000..75cf4d94e --- /dev/null +++ b/www/OPNProxy/src/opnsense/scripts/OPNProxy/download_cleanse_ut1.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" + Copyright (c) 2023 Ad Schellevis + 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 os +import shutil +import sys +import tempfile +import tarfile +import io +import requests + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('filename', help='output filename') + cmd_args = parser.parse_args() + + req_opts = { + 'url': 'http://dsi.ut-capitole.fr/blacklists/download/blacklists.tar.gz', + 'timeout': 120, + 'stream': True + } + try: + req = requests.get(**req_opts) + except Exception as e: + print("unable to download %s" % req_opts['url']) + sys.exit(99) + + directory_map = { + 'blacklists/agressif': 'blacklists/aggressive', + 'blacklists/publicite': 'blacklists/advertisements', + 'blacklists/drogue': 'blacklists/drugs', + 'blacklists/tricheur': None, + 'blacklists/arjel': None, + 'blacklists/associations_religieuses': None, + 'blacklists/dialer': None, + 'blacklists/liste_bu': None, + 'blacklists/reaffected': None, + 'blacklists/strict_redirector': None, + 'blacklists/strong_redirector': None, + 'blacklists/sect': None, + + } + filenames = ['urls', 'domains', 'README', 'global_usage', 'cc-by-sa-4-0.pdf', 'LICENSE.pdf'] + + if 200 <= req.status_code <= 299: + with tempfile.NamedTemporaryFile() as tmp_stream: + shutil.copyfileobj(req.raw, tmp_stream) + tmp_stream.seek(0) + tf = tarfile.open(fileobj=tmp_stream) + with tarfile.open(cmd_args.filename, "w:gz") as tar_handle: + for tf_file in tf.getmembers(): + filename = os.path.basename(tf_file.name) + if tf_file.isreg() and filename in filenames: + target = tf_file.name + dirname = os.path.dirname(tf_file.name) + if dirname in directory_map: + if directory_map[dirname] is None: + continue + else: + target = "%s/%s" % (directory_map[dirname], filename) + fhandle = tf.extractfile(tf_file) + info = tarfile.TarInfo(target) + fhandle.seek(0, io.SEEK_END) + info.size = fhandle.tell() + fhandle.seek(0, io.SEEK_SET) + tar_handle.addfile(info, fhandle) + + tar_handle.close() diff --git a/www/OPNProxy/src/opnsense/scripts/OPNProxy/lib/__init__.py b/www/OPNProxy/src/opnsense/scripts/OPNProxy/lib/__init__.py new file mode 100755 index 000000000..95988b247 --- /dev/null +++ b/www/OPNProxy/src/opnsense/scripts/OPNProxy/lib/__init__.py @@ -0,0 +1,135 @@ +""" + Copyright (c) 2023 Ad Schellevis + 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 copy +import tarfile +import os +import stat +import syslog +import time +import requests +from configparser import ConfigParser + + +class Policy: + def __init__(self, policy_filename): + self._policy_config = policy_filename + self._domain_entries = dict() + self._policy_settings = dict() + self._tf = None + self.load() + + def load(self): + """ load policy database + :return: + """ + self._domain_entries = dict() + self._policy_settings = dict() + # collect all policies per domain, so we can safely overwrite existing content when it exists + cnf = ConfigParser() + cnf.read(self._policy_config) + if cnf.has_section('source'): + blocklist_filename = cnf.get('source', 'blocklist') + if cnf.has_option('source', 'blocklist_download_uri'): + blocklist_ttl = cnf.getint('source', 'blocklist_ttl') + if not os.path.isfile(blocklist_filename) or \ + time.time() - os.stat(blocklist_filename)[stat.ST_MTIME] > blocklist_ttl: + try: + response = requests.get(cnf.get('source', 'blocklist_download_uri'), stream=True) + response.raise_for_status() + with open(blocklist_filename, 'wb') as handle: + for block in response.iter_content(1024): + handle.write(block) + except requests.exceptions.RequestException as e: + # we are unable to download a new blocklist, if a previous version still exists keep using that + syslog.syslog(syslog.LOG_ERR, 'unable to download new blocklist (%s)' % e) + + if os.path.isfile(blocklist_filename) and tarfile.is_tarfile(blocklist_filename): + self._tf = tarfile.open(fileobj=open(blocklist_filename, "rb")) + else: + syslog.syslog(syslog.LOG_ERR, 'default policy rules not available (%s missing)' % blocklist_filename) + + for section in cnf.sections(): + if cnf.has_option(section, 'policy_type') and cnf.has_option(section, 'content'): + self._policy_settings[section] = { + 'action': cnf.get(section, 'action'), + 'id': section.split('_', 1)[-1], + 'applies_on': cnf.get(section, 'applies_on').split(','), + 'source_net': cnf.get(section, 'source_net').split(','), + 'policy_type': cnf.get(section, 'policy_type'), + 'description': cnf.get(section, 'description') + } + ittr_method = self._itr_default if cnf.get(section, 'policy_type') == "default" else self._itr_custom + split_char = ',' if cnf.get(section, 'policy_type') == "default" else '\n' + for is_wildcard, item in ittr_method(cnf.get(section, 'content').split(split_char)): + parts = item.split('/', 1) + domain = parts[0] + if domain not in self._domain_entries: + self._domain_entries[domain] = list() + self._domain_entries[domain].append([ + section, + "/%s" % parts[1] if len(parts) > 1 else "/", + is_wildcard + ]) + + def _itr_default(self, items: list): + if self._tf: + for tf_file in self._tf.getmembers(): + if tf_file.isreg(): + fhandle = self._tf.extractfile(tf_file) + if tf_file.name.count('/') >= 2 and tf_file.name.split('/')[-2] in items: + filename = os.path.basename(tf_file.name) + if filename in ['urls', 'domains']: + for line in fhandle.read().decode().split('\n'): + line = line.strip() + if line: + # assume domains are wildcards (e.g. youtube.com --> .youtube.com) + yield line.find('/') == -1, line + + @staticmethod + def _itr_custom(items: list): + for line in items: + if line.startswith('.') or line.startswith('*'): + # wildcard search, e.g. matches all subdomains of given domain, where * is the absolute toplevel (root) + yield True, line.lstrip('.') + else: + yield False, line + + def __iter__(self): + for domain in self._domain_entries: + # prepare domain policies + policy = { + 'domain': domain, + 'items': [] + } + for entry in self._domain_entries[domain]: + politem = copy.deepcopy(self._policy_settings[entry[0]]) + politem['path'] = entry[1] + politem['wildcard'] = entry[2] + policy['items'].append(politem) + yield policy + + def exists(self, domain): + return domain.split(':')[-1] in self._domain_entries diff --git a/www/OPNProxy/src/opnsense/scripts/OPNProxy/policies_to_redis_proto.py b/www/OPNProxy/src/opnsense/scripts/OPNProxy/policies_to_redis_proto.py new file mode 100755 index 000000000..0e4599d6c --- /dev/null +++ b/www/OPNProxy/src/opnsense/scripts/OPNProxy/policies_to_redis_proto.py @@ -0,0 +1,100 @@ +#!/usr/local/bin/python3 +# -*- coding: utf-8 -*- +""" + Copyright (c) 2023 Ad Schellevis + 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 fcntl +import time +import ujson +from lib import Policy +import redis + + +def redis_proto_parser(*args): + """ + https://redis.io/topics/protocol + :return: + """ + response = ["*%d\r\n$%d\r\n%s\r\n" % (len(args), len(args[0]), args[0])] + for item in args[1:]: + response.append("$%d\r\n%s\r\n" % (len(item), item)) + return "".join(response) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument( + '--redis_host', + help='redis hostname to read keys from (default: 127.0.0.1)', + default='127.0.0.1' + ) + parser.add_argument( + '--redis_port', + help='redis port number (default: 6379)', + type=int, + default=6379 + ) + parser.add_argument( + '--proxy_policies', + help='proxy policies configuration file', + default='/usr/local/etc/squid/proxy_policies.conf' + ) + parser.add_argument('--output', help='output filename', default='/dev/stdout') + + cmd_args = parser.parse_args() + + try: + lck = open('/tmp/policies_to_redis_proto.LCK', 'w+') + fcntl.flock(lck, fcntl.LOCK_EX | fcntl.LOCK_NB) + except IOError: + # already running, exit status 99 + sys.exit(99) + + policy = Policy(cmd_args.proxy_policies) + + # fetch current domain keys from redis + try: + existing_domains = redis.StrictRedis( + host=cmd_args.redis_host, port=cmd_args.redis_port, db=0, decode_responses=True + ).keys('domain:*') + except (redis.exceptions.ConnectionError, redis.exceptions.BusyLoadingError) as e: + existing_domains = list() + + with open(cmd_args.output, 'w') as output_stream: + statistics = {'domains': 0, 'policies': 0, 'generated': time.time()} + # generate delete statements for non existing keys + for domain in existing_domains: + domain = domain.split(':')[-1] + if not policy.exists(domain): + output_stream.write(redis_proto_parser("DEL", "domain:%s" % domain)) + + # generate set statements for new data (upsert) + for item in policy: + statistics['domains'] += 1 + statistics['policies'] += len(item['items']) + output_stream.write(redis_proto_parser("SET", "domain:%s" % item['domain'], ujson.dumps(item))) + + output_stream.write(redis_proto_parser("SET", "domain_statistics", ujson.dumps(statistics))) diff --git a/www/OPNProxy/src/opnsense/scripts/OPNProxy/redis_sync_users.py b/www/OPNProxy/src/opnsense/scripts/OPNProxy/redis_sync_users.py new file mode 100755 index 000000000..3e22e0909 --- /dev/null +++ b/www/OPNProxy/src/opnsense/scripts/OPNProxy/redis_sync_users.py @@ -0,0 +1,76 @@ +#!/usr/local/bin/python3 +# -*- coding: utf-8 -*- +""" + Copyright (c) 2023 Ad Schellevis + 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 fcntl +import sys +import syslog +import redis +import ujson +import xml.etree.ElementTree as ET + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--redis_host', help='redis hostname (default: 127.0.0.1)', default='127.0.0.1') + parser.add_argument('--redis_port', help='redis port number (default: 6379)', type=int, default=6379) + parser.add_argument('username', help='optional username', nargs='?', default=None) + args = parser.parse_args() + + # wait for other redis_sync_users sync events to complete + lck = open('/tmp/redis_sync_users.LCK', 'w+') + fcntl.flock(lck, fcntl.LOCK_EX) + + redisdb = redis.Redis(host=args.redis_host, port=args.redis_port, db=0) + + # ideally we would flush config data using the template system first, but since user settings may change + # more rappidly we opt to read the raw source here. + try: + tree = ET.parse('/conf/config.xml') + xmlroot = tree.getroot() + except (FileNotFoundError, ET.ParseError): + syslog.syslog(syslog.LOG_ERR, 'enable to open /conf/config.xml') + sys.exit(1) + + # merge group membership into user object and flush to redis + membership = dict() + for group in xmlroot.findall('./system/group'): + for member in group.findall('member'): + if member.text not in membership: + membership[member.text] = list() + membership[member.text].append(group.findtext('name')) + + for user in xmlroot.findall('./system/user'): + if args.username is None or args.username == user.findtext('name'): + user_object = dict() + user_object['uid'] = user.findtext('name') + user_object['id'] = user.findtext('uid') + user_object['applies_on'] = ["u:%s" % user.findtext('name')] + if user_object['id'] in membership: + for group in membership[user_object['id']]: + user_object['applies_on'].append("g:%s" % group) + redisdb.set('user:%s' % user_object['uid'], ujson.dumps(user_object)) diff --git a/www/OPNProxy/src/opnsense/scripts/OPNProxy/squid_acl_helper.py b/www/OPNProxy/src/opnsense/scripts/OPNProxy/squid_acl_helper.py new file mode 100755 index 000000000..17d4d87fa --- /dev/null +++ b/www/OPNProxy/src/opnsense/scripts/OPNProxy/squid_acl_helper.py @@ -0,0 +1,217 @@ +#!/usr/local/bin/python3 +# -*- coding: utf-8 -*- +""" + Copyright (c) 2023 Ad Schellevis + 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 decimal +import sys +import syslog +import traceback +from urllib.parse import urlparse +import redis +import ujson +import ipaddress + + +class RedisAuth: + def __init__(self, host, port): + self._redis = redis.Redis(host=host, port=port, db=0) + + def domain_policy_iterator(self, r_fqdn): + """ traverse domain policies + :param r_fqdn: fqdn + :return: + """ + try: + tmp = self._redis.get("domain:%s" % r_fqdn) + if tmp: + domain_policy = ujson.loads(tmp.decode()) + else: + return + except Exception as e: + # connectivity or parse issue, log and return + syslog.syslog(syslog.LOG_ERR, traceback.format_exc().replace('\n', ' ')) + return + + if type(domain_policy.get('items', None)) is list: + for policy in domain_policy['items']: + if type(policy) is dict: + for fieldname in ['id', 'path', 'wildcard', 'action', 'applies_on', 'source_net']: + if fieldname not in policy: + policy[fieldname] = None + yield policy + + def get_user(self, uid): + if uid == "-": + return {'applies_on': set('-')} + try: + tmp = self._redis.get("user:%s" % uid) + if not tmp: + return None + udata = ujson.loads(tmp.decode()) + # cleanse data + udata['applies_on'] = set(udata['applies_on']) if 'applies_on' in udata else set() + except Exception: + syslog.syslog(syslog.LOG_ERR, traceback.format_exc().replace('\n', ' ')) + return None + + return udata + +def in_network(src, networks): + if networks is None or type(networks) is not list or src == '-': + return True + try: + src_net = ipaddress.ip_network(src) + except ValueError: + syslog.syslog(syslog.LOG_ERR, traceback.format_exc().replace('\n', ' ')) + return False + for network in networks: + try: + if src_net.overlaps(ipaddress.ip_network(network)): + return True + except ValueError: + syslog.syslog(syslog.LOG_ERR, traceback.format_exc().replace('\n', ' ')) + + return False + +def match_policy(acl, ident, src, method, uri, sslurlonly=False): + # default response, invalid user + match_res = {'message': "ERR message=\"no (valid) IDENT %s\"\n" % ident} + if uri.find('://') == -1: + base_domain = uri.split(':')[0] + request_path = '/' + else: + uri_parsed = urlparse(uri) + base_domain = uri_parsed.netloc.split(':')[0] + request_path = uri_parsed.path if uri_parsed.path else '/' + + syslog.syslog( + syslog.LOG_NOTICE, + "ACL-REQ |%s| |%s| |%s| |%s| |%s| %s" % (acl, ident, src, method, uri, 'SNI only' if sslurlonly else '') + ) + fqdn = base_domain + user_data = redis_auth.get_user(ident) + if user_data: + acl_decisions = dict() + # traverse domain upwards until either a policy is found or no matches are possible + # matches are prioritized on best path match and accept (higher) or deny. + while len(acl_decisions) == 0: + for this_policy in redis_auth.domain_policy_iterator(fqdn): + is_parent = base_domain != fqdn + match_parent = this_policy['path'] == '/' and is_parent and this_policy['wildcard'] + match_main = request_path.find(this_policy['path']) == 0 and not is_parent + if (match_parent or match_main) and set(this_policy['applies_on']) & user_data['applies_on']: + if not in_network(src, this_policy['source_net']): + continue + tp = 0 if this_policy['action'] == 'deny' else 1 + this_prio = decimal.Decimal("%d.%d" % (len(this_policy['path']), tp)) + acl_decisions[this_prio] = this_policy + acl_decisions[this_prio]['domain'] = fqdn + + if fqdn.find('.') == -1: + if fqdn == '*': + break + else: + # top level wildcard (add extra level) + fqdn = '*' + else: + fqdn = fqdn.split('.', maxsplit=1)[1] + + match_res['user'] = user_data + match_res['user']['applies_on'] = list(user_data['applies_on']) + + if not sslurlonly and method.lower() == 'connect': + # skip connect when full ssl bump is enabled + match_res['policy'] = {'action': 'allow', 'policy_type': 'fallback'} + match_res['message'] = "OK user=\"%s\"\n" % ident + elif len(acl_decisions) > 0: + acl_decision = acl_decisions[sorted(acl_decisions.keys(), reverse=True)[0]] + match_res['policy'] = acl_decision + if match_res['policy']['action'] == 'deny': + match_res['message'] = "ERR message=\"reason:%s policy_type:%s\" user=\"%s\"\n" % ( + acl_decision['id'], acl_decision['policy_type'], ident + ) + else: + match_res['message'] = "OK message=\"whitelisted %s\" user=\"%s\"\n" % (acl_decision['id'], ident) + elif ident != '-': + # network only authentication needs an explicit policy, user-based allows by default + match_res['policy'] = {'action': 'allow', 'policy_type': 'fallback'} + match_res['message'] = "OK user=\"%s\"\n" % ident + + return match_res + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--test_user', help='test mode (singleshot), username') + parser.add_argument('--test_uri', help='test mode (singleshot), uri') + parser.add_argument('--test_src', help='test mode (singleshot), source address', default='-') + parser.add_argument('--redis_host', help='redis hostname (default: 127.0.0.1)', default='127.0.0.1') + parser.add_argument('--redis_port', help='redis port number (default: 6379)', type=int, default=6379) + parser.add_argument('--sslurlonly', help='Log SNI information only enabled', action="store_true", default=False) + parser.add_argument( + '--no_ident', + help='Do not expect iden/user information in the message line', + action="store_true", + default=False + ) + + args = parser.parse_args() + syslog.openlog('squid', facility=syslog.LOG_LOCAL2) + redis_auth = RedisAuth(args.redis_host, args.redis_port) + if args.test_user and args.test_uri: + # test mode, dump raw json object to stdout + result = match_policy(acl='-', ident=args.test_user, src=args.test_src, method='-', uri=args.test_uri) + print (ujson.dumps(result)) + else: + # squid worker mode + while True: + try: + # accept messages like: + # my_ext_acl user 127.0.0.2 GET https://requested.domain/path/ + line = sys.stdin.readline().strip() + if line == "": + sys.exit() + if line: + try: + acl_parts = line.split() + except ValueError: + sys.stdout.write("ERR message=\"missing input\"\n") + break + offset = -1 if args.no_ident else 0 + result = match_policy( + acl=acl_parts[0], + ident='-' if args.no_ident else acl_parts[1], + src=acl_parts[2+offset], + method=acl_parts[3+offset], + uri=acl_parts[4+offset], + sslurlonly=args.sslurlonly + ) + sys.stdout.write(result['message']) + + sys.stdout.flush() + except IOError: + pass diff --git a/www/OPNProxy/src/opnsense/service/conf/actions.d/actions_opnproxy.conf b/www/OPNProxy/src/opnsense/service/conf/actions.d/actions_opnproxy.conf new file mode 100644 index 000000000..bafa0f699 --- /dev/null +++ b/www/OPNProxy/src/opnsense/service/conf/actions.d/actions_opnproxy.conf @@ -0,0 +1,21 @@ +[apply_policies] +command: + /usr/local/opnsense/scripts/OPNProxy/policies_to_redis_proto.py | redis-cli --pipe && + /usr/local/sbin/squid -k reconfigure +parameters: +type:script +message:download proxy policies and apply to redisdb +description:OPNProxy apply policies + + +[sync_users] +command: /usr/local/opnsense/scripts/OPNProxy/redis_sync_users.py +parameters: +type:script +message:synchronise proxy users + +[user.test] +command: /usr/local/opnsense/scripts/OPNProxy/squid_acl_helper.py +parameters: --test_user %s --test_uri %s --test_src %s +type:script_output +message:test user login diff --git a/www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/+TARGETS b/www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/+TARGETS new file mode 100644 index 000000000..b9419e8ae --- /dev/null +++ b/www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/+TARGETS @@ -0,0 +1,2 @@ +proxy_policies.conf:/usr/local/etc/squid/proxy_policies.conf +10-opnproxy-ext.auth.conf:/usr/local/etc/squid/auth/10-opnproxy-ext.auth.conf diff --git a/www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/10-opnproxy-ext.auth.conf b/www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/10-opnproxy-ext.auth.conf new file mode 100644 index 000000000..408895ad8 --- /dev/null +++ b/www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/10-opnproxy-ext.auth.conf @@ -0,0 +1,43 @@ +external_acl_type ext_opnproxy_helper_net ttl=30 negative_ttl=5 %ACL %SRC %METHOD %URI /usr/local/opnsense/scripts/OPNProxy/squid_acl_helper.py --no_ident {% if not helpers.empty('OPNsense.proxy.forward.sslurlonly') %} --sslurlonly {% endif %} + +acl opnproxy_ext_acl_net external ext_opnproxy_helper_net +http_access allow opnproxy_ext_acl_net + +{% if not helpers.empty('OPNsense.proxy.forward.authentication.method') %} +# Login based authentication +external_acl_type ext_opnproxy_helper_usr ttl=30 negative_ttl=5 %ACL %LOGIN %SRC %METHOD %URI /usr/local/opnsense/scripts/OPNProxy/squid_acl_helper.py {% if not helpers.empty('OPNsense.proxy.forward.sslurlonly') %} --sslurlonly {% endif %} + +acl opnproxy_ext_acl_usr external ext_opnproxy_helper_usr +http_access allow opnproxy_ext_acl_usr +{% endif %} + + +{% if not helpers.empty('OPNsense.proxy.forward.icap.enable') %} +{% if not helpers.empty('OPNsense.proxy.forward.icap.ResponseURL') %} +adaptation_access response_mod allow opnproxy_ext_acl_net +{% if not helpers.empty('OPNsense.proxy.forward.authentication.method') %} +adaptation_access response_mod allow opnproxy_ext_acl_usr +{% endif %} +{% endif %} +{% if not helpers.empty('OPNsense.proxy.forward.icap.RequestURL') %} +adaptation_access request_mod allow opnproxy_ext_acl_net +{% if not helpers.empty('OPNsense.proxy.forward.authentication.method') %} +adaptation_access request_mod allow opnproxy_ext_acl_usr +{% endif %} +{% endif %} +{% endif %} + +{% if not helpers.empty('OPNsense.proxy.forward.authentication.method') %} +# explicit disable default allow authenticated users clause +http_access deny local_auth all +{% if not helpers.empty('OPNsense.proxy.forward.icap.enable') %} +{% if not helpers.empty('OPNsense.proxy.forward.icap.ResponseURL') %} +adaptation_access response_mod deny local_auth +{% endif %} +{% if not helpers.empty('OPNsense.proxy.forward.icap.RequestURL') %} +adaptation_access request_mod deny local_auth +{% endif %} +{% endif %} +{% else %} +http_access deny localnet +{% endif %} diff --git a/www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/proxy_policies.conf b/www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/proxy_policies.conf new file mode 100644 index 000000000..ce330f22d --- /dev/null +++ b/www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/proxy_policies.conf @@ -0,0 +1,30 @@ +{% for policy in helpers.toList('Deciso.Proxy.ACL.policies.policy') %} +{% if policy.enabled|default('0') == '1' %} +[policy_{{ policy['@uuid'] }}] +policy_type=default +description={{ policy.description }} +content={{ policy.content }} +applies_on={{ policy.applies_on|default('-') }} +source_net={{ policy.source_net }} +action={{ policy.action }} +{% endif %} + +{% endfor %} + +{% for policy in helpers.toList('Deciso.Proxy.ACL.custom_policies.policy') %} +{% if policy.enabled|default('0') == '1' %} +[policy_{{ policy['@uuid'] }}] +policy_type=custom +description={{ policy.description }} +content={{ policy.content.replace('\n', '\n\t') }} +applies_on={{ policy.applies_on|default('-') }} +source_net={{ policy.source_net }} +action={{ policy.action }} +{% endif %} + +{% endfor %} + +[source] +blocklist=/usr/local/opnsense/data/proxy/blocklists.tar.gz +blocklist_download_uri=https://rulesets.opnsense.org/proxy/blocklists.tar.gz +blocklist_ttl=86300