diff options
-rw-r--r-- | data/templates/zone_policy/nftables.tmpl | 97 | ||||
-rw-r--r-- | interface-definitions/zone-policy.xml.in | 94 | ||||
-rwxr-xr-x | smoketest/scripts/cli/test_zone_policy.py | 63 | ||||
-rwxr-xr-x | src/conf_mode/zone_policy.py | 176 |
4 files changed, 430 insertions, 0 deletions
diff --git a/data/templates/zone_policy/nftables.tmpl b/data/templates/zone_policy/nftables.tmpl new file mode 100644 index 000000000..4575a721c --- /dev/null +++ b/data/templates/zone_policy/nftables.tmpl @@ -0,0 +1,97 @@ +#!/usr/sbin/nft -f + +{% if cleanup_commands is defined %} +{% for command in cleanup_commands %} +{{ command }} +{% endfor %} +{% endif %} + +{% if zone is defined %} +table ip filter { +{% for zone_name, zone_conf in zone.items() if zone_conf.ipv4 %} +{% if zone_conf.local_zone is defined %} + chain VZONE_{{ zone_name }}_IN { + iifname lo counter return +{% for from_zone, from_conf in zone_conf.from.items() if from_conf.firewall.name is defined %} + iifname { {{ zone[from_zone].interface | join(",") }} } counter jump {{ from_conf.firewall.name }} + iifname { {{ zone[from_zone].interface | join(",") }} } counter return +{% endfor %} + counter {{ zone_conf.default_action if zone_conf.default_action is defined else 'drop' }} + } + chain VZONE_{{ zone_name }}_OUT { + oifname lo counter return +{% for from_zone, from_conf in zone_conf.from_local.items() if from_conf.firewall.name is defined %} + oifname { {{ zone[from_zone].interface | join(",") }} } counter jump {{ from_conf.firewall.name }} + oifname { {{ zone[from_zone].interface | join(",") }} } counter return +{% endfor %} + counter {{ zone_conf.default_action if zone_conf.default_action is defined else 'drop' }} + } +{% else %} + chain VZONE_{{ zone_name }} { + iifname { {{ zone_conf.interface | join(",") }} } counter return +{% for from_zone, from_conf in zone_conf.from.items() if from_conf.firewall.name is defined %} +{% if zone[from_zone].local_zone is not defined %} + iifname { {{ zone[from_zone].interface | join(",") }} } counter jump {{ from_conf.firewall.name }} + iifname { {{ zone[from_zone].interface | join(",") }} } counter return +{% endif %} +{% endfor %} + counter {{ zone_conf.default_action if zone_conf.default_action is defined else 'drop' }} + } +{% endif %} +{% endfor %} +} + +table ip6 filter { +{% for zone_name, zone_conf in zone.items() if zone_conf.ipv6 %} +{% if zone_conf.local_zone is defined %} + chain VZONE6_{{ zone_name }}_IN { + iifname lo counter return +{% for from_zone, from_conf in zone_conf.from.items() if from_conf.firewall.ipv6_name is defined %} + iifname { {{ zone[from_zone].interface | join(",") }} } counter jump {{ from_conf.firewall.ipv6_name }} + iifname { {{ zone[from_zone].interface | join(",") }} } counter return +{% endfor %} + counter {{ zone_conf.default_action if zone_conf.default_action is defined else 'drop' }} + } + chain VZONE6_{{ zone_name }}_OUT { + oifname lo counter return +{% for from_zone, from_conf in zone_conf.from_local.items() if from_conf.firewall.ipv6_name is defined %} + oifname { {{ zone[from_zone].interface | join(",") }} } counter jump {{ from_conf.firewall.ipv6_name }} + oifname { {{ zone[from_zone].interface | join(",") }} } counter return +{% endfor %} + counter {{ zone_conf.default_action if zone_conf.default_action is defined else 'drop' }} + } +{% else %} + chain VZONE6_{{ zone_name }} { + iifname { {{ zone_conf.interface | join(",") }} } counter return +{% for from_zone, from_conf in zone_conf.from.items() if from_conf.firewall.ipv6_name is defined %} +{% if zone[from_zone].local_zone is not defined %} + iifname { {{ zone[from_zone].interface | join(",") }} } counter jump {{ from_conf.firewall.ipv6_name }} + iifname { {{ zone[from_zone].interface | join(",") }} } counter return +{% endif %} +{% endfor %} + counter {{ zone_conf.default_action if zone_conf.default_action is defined else 'drop' }} + } +{% endif %} +{% endfor %} +} + +{% for zone_name, zone_conf in zone.items() %} +{% if zone_conf.ipv4 %} +{% if 'local_zone' in zone_conf %} +insert rule ip filter VYOS_FW_LOCAL counter jump VZONE_{{ zone_name }}_IN +insert rule ip filter VYOS_FW_OUTPUT counter jump VZONE_{{ zone_name }}_OUT +{% else %} +insert rule ip filter VYOS_FW_OUT oifname { {{ zone_conf.interface | join(',') }} } counter jump VZONE_{{ zone_name }} +{% endif %} +{% endif %} +{% if zone_conf.ipv6 %} +{% if 'local_zone' in zone_conf %} +insert rule ip6 filter VYOS_FW6_LOCAL counter jump VZONE6_{{ zone_name }}_IN +insert rule ip6 filter VYOS_FW6_OUTPUT counter jump VZONE6_{{ zone_name }}_OUT +{% else %} +insert rule ip6 filter VYOS_FW6_OUT oifname { {{ zone_conf.interface | join(',') }} } counter jump VZONE6_{{ zone_name }} +{% endif %} +{% endif %} +{% endfor %} + +{% endif %} diff --git a/interface-definitions/zone-policy.xml.in b/interface-definitions/zone-policy.xml.in new file mode 100644 index 000000000..52fd73f15 --- /dev/null +++ b/interface-definitions/zone-policy.xml.in @@ -0,0 +1,94 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="zone-policy" owner="${vyos_conf_scripts_dir}/zone_policy.py"> + <properties> + <help>Configure zone-policy</help> + <priority>250</priority> + </properties> + <children> + <tagNode name="zone"> + <properties> + <help>Zone name</help> + <valueHelp> + <format>txt</format> + <description>Zone name</description> + </valueHelp> + </properties> + <children> + #include <include/generic-description.xml.i> + <leafNode name="default-action"> + <properties> + <help>Default-action for traffic coming into this zone</help> + <completionHelp> + <list>drop reject</list> + </completionHelp> + <valueHelp> + <format>drop</format> + <description>Drop silently (default)</description> + </valueHelp> + <valueHelp> + <format>reject</format> + <description>Drop and notify source</description> + </valueHelp> + <constraint> + <regex>^(drop|reject)$</regex> + </constraint> + </properties> + </leafNode> + <tagNode name="from"> + <properties> + <help>Zone from which to filter traffic</help> + <completionHelp> + <path>zone-policy zone</path> + </completionHelp> + </properties> + <children> + <node name="firewall"> + <properties> + <help>Firewall options</help> + </properties> + <children> + <leafNode name="ipv6-name"> + <properties> + <help>IPv6 firewall ruleset</help> + <completionHelp> + <path>firewall ipv6-name</path> + </completionHelp> + </properties> + </leafNode> + <leafNode name="name"> + <properties> + <help>IPv4 firewall ruleset</help> + <completionHelp> + <path>firewall name</path> + </completionHelp> + </properties> + </leafNode> + </children> + </node> + </children> + </tagNode> + <leafNode name="interface"> + <properties> + <help>Interface associated with zone</help> + <valueHelp> + <format>txt</format> + <description>Interface associated with zone</description> + </valueHelp> + <completionHelp> + <script>${vyos_completion_dir}/list_interfaces.py</script> + </completionHelp> + <multi/> + </properties> + </leafNode> + <leafNode name="local-zone"> + <properties> + <help>Zone to be local-zone</help> + <valueless/> + </properties> + </leafNode> + </children> + </tagNode> + </children> + </node> +</interfaceDefinition> diff --git a/smoketest/scripts/cli/test_zone_policy.py b/smoketest/scripts/cli/test_zone_policy.py new file mode 100755 index 000000000..c0af6164b --- /dev/null +++ b/smoketest/scripts/cli/test_zone_policy.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import unittest + +from base_vyostest_shim import VyOSUnitTestSHIM + +from vyos.util import cmd + +class TestZonePolicy(VyOSUnitTestSHIM.TestCase): + def setUp(self): + self.cli_set(['firewall', 'name', 'smoketest', 'default-action', 'drop']) + + def tearDown(self): + self.cli_delete(['zone-policy']) + self.cli_delete(['firewall']) + self.cli_commit() + + def test_basic_zone(self): + self.cli_set(['zone-policy', 'zone', 'smoketest-eth0', 'interface', 'eth0']) + self.cli_set(['zone-policy', 'zone', 'smoketest-eth0', 'from', 'smoketest-local', 'firewall', 'name', 'smoketest']) + self.cli_set(['zone-policy', 'zone', 'smoketest-local', 'local-zone']) + self.cli_set(['zone-policy', 'zone', 'smoketest-local', 'from', 'smoketest-eth0', 'firewall', 'name', 'smoketest']) + + self.cli_commit() + + nftables_search = [ + ['chain VZONE_smoketest-eth0'], + ['chain VZONE_smoketest-local_IN'], + ['chain VZONE_smoketest-local_OUT'], + ['oifname { "eth0" }', 'jump VZONE_smoketest-eth0'], + ['jump VZONE_smoketest-local_IN'], + ['jump VZONE_smoketest-local_OUT'], + ['iifname { "eth0" }', 'jump smoketest'], + ['oifname { "eth0" }', 'jump smoketest'] + ] + + nftables_output = cmd('sudo nft list table ip filter') + + for search in nftables_search: + matched = False + for line in nftables_output.split("\n"): + if all(item in line for item in search): + matched = True + break + self.assertTrue(matched) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/conf_mode/zone_policy.py b/src/conf_mode/zone_policy.py new file mode 100755 index 000000000..92f5624c2 --- /dev/null +++ b/src/conf_mode/zone_policy.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from json import loads +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.util import cmd +from vyos.util import dict_search_args +from vyos.util import run +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +nftables_conf = '/run/nftables_zone.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['zone-policy'] + zone_policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + if zone_policy: + zone_policy['firewall'] = conf.get_config_dict(['firewall'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + return zone_policy + +def verify(zone_policy): + # bail out early - looks like removal from running config + if not zone_policy: + return None + + local_zone = False + interfaces = [] + + if 'zone' in zone_policy: + for zone, zone_conf in zone_policy['zone'].items(): + if 'local_zone' not in zone_conf and 'interface' not in zone_conf: + raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone') + + if 'local_zone' in zone_conf: + if local_zone: + raise ConfigError('There cannot be multiple local zones') + if 'interface' in zone_conf: + raise ConfigError('Local zone cannot have interfaces assigned') + local_zone = True + + if 'interface' in zone_conf: + found_duplicates = [intf for intf in zone_conf['interface'] if intf in interfaces] + + if found_duplicates: + raise ConfigError(f'Interfaces cannot be assigned to multiple zones') + + interfaces += zone_conf['interface'] + + if 'from' in zone_conf: + for from_zone, from_conf in zone_conf['from'].items(): + v4_name = dict_search_args(from_conf, 'firewall', 'name') + if v4_name: + if 'name' not in zone_policy['firewall']: + raise ConfigError(f'Firewall name "{v4_name}" does not exist') + + if not dict_search_args(zone_policy, 'firewall', 'name', v4_name): + raise ConfigError(f'Firewall name "{v4_name}" does not exist') + + v6_name = dict_search_args(from_conf, 'firewall', 'v6_name') + if v6_name: + if 'ipv6_name' not in zone_policy['firewall']: + raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + + if not dict_search_args(zone_policy, 'firewall', 'ipv6_name', v6_name): + raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + + return None + +def has_ipv4_fw(zone_conf): + if 'from' not in zone_conf: + return False + zone_from = zone_conf['from'] + return any([True for fz in zone_from if dict_search_args(zone_from, fz, 'firewall', 'name')]) + +def has_ipv6_fw(zone_conf): + if 'from' not in zone_conf: + return False + zone_from = zone_conf['from'] + return any([True for fz in zone_from if dict_search_args(zone_from, fz, 'firewall', 'ipv6_name')]) + +def get_local_from(zone_policy, local_zone_name): + # Get all zone firewall names from the local zone + out = {} + for zone, zone_conf in zone_policy['zone'].items(): + if zone == local_zone_name: + continue + if 'from' not in zone_conf: + continue + if local_zone_name in zone_conf['from']: + out[zone] = zone_conf['from'][local_zone_name] + return out + +def cleanup_commands(): + commands = [] + for table in ['ip filter', 'ip6 filter']: + json_str = cmd(f'nft -j list table {table}') + obj = loads(json_str) + if 'nftables' not in obj: + continue + for item in obj['nftables']: + if 'rule' in item: + chain = item['rule']['chain'] + handle = item['rule']['handle'] + if 'expr' not in item['rule']: + continue + for expr in item['rule']['expr']: + target = dict_search_args(expr, 'jump', 'target') + if target and target.startswith("VZONE"): + commands.append(f'delete rule {table} {chain} handle {handle}') + for item in obj['nftables']: + if 'chain' in item: + if item['chain']['name'].startswith("VZONE"): + chain = item['chain']['name'] + commands.append(f'delete chain {table} {chain}') + return commands + +def generate(zone_policy): + data = zone_policy or {} + + if os.path.exists(nftables_conf): # Check to see if we've run before + data['cleanup_commands'] = cleanup_commands() + + if 'zone' in data: + for zone, zone_conf in data['zone'].items(): + zone_conf['ipv4'] = has_ipv4_fw(zone_conf) + zone_conf['ipv6'] = has_ipv6_fw(zone_conf) + + if 'local_zone' in zone_conf: + zone_conf['from_local'] = get_local_from(data, zone) + + render(nftables_conf, 'zone_policy/nftables.tmpl', data) + return None + +def apply(zone_policy): + install_result = run(f'nft -f {nftables_conf}') + if install_result == 1: + raise ConfigError('Failed to apply zone-policy') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) |