diff options
Diffstat (limited to 'src/conf_mode/firewall.py')
-rwxr-xr-x | src/conf_mode/firewall.py | 338 |
1 files changed, 220 insertions, 118 deletions
diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index 9e95d7794..768bb127d 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -17,9 +17,7 @@ import os import re -from glob import glob from sys import exit - from vyos.base import Warning from vyos.config import Config from vyos.configdict import is_node_changed @@ -33,34 +31,33 @@ 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.utils.network import get_vrf_members +from vyos.utils.network import get_interface_vrf from vyos import ConfigError from vyos import airbag +from pathlib import Path +from subprocess import run as subp_run airbag.enable() nftables_conf = '/run/nftables.conf' +domain_resolver_usage = '/run/use-vyos-domain-resolver-firewall' -sysfs_config = { - 'all_ping': {'sysfs': '/proc/sys/net/ipv4/icmp_echo_ignore_all', 'enable': '0', 'disable': '1'}, - 'broadcast_ping': {'sysfs': '/proc/sys/net/ipv4/icmp_echo_ignore_broadcasts', 'enable': '0', 'disable': '1'}, - 'directed_broadcast' : {'sysfs': '/proc/sys/net/ipv4/conf/all/bc_forwarding', 'enable': '1', 'disable': '0'}, - 'ip_src_route': {'sysfs': '/proc/sys/net/ipv4/conf/*/accept_source_route'}, - 'ipv6_receive_redirects': {'sysfs': '/proc/sys/net/ipv6/conf/*/accept_redirects'}, - 'ipv6_src_route': {'sysfs': '/proc/sys/net/ipv6/conf/*/accept_source_route', 'enable': '0', 'disable': '-1'}, - 'log_martians': {'sysfs': '/proc/sys/net/ipv4/conf/all/log_martians'}, - 'receive_redirects': {'sysfs': '/proc/sys/net/ipv4/conf/*/accept_redirects'}, - 'send_redirects': {'sysfs': '/proc/sys/net/ipv4/conf/*/send_redirects'}, - 'syn_cookies': {'sysfs': '/proc/sys/net/ipv4/tcp_syncookies'}, - 'twa_hazards_protection': {'sysfs': '/proc/sys/net/ipv4/tcp_rfc1337'} -} +sysctl_file = r'/run/sysctl/10-vyos-firewall.conf' valid_groups = [ 'address_group', 'domain_group', 'network_group', 'port_group', - 'interface_group' + 'interface_group', + ## Added for group ussage in bridge firewall + 'ipv4_address_group', + 'ipv6_address_group', + 'ipv4_network_group', + 'ipv6_network_group' ] nested_group_types = [ @@ -135,13 +132,67 @@ def get_config(config=None): firewall['geoip_updated'] = geoip_updated(conf, firewall) - fqdn_config_parse(firewall) + fqdn_config_parse(firewall, '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: + # Get physical interfaces assigned to the zone if vrf is used: + if 'vrf' in local_zone_conf['member']: + local_zone_conf['vrf_interfaces'] = {} + for vrf_name in local_zone_conf['member']['vrf']: + local_zone_conf['vrf_interfaces'][vrf_name] = ','.join(get_vrf_members(vrf_name)) + 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] set_dependents('conntrack', conf) return firewall -def verify_rule(firewall, rule_conf, ipv6): +def verify_jump_target(firewall, hook, jump_target, family, recursive=False): + targets_seen = [] + targets_pending = [jump_target] + + while targets_pending: + target = targets_pending.pop() + + if 'name' not in firewall[family]: + raise ConfigError(f'Invalid jump-target. Firewall {family} name {target} does not exist on the system') + elif target not in dict_search_args(firewall, family, 'name'): + raise ConfigError(f'Invalid jump-target. Firewall {family} name {target} does not exist on the system') + + target_rules = dict_search_args(firewall, family, 'name', target, 'rule') + no_ipsec_in = hook in ('output', ) + + if target_rules: + for target_rule_conf in target_rules.values(): + # Output hook types will not tolerate 'meta ipsec exists' matches even in jump targets: + if no_ipsec_in and (dict_search_args(target_rule_conf, 'ipsec', 'match_ipsec_in') is not None \ + or dict_search_args(target_rule_conf, 'ipsec', 'match_none_in') is not None): + raise ConfigError(f'Invalid jump-target for {hook}. Firewall {family} name {target} rules contain incompatible ipsec inbound matches') + # Make sure we're not looping back on ourselves somewhere: + if recursive and 'jump_target' in target_rule_conf: + child_target = target_rule_conf['jump_target'] + if child_target in targets_seen: + raise ConfigError(f'Loop detected in jump-targets, firewall {family} name {target} refers to previously traversed {family} name {child_target}') + targets_pending.append(child_target) + if len(targets_seen) == 7: + path_txt = ' -> '.join(targets_seen) + Warning(f'Deep nesting of jump targets has reached 8 levels deep, following the path {path_txt} -> {child_target}!') + + targets_seen.append(target) + +def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if 'action' not in rule_conf: raise ConfigError('Rule action must be defined') @@ -152,12 +203,10 @@ def verify_rule(firewall, rule_conf, ipv6): 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') + if hook != 'name': # This is a bit clumsy, but consolidates a chunk of code. + verify_jump_target(firewall, hook, target, family, recursive=True) 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') + verify_jump_target(firewall, hook, target, family, recursive=False) if rule_conf['action'] == 'offload': if 'offload_target' not in rule_conf: @@ -198,13 +247,45 @@ def verify_rule(firewall, rule_conf, ipv6): 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 {'match_ipsec_in', 'match_none_in'} <= set(rule_conf['ipsec']): + raise ConfigError('Cannot specify both "match-ipsec" and "match-none"') + if {'match_ipsec_out', 'match_none_out'} <= set(rule_conf['ipsec']): + raise ConfigError('Cannot specify both "match-ipsec" and "match-none"') if 'recent' in rule_conf: if not {'count', 'time'} <= set(rule_conf['recent']): raise ConfigError('Recent "count" and "time" values must be defined') + if 'gre' in rule_conf: + if dict_search_args(rule_conf, 'protocol') != 'gre': + raise ConfigError('Protocol must be gre when matching GRE flags and fields') + + if dict_search_args(rule_conf, 'gre', 'key'): + if dict_search_args(rule_conf, 'gre', 'version') == 'pptp': + raise ConfigError('GRE tunnel keys are not present in PPTP') + + if dict_search_args(rule_conf, 'gre', 'flags', 'checksum') is None: + # There is no builtin match in nftables for the GRE key, so we need to do a raw lookup. + # The offset of the key within the packet shifts depending on the C-flag. + # 99% of the time, nobody will have checksums enabled - it's usually a manual config option. + # We can either assume it is unset unless otherwise directed + # (confusing, requires doco to explain why it doesn't work sometimes) + # or, demand an explicit selection to be made for this specific match rule. + # This check enforces the latter. The user is free to create rules for both cases. + raise ConfigError('Matching GRE tunnel key requires an explicit checksum flag match. For most cases, use "gre flags checksum unset"') + + if dict_search_args(rule_conf, 'gre', 'flags', 'key', 'unset') is not None: + raise ConfigError('Matching GRE tunnel key implies "flags key", cannot specify "flags key unset"') + + gre_inner_proto = dict_search_args(rule_conf, 'gre', 'inner_proto') + if gre_inner_proto is not None: + try: + gre_inner_value = int(gre_inner_proto, 0) + if gre_inner_value < 0 or gre_inner_value > 65535: + raise ConfigError('inner-proto outside valid ethertype range 0-65535') + except ValueError: + pass # Symbolic constant, pre-validated before reaching here. + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: if dict_search_args(rule_conf, 'protocol') != 'tcp': @@ -217,9 +298,9 @@ def verify_rule(firewall, rule_conf, ipv6): 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: + if rule_conf['protocol'] == 'icmp' and family == 'ipv6': raise ConfigError(f'Cannot match IPv4 ICMP protocol on IPv6, use ipv6-icmp') - if rule_conf['protocol'] == 'ipv6-icmp' and not ipv6: + if rule_conf['protocol'] == 'ipv6-icmp' and family == 'ipv4': raise ConfigError(f'Cannot match IPv6 ICMP protocol on IPv4, use icmp') for side in ['destination', 'source']: @@ -237,7 +318,18 @@ def verify_rule(firewall, rule_conf, ipv6): 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 + if family == 'ipv6' and group in ['address_group', 'network_group']: + fw_group = f'ipv6_{group}' + elif family == 'bridge': + if group =='ipv4_address_group': + fw_group = 'address_group' + elif group == 'ipv4_network_group': + fw_group = 'network_group' + else: + fw_group = group + else: + fw_group = group + error_group = fw_group.replace("_", "-") if group in ['address_group', 'network_group', 'domain_group']: @@ -273,7 +365,7 @@ def verify_rule(firewall, rule_conf, ipv6): 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' + fwall_group = 'ipv6_address_group' if family == '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') @@ -350,71 +442,66 @@ def verify(firewall): 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']: - 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']: - 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 + for family in ['ipv4', 'ipv6', 'bridge']: + if family in firewall: + for chain in ['name','forward','input','output', 'prerouting']: + if chain in firewall[family]: + for priority, priority_conf in firewall[family][chain].items(): + if 'jump' in priority_conf['default_action'] and 'default_jump_target' not in priority_conf: + raise ConfigError('default-action set to jump, but no default-jump-target specified') + if 'default_jump_target' in priority_conf: + target = priority_conf['default_jump_target'] + if 'jump' not in priority_conf['default_action']: + raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') + if priority_conf['default_jump_target'] == priority: + raise ConfigError(f'Loop detected on default-jump-target.') + if target not in dict_search_args(firewall[family], 'name'): + raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') + if 'rule' in priority_conf: + for rule_id, rule_conf in priority_conf['rule'].items(): + verify_rule(firewall, family, chain, priority, rule_id, rule_conf) + local_zone = False zone_interfaces = [] + zone_vrf = [] 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: + if 'local_zone' not in zone_conf and 'member' 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: + if 'member' 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 'member' in zone_conf: + if 'interface' in zone_conf['member']: + for iface in zone_conf['member']['interface']: - if found_duplicates: - raise ConfigError(f'Interfaces cannot be assigned to multiple zones') + if iface in zone_interfaces: + raise ConfigError(f'Interfaces cannot be assigned to multiple zones') - zone_interfaces += zone_conf['interface'] + iface_vrf = get_interface_vrf(iface) + if iface_vrf != 'default': + Warning(f"Interface {iface} assigned to zone {zone} is in VRF {iface_vrf}. This might not work as expected.") + zone_interfaces.append(iface) + + if 'vrf' in zone_conf['member']: + for vrf in zone_conf['member']['vrf']: + if vrf in zone_vrf: + raise ConfigError(f'VRF cannot be assigned to multiple zones') + zone_vrf.append(vrf) + + if 'vrf_interfaces' in zone_conf: + for vrf_name, vrf_interfaces in zone_conf['vrf_interfaces'].items(): + if not vrf_interfaces: + raise ConfigError( + f'VRF "{vrf_name}" cannot be a member of any zone. It does not contain any interfaces.') if 'intra_zone_filtering' in zone_conf: intra_zone = zone_conf['intra_zone_filtering'] @@ -450,59 +537,74 @@ def verify(firewall): 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_sysfs(firewall): - for name, conf in sysfs_config.items(): - paths = glob(conf['sysfs']) - value = None - - if name in firewall['global_options']: - conf_value = firewall['global_options'][name] - if conf_value in conf: - value = conf[conf_value] - elif conf_value == 'enable': - value = '1' - elif conf_value == 'disable': - value = '0' - - if value: - for path in paths: - with open(path, 'w') as f: - f.write(value) +def parse_firewall_error(output): + # Define the regex patterns to extract the error message and the comment + error_pattern = re.compile(r'Error:\s*(.*?)\n') + comment_pattern = re.compile(r'comment\s+"([^"]+)"') + error_output = [] + + # Find all error messages in the output + error_matches = error_pattern.findall(output) + # Find all comment matches in the output + comment_matches = comment_pattern.findall(output) + + if not error_matches or not comment_matches: + raise ConfigError(f'Unknown firewall error detected: {output}') + + error_output.append('Fail to apply firewall') + # Loop over the matches and process them + for error_message, comment in zip(error_matches, comment_matches): + # Parse the comment + parsed_entries = comment.split('-') + family = 'bridge' if parsed_entries[0] == 'bri' else parsed_entries[0] + if parsed_entries[1] == 'NAM': + chain = 'name' + elif parsed_entries[1] == 'FWD': + chain = 'forward' + elif parsed_entries[1] == 'INP': + chain = 'input' + elif parsed_entries[1] == 'OUT': + chain = 'output' + elif parsed_entries[1] == 'PRE': + chain = 'prerouting' + error_output.append(f'Error found on: firewall {family} {chain} {parsed_entries[2]} rule {parsed_entries[3]}') + error_output.append(f'\tError message: {error_message.strip()}') + + raise ConfigError('\n'.join(error_output)) def apply(firewall): + # Use nft -c option to check current configuration file + completed_process = subp_run(['nft', '-c', '--file', nftables_conf], capture_output=True) + install_result = completed_process.returncode + if install_result == 1: + # We need to handle firewall error + output = completed_process.stderr + parse_firewall_error(output.decode()) + + # No error detected during check, we can apply the new configuration install_result, output = rc_cmd(f'nft --file {nftables_conf}') + # Double check just in case if install_result == 1: raise ConfigError(f'Failed to apply firewall: {output}') - apply_sysfs(firewall) + # 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']: |