mirror of
https://github.com/lucaspalomodevelop/core.git
synced 2026-03-19 19:15:22 +00:00
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:
parent
4789c2a752
commit
1293c51187
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user