From e15b18a565ad2901dd564cf1dd4e853233fcad2b Mon Sep 17 00:00:00 2001 From: Ad Schellevis Date: Mon, 8 Apr 2024 10:56:51 +0200 Subject: [PATCH] www/OPNProxy - move proxy access management feature to the community version. This plugin uses squid's external acl helpers and redis to query policies quicker and more lightweight. It has been part of the business edition for some time, but due to recent changes, it makes sense to add it to the community version as well. Due to the redis requirement, we keep it a separate plugin, so existing setups won't start to pull redis in unexpected. current documentation: https://docs.opnsense.org/vendor/deciso/opnproxy.html --- www/OPNProxy/+POST_DEINSTALL.post | 2 + www/OPNProxy/Makefile | 10 + www/OPNProxy/pkg-descr | 10 + .../src/etc/inc/plugins.inc.d/opnproxy.inc | 50 ++++ .../OPNsense/Proxy/AclController.php | 40 ++++ .../OPNsense/Proxy/Api/AclController.php | 131 +++++++++++ .../Proxy/forms/dialogCustomPolicy.xml | 40 ++++ .../Proxy/forms/dialogDefaultPolicy.xml | 39 ++++ .../mvc/app/models/Deciso/Proxy/ACL.php | 35 +++ .../mvc/app/models/Deciso/Proxy/ACL.xml | 132 +++++++++++ .../Proxy/FieldTypes/CustomPolicyField.php | 94 ++++++++ .../Proxy/FieldTypes/UserGroupField.php | 76 ++++++ .../mvc/app/models/Deciso/Proxy/Menu/Menu.xml | 8 + .../mvc/app/views/Deciso/Proxy/acl.volt | 191 +++++++++++++++ .../scripts/OPNProxy/download_cleanse_ut1.py | 93 ++++++++ .../opnsense/scripts/OPNProxy/lib/__init__.py | 135 +++++++++++ .../OPNProxy/policies_to_redis_proto.py | 100 ++++++++ .../scripts/OPNProxy/redis_sync_users.py | 76 ++++++ .../scripts/OPNProxy/squid_acl_helper.py | 217 ++++++++++++++++++ .../conf/actions.d/actions_opnproxy.conf | 21 ++ .../service/templates/Deciso/Proxy/+TARGETS | 2 + .../Deciso/Proxy/10-opnproxy-ext.auth.conf | 43 ++++ .../Deciso/Proxy/proxy_policies.conf | 30 +++ 23 files changed, 1575 insertions(+) create mode 100644 www/OPNProxy/+POST_DEINSTALL.post create mode 100644 www/OPNProxy/Makefile create mode 100644 www/OPNProxy/pkg-descr create mode 100644 www/OPNProxy/src/etc/inc/plugins.inc.d/opnproxy.inc create mode 100644 www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/AclController.php create mode 100644 www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/Api/AclController.php create mode 100644 www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/forms/dialogCustomPolicy.xml create mode 100644 www/OPNProxy/src/opnsense/mvc/app/controllers/OPNsense/Proxy/forms/dialogDefaultPolicy.xml create mode 100644 www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/ACL.php create mode 100644 www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/ACL.xml create mode 100644 www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/FieldTypes/CustomPolicyField.php create mode 100644 www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/FieldTypes/UserGroupField.php create mode 100644 www/OPNProxy/src/opnsense/mvc/app/models/Deciso/Proxy/Menu/Menu.xml create mode 100644 www/OPNProxy/src/opnsense/mvc/app/views/Deciso/Proxy/acl.volt create mode 100755 www/OPNProxy/src/opnsense/scripts/OPNProxy/download_cleanse_ut1.py create mode 100755 www/OPNProxy/src/opnsense/scripts/OPNProxy/lib/__init__.py create mode 100755 www/OPNProxy/src/opnsense/scripts/OPNProxy/policies_to_redis_proto.py create mode 100755 www/OPNProxy/src/opnsense/scripts/OPNProxy/redis_sync_users.py create mode 100755 www/OPNProxy/src/opnsense/scripts/OPNProxy/squid_acl_helper.py create mode 100644 www/OPNProxy/src/opnsense/service/conf/actions.d/actions_opnproxy.conf create mode 100644 www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/+TARGETS create mode 100644 www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/10-opnproxy-ext.auth.conf create mode 100644 www/OPNProxy/src/opnsense/service/templates/Deciso/Proxy/proxy_policies.conf 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