diff --git a/data/templates/firewall/nftables-nat.j2 b/data/templates/firewall/nftables-nat.j2 index 4254f6a0e..8c8dd3a8b 100644 --- a/data/templates/firewall/nftables-nat.j2 +++ b/data/templates/firewall/nftables-nat.j2 @@ -1,46 +1,59 @@ #!/usr/sbin/nft -f {% import 'firewall/nftables-defines.j2' as group_tmpl %} {% if first_install is not vyos_defined %} delete table ip vyos_nat {% endif %} {% if deleted is not vyos_defined %} table ip vyos_nat { # # Destination NAT rules build up here # chain PREROUTING { type nat hook prerouting priority -100; policy accept; counter jump VYOS_PRE_DNAT_HOOK {% if destination.rule is vyos_defined %} {% for rule, config in destination.rule.items() if config.disable is not vyos_defined %} {{ config | nat_rule(rule, 'destination') }} {% endfor %} {% endif %} } +{% for set_name in ip_fqdn %} + set FQDN_nat_{{ set_name }} { + type ipv4_addr + flags interval + } +{% endfor %} # # Source NAT rules build up here # chain POSTROUTING { type nat hook postrouting priority 100; policy accept; counter jump VYOS_PRE_SNAT_HOOK {% if source.rule is vyos_defined %} {% for rule, config in source.rule.items() if config.disable is not vyos_defined %} {{ config | nat_rule(rule, 'source') }} {% endfor %} {% endif %} + + } +{% for set_name in ip_fqdn %} + set FQDN_nat_{{ set_name }} { + type ipv4_addr + flags interval } +{% endfor %} chain VYOS_PRE_DNAT_HOOK { return } chain VYOS_PRE_SNAT_HOOK { return } {{ group_tmpl.groups(firewall_group, False, True) }} } {% endif %} diff --git a/interface-definitions/include/nat-rule.xml.i b/interface-definitions/include/nat-rule.xml.i index deb13529d..0a7179ff1 100644 --- a/interface-definitions/include/nat-rule.xml.i +++ b/interface-definitions/include/nat-rule.xml.i @@ -1,325 +1,327 @@ <!-- 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/firewall/fqdn.xml.i> #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> #include <include/firewall/log.xml.i> <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/firewall/fqdn.xml.i> #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/firewall.py b/python/vyos/firewall.py index 8913ba152..fe4326807 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -1,672 +1,677 @@ # Copyright (C) 2021-2024 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 csv import gzip import os import re from pathlib import Path from socket import AF_INET from socket import AF_INET6 from socket import getaddrinfo from time import strftime from vyos.remote import download from vyos.template import is_ipv4 from vyos.template import render from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive from vyos.utils.process import cmd from vyos.utils.process import run # Conntrack def conntrack_required(conf): required_nodes = ['nat', 'nat66', 'load-balancing wan'] for path in required_nodes: if conf.exists(path): return True firewall = conf.get_config_dict(['firewall'], key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True) for rules, path in dict_search_recursive(firewall, 'rule'): if any(('state' in rule_conf or 'connection_status' in rule_conf or 'offload_target' in rule_conf) for rule_conf in rules.values()): return True return False # Domain Resolver -def fqdn_config_parse(firewall): - firewall['ip_fqdn'] = {} - firewall['ip6_fqdn'] = {} - - for domain, path in dict_search_recursive(firewall, 'fqdn'): - hook_name = path[1] - priority = path[2] - - fw_name = path[2] - rule = path[4] - suffix = path[5][0] - set_name = f'{hook_name}_{priority}_{rule}_{suffix}' - - if (path[0] == 'ipv4') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'): - firewall['ip_fqdn'][set_name] = domain - elif (path[0] == 'ipv6') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'): - if path[1] == 'name': - set_name = f'name6_{priority}_{rule}_{suffix}' - firewall['ip6_fqdn'][set_name] = domain +def fqdn_config_parse(config, node): + config['ip_fqdn'] = {} + config['ip6_fqdn'] = {} + + for domain, path in dict_search_recursive(config, 'fqdn'): + if node != 'nat': + hook_name = path[1] + priority = path[2] + + rule = path[4] + suffix = path[5][0] + set_name = f'{hook_name}_{priority}_{rule}_{suffix}' + + if (path[0] == 'ipv4') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'): + config['ip_fqdn'][set_name] = domain + elif (path[0] == 'ipv6') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'): + if path[1] == 'name': + set_name = f'name6_{priority}_{rule}_{suffix}' + config['ip6_fqdn'][set_name] = domain + else: + # Parse FQDN for NAT + nat_direction = path[0] + nat_rule = path[2] + suffix = path[3][0] + set_name = f'{nat_direction}_{nat_rule}_{suffix}' + config['ip_fqdn'][set_name] = domain def fqdn_resolve(fqdn, ipv6=False): try: res = getaddrinfo(fqdn, None, AF_INET6 if ipv6 else AF_INET) return set(item[4][0] for item in res) except: return None -# End Domain Resolver - def find_nftables_rule(table, chain, rule_matches=[]): # Find rule in table/chain that matches all criteria and return the handle results = cmd(f'sudo nft --handle list chain {table} {chain}').split("\n") for line in results: if all(rule_match in line for rule_match in rule_matches): handle_search = re.search('handle (\d+)', line) if handle_search: return handle_search[1] return None def remove_nftables_rule(table, chain, handle): cmd(f'sudo nft delete rule {table} {chain} handle {handle}') # Functions below used by template generation def nft_action(vyos_action): if vyos_action == 'accept': return 'return' return vyos_action def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): output = [] if ip_name == 'ip6': def_suffix = '6' family = 'ipv6' else: def_suffix = '' family = 'bri' if ip_name == 'bri' else 'ipv4' if 'state' in rule_conf and rule_conf['state']: states = ",".join([s for s in rule_conf['state']]) if states: output.append(f'ct state {{{states}}}') if 'conntrack_helper' in rule_conf: helper_map = {'h323': ['RAS', 'Q.931'], 'nfs': ['rpc'], 'sqlnet': ['tns']} helper_out = [] for helper in rule_conf['conntrack_helper']: if helper in helper_map: helper_out.extend(helper_map[helper]) else: helper_out.append(helper) if helper_out: helper_str = ','.join(f'"{s}"' for s in helper_out) output.append(f'ct helper {{{helper_str}}}') if 'connection_status' in rule_conf and rule_conf['connection_status']: status = rule_conf['connection_status'] if status['nat'] == 'destination': nat_status = 'dnat' output.append(f'ct status {nat_status}') if status['nat'] == 'source': nat_status = 'snat' output.append(f'ct status {nat_status}') if 'protocol' in rule_conf and rule_conf['protocol'] != 'all': proto = rule_conf['protocol'] operator = '' if proto[0] == '!': operator = '!=' proto = proto[1:] if proto == 'tcp_udp': proto = '{tcp, udp}' output.append(f'meta l4proto {operator} {proto}') for side in ['destination', 'source']: if side in rule_conf: prefix = side[0] side_conf = rule_conf[side] address_mask = side_conf.get('address_mask', None) if 'address' in side_conf: suffix = side_conf['address'] operator = '' exclude = suffix[0] == '!' if exclude: operator = '!= ' suffix = suffix[1:] if address_mask: operator = '!=' if exclude else '==' operator = f'& {address_mask} {operator} ' if suffix.find('-') != -1: # Range start, end = suffix.split('-') if is_ipv4(start): output.append(f'ip {prefix}addr {operator}{suffix}') else: output.append(f'ip6 {prefix}addr {operator}{suffix}') else: if is_ipv4(suffix): output.append(f'ip {prefix}addr {operator}{suffix}') else: output.append(f'ip6 {prefix}addr {operator}{suffix}') if 'fqdn' in side_conf: fqdn = side_conf['fqdn'] hook_name = '' operator = '' if fqdn[0] == '!': operator = '!=' if hook == 'FWD': hook_name = 'forward' if hook == 'INP': hook_name = 'input' if hook == 'OUT': hook_name = 'output' if hook == 'PRE': hook_name = 'prerouting' if hook == 'NAM': hook_name = f'name{def_suffix}' output.append(f'{ip_name} {prefix}addr {operator} @FQDN_{hook_name}_{fw_name}_{rule_id}_{prefix}') if dict_search_args(side_conf, 'geoip', 'country_code'): operator = '' hook_name = '' if dict_search_args(side_conf, 'geoip', 'inverse_match') != None: operator = '!=' if hook == 'FWD': hook_name = 'forward' if hook == 'INP': hook_name = 'input' if hook == 'OUT': hook_name = 'output' if hook == 'PRE': hook_name = 'prerouting' if hook == 'NAM': hook_name = f'name' output.append(f'{ip_name} {prefix}addr {operator} @GEOIP_CC{def_suffix}_{hook_name}_{fw_name}_{rule_id}') if 'mac_address' in side_conf: suffix = side_conf["mac_address"] if suffix[0] == '!': suffix = f'!= {suffix[1:]}' output.append(f'ether {prefix}addr {suffix}') if 'port' in side_conf: proto = rule_conf['protocol'] port = side_conf['port'].split(',') ports = [] negated_ports = [] for p in port: if p[0] == '!': negated_ports.append(p[1:]) else: ports.append(p) if proto == 'tcp_udp': proto = 'th' if ports: ports_str = ','.join(ports) output.append(f'{proto} {prefix}port {{{ports_str}}}') if negated_ports: negated_ports_str = ','.join(negated_ports) output.append(f'{proto} {prefix}port != {{{negated_ports_str}}}') if 'group' in side_conf: group = side_conf['group'] if 'address_group' in group: group_name = group['address_group'] operator = '' exclude = group_name[0] == "!" if exclude: operator = '!=' group_name = group_name[1:] if address_mask: operator = '!=' if exclude else '==' operator = f'& {address_mask} {operator}' output.append(f'{ip_name} {prefix}addr {operator} @A{def_suffix}_{group_name}') elif 'dynamic_address_group' in group: group_name = group['dynamic_address_group'] operator = '' exclude = group_name[0] == "!" if exclude: operator = '!=' group_name = group_name[1:] output.append(f'{ip_name} {prefix}addr {operator} @DA{def_suffix}_{group_name}') # Generate firewall group domain-group elif 'domain_group' in group: group_name = group['domain_group'] operator = '' if group_name[0] == '!': operator = '!=' group_name = group_name[1:] output.append(f'{ip_name} {prefix}addr {operator} @D_{group_name}') elif 'network_group' in group: group_name = group['network_group'] operator = '' if group_name[0] == '!': operator = '!=' group_name = group_name[1:] output.append(f'{ip_name} {prefix}addr {operator} @N{def_suffix}_{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}') if dict_search_args(rule_conf, 'action') == 'synproxy': output.append('ct state invalid,untracked') if 'hop_limit' in rule_conf: operators = {'eq': '==', 'gt': '>', 'lt': '<'} for op, operator in operators.items(): if op in rule_conf['hop_limit']: value = rule_conf['hop_limit'][op] output.append(f'ip6 hoplimit {operator} {value}') if 'inbound_interface' in rule_conf: operator = '' if 'name' in rule_conf['inbound_interface']: iiface = rule_conf['inbound_interface']['name'] if iiface[0] == '!': operator = '!=' iiface = iiface[1:] output.append(f'iifname {operator} {{{iiface}}}') elif 'group' in rule_conf['inbound_interface']: iiface = rule_conf['inbound_interface']['group'] if iiface[0] == '!': operator = '!=' iiface = iiface[1:] output.append(f'iifname {operator} @I_{iiface}') if 'outbound_interface' in rule_conf: operator = '' if 'name' in rule_conf['outbound_interface']: oiface = rule_conf['outbound_interface']['name'] if oiface[0] == '!': operator = '!=' oiface = oiface[1:] output.append(f'oifname {operator} {{{oiface}}}') elif 'group' in rule_conf['outbound_interface']: oiface = rule_conf['outbound_interface']['group'] if oiface[0] == '!': operator = '!=' oiface = oiface[1:] output.append(f'oifname {operator} @I_{oiface}') if 'ttl' in rule_conf: operators = {'eq': '==', 'gt': '>', 'lt': '<'} for op, operator in operators.items(): if op in rule_conf['ttl']: value = rule_conf['ttl'][op] output.append(f'ip ttl {operator} {value}') for icmp in ['icmp', 'icmpv6']: if icmp in rule_conf: if 'type_name' in rule_conf[icmp]: output.append(icmp + ' type ' + rule_conf[icmp]['type_name']) else: if 'code' in rule_conf[icmp]: output.append(icmp + ' code ' + rule_conf[icmp]['code']) if 'type' in rule_conf[icmp]: output.append(icmp + ' type ' + rule_conf[icmp]['type']) if 'packet_length' in rule_conf: lengths_str = ','.join(rule_conf['packet_length']) output.append(f'ip{def_suffix} length {{{lengths_str}}}') if 'packet_length_exclude' in rule_conf: negated_lengths_str = ','.join(rule_conf['packet_length_exclude']) output.append(f'ip{def_suffix} length != {{{negated_lengths_str}}}') if 'packet_type' in rule_conf: output.append(f'pkttype ' + rule_conf['packet_type']) if 'dscp' in rule_conf: dscp_str = ','.join(rule_conf['dscp']) output.append(f'ip{def_suffix} dscp {{{dscp_str}}}') if 'dscp_exclude' in rule_conf: negated_dscp_str = ','.join(rule_conf['dscp_exclude']) output.append(f'ip{def_suffix} dscp != {{{negated_dscp_str}}}') if 'ipsec' in rule_conf: if 'match_ipsec' in rule_conf['ipsec']: output.append('meta ipsec == 1') if 'match_none' in rule_conf['ipsec']: output.append('meta ipsec == 0') if 'fragment' in rule_conf: # Checking for fragmentation after priority -400 is not possible, # so we use a priority -450 hook to set a mark if 'match_frag' in rule_conf['fragment']: output.append('meta mark 0xffff1') if 'match_non_frag' in rule_conf['fragment']: output.append('meta mark != 0xffff1') if 'limit' in rule_conf: if 'rate' in rule_conf['limit']: output.append(f'limit rate {rule_conf["limit"]["rate"]}') if 'burst' in rule_conf['limit']: output.append(f'burst {rule_conf["limit"]["burst"]} packets') if 'recent' in rule_conf: count = rule_conf['recent']['count'] time = rule_conf['recent']['time'] output.append(f'add @RECENT{def_suffix}_{hook}_{fw_name}_{rule_id} {{ {ip_name} saddr limit rate over {count}/{time} burst {count} packets }}') if 'time' in rule_conf: output.append(parse_time(rule_conf['time'])) tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: output.append(parse_tcp_flags(tcp_flags)) # TCP MSS tcp_mss = dict_search_args(rule_conf, 'tcp', 'mss') if tcp_mss: output.append(f'tcp option maxseg size {tcp_mss}') if 'connection_mark' in rule_conf: conn_mark_str = ','.join(rule_conf['connection_mark']) output.append(f'ct mark {{{conn_mark_str}}}') if 'mark' in rule_conf: mark = rule_conf['mark'] operator = '' if mark[0] == '!': operator = '!=' mark = mark[1:] output.append(f'meta mark {operator} {{{mark}}}') if 'vlan' in rule_conf: if 'id' in rule_conf['vlan']: output.append(f'vlan id {rule_conf["vlan"]["id"]}') if 'priority' in rule_conf['vlan']: output.append(f'vlan pcp {rule_conf["vlan"]["priority"]}') if 'log' in rule_conf: action = rule_conf['action'] if 'action' in rule_conf else 'accept' #output.append(f'log prefix "[{fw_name[:19]}-{rule_id}-{action[:1].upper()}]"') output.append(f'log prefix "[{family}-{hook}-{fw_name}-{rule_id}-{action[:1].upper()}]"') ##{family}-{hook}-{fw_name}-{rule_id} if 'log_options' in rule_conf: if 'level' in rule_conf['log_options']: log_level = rule_conf['log_options']['level'] output.append(f'log level {log_level}') if 'group' in rule_conf['log_options']: log_group = rule_conf['log_options']['group'] output.append(f'log group {log_group}') if 'queue_threshold' in rule_conf['log_options']: queue_threshold = rule_conf['log_options']['queue_threshold'] output.append(f'queue-threshold {queue_threshold}') if 'snapshot_length' in rule_conf['log_options']: log_snaplen = rule_conf['log_options']['snapshot_length'] output.append(f'snaplen {log_snaplen}') output.append('counter') if 'add_address_to_group' in rule_conf: for side in ['destination_address', 'source_address']: if side in rule_conf['add_address_to_group']: prefix = side[0] side_conf = rule_conf['add_address_to_group'][side] dyn_group = side_conf['address_group'] if 'timeout' in side_conf: timeout_value = side_conf['timeout'] output.append(f'set update ip{def_suffix} {prefix}addr timeout {timeout_value} @DA{def_suffix}_{dyn_group}') else: output.append(f'set update ip{def_suffix} saddr @DA{def_suffix}_{dyn_group}') set_table = False if 'set' in rule_conf: # Parse set command used in policy route: if 'connection_mark' in rule_conf['set']: conn_mark = rule_conf['set']['connection_mark'] output.append(f'ct mark set {conn_mark}') if 'dscp' in rule_conf['set']: dscp = rule_conf['set']['dscp'] output.append(f'ip{def_suffix} dscp set {dscp}') if 'mark' in rule_conf['set']: mark = rule_conf['set']['mark'] output.append(f'meta mark set {mark}') if 'table' in rule_conf['set']: set_table = True table = rule_conf['set']['table'] if table == 'main': table = '254' mark = 0x7FFFFFFF - int(table) output.append(f'meta mark set {mark}') if 'tcp_mss' in rule_conf['set']: mss = rule_conf['set']['tcp_mss'] output.append(f'tcp option maxseg size set {mss}') if 'action' in rule_conf: if rule_conf['action'] == 'offload': offload_target = rule_conf['offload_target'] output.append(f'flow add @VYOS_FLOWTABLE_{offload_target}') else: output.append(f'{rule_conf["action"]}') if 'jump' in rule_conf['action']: target = rule_conf['jump_target'] output.append(f'NAME{def_suffix}_{target}') if 'queue' in rule_conf['action']: if 'queue' in rule_conf: target = rule_conf['queue'] output.append(f'num {target}') if 'queue_options' in rule_conf: queue_opts = ','.join(rule_conf['queue_options']) output.append(f'{queue_opts}') # Synproxy if 'synproxy' in rule_conf: synproxy_mss = dict_search_args(rule_conf, 'synproxy', 'tcp', 'mss') if synproxy_mss: output.append(f'mss {synproxy_mss}') synproxy_ws = dict_search_args(rule_conf, 'synproxy', 'tcp', 'window_scale') if synproxy_ws: output.append(f'wscale {synproxy_ws} timestamp sack-perm') else: if set_table: output.append('return') output.append(f'comment "{family}-{hook}-{fw_name}-{rule_id}"') return " ".join(output) def parse_tcp_flags(flags): include = [flag for flag in flags if flag != 'not'] exclude = list(flags['not']) if 'not' in flags else [] return f'tcp flags & ({"|".join(include + exclude)}) == {"|".join(include) if include else "0x0"}' def parse_time(time): out = [] if 'startdate' in time: start = time['startdate'] if 'T' not in start and 'starttime' in time: start += f' {time["starttime"]}' out.append(f'time >= "{start}"') if 'starttime' in time and 'startdate' not in time: out.append(f'hour >= "{time["starttime"]}"') if 'stopdate' in time: stop = time['stopdate'] if 'T' not in stop and 'stoptime' in time: stop += f' {time["stoptime"]}' out.append(f'time < "{stop}"') if 'stoptime' in time and 'stopdate' not in time: out.append(f'hour < "{time["stoptime"]}"') if 'weekdays' in time: days = time['weekdays'].split(",") out_days = [f'"{day}"' for day in days if day[0] != '!'] out.append(f'day {{{",".join(out_days)}}}') return " ".join(out) # GeoIP nftables_geoip_conf = '/run/nftables-geoip.conf' geoip_database = '/usr/share/vyos-geoip/dbip-country-lite.csv.gz' geoip_lock_file = '/run/vyos-geoip.lock' def geoip_load_data(codes=[]): data = None if not os.path.exists(geoip_database): return [] try: with gzip.open(geoip_database, mode='rt') as csv_fh: reader = csv.reader(csv_fh) out = [] for start, end, code in reader: if code.lower() in codes: out.append([start, end, code.lower()]) return out except: print('Error: Failed to open GeoIP database') return [] def geoip_download_data(): url = 'https://download.db-ip.com/free/dbip-country-lite-{}.csv.gz'.format(strftime("%Y-%m")) try: dirname = os.path.dirname(geoip_database) if not os.path.exists(dirname): os.mkdir(dirname) download(geoip_database, url) print("Downloaded GeoIP database") return True except: print("Error: Failed to download GeoIP database") return False class GeoIPLock(object): def __init__(self, file): self.file = file def __enter__(self): if os.path.exists(self.file): return False Path(self.file).touch() return True def __exit__(self, exc_type, exc_value, tb): os.unlink(self.file) def geoip_update(firewall, force=False): with GeoIPLock(geoip_lock_file) as lock: if not lock: print("Script is already running") return False if not firewall: print("Firewall is not configured") return True if not os.path.exists(geoip_database): if not geoip_download_data(): return False elif force: geoip_download_data() ipv4_codes = {} ipv6_codes = {} ipv4_sets = {} ipv6_sets = {} # Map country codes to set names for codes, path in dict_search_recursive(firewall, 'country_code'): set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' if ( path[0] == 'ipv4'): for code in codes: ipv4_codes.setdefault(code, []).append(set_name) elif ( path[0] == 'ipv6' ): set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}' for code in codes: ipv6_codes.setdefault(code, []).append(set_name) if not ipv4_codes and not ipv6_codes: if force: print("GeoIP not in use by firewall") return True geoip_data = geoip_load_data([*ipv4_codes, *ipv6_codes]) # Iterate IP blocks to assign to sets for start, end, code in geoip_data: ipv4 = is_ipv4(start) if code in ipv4_codes and ipv4: ip_range = f'{start}-{end}' if start != end else start for setname in ipv4_codes[code]: ipv4_sets.setdefault(setname, []).append(ip_range) if code in ipv6_codes and not ipv4: ip_range = f'{start}-{end}' if start != end else start for setname in ipv6_codes[code]: ipv6_sets.setdefault(setname, []).append(ip_range) render(nftables_geoip_conf, 'firewall/nftables-geoip-update.j2', { 'ipv4_sets': ipv4_sets, 'ipv6_sets': ipv6_sets }) result = run(f'nft --file {nftables_geoip_conf}') if result != 0: print('Error: GeoIP failed to update firewall') return False return True diff --git a/python/vyos/nat.py b/python/vyos/nat.py index e54548788..4fe21ef13 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -1,311 +1,318 @@ # 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: operator = '' if 'name' in rule_conf['inbound_interface']: iiface = rule_conf['inbound_interface']['name'] if iiface[0] == '!': operator = '!=' iiface = iiface[1:] output.append(f'iifname {operator} {{{iiface}}}') else: iiface = rule_conf['inbound_interface']['group'] if iiface[0] == '!': operator = '!=' iiface = iiface[1:] output.append(f'iifname {operator} @I_{iiface}') if 'outbound_interface' in rule_conf: operator = '' if 'name' in rule_conf['outbound_interface']: oiface = rule_conf['outbound_interface']['name'] if oiface[0] == '!': operator = '!=' oiface = oiface[1:] output.append(f'oifname {operator} {{{oiface}}}') else: oiface = rule_conf['outbound_interface']['group'] if oiface[0] == '!': operator = '!=' oiface = oiface[1:] output.append(f'oifname {operator} @I_{oiface}') 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') if 'redirect' in rule_conf['translation']: translation_output = [f'redirect'] redirect_port = dict_search_args(rule_conf, 'translation', 'redirect', 'port') if redirect_port: translation_output.append(f'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') if map_addr: if port: translation_output.append(f'{ip_prefix} prefix to {ip_prefix} {translation_prefix}addr map {{ {map_addr} : {addr} . {port} }}') else: 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}') 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) if ((not addr) or (addr and not is_ip_network(addr))) and port: translation_str = " ".join(translation_output) + (f':{port}') else: translation_str = " ".join(translation_output) if options: translation_str += f' {",".join(options)}' if not ipv6 and '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_prefix[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}') + if 'fqdn' in side_conf: + fqdn = side_conf['fqdn'] + operator = '' + if fqdn[0] == '!': + operator = '!=' + output.append(f' ip {prefix}addr {operator} @FQDN_nat_{nat_type}_{rule_id}_{prefix}') + 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 '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) diff --git a/smoketest/scripts/cli/test_nat.py b/smoketest/scripts/cli/test_nat.py index 5161e47fd..0beafcc6c 100755 --- a/smoketest/scripts/cli/test_nat.py +++ b/smoketest/scripts/cli/test_nat.py @@ -1,308 +1,334 @@ #!/usr/bin/env python3 # # Copyright (C) 2020-2024 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 unittest from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError 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 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', 'name', 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', 'name', 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' interface_group = 'smoketest_ifaces' interface_group_member = 'bond.99' self.cli_set(['firewall', 'group', 'address-group', address_group, 'address', address_group_member]) self.cli_set(['firewall', 'group', 'interface-group', interface_group, 'interface', interface_group_member]) self.cli_set(src_path + ['rule', '100', 'source', 'group', 'address-group', address_group]) self.cli_set(src_path + ['rule', '100', 'outbound-interface', 'group', interface_group]) self.cli_set(src_path + ['rule', '100', 'translation', 'address', 'masquerade']) self.cli_set(src_path + ['rule', '110', 'source', 'group', 'address-group', address_group]) self.cli_set(src_path + ['rule', '110', 'translation', 'address', '203.0.113.1']) self.cli_set(src_path + ['rule', '120', 'source', 'group', 'address-group', address_group]) self.cli_set(src_path + ['rule', '120', 'translation', 'address', '203.0.113.111/32']) self.cli_commit() nftables_search = [ [f'set A_{address_group}'], [f'elements = {{ {address_group_member} }}'], [f'ip saddr @A_{address_group}', f'oifname @I_{interface_group}', 'masquerade'], [f'ip saddr @A_{address_group}', 'snat to 203.0.113.1'], [f'ip saddr @A_{address_group}', 'snat prefix to 203.0.113.111/32'] ] 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', 'name', 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', 'name', 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() - 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', 'name', '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', 'name', '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', 'name', ifname]) self.cli_set(dst_path + ['rule', '10', 'translation', 'redirect', 'port', redirected_port]) self.cli_set(dst_path + ['rule', '20', 'destination', 'address', dst_addr_1]) self.cli_set(dst_path + ['rule', '20', 'destination', 'port', dest_port]) self.cli_set(dst_path + ['rule', '20', 'protocol', protocol]) self.cli_set(dst_path + ['rule', '20', 'inbound-interface', 'name', ifname]) self.cli_set(dst_path + ['rule', '20', 'translation', 'redirect']) self.cli_commit() nftables_search = [ [f'iifname "{ifname}"', f'ip daddr {dst_addr_1}', f'{protocol} dport {dest_port}', f'redirect to :{redirected_port}'], [f'iifname "{ifname}"', f'ip daddr {dst_addr_1}', f'{protocol} dport {dest_port}', f'redirect'] ] 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', 'name', 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', 'name', 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') def test_snat_net_port_map(self): self.cli_set(src_path + ['rule', '10', 'protocol', 'tcp_udp']) self.cli_set(src_path + ['rule', '10', 'source', 'address', '100.64.0.0/25']) self.cli_set(src_path + ['rule', '10', 'translation', 'address', '203.0.113.0/25']) self.cli_set(src_path + ['rule', '10', 'translation', 'port', '1025-3072']) self.cli_set(src_path + ['rule', '20', 'protocol', 'tcp_udp']) self.cli_set(src_path + ['rule', '20', 'source', 'address', '100.64.0.128/25']) self.cli_set(src_path + ['rule', '20', 'translation', 'address', '203.0.113.128/25']) self.cli_set(src_path + ['rule', '20', 'translation', 'port', '1025-3072']) self.cli_commit() nftables_search = [ ['meta l4proto { tcp, udp }', 'snat ip prefix to ip saddr map { 100.64.0.0/25 : 203.0.113.0/25 . 1025-3072 }', 'comment "SRC-NAT-10"'], ['meta l4proto { tcp, udp }', 'snat ip prefix to ip saddr map { 100.64.0.128/25 : 203.0.113.128/25 . 1025-3072 }', 'comment "SRC-NAT-20"'] ] self.verify_nftables(nftables_search, 'ip vyos_nat') + def test_nat_fqdn(self): + source_domain = 'vyos.dev' + destination_domain = 'vyos.io' + + self.cli_set(src_path + ['rule', '1', 'outbound-interface', 'name', 'eth0']) + self.cli_set(src_path + ['rule', '1', 'source', 'fqdn', source_domain]) + self.cli_set(src_path + ['rule', '1', 'translation', 'address', 'masquerade']) + + self.cli_set(dst_path + ['rule', '1', 'destination', 'fqdn', destination_domain]) + self.cli_set(dst_path + ['rule', '1', 'source', 'fqdn', source_domain]) + self.cli_set(dst_path + ['rule', '1', 'destination', 'port', '5122']) + self.cli_set(dst_path + ['rule', '1', 'protocol', 'tcp']) + self.cli_set(dst_path + ['rule', '1', 'translation', 'address', '198.51.100.1']) + self.cli_set(dst_path + ['rule', '1', 'translation', 'port', '22']) + + + self.cli_commit() + + nftables_search = [ + ['set FQDN_nat_destination_1_d'], + ['set FQDN_nat_source_1_s'], + ['oifname "eth0"', 'ip saddr @FQDN_nat_source_1_s', 'masquerade', 'comment "SRC-NAT-1"'], + ['tcp dport 5122', 'ip saddr @FQDN_nat_destination_1_s', 'ip daddr @FQDN_nat_destination_1_d', 'dnat to 198.51.100.1:22', 'comment "DST-NAT-1"'] + ] + + self.verify_nftables(nftables_search, 'ip vyos_nat') if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index 9974a1466..d026e2e90 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -1,494 +1,505 @@ #!/usr/bin/env python3 # # Copyright (C) 2021-2024 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 re from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configdict import is_node_changed from vyos.configdiff import get_config_diff, Diff from vyos.configdep import set_dependents, call_dependents from vyos.configverify import verify_interface_exists from vyos.ethtool import Ethtool from vyos.firewall import fqdn_config_parse from vyos.firewall import geoip_update from vyos.template import render from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import rc_cmd from vyos import ConfigError from vyos import airbag +<<<<<<< HEAD +======= +from pathlib import Path +from subprocess import run as subp_run +>>>>>>> 4c3d037f0 (T6687: add fqdn support to nat rules.) airbag.enable() nftables_conf = '/run/nftables.conf' +domain_resolver_usage = '/run/use-vyos-domain-resolver-firewall' +domain_resolver_usage_nat = '/run/use-vyos-domain-resolver-nat' + sysctl_file = r'/run/sysctl/10-vyos-firewall.conf' valid_groups = [ 'address_group', 'domain_group', 'network_group', 'port_group', 'interface_group' ] nested_group_types = [ 'address_group', 'network_group', 'mac_group', 'port_group', 'ipv6_address_group', 'ipv6_network_group' ] snmp_change_type = { 'unknown': 0, 'add': 1, 'delete': 2, 'change': 3 } snmp_event_source = 1 snmp_trap_mib = 'VYATTA-TRAP-MIB' snmp_trap_name = 'mgmtEventTrap' def geoip_updated(conf, firewall): diff = get_config_diff(conf) node_diff = diff.get_child_nodes_diff(['firewall'], expand_nodes=Diff.DELETE, recursive=True) out = { 'name': [], 'ipv6_name': [], 'deleted_name': [], 'deleted_ipv6_name': [] } updated = False for key, path in dict_search_recursive(firewall, 'geoip'): set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' if (path[0] == 'ipv4'): out['name'].append(set_name) elif (path[0] == 'ipv6'): set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}' out['ipv6_name'].append(set_name) updated = True if 'delete' in node_diff: for key, path in dict_search_recursive(node_diff['delete'], 'geoip'): set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' if (path[0] == 'ipv4'): out['deleted_name'].append(set_name) elif (path[0] == 'ipv6'): set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' out['deleted_ipv6_name'].append(set_name) updated = True if updated: return out return False def get_config(config=None): if config: conf = config else: conf = Config() base = ['firewall'] firewall = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True, with_recursive_defaults=True) firewall['group_resync'] = bool('group' in firewall or is_node_changed(conf, base + ['group'])) if firewall['group_resync']: # Update nat and policy-route as firewall groups were updated set_dependents('group_resync', conf) firewall['geoip_updated'] = geoip_updated(conf, firewall) - fqdn_config_parse(firewall) + fqdn_config_parse(firewall, 'firewall') set_dependents('conntrack', conf) return firewall def verify_rule(firewall, rule_conf, ipv6): if 'action' not in rule_conf: raise ConfigError('Rule action must be defined') if 'jump' in rule_conf['action'] and 'jump_target' not in rule_conf: raise ConfigError('Action set to jump, but no jump-target specified') if 'jump_target' in rule_conf: if 'jump' not in rule_conf['action']: raise ConfigError('jump-target defined, but action jump needed and it is not defined') target = rule_conf['jump_target'] if not ipv6: if target not in dict_search_args(firewall, 'ipv4', 'name'): raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') else: if target not in dict_search_args(firewall, 'ipv6', 'name'): raise ConfigError(f'Invalid jump-target. Firewall ipv6 name {target} does not exist on the system') if rule_conf['action'] == 'offload': if 'offload_target' not in rule_conf: raise ConfigError('Action set to offload, but no offload-target specified') offload_target = rule_conf['offload_target'] if not dict_search_args(firewall, 'flowtable', offload_target): raise ConfigError(f'Invalid offload-target. Flowtable "{offload_target}" does not exist on the system') if rule_conf['action'] != 'synproxy' and 'synproxy' in rule_conf: raise ConfigError('"synproxy" option allowed only for action synproxy') if rule_conf['action'] == 'synproxy': if 'state' in rule_conf: raise ConfigError('For action "synproxy" state cannot be defined') if not rule_conf.get('synproxy', {}).get('tcp'): raise ConfigError('synproxy TCP MSS is not defined') if rule_conf.get('protocol', {}) != 'tcp': raise ConfigError('For action "synproxy" the protocol must be set to TCP') if 'queue_options' in rule_conf: if 'queue' not in rule_conf['action']: raise ConfigError('queue-options defined, but action queue needed and it is not defined') if 'fanout' in rule_conf['queue_options'] and ('queue' not in rule_conf or '-' not in rule_conf['queue']): raise ConfigError('queue-options fanout defined, then queue needs to be defined as a range') if 'queue' in rule_conf and 'queue' not in rule_conf['action']: raise ConfigError('queue defined, but action queue needed and it is not defined') if 'fragment' in rule_conf: if {'match_frag', 'match_non_frag'} <= set(rule_conf['fragment']): raise ConfigError('Cannot specify both "match-frag" and "match-non-frag"') if 'limit' in rule_conf: if 'rate' in rule_conf['limit']: rate_int = re.sub(r'\D', '', rule_conf['limit']['rate']) if int(rate_int) < 1: raise ConfigError('Limit rate integer cannot be less than 1') if 'ipsec' in rule_conf: if {'match_ipsec', 'match_non_ipsec'} <= set(rule_conf['ipsec']): raise ConfigError('Cannot specify both "match-ipsec" and "match-non-ipsec"') if 'recent' in rule_conf: if not {'count', 'time'} <= set(rule_conf['recent']): raise ConfigError('Recent "count" and "time" values must be defined') tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: if dict_search_args(rule_conf, 'protocol') != 'tcp': raise ConfigError('Protocol must be tcp when specifying tcp flags') not_flags = dict_search_args(rule_conf, 'tcp', 'flags', 'not') if not_flags: duplicates = [flag for flag in tcp_flags if flag in not_flags] if duplicates: raise ConfigError(f'Cannot match a tcp flag as set and not set') if 'protocol' in rule_conf: if rule_conf['protocol'] == 'icmp' and ipv6: raise ConfigError(f'Cannot match IPv4 ICMP protocol on IPv6, use ipv6-icmp') if rule_conf['protocol'] == 'ipv6-icmp' and not ipv6: raise ConfigError(f'Cannot match IPv6 ICMP protocol on IPv4, use icmp') for side in ['destination', 'source']: if side in rule_conf: side_conf = rule_conf[side] if len({'address', 'fqdn', 'geoip'} & 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] fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group error_group = fw_group.replace("_", "-") if group in ['address_group', 'network_group', 'domain_group']: types = [t for t in ['address', 'fqdn', 'geoip'] 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(firewall, 'group', fw_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 'port' in side_conf or dict_search_args(side_conf, 'group', 'port_group'): if 'protocol' not in rule_conf: raise ConfigError('Protocol must be defined if specifying a port or port-group') if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group') if 'port' in side_conf and dict_search_args(side_conf, 'group', 'port_group'): raise ConfigError(f'{side} port-group and port cannot both be defined') if 'add_address_to_group' in rule_conf: for type in ['destination_address', 'source_address']: if type in rule_conf['add_address_to_group']: if 'address_group' not in rule_conf['add_address_to_group'][type]: raise ConfigError(f'Dynamic address group must be defined.') else: target = rule_conf['add_address_to_group'][type]['address_group'] fwall_group = 'ipv6_address_group' if ipv6 else 'address_group' group_obj = dict_search_args(firewall, 'group', 'dynamic_group', fwall_group, target) if group_obj is None: raise ConfigError(f'Invalid dynamic address group on firewall rule') if 'log_options' in rule_conf: if 'log' not in rule_conf: raise ConfigError('log-options defined, but log is not enable') if 'snapshot_length' in rule_conf['log_options'] and 'group' not in rule_conf['log_options']: raise ConfigError('log-options snapshot-length defined, but log group is not define') if 'queue_threshold' in rule_conf['log_options'] and 'group' not in rule_conf['log_options']: raise ConfigError('log-options queue-threshold defined, but log group is not define') for direction in ['inbound_interface','outbound_interface']: if direction in rule_conf: if 'name' in rule_conf[direction] and 'group' in rule_conf[direction]: raise ConfigError(f'Cannot specify both interface group and interface name for {direction}') if 'group' in rule_conf[direction]: group_name = rule_conf[direction]['group'] if group_name[0] == '!': group_name = group_name[1:] group_obj = dict_search_args(firewall, 'group', 'interface_group', group_name) if group_obj is None: raise ConfigError(f'Invalid interface group "{group_name}" on firewall rule') if not group_obj: Warning(f'interface-group "{group_name}" has no members!') def verify_nested_group(group_name, group, groups, seen): if 'include' not in group: return seen.append(group_name) for g in group['include']: if g not in groups: raise ConfigError(f'Nested group "{g}" does not exist') if g in seen: raise ConfigError(f'Group "{group_name}" has a circular reference') if 'include' in groups[g]: verify_nested_group(g, groups[g], groups, seen) def verify_hardware_offload(ifname): ethtool = Ethtool(ifname) enabled, fixed = ethtool.get_hw_tc_offload() if not enabled and fixed: raise ConfigError(f'Interface "{ifname}" does not support hardware offload') if not enabled: raise ConfigError(f'Interface "{ifname}" requires "offload hw-tc-offload"') def verify(firewall): if 'flowtable' in firewall: for flowtable, flowtable_conf in firewall['flowtable'].items(): if 'interface' not in flowtable_conf: raise ConfigError(f'Flowtable "{flowtable}" requires at least one interface') for ifname in flowtable_conf['interface']: verify_interface_exists(firewall, ifname) if dict_search_args(flowtable_conf, 'offload') == 'hardware': interfaces = flowtable_conf['interface'] for ifname in interfaces: verify_hardware_offload(ifname) if 'group' in firewall: for group_type in nested_group_types: if group_type in firewall['group']: groups = firewall['group'][group_type] for group_name, group in groups.items(): verify_nested_group(group_name, group, groups, []) if 'ipv4' in firewall: for name in ['name','forward','input','output', 'prerouting']: if name in firewall['ipv4']: for name_id, name_conf in firewall['ipv4'][name].items(): if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: raise ConfigError('default-action set to jump, but no default-jump-target specified') if 'default_jump_target' in name_conf: target = name_conf['default_jump_target'] if 'jump' not in name_conf['default_action']: raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') if name_conf['default_jump_target'] == name_id: raise ConfigError(f'Loop detected on default-jump-target.') ## Now need to check that default-jump-target exists (other firewall chain/name) if target not in dict_search_args(firewall['ipv4'], 'name'): raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') if 'rule' in name_conf: for rule_id, rule_conf in name_conf['rule'].items(): verify_rule(firewall, rule_conf, False) if 'ipv6' in firewall: for name in ['name','forward','input','output', 'prerouting']: if name in firewall['ipv6']: for name_id, name_conf in firewall['ipv6'][name].items(): if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: raise ConfigError('default-action set to jump, but no default-jump-target specified') if 'default_jump_target' in name_conf: target = name_conf['default_jump_target'] if 'jump' not in name_conf['default_action']: raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') if name_conf['default_jump_target'] == name_id: raise ConfigError(f'Loop detected on default-jump-target.') ## Now need to check that default-jump-target exists (other firewall chain/name) if target not in dict_search_args(firewall['ipv6'], 'name'): raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') if 'rule' in name_conf: for rule_id, rule_conf in name_conf['rule'].items(): verify_rule(firewall, rule_conf, True) #### ZONESSSS local_zone = False zone_interfaces = [] if 'zone' in firewall: for zone, zone_conf in firewall['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') if 'intra_zone_filtering' in zone_conf: raise ConfigError('Local zone cannot use intra-zone-filtering') local_zone = True if 'interface' in zone_conf: found_duplicates = [intf for intf in zone_conf['interface'] if intf in zone_interfaces] if found_duplicates: raise ConfigError(f'Interfaces cannot be assigned to multiple zones') zone_interfaces += zone_conf['interface'] if 'intra_zone_filtering' in zone_conf: intra_zone = zone_conf['intra_zone_filtering'] if len(intra_zone) > 1: raise ConfigError('Only one intra-zone-filtering action must be specified') if 'firewall' in intra_zone: v4_name = dict_search_args(intra_zone, 'firewall', 'name') if v4_name and not dict_search_args(firewall, 'ipv4', 'name', v4_name): raise ConfigError(f'Firewall name "{v4_name}" does not exist') v6_name = dict_search_args(intra_zone, 'firewall', 'ipv6_name') if v6_name and not dict_search_args(firewall, 'ipv6', 'name', v6_name): raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') if not v4_name and not v6_name: raise ConfigError('No firewall names specified for intra-zone-filtering') if 'from' in zone_conf: for from_zone, from_conf in zone_conf['from'].items(): if from_zone not in firewall['zone']: raise ConfigError(f'Zone "{zone}" refers to a non-existent or deleted zone "{from_zone}"') v4_name = dict_search_args(from_conf, 'firewall', 'name') if v4_name and not dict_search_args(firewall, 'ipv4', 'name', v4_name): raise ConfigError(f'Firewall name "{v4_name}" does not exist') v6_name = dict_search_args(from_conf, 'firewall', 'ipv6_name') if v6_name and not dict_search_args(firewall, 'ipv6', 'name', v6_name): raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') return None def generate(firewall): if not os.path.exists(nftables_conf): firewall['first_install'] = True if 'zone' in firewall: for local_zone, local_zone_conf in firewall['zone'].items(): if 'local_zone' not in local_zone_conf: continue local_zone_conf['from_local'] = {} for zone, zone_conf in firewall['zone'].items(): if zone == local_zone or 'from' not in zone_conf: continue if local_zone in zone_conf['from']: local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone] render(nftables_conf, 'firewall/nftables.j2', firewall) render(sysctl_file, 'firewall/sysctl-firewall.conf.j2', firewall) return None def apply(firewall): install_result, output = rc_cmd(f'nft --file {nftables_conf}') if install_result == 1: raise ConfigError(f'Failed to apply firewall: {output}') # Apply firewall global-options sysctl settings cmd(f'sysctl -f {sysctl_file}') call_dependents() - # T970 Enable a resolver (systemd daemon) that checks - # domain-group/fqdn addresses and update entries for domains by timeout - # If router loaded without internet connection or for synchronization - domain_action = 'stop' - if dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'] or firewall['ip6_fqdn']: - domain_action = 'restart' + ## DOMAIN RESOLVER + domain_action = 'restart' + if dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'].items() or firewall['ip6_fqdn'].items(): + text = f'# Automatically generated by firewall.py\nThis file indicates that vyos-domain-resolver service is used by the firewall.\n' + Path(domain_resolver_usage).write_text(text) + else: + Path(domain_resolver_usage).unlink(missing_ok=True) + if not Path('/run').glob('use-vyos-domain-resolver*'): + domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') if firewall['geoip_updated']: # Call helper script to Update set contents if 'name' in firewall['geoip_updated'] or 'ipv6_name' in firewall['geoip_updated']: print('Updating GeoIP. Please wait...') geoip_update(firewall) return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1) diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 39803fa02..98b2f3f29 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -1,264 +1,284 @@ #!/usr/bin/env python3 # # Copyright (C) 2020-2024 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 sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configdep import set_dependents, call_dependents 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.file import write_file from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.process import call from vyos.utils.network import is_addr_assigned from vyos.utils.network import interface_exists +from vyos.firewall import fqdn_config_parse from vyos import ConfigError from vyos import airbag airbag.enable() k_mod = ['nft_nat', 'nft_chain_nat'] nftables_nat_config = '/run/nftables_nat.conf' nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' +domain_resolver_usage = '/run/use-vyos-domain-resolver-nat' +domain_resolver_usage_firewall = '/run/use-vyos-domain-resolver-firewall' valid_groups = [ 'address_group', 'domain_group', 'network_group', 'port_group' ] 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, with_recursive_defaults=True) set_dependents('conntrack', conf) if not conf.exists(base): 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) # Remove dynamic firewall groups if present: if 'dynamic_group' in nat['firewall_group']: del nat['firewall_group']['dynamic_group'] + fqdn_config_parse(nat, 'nat') + return nat 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} ports can only be specified when '\ 'protocol is either tcp, udp or tcp_udp!') 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 nat 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 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 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' in config: if 'name' in config['outbound_interface'] and 'group' in config['outbound_interface']: raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for nat source rule "{rule}"') elif 'name' in config['outbound_interface']: interface_name = config['outbound_interface']['name'] if interface_name not in 'any': if interface_name.startswith('!'): interface_name = interface_name[1:] if not interface_exists(interface_name): Warning(f'Interface "{interface_name}" for source NAT rule "{rule}" does not exist!') else: group_name = config['outbound_interface']['group'] if group_name[0] == '!': group_name = group_name[1:] group_obj = dict_search_args(nat['firewall_group'], 'interface_group', group_name) if group_obj is None: raise ConfigError(f'Invalid interface group "{group_name}" on source nat rule') if not group_obj: Warning(f'interface-group "{group_name}" has no members!') if not dict_search('translation.address', config) and not dict_search('translation.port', 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' in config: if 'name' in config['inbound_interface'] and 'group' in config['inbound_interface']: raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for destination nat rule "{rule}"') elif 'name' in config['inbound_interface']: interface_name = config['inbound_interface']['name'] if interface_name not in 'any': if interface_name.startswith('!'): interface_name = interface_name[1:] if not interface_exists(interface_name): Warning(f'Interface "{interface_name}" for destination NAT rule "{rule}" does not exist!') else: group_name = config['inbound_interface']['group'] if group_name[0] == '!': group_name = group_name[1:] group_obj = dict_search_args(nat['firewall_group'], 'interface_group', group_name) if group_obj is None: raise ConfigError(f'Invalid interface group "{group_name}" on destination nat rule') if not group_obj: Warning(f'interface-group "{group_name}" has no members!') if not dict_search('translation.address', config) and not dict_search('translation.port', config) and 'redirect' not in config['translation']: 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} 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 --check --file {nftables_nat_config}') if tmp > 0: raise ConfigError('Configuration file errors encountered!') tmp = run(f'nft --check --file {nftables_static_nat_conf}') if tmp > 0: raise ConfigError('Configuration file errors encountered!') return None def apply(nat): check_kmod(k_mod) cmd(f'nft --file {nftables_nat_config}') cmd(f'nft --file {nftables_static_nat_conf}') if not nat or 'deleted' in nat: os.unlink(nftables_nat_config) os.unlink(nftables_static_nat_conf) call_dependents() + # DOMAIN RESOLVER + if nat and 'deleted' not in nat: + domain_action = 'restart' + if nat['ip_fqdn'].items(): + text = f'# Automatically generated by nat.py\nThis file indicates that vyos-domain-resolver service is used by nat.\n' + write_file(domain_resolver_usage, text) + elif os.path.exists(domain_resolver_usage): + os.unlink(domain_resolver_usage) + if not os.path.exists(domain_resolver_usage_firewall): + # Firewall not using domain resolver + domain_action = 'stop' + call(f'systemctl {domain_action} vyos-domain-resolver.service') + return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1) diff --git a/src/helpers/vyos-domain-resolver.py b/src/helpers/vyos-domain-resolver.py index 57cfcabd7..f5a1d9297 100755 --- a/src/helpers/vyos-domain-resolver.py +++ b/src/helpers/vyos-domain-resolver.py @@ -1,178 +1,187 @@ #!/usr/bin/env python3 # # Copyright (C) 2022-2024 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 json import time from vyos.configdict import dict_merge from vyos.configquery import ConfigTreeQuery from vyos.firewall import fqdn_config_parse from vyos.firewall import fqdn_resolve from vyos.utils.commit import commit_in_progress from vyos.utils.dict import dict_search_args from vyos.utils.process import cmd from vyos.utils.process import run from vyos.xml_ref import get_defaults base = ['firewall'] timeout = 300 cache = False +base_firewall = ['firewall'] +base_nat = ['nat'] domain_state = {} ipv4_tables = { 'ip vyos_mangle', 'ip vyos_filter', 'ip vyos_nat', 'ip raw' } ipv6_tables = { 'ip6 vyos_mangle', 'ip6 vyos_filter', 'ip6 raw' } -def get_config(conf): - firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, +def get_config(conf, node): + node_config = conf.get_config_dict(node, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) - default_values = get_defaults(base, get_first_key=True) + default_values = get_defaults(node, get_first_key=True) - firewall = dict_merge(default_values, firewall) + node_config = dict_merge(default_values, node_config) global timeout, cache - if 'resolver_interval' in firewall: - timeout = int(firewall['resolver_interval']) + if 'resolver_interval' in node_config: + timeout = int(node_config['resolver_interval']) - if 'resolver_cache' in firewall: + if 'resolver_cache' in node_config: cache = True - fqdn_config_parse(firewall) + fqdn_config_parse(node_config, node[0]) - return firewall + return node_config def resolve(domains, ipv6=False): global domain_state ip_list = set() for domain in domains: resolved = fqdn_resolve(domain, ipv6=ipv6) if resolved and cache: domain_state[domain] = resolved elif not resolved: if domain not in domain_state: continue resolved = domain_state[domain] ip_list = ip_list | resolved return ip_list def nft_output(table, set_name, ip_list): output = [f'flush set {table} {set_name}'] if ip_list: ip_str = ','.join(ip_list) output.append(f'add element {table} {set_name} {{ {ip_str} }}') return output def nft_valid_sets(): try: valid_sets = [] sets_json = cmd('nft --json list sets') sets_obj = json.loads(sets_json) for obj in sets_obj['nftables']: if 'set' in obj: family = obj['set']['family'] table = obj['set']['table'] name = obj['set']['name'] valid_sets.append((f'{family} {table}', name)) return valid_sets except: return [] -def update(firewall): +def update_fqdn(config, node): conf_lines = [] count = 0 - valid_sets = nft_valid_sets() - domain_groups = dict_search_args(firewall, 'group', 'domain_group') - if domain_groups: - for set_name, domain_config in domain_groups.items(): - if 'address' not in domain_config: - continue - - nft_set_name = f'D_{set_name}' - domains = domain_config['address'] - - ip_list = resolve(domains, ipv6=False) - for table in ipv4_tables: - if (table, nft_set_name) in valid_sets: - conf_lines += nft_output(table, nft_set_name, ip_list) - - ip6_list = resolve(domains, ipv6=True) - for table in ipv6_tables: - if (table, nft_set_name) in valid_sets: - conf_lines += nft_output(table, nft_set_name, ip6_list) + if node == 'firewall': + domain_groups = dict_search_args(config, 'group', 'domain_group') + if domain_groups: + for set_name, domain_config in domain_groups.items(): + if 'address' not in domain_config: + continue + nft_set_name = f'D_{set_name}' + domains = domain_config['address'] + + ip_list = resolve(domains, ipv6=False) + for table in ipv4_tables: + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip_list) + ip6_list = resolve(domains, ipv6=True) + for table in ipv6_tables: + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip6_list) + count += 1 + + for set_name, domain in config['ip_fqdn'].items(): + table = 'ip vyos_filter' + nft_set_name = f'FQDN_{set_name}' + ip_list = resolve([domain], ipv6=False) + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip_list) count += 1 - for set_name, domain in firewall['ip_fqdn'].items(): - table = 'ip vyos_filter' - nft_set_name = f'FQDN_{set_name}' - - ip_list = resolve([domain], ipv6=False) - - if (table, nft_set_name) in valid_sets: - conf_lines += nft_output(table, nft_set_name, ip_list) - count += 1 - - for set_name, domain in firewall['ip6_fqdn'].items(): - table = 'ip6 vyos_filter' - nft_set_name = f'FQDN_{set_name}' + for set_name, domain in config['ip6_fqdn'].items(): + table = 'ip6 vyos_filter' + nft_set_name = f'FQDN_{set_name}' + ip_list = resolve([domain], ipv6=True) + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip_list) + count += 1 - ip_list = resolve([domain], ipv6=True) - if (table, nft_set_name) in valid_sets: - conf_lines += nft_output(table, nft_set_name, ip_list) - count += 1 + else: + # It's NAT + for set_name, domain in config['ip_fqdn'].items(): + table = 'ip vyos_nat' + nft_set_name = f'FQDN_nat_{set_name}' + ip_list = resolve([domain], ipv6=False) + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip_list) + count += 1 nft_conf_str = "\n".join(conf_lines) + "\n" code = run(f'nft --file -', input=nft_conf_str) - print(f'Updated {count} sets - result: {code}') + print(f'Updated {count} sets in {node} - result: {code}') if __name__ == '__main__': print(f'VyOS domain resolver') count = 1 while commit_in_progress(): if ( count % 60 == 0 ): print(f'Commit still in progress after {count}s - waiting') count += 1 time.sleep(1) conf = ConfigTreeQuery() - firewall = get_config(conf) + firewall = get_config(conf, base_firewall) + nat = get_config(conf, base_nat) print(f'interval: {timeout}s - cache: {cache}') while True: - update(firewall) + update_fqdn(firewall, 'firewall') + update_fqdn(nat, 'nat') time.sleep(timeout) diff --git a/src/systemd/vyos-domain-resolver.service b/src/systemd/vyos-domain-resolver.service index c56b51f0c..e63ae5e34 100644 --- a/src/systemd/vyos-domain-resolver.service +++ b/src/systemd/vyos-domain-resolver.service @@ -1,13 +1,14 @@ [Unit] Description=VyOS firewall domain resolver After=vyos-router.service +ConditionPathExistsGlob=/run/use-vyos-domain-resolver* [Service] Type=simple Restart=always ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/vyos-domain-resolver.py StandardError=journal StandardOutput=journal [Install] WantedBy=multi-user.target