Firewall: Automation: Filter - offer "multi-select" on source and destination addresses.

When selecting multiple source or targets, a cartesian product is created for all combinations (one defined rule turns into multiple actual rules).
In order to make this possible, we needed to refactor the base rule parsing. our generic `reader()` multiplies rules, which it already did for interfaces and ipprotocol.
When feeding lists to `pf(4)` a similar action would be performed.

The `convertAddress()` method has been renamed to `legacyMoveAddressFields()` as it now only remaps field structures into flattened fields, without validating their contents.
This is needed so we can split source/destinations without caring about their validity (yet), `mapAddressInfo()` is added next which contains the same logic as previously in `convertAddress()` but executed after splitting the fields.

The "Automation" module is more or less a reference implementation to show how the backend handles these now, 22fd0bf8763e14a5e1e7694853af0893dae585b7 is required for this to work.

All changes should be backwards compatible, but deliver a slightly different ruleset in some cases (when multiple entries are already used), e.g. the rule below would be split into two on our end now:

397a3dcdce/src/etc/inc/filter.lib.inc (L231-L237)

`pfctl -sr` already showed two before our change.
This commit is contained in:
Ad Schellevis 2024-12-05 15:30:26 +01:00
parent 4789c2a752
commit 1293c51187
6 changed files with 198 additions and 97 deletions

View File

@ -54,7 +54,7 @@
<id>rule.source_net</id>
<label>Source</label>
<type>text</type>
<style>net_selector</style>
<style>net_selector net_selector_multi</style>
</field>
<field>
<id>rule.source_port</id>
@ -73,7 +73,7 @@
<id>rule.destination_net</id>
<label>Destination</label>
<type>text</type>
<style>net_selector</style>
<style>net_selector net_selector_multi</style>
</field>
<field>
<id>rule.destination_not</id>

View File

@ -28,19 +28,105 @@
namespace OPNsense\Firewall;
use OPNsense\Firewall\Alias;
/**
* Class Rule basic rule parsing logic
* @package OPNsense\Firewall
*/
abstract class Rule
{
protected $rule = array();
protected $interfaceMapping = array();
protected $ruleDebugInfo = array();
protected $rule = [];
protected $interfaceMapping = [];
protected $ruleDebugInfo = [];
protected static $aliasMap = [];
/* ease the reuse of parsing for pf keywords by using class constants */
const PARSE_PROTO = 'parseReplaceSimple,tcp/udp:{tcp udp}|a/n:"a/n",proto ';
protected function loadAliasMap()
{
if (empty(static::$aliasMap)) {
static::$aliasMap['any'] = 'any';
static::$aliasMap['(self)'] = '(self)';
foreach ($this->interfaceMapping as $ifname => $payload) {
if (!empty($payload['if'])) {
static::$aliasMap[$ifname] = sprintf("(%s:network)", $payload['if']);
if (preg_match("/^(wan|lan|opt[0-9]+)$/", $ifname, $matches)) {
static::$aliasMap[$ifname . 'ip'] = sprintf("(%s)", $payload['if']);
}
}
}
foreach ((new Alias())->aliases->alias->iterateItems() as $alias) {
if (preg_match("/port/i", (string)$alias->type)) {
continue;
}
static::$aliasMap[(string)$alias->name] = sprintf('$%s', $alias->name);
}
}
}
/**
* maps address definitions into tags our pf(4) ruleset understands
*/
protected function mapAddressInfo(&$rule)
{
foreach (['from', 'to'] as $tag) {
/* always strip placeholders (e.g. <alias>) so we validate them as we would for an ordinary alias */
$content = !empty($rule[$tag]) ? trim($rule[$tag], '<>') : '';
if (empty($rule[$tag]) && $rule[$tag] != '0') {
/* any source/destination (omit value) */
null;
} elseif (str_starts_with($rule[$tag], '<') && str_ends_with($rule[$tag], '>')) {
/* literal alias by automated rules, unvalidated, might want to change the callers some day */
null;
} elseif (str_starts_with($rule[$tag], '(') && str_ends_with($rule[$tag], ')')) {
/* literal interface by automated rules, unvalidated, might want to change the callers some day */
null;
} elseif (isset(static::$aliasMap[$rule[$tag]])) {
$is_interface = isset($this->interfaceMapping[$rule[$tag]]);
$rule[$tag] = static::$aliasMap[$rule[$tag]];
/* historically pf(4) excludes link-local on :network to avoid anti-spoof overlap */
if ($rule['ipprotocol'] == 'inet6' && $is_interface && $this instanceof FilterRule) {
$rule[$tag] .= ',fe80::/10';
}
} elseif (!Util::isIpAddress($rule[$tag]) && !Util::isSubnet($rule[$tag])) {
$rule['disabled'] = true;
$rule[$tag] = json_encode($rule[$tag]);
$this->log("Unable to convert address, see {$tag} for details");
}
if (!empty($rule[$tag . '_not']) && !empty($rule[$tag]) && $rule[$tag] != 'any') {
$rule[$tag] = '!' . $rule[$tag];
}
/* port handling */
$pfield = sprintf('%s_port', $tag);
if (isset($rule[$pfield])) {
$port = str_replace('-', ':', $rule[$pfield]);
if (strpos($port, ':any') !== false xor strpos($port, 'any:') !== false) {
// convert 'any' to upper or lower bound when provided in range. e.g. 80:any --> 80:65535
$port = str_replace('any', strpos($port, ':any') !== false ? '65535' : '1', $port);
}
if ($port == 'any') {
$rule[$pfield] = null;
} elseif (Util::isPort($port)) {
$rule[$pfield] = $port;
} elseif (Util::isAlias($port)) {
$rule[$pfield] = '$' . $port;
if (!Util::isAlias($port, true)) {
// unable to map port
$rule['disabled'] = true;
$this->log("Unable to map port {$port}, empty?");
}
} elseif (!empty($port)) {
$rule['disabled'] = true;
$this->log("Unable to map port {$port}, config error?");
}
}
}
}
/**
* init Rule
* @param array $interfaceMapping internal interface mapping
@ -50,6 +136,7 @@ abstract class Rule
{
$this->interfaceMapping = $interfaceMapping;
$this->rule = $conf;
$this->loadAliasMap();
}
/**
@ -173,35 +260,50 @@ abstract class Rule
*/
protected function reader($type = null)
{
$interfaces = empty($this->rule['interface']) ? array(null) : explode(',', $this->rule['interface']);
foreach ($interfaces as $interface) {
if (isset($this->rule['ipprotocol']) && $this->rule['ipprotocol'] == 'inet46') {
$ipprotos = array('inet', 'inet6');
} elseif (isset($this->rule['ipprotocol'])) {
$ipprotos = array($this->rule['ipprotocol']);
} elseif (!empty($type) && $type = 'npt') {
$ipprotos = array('inet6');
} else {
$ipprotos = array(null);
}
foreach ($ipprotos as $ipproto) {
$rule = $this->rule;
if ($ipproto == 'inet6' && !empty($this->interfaceMapping[$interface]['IPv6_override'])) {
$rule['interface'] = $this->interfaceMapping[$interface]['IPv6_override'];
} else {
$rule['interface'] = $interface;
$rule = array_replace([], $this->rule); /* deep copy before use */
$this->legacyMoveAddressFields($rule);
$interfaces = empty($rule['interface']) ? [null] : explode(',', $rule['interface']);
$froms = empty($rule['from']) ? [null] : explode(',', $rule['from']);
$tos = empty($rule['to']) ? [null] : explode(',', $rule['to']);
if (isset($rule['ipprotocol']) && $rule['ipprotocol'] == 'inet46') {
$ipprotos = ['inet', 'inet6'];
} elseif (isset($rule['ipprotocol'])) {
$ipprotos = [$rule['ipprotocol']];
} elseif (!empty($type) && $type = 'npt') {
$ipprotos = ['inet6'];
} else {
$ipprotos = [null];
}
/* generate cartesian product of fields that may contain multiple options */
$meta_rules = [];
foreach ($froms as $from) {
foreach ($tos as $to) {
foreach ($interfaces as $interface) {
foreach ($ipprotos as $ipproto) {
$meta_rules[] = [
'ipprotocol' => $ipproto,
'interface' => $interface,
'from' => $from,
'to' => $to
];
}
}
$rule['ipprotocol'] = $ipproto;
$this->convertAddress($rule);
// disable rule when interface not found
if (!empty($interface) && empty($this->interfaceMapping[$interface]['if'])) {
$this->log("Interface {$interface} not found");
$rule['disabled'] = true;
}
yield $rule;
}
}
foreach ($meta_rules as $meta_rule) {
$rulecpy = array_merge($rule, $meta_rule);
$this->mapAddressInfo($rulecpy);
$interface = $rulecpy['interface'];
if ($rulecpy['ipprotocol'] == 'inet6' && !empty($this->interfaceMapping[$interface]['IPv6_override'])) {
$rulecpy['interface'] = $this->interfaceMapping[$interface]['IPv6_override'];
}
// disable rule when interface not found
if (!empty($interface) && empty($this->interfaceMapping[$interface]['if'])) {
$this->log("Interface {$interface} not found");
$rulecpy['disabled'] = true;
}
yield $rulecpy;
}
}
/**
@ -236,12 +338,12 @@ abstract class Rule
}
/**
* convert source/destination address entries as used by the gui
* convert source/destination structures as used by the gui into simple flat structures.
* @param array $rule rule
*/
protected function convertAddress(&$rule)
protected function legacyMoveAddressFields(&$rule)
{
$fields = array();
$fields = [];
$fields['source'] = 'from';
$fields['destination'] = 'to';
$interfaces = $this->interfaceMapping;
@ -250,61 +352,18 @@ abstract class Rule
if (isset($rule[$tag]['any'])) {
$rule[$target] = 'any';
} elseif (!empty($rule[$tag]['network'])) {
$network_name = $rule[$tag]['network'];
$matches = '';
if ($network_name == '(self)') {
$rule[$target] = $network_name;
} elseif (preg_match("/^(wan|lan|opt[0-9]+)ip$/", $network_name, $matches)) {
if (!empty($interfaces[$matches[1]]['if'])) {
$rule[$target] = "({$interfaces[$matches[1]]['if']})";
}
} elseif (!empty($interfaces[$network_name]['if'])) {
$rule[$target] = "({$interfaces[$network_name]['if']}:network)";
if ($rule['ipprotocol'] == 'inet6' && $this instanceof FilterRule && $rule['interface'] == $network_name) {
/* historically pf(4) excludes link-local on :network to avoid anti-spoof overlap */
$rule[$target] .= ',fe80::/10';
}
} elseif (Util::isIpAddress($rule[$tag]['network']) || Util::isSubnet($rule[$tag]['network'])) {
$rule[$target] = $rule[$tag]['network'];
} elseif (Util::isAlias($rule[$tag]['network'])) {
$rule[$target] = '$' . $rule[$tag]['network'];
} elseif ($rule[$tag]['network'] == 'any') {
$rule[$target] = $rule[$tag]['network'];
}
} elseif (!empty($rule[$tag]['address'])) {
if (
Util::isIpAddress($rule[$tag]['address']) || Util::isSubnet($rule[$tag]['address']) ||
Util::isPort($rule[$tag]['address'])
) {
$rule[$target] = $rule[$tag]['address'];
} elseif (Util::isAlias($rule[$tag]['address'])) {
$rule[$target] = '$' . $rule[$tag]['address'];
}
$rule[$target] = $rule[$tag]['network'];
} elseif (!empty($rule[$tag]['address'])) {
$rule[$target] = $rule[$tag]['address'];
}
if (!empty($rule[$target]) && $rule[$target] != 'any' && isset($rule[$tag]['not'])) {
$rule[$target] = "!" . $rule[$target];
}
if (isset($rule['protocol']) && in_array(strtolower($rule['protocol']), array("tcp","udp","tcp/udp"))) {
$port = !empty($rule[$tag]['port']) ? str_replace('-', ':', $rule[$tag]['port']) : null;
if ($port == null || $port == 'any') {
$port = null;
} elseif (strpos($port, ':any') !== false xor strpos($port, 'any:') !== false) {
// convert 'any' to upper or lower bound when provided in range. e.g. 80:any --> 80:65535
$port = str_replace('any', strpos($port, ':any') !== false ? '65535' : '1', $port);
}
if (Util::isPort($port)) {
$rule[$target . "_port"] = $port;
} elseif (Util::isAlias($port)) {
$rule[$target . "_port"] = '$' . $port;
if (!Util::isAlias($port, true)) {
// unable to map port
$rule['disabled'] = true;
$this->log("Unable to map port {$port}, empty?");
}
} elseif (!empty($port)) {
$rule['disabled'] = true;
$this->log("Unable to map port {$port}, config error?");
}
$rule[$target . '_not'] = isset($rule[$tag]['not']); /* to be used in mapAddressInfo() */
if (
isset($rule['protocol']) &&
in_array(strtolower($rule['protocol']), ["tcp", "udp", "tcp/udp"]) &&
!empty($rule[$tag]['port'])
) {
$rule[$target . "_port"] = $rule[$tag]['port'];
}
if (!isset($rule[$target])) {
// couldn't convert address, disable rule

View File

@ -70,24 +70,22 @@ class FilterRuleContainerField extends ContainerField
}
}
// source / destination mapping
$result['source'] = array();
if (!empty((string)$this->source_net)) {
$result['source']['network'] = (string)$this->source_net;
$result['from'] = (string)$this->source_net;
if (!empty((string)$this->source_not)) {
$result['source']['not'] = true;
$result['from_not'] = true;
}
if (!empty((string)$this->source_port)) {
$result['source']['port'] = (string)$this->source_port;
$result['from_port'] = (string)$this->source_port;
}
}
$result['destination'] = array();
if (!empty((string)$this->destination_net)) {
$result['destination']['network'] = (string)$this->destination_net;
$result['to'] = (string)$this->destination_net;
if (!empty((string)$this->destination_not)) {
$result['destination']['not'] = true;
$result['to_not'] = true;
}
if (!empty((string)$this->destination_port)) {
$result['destination']['port'] = (string)$this->destination_port;
$result['to_port'] = (string)$this->destination_port;
}
}
// field mappings and differences

View File

@ -77,6 +77,19 @@ class Filter extends BaseModel
$rule->source_net->__reference
));
}
// when multiple values are offered for source/destination, prevent "any" being used in combination
foreach (['source_net', 'destination_net'] as $fieldname) {
if (
strpos($rule->$fieldname, ',') !== false &&
in_array('any', explode(',', $rule->$fieldname))
) {
$messages->appendMessage(new Message(
gettext("Any can not be combined with other aliases"),
$rule->$fieldname->__reference
));
}
}
// Additional source nat validations
if ($rule->target !== null) {
$target_is_addr = Util::isSubnet($rule->target) || Util::isIpAddress($rule->target);

View File

@ -61,6 +61,7 @@
<source_net type="NetworkAliasField">
<Default>any</Default>
<Required>Y</Required>
<Multiple>Y</Multiple>
</source_net>
<source_not type="BooleanField">
<Default>0</Default>
@ -76,6 +77,7 @@
<destination_net type="NetworkAliasField">
<Default>any</Default>
<Required>Y</Required>
<Multiple>Y</Multiple>
</destination_net>
<destination_not type="BooleanField">
<Default>0</Default>

View File

@ -106,7 +106,36 @@
// replace all "net" selectors with details retrieved from "list_network_select_options" endpoint
ajaxGet('/api/firewall/{{ruleController}}/list_network_select_options', [], function(data, status){
if (data.single) {
$(".net_selector").replaceInputWithSelector(data);
$(".net_selector").each(function(){
$(this).replaceInputWithSelector(data, $(this).hasClass('net_selector_multi'));
/* enforce single selection when "single host or network" or "any" are selected */
if ($(this).hasClass('net_selector_multi')) {
$("select[for='" + $(this).attr('id') + "']").on('shown.bs.select', function(){
$(this).data('previousValue', $(this).val());
}).change(function(){
let prev = Array.isArray($(this).data('previousValue')) ? $(this).data('previousValue') : [];
let is_single = $(this).val().includes('') || $(this).val().includes('any');
let was_single = prev.includes('') || prev.includes('any');
let refresh = false;
if (was_single && is_single && $(this).val().length > 1) {
$(this).val($(this).val().filter(value => !prev.includes(value)));
refresh = true;
} else if (is_single && $(this).val().length > 1) {
if ($(this).val().includes('any') && !prev.includes('any')) {
$(this).val('any');
} else{
$(this).val('');
}
refresh = true;
}
if (refresh) {
$(this).selectpicker('refresh');
$(this).trigger('change');
}
$(this).data('previousValue', $(this).val());
});
}
});
}
});
});