diff --git a/interface-definitions/include/firewall/firewall-hashing-parameters.xml.i b/interface-definitions/include/firewall/firewall-hashing-parameters.xml.i new file mode 100644 index 000000000..7f34de3ba --- /dev/null +++ b/interface-definitions/include/firewall/firewall-hashing-parameters.xml.i @@ -0,0 +1,35 @@ +<!-- include start from firewall/firewall-hashing-parameters.xml.i --> +<leafNode name="hash"> + <properties> + <help>Define the parameters of the packet header to apply the hashing</help> + <completionHelp> + <list>source-address destination-address source-port destination-port random</list> + </completionHelp> + <valueHelp> + <format>source-address</format> + <description>Use source IP address for hashing</description> + </valueHelp> + <valueHelp> + <format>destination-address</format> + <description>Use destination IP address for hashing</description> + </valueHelp> + <valueHelp> + <format>source-port</format> + <description>Use source port for hashing</description> + </valueHelp> + <valueHelp> + <format>destination-port</format> + <description>Use destination port for hashing</description> + </valueHelp> + <valueHelp> + <format>random</format> + <description>Do not use information from ip header. Use random value.</description> + </valueHelp> + <constraint> + <regex>(source-address|destination-address|source-port|destination-port|random)</regex> + </constraint> + <multi/> + </properties> + <defaultValue>random</defaultValue> +</leafNode> +<!-- include end --> \ No newline at end of file diff --git a/interface-definitions/include/firewall/nat-balance.xml.i b/interface-definitions/include/firewall/nat-balance.xml.i new file mode 100644 index 000000000..01793f06b --- /dev/null +++ b/interface-definitions/include/firewall/nat-balance.xml.i @@ -0,0 +1,28 @@ +<!-- include start from firewall/nat-balance.xml.i --> +<tagNode name="backend"> + <properties> + <help>Translated IP address</help> + <valueHelp> + <format>ipv4</format> + <description>IPv4 address to match</description> + </valueHelp> + <constraint> + <validator name="ipv4-address"/> + </constraint> + </properties> + <children> + <leafNode name="weight"> + <properties> + <help>Set probability for this output value</help> + <valueHelp> + <format>u32:1-100</format> + <description>Set probability for this output value</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--allow-range --range 1-100"/> + </constraint> + </properties> + </leafNode> + </children> +</tagNode> +<!-- include end --> \ No newline at end of file diff --git a/interface-definitions/include/nat-rule.xml.i b/interface-definitions/include/nat-rule.xml.i index 7b3b8804e..6234e6195 100644 --- a/interface-definitions/include/nat-rule.xml.i +++ b/interface-definitions/include/nat-rule.xml.i @@ -1,321 +1,330 @@ <!-- include start from nat-rule.xml.i --> <tagNode name="rule"> <properties> <help>Rule number for NAT</help> <valueHelp> <format>u32:1-999999</format> <description>Number of NAT rule</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-999999"/> </constraint> <constraintErrorMessage>NAT rule number must be between 1 and 999999</constraintErrorMessage> </properties> <children> #include <include/generic-description.xml.i> <node name="destination"> <properties> <help>NAT destination parameters</help> </properties> <children> #include <include/nat-address.xml.i> #include <include/nat-port.xml.i> #include <include/firewall/source-destination-group.xml.i> </children> </node> #include <include/generic-disable-node.xml.i> #include <include/nat-exclude.xml.i> + <node name="load-balance"> + <properties> + <help>Apply NAT load balance</help> + </properties> + <children> + #include <include/firewall/firewall-hashing-parameters.xml.i> + #include <include/firewall/nat-balance.xml.i> + </children> + </node> <leafNode name="log"> <properties> <help>NAT rule logging</help> <valueless/> </properties> </leafNode> <leafNode name="packet-type"> <properties> <help>Packet type</help> <completionHelp> <list>broadcast host multicast other</list> </completionHelp> <valueHelp> <format>broadcast</format> <description>Match broadcast packet type</description> </valueHelp> <valueHelp> <format>host</format> <description>Match host packet type, addressed to local host</description> </valueHelp> <valueHelp> <format>multicast</format> <description>Match multicast packet type</description> </valueHelp> <valueHelp> <format>other</format> <description>Match packet addressed to another host</description> </valueHelp> <constraint> <regex>(broadcast|host|multicast|other)</regex> </constraint> </properties> </leafNode> <leafNode name="protocol"> <properties> <help>Protocol to NAT</help> <completionHelp> <list>all ip hopopt icmp igmp ggp ipencap st tcp egp igp pup udp tcp_udp hmp xns-idp rdp iso-tp4 dccp xtp ddp idpr-cmtp ipv6 ipv6-route ipv6-frag idrp rsvp gre esp ah skip ipv6-icmp ipv6-nonxt ipv6-opts rspf vmtp eigrp ospf ax.25 ipip etherip encap 99 pim ipcomp vrrp l2tp isis sctp fc mobility-header udplite mpls-in-ip manet hip shim6 wesp rohc</list> </completionHelp> <valueHelp> <format>all</format> <description>All IP protocols</description> </valueHelp> <valueHelp> <format>ip</format> <description>Internet Protocol, pseudo protocol number</description> </valueHelp> <valueHelp> <format>hopopt</format> <description>IPv6 Hop-by-Hop Option [RFC1883]</description> </valueHelp> <valueHelp> <format>icmp</format> <description>internet control message protocol</description> </valueHelp> <valueHelp> <format>igmp</format> <description>Internet Group Management</description> </valueHelp> <valueHelp> <format>ggp</format> <description>gateway-gateway protocol</description> </valueHelp> <valueHelp> <format>ipencap</format> <description>IP encapsulated in IP (officially IP)</description> </valueHelp> <valueHelp> <format>st</format> <description>ST datagram mode</description> </valueHelp> <valueHelp> <format>tcp</format> <description>transmission control protocol</description> </valueHelp> <valueHelp> <format>egp</format> <description>exterior gateway protocol</description> </valueHelp> <valueHelp> <format>igp</format> <description>any private interior gateway (Cisco)</description> </valueHelp> <valueHelp> <format>pup</format> <description>PARC universal packet protocol</description> </valueHelp> <valueHelp> <format>udp</format> <description>user datagram protocol</description> </valueHelp> <valueHelp> <format>tcp_udp</format> <description>Both TCP and UDP</description> </valueHelp> <valueHelp> <format>hmp</format> <description>host monitoring protocol</description> </valueHelp> <valueHelp> <format>xns-idp</format> <description>Xerox NS IDP</description> </valueHelp> <valueHelp> <format>rdp</format> <description>"reliable datagram" protocol</description> </valueHelp> <valueHelp> <format>iso-tp4</format> <description>ISO Transport Protocol class 4 [RFC905]</description> </valueHelp> <valueHelp> <format>dccp</format> <description>Datagram Congestion Control Prot. [RFC4340]</description> </valueHelp> <valueHelp> <format>xtp</format> <description>Xpress Transfer Protocol</description> </valueHelp> <valueHelp> <format>ddp</format> <description>Datagram Delivery Protocol</description> </valueHelp> <valueHelp> <format>idpr-cmtp</format> <description>IDPR Control Message Transport</description> </valueHelp> <valueHelp> <format>Ipv6</format> <description>Internet Protocol, version 6</description> </valueHelp> <valueHelp> <format>ipv6-route</format> <description>Routing Header for IPv6</description> </valueHelp> <valueHelp> <format>ipv6-frag</format> <description>Fragment Header for IPv6</description> </valueHelp> <valueHelp> <format>idrp</format> <description>Inter-Domain Routing Protocol</description> </valueHelp> <valueHelp> <format>rsvp</format> <description>Reservation Protocol</description> </valueHelp> <valueHelp> <format>gre</format> <description>General Routing Encapsulation</description> </valueHelp> <valueHelp> <format>esp</format> <description>Encap Security Payload [RFC2406]</description> </valueHelp> <valueHelp> <format>ah</format> <description>Authentication Header [RFC2402]</description> </valueHelp> <valueHelp> <format>skip</format> <description>SKIP</description> </valueHelp> <valueHelp> <format>ipv6-icmp</format> <description>ICMP for IPv6</description> </valueHelp> <valueHelp> <format>ipv6-nonxt</format> <description>No Next Header for IPv6</description> </valueHelp> <valueHelp> <format>ipv6-opts</format> <description>Destination Options for IPv6</description> </valueHelp> <valueHelp> <format>rspf</format> <description>Radio Shortest Path First (officially CPHB)</description> </valueHelp> <valueHelp> <format>vmtp</format> <description>Versatile Message Transport</description> </valueHelp> <valueHelp> <format>eigrp</format> <description>Enhanced Interior Routing Protocol (Cisco)</description> </valueHelp> <valueHelp> <format>ospf</format> <description>Open Shortest Path First IGP</description> </valueHelp> <valueHelp> <format>ax.25</format> <description>AX.25 frames</description> </valueHelp> <valueHelp> <format>ipip</format> <description>IP-within-IP Encapsulation Protocol</description> </valueHelp> <valueHelp> <format>etherip</format> <description>Ethernet-within-IP Encapsulation [RFC3378]</description> </valueHelp> <valueHelp> <format>encap</format> <description>Yet Another IP encapsulation [RFC1241]</description> </valueHelp> <valueHelp> <format>99</format> <description>Any private encryption scheme</description> </valueHelp> <valueHelp> <format>pim</format> <description>Protocol Independent Multicast</description> </valueHelp> <valueHelp> <format>ipcomp</format> <description>IP Payload Compression Protocol</description> </valueHelp> <valueHelp> <format>vrrp</format> <description>Virtual Router Redundancy Protocol [RFC5798]</description> </valueHelp> <valueHelp> <format>l2tp</format> <description>Layer Two Tunneling Protocol [RFC2661]</description> </valueHelp> <valueHelp> <format>isis</format> <description>IS-IS over IPv4</description> </valueHelp> <valueHelp> <format>sctp</format> <description>Stream Control Transmission Protocol</description> </valueHelp> <valueHelp> <format>fc</format> <description>Fibre Channel</description> </valueHelp> <valueHelp> <format>mobility-header</format> <description>Mobility Support for IPv6 [RFC3775]</description> </valueHelp> <valueHelp> <format>udplite</format> <description>UDP-Lite [RFC3828]</description> </valueHelp> <valueHelp> <format>mpls-in-ip</format> <description>MPLS-in-IP [RFC4023]</description> </valueHelp> <valueHelp> <format>manet</format> <description>MANET Protocols [RFC5498]</description> </valueHelp> <valueHelp> <format>hip</format> <description>Host Identity Protocol</description> </valueHelp> <valueHelp> <format>shim6</format> <description>Shim6 Protocol</description> </valueHelp> <valueHelp> <format>wesp</format> <description>Wrapped Encapsulating Security Payload</description> </valueHelp> <valueHelp> <format>rohc</format> <description>Robust Header Compression</description> </valueHelp> <valueHelp> <format>u32:0-255</format> <description>IP protocol number</description> </valueHelp> <constraint> <validator name="ip-protocol"/> </constraint> </properties> <defaultValue>all</defaultValue> </leafNode> <node name="source"> <properties> <help>NAT source parameters</help> </properties> <children> #include <include/nat-address.xml.i> #include <include/nat-port.xml.i> #include <include/firewall/source-destination-group.xml.i> </children> </node> </children> </tagNode> <!-- include end --> diff --git a/python/vyos/nat.py b/python/vyos/nat.py index 603fedb9b..418efe649 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -1,248 +1,281 @@ #!/usr/bin/env python3 # # Copyright (C) 2022 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/>. from vyos.template import is_ip_network from vyos.utils.dict import dict_search_args from vyos.template import bracketize_ipv6 def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): output = [] ip_prefix = 'ip6' if ipv6 else 'ip' log_prefix = ('DST' if nat_type == 'destination' else 'SRC') + f'-NAT-{rule_id}' log_suffix = '' if ipv6: log_prefix = log_prefix.replace("NAT-", "NAT66-") ignore_type_addr = False translation_str = '' if 'inbound_interface' in rule_conf: ifname = rule_conf['inbound_interface'] if ifname != 'any': output.append(f'iifname "{ifname}"') if 'outbound_interface' in rule_conf: ifname = rule_conf['outbound_interface'] if ifname != 'any': output.append(f'oifname "{ifname}"') if 'protocol' in rule_conf and rule_conf['protocol'] != 'all': protocol = rule_conf['protocol'] if protocol == 'tcp_udp': protocol = '{ tcp, udp }' output.append(f'meta l4proto {protocol}') if 'packet_type' in rule_conf: output.append(f'pkttype ' + rule_conf['packet_type']) if 'exclude' in rule_conf: translation_str = 'return' log_suffix = '-EXCL' elif 'translation' in rule_conf: addr = dict_search_args(rule_conf, 'translation', 'address') port = dict_search_args(rule_conf, 'translation', 'port') redirect_port = dict_search_args(rule_conf, 'translation', 'redirect', 'port') if redirect_port: translation_output = [f'redirect to {redirect_port}'] else: translation_prefix = nat_type[:1] translation_output = [f'{translation_prefix}nat'] if addr and is_ip_network(addr): if not ipv6: map_addr = dict_search_args(rule_conf, nat_type, 'address') translation_output.append(f'{ip_prefix} prefix to {ip_prefix} {translation_prefix}addr map {{ {map_addr} : {addr} }}') ignore_type_addr = True else: translation_output.append(f'prefix to {addr}') elif addr == 'masquerade': if port: addr = f'{addr} to ' translation_output = [addr] log_suffix = '-MASQ' else: translation_output.append('to') if addr: addr = bracketize_ipv6(addr) translation_output.append(addr) options = [] addr_mapping = dict_search_args(rule_conf, 'translation', 'options', 'address_mapping') port_mapping = dict_search_args(rule_conf, 'translation', 'options', 'port_mapping') if addr_mapping == 'persistent': options.append('persistent') if port_mapping and port_mapping != 'none': options.append(port_mapping) translation_str = " ".join(translation_output) + (f':{port}' if port else '') if options: translation_str += f' {",".join(options)}' + if 'backend' in rule_conf['load_balance']: + hash_input_items = [] + current_prob = 0 + nat_map = [] + + for trans_addr, addr in rule_conf['load_balance']['backend'].items(): + item_prob = int(addr['weight']) + upper_limit = current_prob + item_prob - 1 + hash_val = str(current_prob) + '-' + str(upper_limit) + element = hash_val + " : " + trans_addr + nat_map.append(element) + current_prob = current_prob + item_prob + + elements = ' , '.join(nat_map) + + if 'hash' in rule_conf['load_balance'] and 'random' in rule_conf['load_balance']['hash']: + translation_str += ' numgen random mod 100 map ' + '{ ' + f'{elements}' + ' }' + else: + for input_param in rule_conf['load_balance']['hash']: + if input_param == 'source-address': + param = 'ip saddr' + elif input_param == 'destination-address': + param = 'ip daddr' + elif input_param == 'source-port': + prot = rule_conf['protocol'] + param = f'{prot} sport' + elif input_param == 'destination-port': + prot = rule_conf['protocol'] + param = f'{prot} dport' + hash_input_items.append(param) + hash_input = ' . '.join(hash_input_items) + translation_str += f' jhash ' + f'{hash_input}' + ' mod 100 map ' + '{ ' + f'{elements}' + ' }' + for target in ['source', 'destination']: if target not in rule_conf: continue side_conf = rule_conf[target] prefix = target[:1] addr = dict_search_args(side_conf, 'address') if addr and not (ignore_type_addr and target == nat_type): operator = '' if addr[:1] == '!': operator = '!=' addr = addr[1:] output.append(f'{ip_prefix} {prefix}addr {operator} {addr}') addr_prefix = dict_search_args(side_conf, 'prefix') if addr_prefix and ipv6: operator = '' if addr_prefix[:1] == '!': operator = '!=' addr_prefix = addr[1:] output.append(f'ip6 {prefix}addr {operator} {addr_prefix}') port = dict_search_args(side_conf, 'port') if port: protocol = rule_conf['protocol'] if protocol == 'tcp_udp': protocol = 'th' operator = '' if port[:1] == '!': operator = '!=' port = port[1:] output.append(f'{protocol} {prefix}port {operator} {{ {port} }}') if 'group' in side_conf: group = side_conf['group'] if 'address_group' in group and not (ignore_type_addr and target == nat_type): group_name = group['address_group'] operator = '' if group_name[0] == '!': operator = '!=' group_name = group_name[1:] output.append(f'{ip_prefix} {prefix}addr {operator} @A_{group_name}') # Generate firewall group domain-group elif 'domain_group' in group and not (ignore_type_addr and target == nat_type): group_name = group['domain_group'] operator = '' if group_name[0] == '!': operator = '!=' group_name = group_name[1:] output.append(f'{ip_prefix} {prefix}addr {operator} @D_{group_name}') elif 'network_group' in group and not (ignore_type_addr and target == nat_type): group_name = group['network_group'] operator = '' if group_name[0] == '!': operator = '!=' group_name = group_name[1:] output.append(f'{ip_prefix} {prefix}addr {operator} @N_{group_name}') if 'mac_group' in group: group_name = group['mac_group'] operator = '' if group_name[0] == '!': operator = '!=' group_name = group_name[1:] output.append(f'ether {prefix}addr {operator} @M_{group_name}') if 'port_group' in group: proto = rule_conf['protocol'] group_name = group['port_group'] if proto == 'tcp_udp': proto = 'th' operator = '' if group_name[0] == '!': operator = '!=' group_name = group_name[1:] output.append(f'{proto} {prefix}port {operator} @P_{group_name}') output.append('counter') if 'log' in rule_conf: output.append(f'log prefix "[{log_prefix}{log_suffix}]"') if translation_str: output.append(translation_str) output.append(f'comment "{log_prefix}"') return " ".join(output) def parse_nat_static_rule(rule_conf, rule_id, nat_type): output = [] log_prefix = ('STATIC-DST' if nat_type == 'destination' else 'STATIC-SRC') + f'-NAT-{rule_id}' log_suffix = '' ignore_type_addr = False translation_str = '' if 'inbound_interface' in rule_conf: ifname = rule_conf['inbound_interface'] ifprefix = 'i' if nat_type == 'destination' else 'o' if ifname != 'any': output.append(f'{ifprefix}ifname "{ifname}"') if 'exclude' in rule_conf: translation_str = 'return' log_suffix = '-EXCL' elif 'translation' in rule_conf: translation_prefix = nat_type[:1] translation_output = [f'{translation_prefix}nat'] addr = dict_search_args(rule_conf, 'translation', 'address') map_addr = dict_search_args(rule_conf, 'destination', 'address') if nat_type == 'source': addr, map_addr = map_addr, addr # Swap if addr and is_ip_network(addr): translation_output.append(f'ip prefix to ip {translation_prefix}addr map {{ {map_addr} : {addr} }}') ignore_type_addr = True elif addr: translation_output.append(f'to {addr}') options = [] addr_mapping = dict_search_args(rule_conf, 'translation', 'options', 'address_mapping') port_mapping = dict_search_args(rule_conf, 'translation', 'options', 'port_mapping') if addr_mapping == 'persistent': options.append('persistent') if port_mapping and port_mapping != 'none': options.append(port_mapping) if options: translation_output.append(",".join(options)) translation_str = " ".join(translation_output) prefix = nat_type[:1] addr = dict_search_args(rule_conf, 'translation' if nat_type == 'source' else nat_type, 'address') if addr and not ignore_type_addr: output.append(f'ip {prefix}addr {addr}') output.append('counter') if translation_str: output.append(translation_str) if 'log' in rule_conf: output.append(f'log prefix "[{log_prefix}{log_suffix}]"') output.append(f'comment "{log_prefix}"') return " ".join(output) diff --git a/smoketest/scripts/cli/test_nat.py b/smoketest/scripts/cli/test_nat.py index 28d566eba..e6eaedeff 100755 --- a/smoketest/scripts/cli/test_nat.py +++ b/smoketest/scripts/cli/test_nat.py @@ -1,256 +1,292 @@ #!/usr/bin/env python3 # # Copyright (C) 2020-2022 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 import unittest from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError from vyos.utils.process import cmd from vyos.utils.dict import dict_search base_path = ['nat'] src_path = base_path + ['source'] dst_path = base_path + ['destination'] static_path = base_path + ['static'] nftables_nat_config = '/run/nftables_nat.conf' nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' class TestNAT(VyOSUnitTestSHIM.TestCase): @classmethod def setUpClass(cls): super(TestNAT, cls).setUpClass() # ensure we can also run this test on a live system - so lets clean # out the current configuration :) cls.cli_delete(cls, base_path) def tearDown(self): self.cli_delete(base_path) self.cli_commit() self.assertFalse(os.path.exists(nftables_nat_config)) self.assertFalse(os.path.exists(nftables_static_nat_conf)) def verify_nftables(self, nftables_search, table, inverse=False, args=''): nftables_output = cmd(f'sudo nft {args} list table {table}') 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(not matched if inverse else matched, msg=search) def wait_for_domain_resolver(self, table, set_name, element, max_wait=10): # Resolver no longer blocks commit, need to wait for daemon to populate set count = 0 while count < max_wait: code = run(f'sudo nft get element {table} {set_name} {{ {element} }}') if code == 0: return True count += 1 sleep(1) return False def test_snat(self): rules = ['100', '110', '120', '130', '200', '210', '220', '230'] outbound_iface_100 = 'eth0' outbound_iface_200 = 'eth1' nftables_search = ['jump VYOS_PRE_SNAT_HOOK'] 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.cli_set(src_path + ['rule', rule, 'source', 'address', network]) self.cli_set(src_path + ['rule', rule, 'outbound-interface', outbound_iface_100]) self.cli_set(src_path + ['rule', rule, 'translation', 'address', 'masquerade']) nftables_search.append([f'saddr {network}', f'oifname "{outbound_iface_100}"', 'masquerade']) else: self.cli_set(src_path + ['rule', rule, 'destination', 'address', network]) self.cli_set(src_path + ['rule', rule, 'outbound-interface', outbound_iface_200]) self.cli_set(src_path + ['rule', rule, 'exclude']) nftables_search.append([f'daddr {network}', f'oifname "{outbound_iface_200}"', 'return']) self.cli_commit() self.verify_nftables(nftables_search, 'ip vyos_nat') def test_snat_groups(self): address_group = 'smoketest_addr' address_group_member = '192.0.2.1' rule = '100' outbound_iface = 'eth0' self.cli_set(['firewall', 'group', 'address-group', address_group, 'address', address_group_member]) self.cli_set(src_path + ['rule', rule, 'source', 'group', 'address-group', address_group]) self.cli_set(src_path + ['rule', rule, 'outbound-interface', outbound_iface]) self.cli_set(src_path + ['rule', rule, 'translation', 'address', 'masquerade']) self.cli_commit() nftables_search = [ [f'set A_{address_group}'], [f'elements = {{ {address_group_member} }}'], [f'ip saddr @A_{address_group}', f'oifname "{outbound_iface}"', 'masquerade'] ] self.verify_nftables(nftables_search, 'ip vyos_nat') self.cli_delete(['firewall']) 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' nftables_search = ['jump VYOS_PRE_DNAT_HOOK'] for rule in rules: port = f'10{rule}' self.cli_set(dst_path + ['rule', rule, 'source', 'port', port]) self.cli_set(dst_path + ['rule', rule, 'translation', 'address', '192.0.2.1']) self.cli_set(dst_path + ['rule', rule, 'translation', 'port', port]) rule_search = [f'dnat to 192.0.2.1:{port}'] if int(rule) < 200: self.cli_set(dst_path + ['rule', rule, 'protocol', inbound_proto_100]) self.cli_set(dst_path + ['rule', rule, 'inbound-interface', inbound_iface_100]) rule_search.append(f'{inbound_proto_100} sport {port}') rule_search.append(f'iifname "{inbound_iface_100}"') else: self.cli_set(dst_path + ['rule', rule, 'protocol', inbound_proto_200]) self.cli_set(dst_path + ['rule', rule, 'inbound-interface', inbound_iface_200]) rule_search.append(f'iifname "{inbound_iface_200}"') nftables_search.append(rule_search) self.cli_commit() self.verify_nftables(nftables_search, 'ip vyos_nat') def test_snat_required_translation_address(self): # T2813: Ensure translation address is specified rule = '5' self.cli_set(src_path + ['rule', rule, 'source', 'address', '192.0.2.0/24']) # check validate() - outbound-interface must be defined with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_set(src_path + ['rule', rule, 'outbound-interface', 'eth0']) # check validate() - translation address not specified with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_set(src_path + ['rule', rule, 'translation', 'address', 'masquerade']) self.cli_commit() def test_dnat_negated_addresses(self): # T3186: negated addresses are not accepted by nftables rule = '1000' self.cli_set(dst_path + ['rule', rule, 'destination', 'address', '!192.0.2.1']) self.cli_set(dst_path + ['rule', rule, 'destination', 'port', '53']) self.cli_set(dst_path + ['rule', rule, 'inbound-interface', 'eth0']) self.cli_set(dst_path + ['rule', rule, 'protocol', 'tcp_udp']) self.cli_set(dst_path + ['rule', rule, 'source', 'address', '!192.0.2.1']) self.cli_set(dst_path + ['rule', rule, 'translation', 'address', '192.0.2.1']) self.cli_set(dst_path + ['rule', rule, 'translation', 'port', '53']) self.cli_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.cli_set(src_path) self.cli_set(dst_path) self.cli_set(static_path) self.cli_commit() def test_dnat_without_translation_address(self): self.cli_set(dst_path + ['rule', '1', 'inbound-interface', 'eth1']) self.cli_set(dst_path + ['rule', '1', 'destination', 'port', '443']) self.cli_set(dst_path + ['rule', '1', 'protocol', 'tcp']) self.cli_set(dst_path + ['rule', '1', 'packet-type', 'host']) self.cli_set(dst_path + ['rule', '1', 'translation', 'port', '443']) self.cli_commit() nftables_search = [ ['iifname "eth1"', 'tcp dport 443', 'pkttype host', 'dnat to :443'] ] self.verify_nftables(nftables_search, 'ip vyos_nat') def test_static_nat(self): dst_addr_1 = '10.0.1.1' translate_addr_1 = '192.168.1.1' dst_addr_2 = '203.0.113.0/24' translate_addr_2 = '192.0.2.0/24' ifname = 'eth0' self.cli_set(static_path + ['rule', '10', 'destination', 'address', dst_addr_1]) self.cli_set(static_path + ['rule', '10', 'inbound-interface', ifname]) self.cli_set(static_path + ['rule', '10', 'translation', 'address', translate_addr_1]) self.cli_set(static_path + ['rule', '20', 'destination', 'address', dst_addr_2]) self.cli_set(static_path + ['rule', '20', 'inbound-interface', ifname]) self.cli_set(static_path + ['rule', '20', 'translation', 'address', translate_addr_2]) self.cli_commit() nftables_search = [ [f'iifname "{ifname}"', f'ip daddr {dst_addr_1}', f'dnat to {translate_addr_1}'], [f'oifname "{ifname}"', f'ip saddr {translate_addr_1}', f'snat to {dst_addr_1}'], [f'iifname "{ifname}"', f'dnat ip prefix to ip daddr map {{ {dst_addr_2} : {translate_addr_2} }}'], [f'oifname "{ifname}"', f'snat ip prefix to ip saddr map {{ {translate_addr_2} : {dst_addr_2} }}'] ] self.verify_nftables(nftables_search, 'ip vyos_static_nat') def test_dnat_redirect(self): dst_addr_1 = '10.0.1.1' dest_port = '5122' protocol = 'tcp' redirected_port = '22' ifname = 'eth0' self.cli_set(dst_path + ['rule', '10', 'destination', 'address', dst_addr_1]) self.cli_set(dst_path + ['rule', '10', 'destination', 'port', dest_port]) self.cli_set(dst_path + ['rule', '10', 'protocol', protocol]) self.cli_set(dst_path + ['rule', '10', 'inbound-interface', ifname]) self.cli_set(dst_path + ['rule', '10', 'translation', 'redirect', 'port', redirected_port]) self.cli_commit() nftables_search = [ [f'iifname "{ifname}"', f'ip daddr {dst_addr_1}', f'{protocol} dport {dest_port}', f'redirect to :{redirected_port}'] ] self.verify_nftables(nftables_search, 'ip vyos_nat') + def test_nat_balance(self): + ifname = 'eth0' + member_1 = '198.51.100.1' + weight_1 = '10' + member_2 = '198.51.100.2' + weight_2 = '90' + member_3 = '192.0.2.1' + weight_3 = '35' + member_4 = '192.0.2.2' + weight_4 = '65' + dst_port = '443' + + self.cli_set(dst_path + ['rule', '1', 'inbound-interface', ifname]) + self.cli_set(dst_path + ['rule', '1', 'protocol', 'tcp']) + self.cli_set(dst_path + ['rule', '1', 'destination', 'port', dst_port]) + self.cli_set(dst_path + ['rule', '1', 'load-balance', 'hash', 'source-address']) + self.cli_set(dst_path + ['rule', '1', 'load-balance', 'hash', 'source-port']) + self.cli_set(dst_path + ['rule', '1', 'load-balance', 'hash', 'destination-address']) + self.cli_set(dst_path + ['rule', '1', 'load-balance', 'hash', 'destination-port']) + self.cli_set(dst_path + ['rule', '1', 'load-balance', 'backend', member_1, 'weight', weight_1]) + self.cli_set(dst_path + ['rule', '1', 'load-balance', 'backend', member_2, 'weight', weight_2]) + + self.cli_set(src_path + ['rule', '1', 'outbound-interface', ifname]) + self.cli_set(src_path + ['rule', '1', 'load-balance', 'hash', 'random']) + self.cli_set(src_path + ['rule', '1', 'load-balance', 'backend', member_3, 'weight', weight_3]) + self.cli_set(src_path + ['rule', '1', 'load-balance', 'backend', member_4, 'weight', weight_4]) + + self.cli_commit() + + nftables_search = [ + [f'iifname "{ifname}"', f'tcp dport {dst_port}', f'dnat to jhash ip saddr . tcp sport . ip daddr . tcp dport mod 100 map', f'0-9 : {member_1}, 10-99 : {member_2}'], + [f'oifname "{ifname}"', f'snat to numgen random mod 100 map', f'0-34 : {member_3}, 35-99 : {member_4}'] + ] + + self.verify_nftables(nftables_search, 'ip vyos_nat') + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index b27470b6e..8e3a11ff4 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -1,282 +1,293 @@ #!/usr/bin/env python3 # # Copyright (C) 2020-2023 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.base import Warning from vyos.config import Config from vyos.configdict import dict_merge from vyos.template import render from vyos.template import is_ip_network from vyos.utils.kernel import check_kmod from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.process import cmd from vyos.utils.process import run from vyos.utils.network 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'] nftables_nat_config = '/run/nftables_nat.conf' nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' valid_groups = [ 'address_group', 'domain_group', 'network_group', 'port_group' ] 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, groups_dict): """ Common verify steps used for both source and destination NAT """ if (dict_search('translation.port', config) != None or dict_search('translation.redirect.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 is_ip_network(dict_search('translation.address', config)): raise ConfigError(f'{err_msg}\n' \ 'Cannot use ports with an IPv4 network as translation address as it\n' \ 'statically maps a whole network of addresses onto another\n' \ 'network of addresses') for side in ['destination', 'source']: if side in config: side_conf = config[side] if len({'address', 'fqdn'} & set(side_conf)) > 1: raise ConfigError('Only one of address, fqdn or geoip can be specified') if 'group' in side_conf: if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: raise ConfigError('Only one address-group, network-group or domain-group can be specified') for group in valid_groups: if group in side_conf['group']: group_name = side_conf['group'][group] error_group = group.replace("_", "-") if group in ['address_group', 'network_group', 'domain_group']: types = [t for t in ['address', 'fqdn'] if t in side_conf] if types: raise ConfigError(f'{error_group} and {types[0]} cannot both be defined') if group_name and group_name[0] == '!': group_name = group_name[1:] group_obj = dict_search_args(groups_dict, group, group_name) if group_obj is None: raise ConfigError(f'Invalid {error_group} "{group_name}" on firewall rule') if not group_obj: Warning(f'{error_group} "{group_name}" has no members!') if dict_search_args(side_conf, 'group', 'port_group'): if 'protocol' not in config: raise ConfigError('Protocol must be defined if specifying a port-group') if config['protocol'] not in ['tcp', 'udp', 'tcp_udp']: raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port-group') + if 'load_balance' in config: + for item in ['source-port', 'destination-port']: + if item in config['load_balance']['hash'] and config['protocol'] not in ['tcp', 'udp']: + raise ConfigError('Protocol must be tcp or udp when specifying hash ports') + count = 0 + if 'backend' in config['load_balance']: + for member in config['load_balance']['backend']: + weight = config['load_balance']['backend'][member]['weight'] + count = count + int(weight) + if count != 100: + Warning(f'Sum of weight for nat load balance rule is not 100. You may get unexpected behaviour') + 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', 'static']: if direction in nat: default_values = defaults(base + [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): if get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_HELPER'): nat['helper_functions'] = 'remove' # Retrieve current table handler positions nat['pre_ct_ignore'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_HELPER') nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'NAT_CONNTRACK') nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_HELPER') nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'NAT_CONNTRACK') nat['deleted'] = '' return nat nat['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) # 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', 'VYOS_CT_IGNORE') nat['pre_ct_conntrack'] = get_handler(condensed_json, 'PREROUTING', 'VYOS_CT_PREROUTING_HOOK') nat['out_ct_ignore'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_CT_IGNORE') nat['out_ct_conntrack'] = get_handler(condensed_json, 'OUTPUT', 'VYOS_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} outbound-interface not specified') if config['outbound_interface'] not in 'any' and config['outbound_interface'] not in interfaces(): Warning(f'rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') if not dict_search('translation.address', config) and not dict_search('translation.port', config): - if 'exclude' not in config: + if 'exclude' not in config and 'backend' not in config['load_balance']: raise ConfigError(f'{err_msg} translation requires address and/or port') addr = dict_search('translation.address', config) if addr != None and addr != 'masquerade' and not is_ip_network(addr): for ip in addr.split('-'): if not is_addr_assigned(ip): Warning(f'IP address {ip} does not exist on the system!') # common rule verification verify_rule(config, err_msg, nat['firewall_group']) - 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') elif config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') if not dict_search('translation.address', config) and not dict_search('translation.port', config) and not dict_search('translation.redirect.port', config): - if 'exclude' not in config: + if 'exclude' not in config and 'backend' not in config['load_balance']: raise ConfigError(f'{err_msg} translation requires address and/or port') # common rule verification verify_rule(config, err_msg, nat['firewall_group']) if dict_search('static.rule', nat): for rule, config in dict_search('static.rule', nat).items(): err_msg = f'Static NAT configuration error in rule {rule}:' if 'inbound_interface' not in config: raise ConfigError(f'{err_msg}\n' \ 'inbound-interface not specified') # common rule verification verify_rule(config, err_msg, nat['firewall_group']) return None def generate(nat): if not os.path.exists(nftables_nat_config): nat['first_install'] = True render(nftables_nat_config, 'firewall/nftables-nat.j2', nat) render(nftables_static_nat_conf, 'firewall/nftables-static-nat.j2', nat) # dry-run newly generated configuration tmp = run(f'nft -c -f {nftables_nat_config}') if tmp > 0: raise ConfigError('Configuration file errors encountered!') tmp = run(f'nft -c -f {nftables_static_nat_conf}') if tmp > 0: raise ConfigError('Configuration file errors encountered!') return None def apply(nat): cmd(f'nft -f {nftables_nat_config}') cmd(f'nft -f {nftables_static_nat_conf}') if not nat or 'deleted' in nat: os.unlink(nftables_nat_config) os.unlink(nftables_static_nat_conf) 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)