diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Firewall/forms/dialogFilterRule.xml b/src/opnsense/mvc/app/controllers/OPNsense/Firewall/forms/dialogFilterRule.xml
index e0aa5a8db..f0dee1842 100644
--- a/src/opnsense/mvc/app/controllers/OPNsense/Firewall/forms/dialogFilterRule.xml
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Firewall/forms/dialogFilterRule.xml
@@ -54,7 +54,7 @@
rule.source_net
text
-
+
rule.source_port
@@ -73,7 +73,7 @@
rule.destination_net
text
-
+
rule.destination_not
diff --git a/src/opnsense/mvc/app/library/OPNsense/Firewall/Rule.php b/src/opnsense/mvc/app/library/OPNsense/Firewall/Rule.php
index 167027693..23215c013 100644
--- a/src/opnsense/mvc/app/library/OPNsense/Firewall/Rule.php
+++ b/src/opnsense/mvc/app/library/OPNsense/Firewall/Rule.php
@@ -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. ) 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
diff --git a/src/opnsense/mvc/app/models/OPNsense/Firewall/FieldTypes/FilterRuleField.php b/src/opnsense/mvc/app/models/OPNsense/Firewall/FieldTypes/FilterRuleField.php
index a1873da4f..337aba045 100644
--- a/src/opnsense/mvc/app/models/OPNsense/Firewall/FieldTypes/FilterRuleField.php
+++ b/src/opnsense/mvc/app/models/OPNsense/Firewall/FieldTypes/FilterRuleField.php
@@ -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
diff --git a/src/opnsense/mvc/app/models/OPNsense/Firewall/Filter.php b/src/opnsense/mvc/app/models/OPNsense/Firewall/Filter.php
index af2d19a65..b401ee182 100644
--- a/src/opnsense/mvc/app/models/OPNsense/Firewall/Filter.php
+++ b/src/opnsense/mvc/app/models/OPNsense/Firewall/Filter.php
@@ -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);
diff --git a/src/opnsense/mvc/app/models/OPNsense/Firewall/Filter.xml b/src/opnsense/mvc/app/models/OPNsense/Firewall/Filter.xml
index 8b9bd4011..0dce58eed 100644
--- a/src/opnsense/mvc/app/models/OPNsense/Firewall/Filter.xml
+++ b/src/opnsense/mvc/app/models/OPNsense/Firewall/Filter.xml
@@ -61,6 +61,7 @@
any
Y
+ Y
0
@@ -76,6 +77,7 @@
any
Y
+ Y
0
diff --git a/src/opnsense/mvc/app/views/OPNsense/Firewall/filter.volt b/src/opnsense/mvc/app/views/OPNsense/Firewall/filter.volt
index 428c974ff..2e817c6a8 100644
--- a/src/opnsense/mvc/app/views/OPNsense/Firewall/filter.volt
+++ b/src/opnsense/mvc/app/views/OPNsense/Firewall/filter.volt
@@ -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());
+ });
+ }
+ });
}
});
});