diff --git a/smoketest/scripts/cli/test_nat.py b/smoketest/scripts/cli/test_nat.py index 7ca82f86f..b82805661 100755 --- a/smoketest/scripts/cli/test_nat.py +++ b/smoketest/scripts/cli/test_nat.py @@ -1,160 +1,170 @@ #!/usr/bin/env python3 # # Copyright (C) 2020 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 import jmespath import json import unittest from vyos.configsession import ConfigSession from vyos.configsession import ConfigSessionError from vyos.util import cmd from vyos.util import dict_search base_path = ['nat'] src_path = base_path + ['source'] dst_path = base_path + ['destination'] class TestNAT(unittest.TestCase): def setUp(self): # ensure we can also run this test on a live system - so lets clean # out the current configuration :) self.session = ConfigSession(os.getpid()) self.session.delete(base_path) def tearDown(self): self.session.delete(base_path) self.session.commit() def test_snat(self): rules = ['100', '110', '120', '130', '200', '210', '220', '230'] outbound_iface_100 = 'eth0' outbound_iface_200 = 'eth1' for rule in rules: network = f'192.168.{rule}.0/24' # depending of rule order we check either for source address for NAT # or configured destination address for NAT if int(rule) < 200: self.session.set(src_path + ['rule', rule, 'source', 'address', network]) self.session.set(src_path + ['rule', rule, 'outbound-interface', outbound_iface_100]) self.session.set(src_path + ['rule', rule, 'translation', 'address', 'masquerade']) else: self.session.set(src_path + ['rule', rule, 'destination', 'address', network]) self.session.set(src_path + ['rule', rule, 'outbound-interface', outbound_iface_200]) self.session.set(src_path + ['rule', rule, 'exclude']) self.session.commit() tmp = cmd('sudo nft -j list table nat') data_json = jmespath.search('nftables[?rule].rule[?chain]', json.loads(tmp)) for idx in range(0, len(data_json)): rule = str(rules[idx]) data = data_json[idx] network = f'192.168.{rule}.0/24' self.assertEqual(data['chain'], 'POSTROUTING') self.assertEqual(data['comment'], f'SRC-NAT-{rule}') self.assertEqual(data['family'], 'ip') self.assertEqual(data['table'], 'nat') iface = dict_search('match.right', data['expr'][0]) direction = dict_search('match.left.payload.field', data['expr'][1]) address = dict_search('match.right.prefix.addr', data['expr'][1]) mask = dict_search('match.right.prefix.len', data['expr'][1]) if int(rule) < 200: self.assertEqual(direction, 'saddr') self.assertEqual(iface, outbound_iface_100) # check for masquerade keyword self.assertIn('masquerade', data['expr'][3]) else: self.assertEqual(direction, 'daddr') self.assertEqual(iface, outbound_iface_200) # check for return keyword due to 'exclude' self.assertIn('return', data['expr'][3]) self.assertEqual(f'{address}/{mask}', network) def test_dnat(self): rules = ['100', '110', '120', '130', '200', '210', '220', '230'] inbound_iface_100 = 'eth0' inbound_iface_200 = 'eth1' inbound_proto_100 = 'udp' inbound_proto_200 = 'tcp' for rule in rules: port = f'10{rule}' self.session.set(dst_path + ['rule', rule, 'source', 'port', port]) self.session.set(dst_path + ['rule', rule, 'translation', 'address', '192.0.2.1']) self.session.set(dst_path + ['rule', rule, 'translation', 'port', port]) if int(rule) < 200: self.session.set(dst_path + ['rule', rule, 'protocol', inbound_proto_100]) self.session.set(dst_path + ['rule', rule, 'inbound-interface', inbound_iface_100]) else: self.session.set(dst_path + ['rule', rule, 'protocol', inbound_proto_200]) self.session.set(dst_path + ['rule', rule, 'inbound-interface', inbound_iface_200]) self.session.commit() tmp = cmd('sudo nft -j list table nat') data_json = jmespath.search('nftables[?rule].rule[?chain]', json.loads(tmp)) for idx in range(0, len(data_json)): rule = str(rules[idx]) data = data_json[idx] port = int(f'10{rule}') self.assertEqual(data['chain'], 'PREROUTING') self.assertEqual(data['comment'].split()[0], f'DST-NAT-{rule}') self.assertEqual(data['family'], 'ip') self.assertEqual(data['table'], 'nat') iface = dict_search('match.right', data['expr'][0]) direction = dict_search('match.left.payload.field', data['expr'][1]) protocol = dict_search('match.left.payload.protocol', data['expr'][1]) dnat_addr = dict_search('dnat.addr', data['expr'][3]) dnat_port = dict_search('dnat.port', data['expr'][3]) self.assertEqual(direction, 'sport') self.assertEqual(dnat_addr, '192.0.2.1') self.assertEqual(dnat_port, port) if int(rule) < 200: self.assertEqual(iface, inbound_iface_100) self.assertEqual(protocol, inbound_proto_100) else: self.assertEqual(iface, inbound_iface_200) - def test_snat_required_translation_address(self): # T2813: Ensure translation address is specified rule = '5' self.session.set(src_path + ['rule', rule, 'source', 'address', '192.0.2.0/24']) # check validate() - outbound-interface must be defined with self.assertRaises(ConfigSessionError): self.session.commit() self.session.set(src_path + ['rule', rule, 'outbound-interface', 'eth0']) # check validate() - translation address not specified with self.assertRaises(ConfigSessionError): self.session.commit() self.session.set(src_path + ['rule', rule, 'translation', 'address', 'masquerade']) self.session.commit() + def test_nat_no_rules(self): + # T3206: deleting all rules but keep the direction 'destination' or + # 'source' resulteds in KeyError: 'rule'. + # + # Test that both 'nat destination' and 'nat source' nodes can exist + # without any rule + self.session.set(src_path) + self.session.set(dst_path) + self.session.commit() + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 1ccec3d2e..2d98cb11b 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -1,204 +1,204 @@ #!/usr/bin/env python3 # # Copyright (C) 2020 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 jmespath import json import os from distutils.version import LooseVersion from platform import release as kernel_version from sys import exit from netifaces import interfaces from vyos.config import Config from vyos.configdict import dict_merge from vyos.template import render from vyos.util import cmd from vyos.util import check_kmod from vyos.util import dict_search from vyos.validate import is_addr_assigned from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() if LooseVersion(kernel_version()) > LooseVersion('5.1'): k_mod = ['nft_nat', 'nft_chain_nat'] else: k_mod = ['nft_nat', 'nft_chain_nat_ipv4'] iptables_nat_config = '/tmp/vyos-nat-rules.nft' def get_handler(json, chain, target): """ Get nftable rule handler number of given chain/target combination. Handler is required when adding NAT/Conntrack helper targets """ for x in json: if x['chain'] != chain: continue if x['target'] != target: continue return x['handle'] return None def verify_rule(config, err_msg): """ Common verify steps used for both source and destination NAT """ if (dict_search('translation.port', config) != None or dict_search('destination.port', config) != None or dict_search('source.port', config)): if config['protocol'] not in ['tcp', 'udp', 'tcp_udp']: raise ConfigError(f'{err_msg}\n' \ 'ports can only be specified when protocol is '\ 'either tcp, udp or tcp_udp!') if '/' in (dict_search('translation.address', config) or []): raise ConfigError(f'{err_msg}\n' \ 'Cannot use ports with an IPv4net type translation address as it\n' \ 'statically maps a whole network of addresses onto another\n' \ 'network of addresses') def get_config(config=None): if config: conf = config else: conf = Config() base = ['nat'] nat = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) # T2665: we must add the tagNode defaults individually until this is # moved to the base class for direction in ['source', 'destination']: if direction in nat: default_values = defaults(base + [direction, 'rule']) - for rule in nat[direction]['rule']: + for rule in dict_search(f'{direction}.rule', nat) or []: nat[direction]['rule'][rule] = dict_merge(default_values, nat[direction]['rule'][rule]) # read in current nftable (once) for further processing tmp = cmd('nft -j list table raw') nftable_json = json.loads(tmp) # condense the full JSON table into a list with only relevand informations pattern = 'nftables[?rule].rule[?expr[].jump].{chain: chain, handle: handle, target: expr[].jump.target | [0]}' condensed_json = jmespath.search(pattern, nftable_json) if not conf.exists(base): nat['helper_functions'] = 'remove' # Retrieve current table handler positions nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_HELPER') nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK') nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_HELPER') nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'NAT_CONNTRACK') nat['deleted'] = '' return nat # check if NAT connection tracking helpers need to be set up - this has to # be done only once if not get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK'): nat['helper_functions'] = 'add' # Retrieve current table handler positions nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_IGNORE') nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'VYATTA_CT_PREROUTING_HOOK') nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_IGNORE') nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'VYATTA_CT_OUTPUT_HOOK') return nat def verify(nat): if not nat or 'deleted' in nat: # no need to verify the CLI as NAT is going to be deactivated return None if 'helper_functions' in nat: if not (nat['pre_ct_ignore'] or nat['pre_ct_conntrack'] or nat['out_ct_ignore'] or nat['out_ct_conntrack']): raise Exception('could not determine nftable ruleset handlers') if dict_search('source.rule', nat): for rule, config in dict_search('source.rule', nat).items(): err_msg = f'Source NAT configuration error in rule {rule}:' if 'outbound_interface' not in config: raise ConfigError(f'{err_msg}\n' \ 'outbound-interface not specified') else: if config['outbound_interface'] not in 'any' and config['outbound_interface'] not in interfaces(): print(f'WARNING: rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') addr = dict_search('translation.address', config) if addr != None: if addr != 'masquerade': for ip in addr.split('-'): if not is_addr_assigned(ip): print(f'WARNING: IP address {ip} does not exist on the system!') elif 'exclude' not in config: raise ConfigError(f'{err_msg}\n' \ 'translation address not specified') # common rule verification verify_rule(config, err_msg) if dict_search('destination.rule', nat): for rule, config in dict_search('destination.rule', nat).items(): err_msg = f'Destination NAT configuration error in rule {rule}:' if 'inbound_interface' not in config: raise ConfigError(f'{err_msg}\n' \ 'inbound-interface not specified') else: if config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): print(f'WARNING: rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') if dict_search('translation.address', config) == None and 'exclude' not in config: raise ConfigError(f'{err_msg}\n' \ 'translation address not specified') # common rule verification verify_rule(config, err_msg) return None def generate(nat): render(iptables_nat_config, 'firewall/nftables-nat.tmpl', nat, permission=0o755) return None def apply(nat): cmd(f'{iptables_nat_config}') if os.path.isfile(iptables_nat_config): os.unlink(iptables_nat_config) return None if __name__ == '__main__': try: check_kmod(k_mod) c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1)