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
This commit is contained in:
Ad Schellevis 2024-04-08 10:56:51 +02:00
parent 57ae4626a2
commit e15b18a565
23 changed files with 1575 additions and 0 deletions

View file

@ -0,0 +1,2 @@
#!/bin/sh
rm /usr/local/etc/squid/auth/10-opnproxy-ext.auth.conf

10
www/OPNProxy/Makefile Normal file
View file

@ -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"

10
www/OPNProxy/pkg-descr Normal file
View file

@ -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

View file

@ -0,0 +1,50 @@
<?php
/*
* Copyright (C) 2023 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.
*/
function opnproxy_configure()
{
return array(
'user_changed' => ['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));
}
}

View file

@ -0,0 +1,40 @@
<?php
/*
* Copyright (C) 2024 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.
*/
namespace OPNsense\Proxy;
class AclController extends \OPNsense\Base\IndexController
{
public function indexAction()
{
$this->view->pick('Deciso/Proxy/acl');
$this->view->formDialogDefaultPolicy = $this->getForm("dialogDefaultPolicy");
$this->view->formDialogCustomPolicy = $this->getForm("dialogCustomPolicy");
}
}

View file

@ -0,0 +1,131 @@
<?php
/*
* Copyright (C) 2023 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.
*/
namespace OPNsense\Proxy\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
use OPNsense\Core\Backend;
class AclController extends ApiMutableModelControllerBase
{
protected static $internalModelName = 'proxy';
protected static $internalModelClass = 'Deciso\Proxy\ACL';
public function searchPolicyAction()
{
return $this->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");
}
}

View file

@ -0,0 +1,40 @@
<form>
<field>
<id>custom_policy.enabled</id>
<label>enabled</label>
<type>checkbox</type>
<help>Enable this item</help>
</field>
<field>
<id>custom_policy.applies_on</id>
<label>User / Group</label>
<type>select_multiple</type>
<help>ACL applies on selected users and groups. Users are prefixed with *, best use groups to structure policies</help>
</field>
<field>
<id>custom_policy.source_net</id>
<label>Source</label>
<type>select_multiple</type>
<style>tokenize</style>
<allownew>true</allownew>
<help>source ip or network, examples 10.0.0.0/24, 10.0.0.1</help>
</field>
<field>
<id>custom_policy.action</id>
<label>Action</label>
<type>dropdown</type>
<help>Action to perform.</help>
</field>
<field>
<id>custom_policy.content</id>
<label>Content</label>
<type>textbox</type>
<help>List of domains and path entries, prefix with . to include subdomains (e.g. .com to block all .com domains). To match all use *</help>
<allownew>true</allownew>
</field>
<field>
<id>custom_policy.description</id>
<label>Description</label>
<type>text</type>
</field>
</form>

View file

@ -0,0 +1,39 @@
<form>
<field>
<id>policy.enabled</id>
<label>enabled</label>
<type>checkbox</type>
<help>Enable this item</help>
</field>
<field>
<id>policy.applies_on</id>
<label>User / Group</label>
<type>select_multiple</type>
<help>ACL applies on selected users and groups. Users are prefixed with *, best use groups to structure policies</help>
</field>
<field>
<id>policy.source_net</id>
<label>Source</label>
<type>select_multiple</type>
<style>tokenize</style>
<allownew>true</allownew>
<help>source ip or network, examples 10.0.0.0/24, 10.0.0.1</help>
</field>
<field>
<id>policy.action</id>
<label>Action</label>
<type>dropdown</type>
<help>Action to perform.</help>
</field>
<field>
<id>policy.content</id>
<label>Content</label>
<type>select_multiple</type>
<help>List of standard categories</help>
</field>
<field>
<id>policy.description</id>
<label>Description</label>
<type>text</type>
</field>
</form>

View file

@ -0,0 +1,35 @@
<?php
/*
* Copyright (C) 2023 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.
*/
namespace Deciso\Proxy;
use OPNsense\Base\BaseModel;
class ACL extends BaseModel
{
}

View file

@ -0,0 +1,132 @@
<model>
<mount>//Deciso/Proxy/ACL</mount>
<version>1.0.0</version>
<description>
OPNsense central management / Proxy module
</description>
<items>
<policies>
<policy type="ArrayField">
<enabled type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</enabled>
<action type="OptionField">
<Required>Y</Required>
<Default>deny</Default>
<OptionValues>
<deny>Deny</deny>
<allow>Allow</allow>
</OptionValues>
</action>
<content type="OptionField">
<Required>Y</Required>
<Multiple>Y</Multiple>
<OptionValues>
<adult>adult</adult>
<aggressive>aggressive</aggressive>
<astrology>astrology</astrology>
<audio-video>audio-video</audio-video>
<bank>bank</bank>
<bitcoin>bitcoin</bitcoin>
<blog>blog</blog>
<celebrity>celebrity</celebrity>
<chat>chat</chat>
<child>child</child>
<cleaning>cleaning</cleaning>
<cooking>cooking</cooking>
<cryptojacking>cryptojacking</cryptojacking>
<dangerous_material>dangerous_material</dangerous_material>
<dating>dating</dating>
<ddos>ddos</ddos>
<doh>doh</doh>
<download>download</download>
<drugs>drugs</drugs>
<educational_games>educational_games</educational_games>
<filehosting>filehosting</filehosting>
<financial>financial</financial>
<forums>forums</forums>
<gambling>gambling</gambling>
<games>games</games>
<hacking>hacking</hacking>
<jobsearch>jobsearch</jobsearch>
<lingerie>lingerie</lingerie>
<malware>malware</malware>
<manga>manga</manga>
<marketingware>marketingware</marketingware>
<mixed_adult>mixed_adult</mixed_adult>
<mobile-phone>mobile-phone</mobile-phone>
<phishing>phishing</phishing>
<press>press</press>
<advertisements>advertisements</advertisements>
<radio>radio</radio>
<redirector>redirector</redirector>
<remote-control>remote-control</remote-control>
<sexual_education>sexual_education</sexual_education>
<shopping>shopping</shopping>
<shortener>shortener</shortener>
<social_networks>social_networks</social_networks>
<sports>sports</sports>
<stalkerware>stalkerware</stalkerware>
<translation>translation</translation>
<update>update</update>
<vpn>vpn</vpn>
<warez>warez</warez>
<webmail>webmail</webmail>
</OptionValues>
</content>
<applies_on type=".\UserGroupField">
<Multiple>Y</Multiple>
<Required>N</Required>
<ValidationMessage>You need to select at least one user or group for who this list applies</ValidationMessage>
</applies_on>
<source_net type="NetworkField">
<Required>N</Required>
<WildcardEnabled>N</WildcardEnabled>
<FieldSeparator>,</FieldSeparator>
<asList>Y</asList>
</source_net>
<description type="TextField">
<Required>Y</Required>
<mask>/^([\t\n\v\f\r 0-9a-zA-Z.\-,_\x{00A0}-\x{FFFF}]){1,255}$/u</mask>
<ValidationMessage>Description should be a string between 1 and 255 characters</ValidationMessage>
</description>
</policy>
</policies>
<custom_policies>
<policy type="ArrayField">
<enabled type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</enabled>
<action type="OptionField">
<Required>Y</Required>
<Default>deny</Default>
<OptionValues>
<deny>Deny</deny>
<allow>Allow</allow>
</OptionValues>
</action>
<content type=".\CustomPolicyField">
<Required>Y</Required>
</content>
<applies_on type=".\UserGroupField">
<Multiple>Y</Multiple>
<Required>N</Required>
<ValidationMessage>You need to select at least one user or group for who this list applies</ValidationMessage>
</applies_on>
<source_net type="NetworkField">
<Required>N</Required>
<WildcardEnabled>N</WildcardEnabled>
<FieldSeparator>,</FieldSeparator>
<asList>Y</asList>
</source_net>
<description type="TextField">
<Required>Y</Required>
<mask>/^([\t\n\v\f\r 0-9a-zA-Z.\-,_\x{00A0}-\x{FFFF}]){1,255}$/u</mask>
<ValidationMessage>Description should be a string between 1 and 255 characters</ValidationMessage>
</description>
</policy>
</custom_policies>
</items>
</model>

View file

@ -0,0 +1,94 @@
<?php
/*
* Copyright (C) 2023 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.
*/
namespace Deciso\Proxy\FieldTypes;
use OPNsense\Base\FieldTypes\BaseField;
use OPNsense\Base\Validators\CallbackValidator;
use OPNsense\Core\Config;
/**
* Class UserGroupField
*/
class CustomPolicyField extends BaseField
{
protected $internalIsContainer = false;
protected $internalValidationMessage = "invalid domain and path combination";
private $separatorchar = "\n";
/**
* split and yield items
* @param array $data to validate
* @return \Generator
*/
private function getItems($data)
{
foreach (explode($this->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;
}
}

View file

@ -0,0 +1,76 @@
<?php
/*
* Copyright (C) 2023 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.
*/
namespace Deciso\Proxy\FieldTypes;
use OPNsense\Base\FieldTypes\BaseListField;
use OPNsense\Core\Config;
/**
* Class UserGroupField
*/
class UserGroupField extends BaseListField
{
/**
* @var array collected options
*/
private static $internalCacheOptionList = array();
/**
* @return string identifying selected options
*/
private function optionSetId()
{
return "0";
}
/**
* generate validation data (list of countries)
*/
protected function actionPostLoadingEvent()
{
$setid = $this->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];
}
}

View file

@ -0,0 +1,8 @@
<menu>
<Services>
<SquidWebProxy>
<ACL VisibleName="Access control" order="15" url="/ui/proxy/acl">
</ACL>
</SquidWebProxy>
</Services>
</menu>

View file

@ -0,0 +1,191 @@
<script>
$( document ).ready(function() {
$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
$("#apply_div").show();
if (e.target.id == 'policies_tab') {
$("#grid-policies").UIBootgrid(
{ search:'/api/proxy/acl/searchPolicy/',
get:'/api/proxy/acl/getPolicy/',
set:'/api/proxy/acl/setPolicy/',
add:'/api/proxy/acl/addPolicy/',
del:'/api/proxy/acl/delPolicy/',
toggle:'/api/proxy/acl/togglePolicy/'
}
);
} else if (e.target.id == 'custom_policies_tab') {
$("#grid-custom_policies").UIBootgrid(
{ search:'/api/proxy/acl/searchCustomPolicy/',
get:'/api/proxy/acl/getCustomPolicy/',
set:'/api/proxy/acl/setCustomPolicy/',
add:'/api/proxy/acl/addCustomPolicy/',
del:'/api/proxy/acl/delCustomPolicy/',
toggle:'/api/proxy/acl/toggleCustomPolicy/'
}
);
} else if (e.target.id == 'policy_tester_tab') {
$("#apply_div").hide();
}
});
$("#reconfigureAct").SimpleActionButton();
$("#tester_exec").click(function(){
$("#tester_exec_spinner").show();
ajaxCall('/api/proxy/acl/test', {'user': $("#tester_name").val(), 'uri': $("#tester_uri").val(), 'src': $("#tester_src").val()}, function(data, status){
$("#policy_tester_result").empty();
$("#policy_tester_result").append($("<span/>").text("{{lang._('Result')}}"));
if (data.user !== undefined || data.message !== undefined) {
$("#policy_tester_result").append($("<pre style='white-space: pre-wrap; word-break: keep-all;'/>").text(JSON.stringify(data, null, 2)));
} else {
$("#policy_tester_result").append($("<span/>").text("-"));
}
$("#tester_exec_spinner").hide();
});
});
// update history on tab state and implement navigation
if (window.location.hash != "") {
$('a[href="' + window.location.hash + '"]').click();
} else {
$('a[href="#policies"]').click();
}
$('.nav-tabs a').on('shown.bs.tab', function (e) {
history.pushState(null, null, e.target.hash);
});
// Extended policies depend on redis
ajaxGet('/api/redis/service/status', {}, function(data, status){
if (data.status !== "running") {
BootstrapDialog.show({
type:BootstrapDialog.TYPE_WARNING,
title: "{{ lang._('ACL')}}",
message: $("#redis_message").html()
});
}
});
});
</script>
<style>
#custom_policy\.content {
white-space: nowrap;
height: 300px;
}
</style>
<div id="redis_message" style="display:none">
{{ lang._('The Redis service is not active, make sure to configure it via : %s Services -> Redis %s first')|format('<a href="/ui/redis">', '</a>') }}
</div>
<ul class="nav nav-tabs" data-tabs="tabs" id="maintabs">
<li><a data-toggle="tab" id="policies_tab" href="#policies">{{ lang._('Default Policies') }}</a></li>
<li><a data-toggle="tab" id="custom_policies_tab" href="#custom_policies">{{ lang._('Custom policies') }}</a></li>
<li><a data-toggle="tab" id="policy_tester_tab" href="#policy_tester">{{ lang._('Policy tester') }}</a></li>
</ul>
<div class="tab-content content-box">
<div id="policies" class="tab-pane fade in">
<!-- tab page "standard policies" -->
<table id="grid-policies" class="table table-condensed table-hover table-striped" data-editDialog="DialogDefaultPolicy" data-editAlert="PolicyChangeMessage">
<thead>
<tr>
<th data-column-id="uuid" data-type="string" data-identifier="true" data-visible="false">{{ lang._('ID') }}</th>
<th data-column-id="enabled" data-width="6em" data-type="string" data-formatter="rowtoggle">{{ lang._('Enabled') }}</th>
<th data-column-id="description" data-type="string">{{ lang._('Description') }}</th>
<th data-column-id="action" data-type="string">{{ lang._('Action') }}</th>
<th data-column-id="commands" data-width="7em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<button data-action="add" type="button" class="btn btn-xs btn-default"><span class="fa fa-plus"></span></button>
<button data-action="deleteSelected" type="button" class="btn btn-xs btn-default"><span class="fa fa-trash-o"></span></button>
</td>
</tr>
</tfoot>
</table>
</div>
<div id="custom_policies" class="tab-pane fade in">
<!-- tab page "custom_policies" -->
<table id="grid-custom_policies" class="table table-condensed table-hover table-striped" data-editDialog="DialogCustomPolicy" data-editAlert="PolicyChangeMessage">
<thead>
<tr>
<th data-column-id="uuid" data-type="string" data-identifier="true" data-visible="false">{{ lang._('ID') }}</th>
<th data-column-id="enabled" data-width="6em" data-type="string" data-formatter="rowtoggle">{{ lang._('Enabled') }}</th>
<th data-column-id="description" data-type="string">{{ lang._('Description') }}</th>
<th data-column-id="action" data-type="string">{{ lang._('Action') }}</th>
<th data-column-id="commands" data-width="7em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<button data-action="add" type="button" class="btn btn-xs btn-default"><span class="fa fa-plus"></span></button>
<button data-action="deleteSelected" type="button" class="btn btn-xs btn-default"><span class="fa fa-trash-o"></span></button>
</td>
</tr>
</tfoot>
</table>
</div>
<div id="policy_tester" class="tab-pane fade in">
<div class="col-md-12">
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>{{ lang._('Property') }}</th>
<th>{{ lang._('Value') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ lang._('Username') }}</td>
<td><input type="text" id='tester_name'></td>
</tr>
<tr>
<td>{{ lang._('Source') }}</td>
<td><input type="text" id='tester_src'></td>
</tr>
<tr>
<td>{{ lang._('Uri') }}</td>
<td><input type="text" id='tester_uri'></td>
</tr>
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<button class="btn btn-primary" id="tester_exec">
{{ lang._('Test') }}
<i id="tester_exec_spinner" class="fa fa-spinner fa-pulse" aria-hidden="true" style="display:none;"></i>
</button>
</td>
</tr>
</tfoot>
</table>
<div id="policy_tester_result">
</div>
</table>
</div>
</div>
<div class="col-md-12" id="apply_div">
<div id="PolicyChangeMessage" class="alert alert-info" style="display: none" role="alert">
{{ lang._('After changing settings, please remember to apply them with the button below') }}
</div>
<hr/>
<button class="btn btn-primary" id="reconfigureAct"
data-endpoint='/api/proxy/acl/apply'
data-label="{{ lang._('Apply') }}"
data-error-title="{{ lang._('Error configuring policies') }}"
type="button"
></button>
<br/><br/>
</div>
</div>
{{ 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')])}}

View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
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()

View file

@ -0,0 +1,135 @@
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
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

View file

@ -0,0 +1,100 @@
#!/usr/local/bin/python3
# -*- coding: utf-8 -*-
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
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)))

View file

@ -0,0 +1,76 @@
#!/usr/local/bin/python3
# -*- coding: utf-8 -*-
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
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))

View file

@ -0,0 +1,217 @@
#!/usr/local/bin/python3
# -*- coding: utf-8 -*-
"""
Copyright (c) 2023 Ad Schellevis <ad@opnsense.org>
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

View file

@ -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

View file

@ -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

View file

@ -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 %}

View file

@ -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