Firewall / Aliases - Allow to create firewall rules for logged in OpenVPN user groups. (https://github.com/opnsense/core/issues/6312) (#6367)

o extend model with authgroup type (currently only for OpenVPN)
o add controller action to list user groups
o modify alias form to show group list in a similar way as network groups, simplify some of the code to prevent copying.
o add AuthGroup parser to glue the output of list_group_members.php and ovpn_status.py to a set of addresses per group for our new authgroup alias type to use
o hook 'learn-address' event in openvpn to trigger an alias update

Although theoretically we could pass addresses and common_names from learn-address further in our pipeline, for now we choose to use a common approach which should always offer the correct dataset (also after changing aliases and re-applying them). If for some reason this isn't fast enough, there are always options available to improve the situation, but usually at a cost in terms of complexity.
This commit is contained in:
Ad Schellevis 2023-03-01 14:47:19 +01:00 committed by GitHub
parent 0aa9e0bea0
commit bee2f8929f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 219 additions and 27 deletions

View File

@ -666,6 +666,7 @@ function openvpn_reconfigure($mode, $settings, $device_only = false)
}
if (!empty($settings['authmode'])) {
$conf .= "auth-user-pass-verify \"/usr/local/opnsense/scripts/openvpn/ovpn_event.py '{$vpnid}'\" via-env\n";
$conf .= "learn-address \"/usr/local/opnsense/scripts/openvpn/ovpn_event.py '{$vpnid}'\"\n";
}
break;
}

View File

@ -66,13 +66,32 @@ class AliasController extends ApiMutableModelControllerBase
$filter_funct
);
// append category uuid's so we can use these in the frontend
$tmp = [];
/**
* remap some source data from the model as searchBase() is not able to distinct this.
* - category uuid's
* - unix group id's to names in content fields
*/
$categories = [];
$types = [];
foreach ($this->getModel()->aliases->alias->iterateItems() as $key => $alias) {
$tmp[$key] = !empty((string)$alias->categories) ? explode(',', (string)$alias->categories) : [];
$categories[$key] = !empty((string)$alias->categories) ? explode(',', (string)$alias->categories) : [];
$types[$key] = (string)$alias->type;
}
$group_mapping = null;
foreach ($result['rows'] as &$record) {
$record['categories_uuid'] = $tmp[$record['uuid']];
$record['categories_uuid'] = $categories[$record['uuid']];
if ($types[$record['uuid']] == 'authgroup') {
if ($group_mapping === null) {
$group_mapping = $this->listUserGroupsAction();
}
$groups = [];
foreach (explode(',', $record['content']) as $grp) {
if (isset($group_mapping[$grp])) {
$groups[] = $group_mapping[$grp]['name'];
}
}
$record['content'] = implode(',', $groups);
}
}
return $result;
@ -266,6 +285,29 @@ class AliasController extends ApiMutableModelControllerBase
return $result;
}
/**
* list user groups
* @return array user groups
*/
public function listUserGroupsAction()
{
$result = [];
$cnf = Config::getInstance()->object();
if (isset($cnf->system->group)) {
foreach ($cnf->system->group as $group) {
$name = (string)$group->name;
if ($name != 'all') {
$result[(string)$group->gid] = [
"name" => $name,
"gid" => (string)$group->gid
];
}
}
ksort($result);
}
return $result;
}
/**
* list network alias types
* @return array indexed by country alias name

View File

@ -38,6 +38,7 @@
<mac>MAC address</mac>
<asn>BGP ASN</asn>
<dynipv6host>Dynamic IPv6 Host</dynipv6host>
<authgroup>OpenVPN group</authgroup>
<internal>Internal (automatic)</internal>
<external>External (advanced)</external>
</OptionValues>

View File

@ -32,6 +32,7 @@ use OPNsense\Base\FieldTypes\BaseField;
use OPNsense\Base\Validators\CallbackValidator;
use OPNsense\Phalcon\Filter\Validation\Validator\Regex;
use OPNsense\Phalcon\Filter\Validation\Validator\ExclusionIn;
use OPNsense\Core\Config;
use Phalcon\Messages\Message;
use OPNsense\Firewall\Util;
@ -54,7 +55,12 @@ class AliasContentField extends BaseField
/**
* @var array list of known countries
*/
private static $internalCountryCodes = array();
private static $internalCountryCodes = [];
/**
* @var array list of known user groups
*/
private static $internalAuthGroups = [];
/**
* item separator
@ -117,6 +123,23 @@ class AliasContentField extends BaseField
return self::$internalCountryCodes;
}
/**
* fetch valid user groups
* @return array valid groups
*/
public function getUserGroups()
{
if (empty(self::$internalAuthGroups)) {
$cnf = Config::getInstance()->object();
if (isset($cnf->system->group)) {
foreach ($cnf->system->group as $group) {
self::$internalAuthGroups[(string)$group->gid] = (string)$group->name;
}
}
}
return self::$internalAuthGroups;
}
/**
* Validate port alias options
* @param array $data to validate
@ -304,6 +327,25 @@ class AliasContentField extends BaseField
return $messages;
}
/**
* Validate (partial) mac address options
* @param array $data to validate
* @return array
* @throws \OPNsense\Base\ModelException
*/
private function validateGroups($data)
{
$messages = [];
$all_groups = $this->getUserGroups();
foreach ($this->getItems($data) as $group) {
if (!isset($all_groups[$group])) {
$messages[] = sprintf(gettext('Entry "%s" is not a valid group id.'), $group);
}
}
return $messages;
}
//
/**
* retrieve field validators for this field type
* @return array
@ -361,6 +403,12 @@ class AliasContentField extends BaseField
}
]);
break;
case "authgroup":
$validators[] = new CallbackValidator(["callback" => function ($data) {
return $this->validateGroups($data);
}
]);
break;
default:
break;
}

View File

@ -96,7 +96,7 @@
$("#grid-aliases").bootgrid().on("loaded.rs.jquery.bootgrid", function (e){
// network content field should only contain valid aliases, we need to fetch them separately
// since the form field misses context
ajaxGet("/api/firewall/alias/listNetworkAliases", {}, function(data){
ajaxGet("/api/firewall/alias/list_network_aliases", {}, function(data){
$("#network_content").empty();
$.each(data, function(alias, value) {
let $opt = $("<option/>").val(alias).text(value.name);
@ -250,14 +250,30 @@
});
});
/**
* fetch user groups
**/
ajaxGet("/api/firewall/alias/list_user_groups", {}, function(data){
$("#authgroup_content").empty();
$.each(data, function(alias, value) {
let $opt = $("<option/>").val(alias).text(value.name);
$opt.data('subtext', value.description);
$("#authgroup_content").append($opt);
});
$("#authgroup_content").selectpicker('refresh');
});
/**
* hook network group type changes, replicate content
*/
$("#network_content").change(function(){
$("#network_content, #authgroup_content").change(function(){
let target = $(this);
console.log(target);
let $content = $("#alias\\.content");
$content.unbind('tokenize:tokens:change');
$content.tokenize2().trigger('tokenize:clear');
$("#network_content").each(function () {
target.each(function () {
$.each($(this).val(), function(key, item){
$content.tokenize2().trigger('tokenize:tokens:add', item);
});
@ -270,7 +286,6 @@
});
});
/**
* Type selector, show correct type input.
*/
@ -280,6 +295,10 @@
$("#row_alias\\.interface").hide();
$("#copy-paste").hide();
switch ($(this).val()) {
case 'authgroup':
$("#alias_type_authgroup").show();
$("#alias\\.proto").selectpicker('hide');
break;
case 'geoip':
$("#alias_type_geoip").show();
$("#alias\\.proto").selectpicker('show');
@ -321,24 +340,18 @@
*/
$("#alias\\.content").change(function(){
var items = $(this).val();
$(".geoip_select").each(function(){
var geo_item = $(this);
geo_item.val([]);
for (var i=0; i < items.length; ++i) {
geo_item.find('option[value="' + $.escapeSelector(items[i]) + '"]').prop("selected", true);
}
['#authgroup_content', '#network_content', '.geoip_select'].forEach(function(target){
console.log(target);
$(target).each(function(){
var content_item = $(this);
content_item.val([]);
for (var i=0; i < items.length; ++i) {
content_item.find('option[value="' + $.escapeSelector(items[i]) + '"]').prop("selected", true);
}
});
$(target).selectpicker('refresh');
});
$(".geoip_select").selectpicker('refresh');
geoip_update_labels();
$("#network_content").each(function(){
var network_item = $(this);
network_item.val([]);
for (var i=0; i < items.length; ++i) {
network_item.find('option[value="' + $.escapeSelector(items[i]) + '"]').prop("selected", true);
}
});
$("#network_content").selectpicker('refresh');
});
/**
@ -574,7 +587,7 @@
<div class="hidden">
<!-- filter per type container -->
<div id="type_filter_container" class="btn-group">
<select id="type_filter" data-title="{{ lang._('Filter type') }}" class="selectpicker" multiple="multiple" data-width="200px">
<select id="type_filter" data-title="{{ lang._('Filter type') }}" class="selectpicker" data-live-search="true" multiple="multiple" data-width="200px">
<option value="host">{{ lang._('Host(s)') }}</option>
<option value="network">{{ lang._('Network(s)') }}</option>
<option value="port">{{ lang._('Port(s)') }}</option>
@ -585,6 +598,7 @@
<option value="mac">{{ lang._('MAC address') }}</option>
<option value="asn">{{ lang._('BGP ASN') }}</option>
<option value="dynipv6host">{{ lang._('Dynamic IPv6 Host') }}</option>
<option value="authgroup">{{ lang._('(OpenVPN) user groups') }}</option>
<option value="internal">{{ lang._('Internal (automatic)') }}</option>
<option value="external">{{ lang._('External (advanced)') }}</option>
</select>
@ -813,6 +827,10 @@
<tbody>
</tbody>
</table>
<div class="alias_type" id="alias_type_authgroup" style="display: none;">
<select multiple="multiple" class="selectpicker" id="authgroup_content" data-live-search="true">
</select>
</div>
<a href="#" class="text-danger" id="clear-options_alias.content"><i class="fa fa-times-circle"></i>
<small>{{lang._('Clear All')}}</small></a><span id="copy-paste">

View File

@ -38,6 +38,7 @@ from .uri import UriParser
from .arpcache import ArpCache
from .bgpasn import BGPASN
from .interface import InterfaceParser
from .auth import AuthGroup
from .base import BaseContentParser
@ -215,6 +216,8 @@ class Alias(object):
return ArpCache(**self._properties)
elif self._type == 'asn':
return BGPASN(**self._properties)
elif self._type == 'authgroup':
return AuthGroup(**self._properties)
else:
return None

View File

@ -0,0 +1,74 @@
"""
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 subprocess
import syslog
import ujson
from .base import BaseContentParser
class AuthGroup(BaseContentParser):
_auth_db = None
@classmethod
def _update(cls):
cls._auth_db = {}
try:
params = ['/usr/local/opnsense/scripts/auth/list_group_members.php']
group_members = ujson.loads(subprocess.run(params, capture_output=True, text=True).stdout.strip())
except (ValueError, FileNotFoundError):
syslog.syslog(syslog.LOG_ERR, 'error fetching group members (%s)' % " ".join(params))
return
try:
params = ['/usr/local/opnsense/scripts/openvpn/ovpn_status.py', '--options', 'server']
ovpn_status = ujson.loads(subprocess.run(params, capture_output=True,text=True).stdout.strip())
except (ValueError, FileNotFoundError):
syslog.syslog(syslog.LOG_ERR, 'error fetching openvpn clients (%s)' % " ".join(params))
return
users = {}
for server in ovpn_status.get('server', None):
if server:
for client in ovpn_status['server'][server].get('client_list', []):
if type(client) is dict and 'common_name' in client:
if client['common_name'] not in users:
users[client['common_name']] = []
if client.get('virtual_address', '') != '':
users[client['common_name']].append(client.get('virtual_address'))
if client.get('virtual_ipv6_address', '') != '':
users[client['common_name']].append(client.get('virtual_ipv6_address'))
for grp in group_members:
cls._auth_db[grp] = []
for user in group_members[grp].get('members', []):
for address in users.get(user, []):
cls._auth_db[grp].append(address)
def iter_addresses(self, group):
if self._auth_db is None:
self._update()
if group in self._auth_db:
for address in self._auth_db[group]:
yield address

View File

@ -66,6 +66,11 @@ def main(params):
sys.exit(subprocess.run("%s/client_connect.php" % cmd_path).returncode)
elif params.script_type == 'client-disconnect':
sys.exit(subprocess.run("%s/client_disconnect.sh" % cmd_path).returncode)
elif params.script_type == 'learn-address':
if os.fork() == 0:
sys.exit(subprocess.run(
['/usr/local/opnsense/scripts/filter/update_tables.py', '--types', 'authgroup']
).returncode)
if __name__ == '__main__':

View File

@ -34,7 +34,7 @@
<ttl>{{ system.aliasesresolveinterval|default('300') }}</ttl>
{% elif alias.type == 'mac' %}
<ttl>30</ttl>
{% elif alias.type == 'dynipv6host' %}
{% elif alias.type in ['dynipv6host', 'authgroup'] %}
<ttl>1</ttl>
{% endif %}
</table>