diff options
Diffstat (limited to 'src')
27 files changed, 1310 insertions, 256 deletions
diff --git a/src/completion/list_login_ttys.py b/src/completion/list_login_ttys.py new file mode 100644 index 000000000..4d77a1b8b --- /dev/null +++ b/src/completion/list_login_ttys.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 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/>. + +from vyos.utils.serial import get_serial_units + +if __name__ == '__main__': + # Autocomplete uses runtime state rather than the config tree, as a manual + # restart/cleanup may be needed for deleted devices. + tty_completions = [ '<text>' ] + [ x['device'] for x in get_serial_units() if 'device' in x ] + print(' '.join(tty_completions)) + + diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index ec6b86ef2..352d5cbb1 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -128,7 +128,49 @@ def get_config(config=None): return firewall -def verify_rule(firewall, rule_conf, ipv6): +def verify_jump_target(firewall, root_chain, jump_target, ipv6, recursive=False): + targets_seen = [] + targets_pending = [jump_target] + + while targets_pending: + target = targets_pending.pop() + + 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') + target_rules = dict_search_args(firewall, 'ipv4', 'name', target, 'rule') + 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') + target_rules = dict_search_args(firewall, 'ipv6', 'name', target, 'rule') + + no_ipsec_in = root_chain 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): + if not ipv6: + raise ConfigError(f'Invalid jump-target for {root_chain}. Firewall name {target} rules contain incompatible ipsec inbound matches') + else: + raise ConfigError(f'Invalid jump-target for {root_chain}. Firewall ipv6 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: + if not ipv6: + raise ConfigError(f'Loop detected in jump-targets, firewall name {target} refers to previously traversed name {child_target}') + else: + raise ConfigError(f'Loop detected in jump-targets, firewall ipv6 name {target} refers to previously traversed ipv6 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, chain_name, rule_conf, ipv6): if 'action' not in rule_conf: raise ConfigError('Rule action must be defined') @@ -139,12 +181,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 chain_name != 'name': # This is a bit clumsy, but consolidates a chunk of code. + verify_jump_target(firewall, chain_name, target, ipv6, 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, chain_name, target, ipv6, recursive=False) if rule_conf['action'] == 'offload': if 'offload_target' not in rule_conf: @@ -185,8 +225,10 @@ 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']): @@ -349,13 +391,11 @@ def verify(firewall): 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') + verify_jump_target(firewall, name, target, False, recursive=True) if 'rule' in name_conf: for rule_id, rule_conf in name_conf['rule'].items(): - verify_rule(firewall, rule_conf, False) + verify_rule(firewall, name, rule_conf, False) if 'ipv6' in firewall: for name in ['name','forward','input','output', 'prerouting']: @@ -369,13 +409,11 @@ def verify(firewall): 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') + verify_jump_target(firewall, name, target, True, recursive=True) if 'rule' in name_conf: for rule_id, rule_conf in name_conf['rule'].items(): - verify_rule(firewall, rule_conf, True) + verify_rule(firewall, name, rule_conf, True) #### ZONESSSS local_zone = False diff --git a/src/conf_mode/interfaces_geneve.py b/src/conf_mode/interfaces_geneve.py index 769139e0f..007708d4a 100755 --- a/src/conf_mode/interfaces_geneve.py +++ b/src/conf_mode/interfaces_geneve.py @@ -24,6 +24,7 @@ from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_bond_bridge_member +from vyos.configverify import verify_vrf from vyos.ifconfig import GeneveIf from vyos.utils.network import interface_exists from vyos import ConfigError @@ -59,6 +60,7 @@ def verify(geneve): verify_mtu_ipv6(geneve) verify_address(geneve) + verify_vrf(geneve) verify_bond_bridge_member(geneve) verify_mirror_redirect(geneve) diff --git a/src/conf_mode/interfaces_l2tpv3.py b/src/conf_mode/interfaces_l2tpv3.py index e25793543..b9f827bee 100755 --- a/src/conf_mode/interfaces_l2tpv3.py +++ b/src/conf_mode/interfaces_l2tpv3.py @@ -24,6 +24,7 @@ from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_bond_bridge_member +from vyos.configverify import verify_vrf from vyos.ifconfig import L2TPv3If from vyos.utils.kernel import check_kmod from vyos.utils.network import is_addr_assigned @@ -76,6 +77,7 @@ def verify(l2tpv3): verify_mtu_ipv6(l2tpv3) verify_address(l2tpv3) + verify_vrf(l2tpv3) verify_bond_bridge_member(l2tpv3) verify_mirror_redirect(l2tpv3) return None diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index 0dc76b39a..a03bd5959 100755 --- a/src/conf_mode/interfaces_openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -235,10 +235,6 @@ def verify_pki(openvpn): def verify(openvpn): if 'deleted' in openvpn: - # remove totp secrets file if totp is not configured - if os.path.isfile(otp_file.format(**openvpn)): - os.remove(otp_file.format(**openvpn)) - verify_bridge_delete(openvpn) return None @@ -326,8 +322,8 @@ def verify(openvpn): if v4addr in openvpn['local_address'] and 'subnet_mask' not in openvpn['local_address'][v4addr]: raise ConfigError('Must specify IPv4 "subnet-mask" for local-address') - if dict_search('encryption.ncp_ciphers', openvpn): - raise ConfigError('NCP ciphers can only be used in client or server mode') + if dict_search('encryption.data_ciphers', openvpn): + raise ConfigError('Cipher negotiation can only be used in client or server mode') else: # checks for client-server or site-to-site bridged @@ -524,7 +520,7 @@ def verify(openvpn): if dict_search('encryption.cipher', openvpn): raise ConfigError('"encryption cipher" option is deprecated for TLS mode. ' - 'Use "encryption ncp-ciphers" instead') + 'Use "encryption data-ciphers" instead') if dict_search('encryption.cipher', openvpn) == 'none': print('Warning: "encryption none" was specified!') @@ -635,9 +631,19 @@ def generate_pki_files(openvpn): def generate(openvpn): + if 'deleted' in openvpn: + # remove totp secrets file if totp is not configured + if os.path.isfile(otp_file.format(**openvpn)): + os.remove(otp_file.format(**openvpn)) + return None + + if 'disable' in openvpn: + return None + interface = openvpn['ifname'] directory = os.path.dirname(cfg_file.format(**openvpn)) openvpn['plugin_dir'] = '/usr/lib/openvpn' + # create base config directory on demand makedir(directory, user, group) # enforce proper permissions on /run/openvpn @@ -654,9 +660,6 @@ def generate(openvpn): if os.path.isdir(service_dir): rmtree(service_dir, ignore_errors=True) - if 'deleted' in openvpn or 'disable' in openvpn: - return None - # create client config directory on demand makedir(ccd_dir, user, group) diff --git a/src/conf_mode/interfaces_vti.py b/src/conf_mode/interfaces_vti.py index e6a833df7..20629c6c1 100755 --- a/src/conf_mode/interfaces_vti.py +++ b/src/conf_mode/interfaces_vti.py @@ -19,6 +19,7 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_vrf from vyos.ifconfig import VTIIf from vyos import ConfigError from vyos import airbag @@ -38,6 +39,7 @@ def get_config(config=None): return vti def verify(vti): + verify_vrf(vti) verify_mirror_redirect(vti) return None diff --git a/src/conf_mode/interfaces_vxlan.py b/src/conf_mode/interfaces_vxlan.py index aca0a20e4..68646e8ff 100755 --- a/src/conf_mode/interfaces_vxlan.py +++ b/src/conf_mode/interfaces_vxlan.py @@ -28,6 +28,7 @@ from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_source_interface from vyos.configverify import verify_bond_bridge_member +from vyos.configverify import verify_vrf from vyos.ifconfig import Interface from vyos.ifconfig import VXLANIf from vyos.template import is_ipv6 @@ -216,6 +217,7 @@ def verify(vxlan): verify_mtu_ipv6(vxlan) verify_address(vxlan) + verify_vrf(vxlan) verify_bond_bridge_member(vxlan) verify_mirror_redirect(vxlan) diff --git a/src/conf_mode/policy_route.py b/src/conf_mode/policy_route.py index c58fe1bce..223175b8a 100755 --- a/src/conf_mode/policy_route.py +++ b/src/conf_mode/policy_route.py @@ -25,6 +25,9 @@ from vyos.template import render from vyos.utils.dict import dict_search_args from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.network import get_vrf_tableid +from vyos.defaults import rt_global_table +from vyos.defaults import rt_global_vrf from vyos import ConfigError from vyos import airbag airbag.enable() @@ -83,6 +86,9 @@ def verify_rule(policy, name, rule_conf, ipv6, rule_id): if not tcp_flags or 'syn' not in tcp_flags: raise ConfigError(f'{name} rule {rule_id}: TCP SYN flag must be set to modify TCP-MSS') + if 'vrf' in rule_conf['set'] and 'table' in rule_conf['set']: + raise ConfigError(f'{name} rule {rule_id}: Cannot set both forwarding route table and VRF') + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: if dict_search_args(rule_conf, 'protocol') != 'tcp': @@ -152,15 +158,26 @@ def apply_table_marks(policy): for name, pol_conf in policy[route].items(): if 'rule' in pol_conf: for rule_id, rule_conf in pol_conf['rule'].items(): + vrf_table_id = None set_table = dict_search_args(rule_conf, 'set', 'table') - if set_table: + set_vrf = dict_search_args(rule_conf, 'set', 'vrf') + if set_vrf: + if set_vrf == 'default': + vrf_table_id = rt_global_vrf + else: + vrf_table_id = get_vrf_tableid(set_vrf) + elif set_table: if set_table == 'main': - set_table = '254' - if set_table in tables: + vrf_table_id = rt_global_table + else: + vrf_table_id = set_table + if vrf_table_id is not None: + vrf_table_id = int(vrf_table_id) + if vrf_table_id in tables: continue - tables.append(set_table) - table_mark = mark_offset - int(set_table) - cmd(f'{cmd_str} rule add pref {set_table} fwmark {table_mark} table {set_table}') + tables.append(vrf_table_id) + table_mark = mark_offset - vrf_table_id + cmd(f'{cmd_str} rule add pref {vrf_table_id} fwmark {table_mark} table {vrf_table_id}') def cleanup_table_marks(): for cmd_str in ['ip', 'ip -6']: diff --git a/src/conf_mode/service_snmp.py b/src/conf_mode/service_snmp.py index 6f025cc23..c9c0ed9a0 100755 --- a/src/conf_mode/service_snmp.py +++ b/src/conf_mode/service_snmp.py @@ -41,6 +41,7 @@ config_file_client = r'/etc/snmp/snmp.conf' config_file_daemon = r'/etc/snmp/snmpd.conf' config_file_access = r'/usr/share/snmp/snmpd.conf' config_file_user = r'/var/lib/snmp/snmpd.conf' +default_script_dir = r'/config/user-data/' systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf' systemd_service = 'snmpd.service' @@ -85,8 +86,20 @@ def get_config(config=None): tmp = {'::1': {'port': '161'}} snmp['listen_address'] = dict_merge(tmp, snmp['listen_address']) + if 'script_extensions' in snmp and 'extension_name' in snmp['script_extensions']: + for key, val in snmp['script_extensions']['extension_name'].items(): + if 'script' not in val: + continue + script_path = val['script'] + # if script has not absolute path, use pre configured path + if not os.path.isabs(script_path): + script_path = os.path.join(default_script_dir, script_path) + + snmp['script_extensions']['extension_name'][key]['script'] = script_path + return snmp + def verify(snmp): if 'deleted' in snmp: return None diff --git a/src/conf_mode/system_conntrack.py b/src/conf_mode/system_conntrack.py index aa290788c..2529445bf 100755 --- a/src/conf_mode/system_conntrack.py +++ b/src/conf_mode/system_conntrack.py @@ -13,7 +13,7 @@ # # 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 os from sys import exit @@ -24,7 +24,8 @@ from vyos.configdep import set_dependents, call_dependents from vyos.utils.dict import dict_search 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.file import write_file +from vyos.utils.process import cmd, call from vyos.utils.process import rc_cmd from vyos.template import render from vyos import ConfigError @@ -34,6 +35,7 @@ airbag.enable() conntrack_config = r'/etc/modprobe.d/vyatta_nf_conntrack.conf' sysctl_file = r'/run/sysctl/10-vyos-conntrack.conf' nftables_ct_file = r'/run/nftables-ct.conf' +vyos_conntrack_logger_config = r'/run/vyos-conntrack-logger.conf' # Every ALG (Application Layer Gateway) consists of either a Kernel Object # also called a Kernel Module/Driver or some rules present in iptables @@ -113,6 +115,7 @@ def get_config(config=None): return conntrack + def verify(conntrack): for inet in ['ipv4', 'ipv6']: if dict_search_args(conntrack, 'ignore', inet, 'rule') != None: @@ -181,6 +184,11 @@ def generate(conntrack): if not os.path.exists(nftables_ct_file): conntrack['first_install'] = True + if 'log' not in conntrack: + # Remove old conntrack-logger config and return + if os.path.exists(vyos_conntrack_logger_config): + os.unlink(vyos_conntrack_logger_config) + # Determine if conntrack is needed conntrack['ipv4_firewall_action'] = 'return' conntrack['ipv6_firewall_action'] = 'return' @@ -199,6 +207,11 @@ def generate(conntrack): render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.j2', conntrack) render(sysctl_file, 'conntrack/sysctl.conf.j2', conntrack) render(nftables_ct_file, 'conntrack/nftables-ct.j2', conntrack) + + if 'log' in conntrack: + log_conf_json = json.dumps(conntrack['log'], indent=4) + write_file(vyos_conntrack_logger_config, log_conf_json) + return None def apply(conntrack): @@ -243,8 +256,12 @@ def apply(conntrack): # See: https://bugzilla.redhat.com/show_bug.cgi?id=1264080 cmd(f'sysctl -f {sysctl_file}') + if 'log' in conntrack: + call(f'systemctl restart vyos-conntrack-logger.service') + return None + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index 19bbb8875..27bf92e0b 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -19,8 +19,10 @@ from pathlib import Path from vyos.config import Config from vyos.utils.process import call +from vyos.utils.serial import restart_login_consoles from vyos.system import grub_util from vyos.template import render +from vyos.defaults import directories from vyos import ConfigError from vyos import airbag airbag.enable() @@ -74,7 +76,6 @@ def generate(console): for root, dirs, files in os.walk(base_dir): for basename in files: if 'serial-getty' in basename: - call(f'systemctl stop {basename}') os.unlink(os.path.join(root, basename)) if not console or 'device' not in console: @@ -122,6 +123,11 @@ def apply(console): # Reload systemd manager configuration call('systemctl daemon-reload') + # Service control moved to vyos.utils.serial to unify checks and prompts. + # If users are connected, we want to show an informational message on completing + # the process, but not halt configuration processing with an interactive prompt. + restart_login_consoles(prompt_user=False, quiet=False) + if not console: return None @@ -129,13 +135,6 @@ def apply(console): # Configure screen blank powersaving on VGA console call('/usr/bin/setterm -blank 15 -powersave powerdown -powerdown 60 -term linux </dev/tty1 >/dev/tty1 2>&1') - # Start getty process on configured serial interfaces - for device in console['device']: - # Only start console if it exists on the running system. If a user - # detaches a USB serial console and reboots - it should not fail! - if os.path.exists(f'/dev/{device}'): - call(f'systemctl restart serial-getty@{device}.service') - return None if __name__ == '__main__': diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py index 571ce55ec..180686924 100755 --- a/src/conf_mode/system_option.py +++ b/src/conf_mode/system_option.py @@ -31,6 +31,7 @@ from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_running from vyos.utils.network import is_addr_assigned from vyos.utils.network import is_intf_addr_assigned +from vyos.configdep import set_dependents, call_dependents from vyos import ConfigError from vyos import airbag airbag.enable() @@ -55,6 +56,12 @@ def get_config(config=None): get_first_key=True, with_recursive_defaults=True) + if 'performance' in options: + # Update IPv4 and IPv6 options after TuneD reapplies + # sysctl from config files + for protocol in ['ip', 'ipv6']: + set_dependents(protocol, conf) + return options def verify(options): @@ -145,6 +152,8 @@ def apply(options): else: cmd('systemctl stop tuned.service') + call_dependents() + # Keyboard layout - there will be always the default key inside the dict # but we check for key existence anyway if 'keyboard_layout' in options: diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 184725573..72b178c89 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sys import exit +from jmespath import search from json import loads from vyos.config import Config @@ -70,6 +71,14 @@ def has_rule(af : str, priority : int, table : str=None): return True return False +def is_nft_vrf_zone_rule_setup() -> bool: + """ + Check if an nftables connection tracking rule already exists + """ + tmp = loads(cmd('sudo nft -j list table inet vrf_zones')) + num_rules = len(search("nftables[].rule[].chain", tmp)) + return bool(num_rules) + def vrf_interfaces(c, match): matched = [] old_level = c.get_level() @@ -264,6 +273,7 @@ def apply(vrf): if not has_rule(afi, 2000, 'l3mdev'): call(f'ip {afi} rule add pref 2000 l3mdev unreachable') + nft_vrf_zone_rule_setup = False for name, config in vrf['name'].items(): table = config['table'] if not interface_exists(name): @@ -302,7 +312,12 @@ def apply(vrf): nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}' cmd(f'nft {nft_add_element}') - if vrf['conntrack']: + # Only call into nftables as long as there is nothing setup to avoid wasting + # CPU time and thus lenghten the commit process + if not nft_vrf_zone_rule_setup: + nft_vrf_zone_rule_setup = is_nft_vrf_zone_rule_setup() + # Install nftables conntrack rules only once + if vrf['conntrack'] and not nft_vrf_zone_rule_setup: for chain, rule in nftables_rules.items(): cmd(f'nft add rule inet vrf_zones {chain} {rule}') diff --git a/src/migration-scripts/firewall/16-to-17 b/src/migration-scripts/firewall/16-to-17 new file mode 100755 index 000000000..ad0706f04 --- /dev/null +++ b/src/migration-scripts/firewall/16-to-17 @@ -0,0 +1,60 @@ +# Copyright (C) 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/>. + +# +# T4694: Adding rt ipsec exists/missing match to firewall configs. +# This involves a syntax change for IPsec matches, reflecting that different +# nftables expressions are required depending on whether we're matching a +# decrypted packet or a packet that will be encrypted - it's directional. +# The old rules only matched decrypted packets, those matches are now *-in: + # from: set firewall <family> <chainspec> rule <rule#> ipsec match-ipsec|match-none + # to: set firewall <family> <chainspec> rule <rule#> ipsec match-ipsec-in|match-none-in +# +# The <chainspec> positions this match allowed were: +# name (any custom chains), forward filter, input filter, prerouting raw. +# There are positions where it was possible to set, but it would never commit +# (nftables rejects 'meta ipsec' in output hooks), they are not considered here. +# + +from vyos.configtree import ConfigTree + +firewall_base = ['firewall'] + +def migrate_chain(config: ConfigTree, path: list[str]) -> None: + if not config.exists(path + ['rule']): + return + + for rule_num in config.list_nodes(path + ['rule']): + tmp_path = path + ['rule', rule_num, 'ipsec'] + if config.exists(tmp_path + ['match-ipsec']): + config.delete(tmp_path + ['match-ipsec']) + config.set(tmp_path + ['match-ipsec-in']) + elif config.exists(tmp_path + ['match-none']): + config.delete(tmp_path + ['match-none']) + config.set(tmp_path + ['match-none-in']) + +def migrate(config: ConfigTree) -> None: + if not config.exists(firewall_base): + # Nothing to do + return + + for family in ['ipv4', 'ipv6']: + tmp_path = firewall_base + [family, 'name'] + if config.exists(tmp_path): + for custom_fwname in config.list_nodes(tmp_path): + migrate_chain(config, tmp_path + [custom_fwname]) + + for base_hook in [['forward', 'filter'], ['input', 'filter'], ['prerouting', 'raw']]: + tmp_path = firewall_base + [family] + base_hook + migrate_chain(config, tmp_path) diff --git a/src/migration-scripts/openvpn/1-to-2 b/src/migration-scripts/openvpn/1-to-2 index b7b7d4c77..2baa7302c 100644 --- a/src/migration-scripts/openvpn/1-to-2 +++ b/src/migration-scripts/openvpn/1-to-2 @@ -20,12 +20,8 @@ from vyos.configtree import ConfigTree def migrate(config: ConfigTree) -> None: - if not config.exists(['interfaces', 'openvpn']): - # Nothing to do - return - - ovpn_intfs = config.list_nodes(['interfaces', 'openvpn']) - for i in ovpn_intfs: + ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False) + for i in ovpn_intfs: # Remove 'encryption cipher' and add this value to 'encryption ncp-ciphers' # for server and client mode. # Site-to-site mode still can use --cipher option diff --git a/src/migration-scripts/openvpn/2-to-3 b/src/migration-scripts/openvpn/2-to-3 index 0b9073ae6..4e6b3c8b7 100644 --- a/src/migration-scripts/openvpn/2-to-3 +++ b/src/migration-scripts/openvpn/2-to-3 @@ -20,12 +20,8 @@ from vyos.configtree import ConfigTree def migrate(config: ConfigTree) -> None: - if not config.exists(['interfaces', 'openvpn']): - # Nothing to do - return - - ovpn_intfs = config.list_nodes(['interfaces', 'openvpn']) - for i in ovpn_intfs: + ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False) + for i in ovpn_intfs: mode = config.return_value(['interfaces', 'openvpn', i, 'mode']) if mode != 'server': # If it's a client or a site-to-site OpenVPN interface, diff --git a/src/migration-scripts/openvpn/3-to-4 b/src/migration-scripts/openvpn/3-to-4 new file mode 100644 index 000000000..0529491c1 --- /dev/null +++ b/src/migration-scripts/openvpn/3-to-4 @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see <http://www.gnu.org/licenses/>. +# Renames ncp-ciphers option to data-ciphers + +from vyos.configtree import ConfigTree + +def migrate(config: ConfigTree) -> None: + ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False) + for i in ovpn_intfs: + #Rename 'encryption ncp-ciphers' with 'encryption data-ciphers' + ncp_cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers'] + if config.exists(ncp_cipher_path): + config.rename(ncp_cipher_path, 'data-ciphers') diff --git a/src/op_mode/connect_disconnect.py b/src/op_mode/connect_disconnect.py index 379890c54..8903f916a 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -97,7 +97,7 @@ def main(): group = parser.add_mutually_exclusive_group() group.add_argument("--connect", help="Bring up a connection-oriented network interface", action="store_true") group.add_argument("--disconnect", help="Take down connection-oriented network interface", action="store_true") - group.add_argument("--interface", help="Interface name", action="store", required=True) + parser.add_argument("--interface", help="Interface name", action="store", required=True) args = parser.parse_args() if args.connect or args.disconnect: diff --git a/src/op_mode/generate_ovpn_client_file.py b/src/op_mode/generate_ovpn_client_file.py index 2d96fe217..974f7d9b6 100755 --- a/src/op_mode/generate_ovpn_client_file.py +++ b/src/op_mode/generate_ovpn_client_file.py @@ -19,42 +19,53 @@ import argparse from jinja2 import Template from textwrap import fill -from vyos.configquery import ConfigTreeQuery +from vyos.config import Config from vyos.ifconfig import Section client_config = """ client nobind -remote {{ remote_host }} {{ port }} +remote {{ local_host if local_host else 'x.x.x.x' }} {{ port }} remote-cert-tls server -proto {{ 'tcp-client' if protocol == 'tcp-active' else 'udp' }} -dev {{ device }} -dev-type {{ device }} +proto {{ 'tcp-client' if protocol == 'tcp-passive' else 'udp' }} +dev {{ device_type }} +dev-type {{ device_type }} persist-key persist-tun verb 3 # Encryption options +{# Define the encryption map #} +{% set encryption_map = { + 'des': 'DES-CBC', + '3des': 'DES-EDE3-CBC', + 'bf128': 'BF-CBC', + 'bf256': 'BF-CBC', + 'aes128gcm': 'AES-128-GCM', + 'aes128': 'AES-128-CBC', + 'aes192gcm': 'AES-192-GCM', + 'aes192': 'AES-192-CBC', + 'aes256gcm': 'AES-256-GCM', + 'aes256': 'AES-256-CBC' +} %} + {% if encryption is defined and encryption is not none %} -{% if encryption.cipher is defined and encryption.cipher is not none %} -cipher {{ encryption.cipher }} -{% if encryption.cipher == 'bf128' %} -keysize 128 -{% elif encryption.cipher == 'bf256' %} -keysize 256 +{% if encryption.ncp_ciphers is defined and encryption.ncp_ciphers is not none %} +cipher {% for algo in encryption.ncp_ciphers %} +{{ encryption_map[algo] if algo in encryption_map.keys() else algo }}{% if not loop.last %}:{% endif %} +{% endfor %} + +data-ciphers {% for algo in encryption.ncp_ciphers %} +{{ encryption_map[algo] if algo in encryption_map.keys() else algo }}{% if not loop.last %}:{% endif %} +{% endfor %} {% endif %} -{% endif %} -{% if encryption.ncp_ciphers is defined and encryption.ncp_ciphers is not none %} -data-ciphers {{ encryption.ncp_ciphers }} -{% endif %} {% endif %} {% if hash is defined and hash is not none %} auth {{ hash }} {% endif %} -keysize 256 -comp-lzo {{ '' if use_lzo_compression is defined else 'no' }} +{{ 'comp-lzo' if use_lzo_compression is defined else '' }} <ca> -----BEGIN CERTIFICATE----- @@ -79,7 +90,7 @@ comp-lzo {{ '' if use_lzo_compression is defined else 'no' }} """ -config = ConfigTreeQuery() +config = Config() base = ['interfaces', 'openvpn'] if not config.exists(base): @@ -89,10 +100,22 @@ if not config.exists(base): if __name__ == '__main__': parser = argparse.ArgumentParser() - parser.add_argument("-i", "--interface", type=str, help='OpenVPN interface the client is connecting to', required=True) - parser.add_argument("-a", "--ca", type=str, help='OpenVPN CA cerificate', required=True) - parser.add_argument("-c", "--cert", type=str, help='OpenVPN client cerificate', required=True) - parser.add_argument("-k", "--key", type=str, help='OpenVPN client cerificate key', action="store") + parser.add_argument( + "-i", + "--interface", + type=str, + help='OpenVPN interface the client is connecting to', + required=True, + ) + parser.add_argument( + "-a", "--ca", type=str, help='OpenVPN CA cerificate', required=True + ) + parser.add_argument( + "-c", "--cert", type=str, help='OpenVPN client cerificate', required=True + ) + parser.add_argument( + "-k", "--key", type=str, help='OpenVPN client cerificate key', action="store" + ) args = parser.parse_args() interface = args.interface @@ -114,33 +137,25 @@ if __name__ == '__main__': if not config.exists(['pki', 'certificate', cert, 'private', 'key']): exit(f'OpenVPN certificate key "{key}" does not exist!') - ca = config.value(['pki', 'ca', ca, 'certificate']) + config = config.get_config_dict( + base + [interface], + key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True, + with_pki=True, + ) + + ca = config['pki']['ca'][ca]['certificate'] ca = fill(ca, width=64) - cert = config.value(['pki', 'certificate', cert, 'certificate']) + cert = config['pki']['certificate'][cert]['certificate'] cert = fill(cert, width=64) - key = config.value(['pki', 'certificate', key, 'private', 'key']) + key = config['pki']['certificate'][key]['private']['key'] key = fill(key, width=64) - remote_host = config.value(base + [interface, 'local-host']) - - ovpn_conf = config.get_config_dict(base + [interface], key_mangling=('-', '_'), get_first_key=True) - - port = '1194' if 'local_port' not in ovpn_conf else ovpn_conf['local_port'] - proto = 'udp' if 'protocol' not in ovpn_conf else ovpn_conf['protocol'] - device = 'tun' if 'device_type' not in ovpn_conf else ovpn_conf['device_type'] - - config = { - 'interface' : interface, - 'ca' : ca, - 'cert' : cert, - 'key' : key, - 'device' : device, - 'port' : port, - 'proto' : proto, - 'remote_host' : remote_host, - 'address' : [], - } - -# Clear out terminal first -print('\x1b[2J\x1b[H') -client = Template(client_config, trim_blocks=True).render(config) -print(client) + + config['ca'] = ca + config['cert'] = cert + config['key'] = key + config['port'] = '1194' if 'local_port' not in config else config['local_port'] + + client = Template(client_config, trim_blocks=True).render(config) + print(client) diff --git a/src/op_mode/ikev2_profile_generator.py b/src/op_mode/ikev2_profile_generator.py index b193d8109..cf2bc6d5c 100755 --- a/src/op_mode/ikev2_profile_generator.py +++ b/src/op_mode/ikev2_profile_generator.py @@ -105,10 +105,39 @@ vyos2windows_integrity = { } # IOS 14.2 and later do no support dh-group 1,2 and 5. Supported DH groups would -# be: 14, 15, 16, 17, 18, 19, 20, 21, 31 -ios_supported_dh_groups = ['14', '15', '16', '17', '18', '19', '20', '21', '31'] -# Windows 10 only allows a limited set of DH groups -windows_supported_dh_groups = ['1', '2', '14', '24'] +# be: 14, 15, 16, 17, 18, 19, 20, 21, 31, 32 +vyos2apple_dh_group = { + '14' : '14', + '15' : '15', + '16' : '16', + '17' : '17', + '18' : '18', + '19' : '19', + '20' : '20', + '21' : '21', + '31' : '31', + '32' : '32' +} + +# Newer versions of Windows support groups 19 and 20, albeit under a different naming convention +vyos2windows_dh_group = { + '1' : 'Group1', + '2' : 'Group2', + '14' : 'Group14', + '19' : 'ECP256', + '20' : 'ECP384', + '24' : 'Group24' +} + +# For PFS, Windows also has its own inconsistent naming scheme for each group +vyos2windows_pfs_group = { + '1' : 'PFS1', + '2' : 'PFS2', + '14' : 'PFS2048', + '19' : 'ECP256', + '20' : 'ECP384', + '24' : 'PFS24' +} parser = argparse.ArgumentParser() parser.add_argument('--os', const='all', nargs='?', choices=['ios', 'windows'], help='Operating system used for config generation', required=True) @@ -181,7 +210,7 @@ if args.os == 'ios': # https://stackoverflow.com/a/9427216 data['ca_certificates'] = [dict(t) for t in {tuple(d.items()) for d in data['ca_certificates']}] -esp_proposals = conf.get_config_dict(ipsec_base + ['esp-group', data['esp_group'], 'proposal'], +esp_group = conf.get_config_dict(ipsec_base + ['esp-group', data['esp_group']], key_mangling=('-', '_'), get_first_key=True) ike_proposal = conf.get_config_dict(ipsec_base + ['ike-group', data['ike_group'], 'proposal'], key_mangling=('-', '_'), get_first_key=True) @@ -192,7 +221,29 @@ ike_proposal = conf.get_config_dict(ipsec_base + ['ike-group', data['ike_group'] vyos2client_cipher = vyos2apple_cipher if args.os == 'ios' else vyos2windows_cipher; vyos2client_integrity = vyos2apple_integrity if args.os == 'ios' else vyos2windows_integrity; -supported_dh_groups = ios_supported_dh_groups if args.os == 'ios' else windows_supported_dh_groups; +vyos2client_dh_group = vyos2apple_dh_group if args.os == 'ios' else vyos2windows_dh_group + +def transform_pfs(pfs, ike_dh_group): + pfs_enabled = (pfs != 'disable') + if pfs == 'enable': + pfs_dh_group = ike_dh_group + elif pfs.startswith('dh-group'): + pfs_dh_group = pfs.removeprefix('dh-group') + + if args.os == 'ios': + if pfs_enabled: + if pfs_dh_group not in set(vyos2apple_dh_group): + exit(f'The PFS group configured for "{args.connection}" is not supported by the client!') + return pfs_dh_group + else: + return None + else: + if pfs_enabled: + if pfs_dh_group not in set(vyos2windows_pfs_group): + exit(f'The PFS group configured for "{args.connection}" is not supported by the client!') + return vyos2windows_pfs_group[ pfs_dh_group ] + else: + return 'None' # Create a dictionary containing client conform IKE settings ike = {} @@ -201,24 +252,28 @@ for _, proposal in ike_proposal.items(): if {'dh_group', 'encryption', 'hash'} <= set(proposal): if (proposal['encryption'] in set(vyos2client_cipher) and proposal['hash'] in set(vyos2client_integrity) and - proposal['dh_group'] in set(supported_dh_groups)): + proposal['dh_group'] in set(vyos2client_dh_group)): # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ] proposal['hash'] = vyos2client_integrity[ proposal['hash'] ] + # DH group will need to be transformed later after we calculate PFS group ike.update( { str(count) : proposal } ) count += 1 -# Create a dictionary containing Apple conform ESP settings +# Create a dictionary containing client conform ESP settings esp = {} count = 1 -for _, proposal in esp_proposals.items(): +for _, proposal in esp_group['proposal'].items(): if {'encryption', 'hash'} <= set(proposal): if proposal['encryption'] in set(vyos2client_cipher) and proposal['hash'] in set(vyos2client_integrity): # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ] proposal['hash'] = vyos2client_integrity[ proposal['hash'] ] + # Copy PFS setting from the group, if present (we will need to + # transform this later once the IKE group is selected) + proposal['pfs'] = esp_group.get('pfs', 'enable') esp.update( { str(count) : proposal } ) count += 1 @@ -230,8 +285,10 @@ try: tmp += f'({number}) Encryption {options["encryption"]}, Integrity {options["hash"]}, DH group {options["dh_group"]}\n' tmp += '\nSelect one of the above IKE groups: ' data['ike_encryption'] = ike[ ask_input(tmp, valid_responses=list(ike)) ] - else: + elif len(ike) == 1: data['ike_encryption'] = ike['1'] + else: + exit(f'None of the configured IKE proposals for "{args.connection}" are supported by the client!') if len(esp) > 1: tmp = '\n' @@ -239,12 +296,18 @@ try: tmp += f'({number}) Encryption {options["encryption"]}, Integrity {options["hash"]}\n' tmp += '\nSelect one of the above ESP groups: ' data['esp_encryption'] = esp[ ask_input(tmp, valid_responses=list(esp)) ] - else: + elif len(esp) == 1: data['esp_encryption'] = esp['1'] + else: + exit(f'None of the configured ESP proposals for "{args.connection}" are supported by the client!') except KeyboardInterrupt: exit("Interrupted") +# Transform the DH and PFS groups now that all selections are known +data['esp_encryption']['pfs'] = transform_pfs(data['esp_encryption']['pfs'], data['ike_encryption']['dh_group']) +data['ike_encryption']['dh_group'] = vyos2client_dh_group[ data['ike_encryption']['dh_group'] ] + print('\n\n==== <snip> ====') if args.os == 'ios': print(render_to_string('ipsec/ios_profile.j2', data)) diff --git a/src/op_mode/interfaces.py b/src/op_mode/interfaces.py index 14ffdca9f..e7afc4caa 100755 --- a/src/op_mode/interfaces.py +++ b/src/op_mode/interfaces.py @@ -445,12 +445,24 @@ def _format_show_counters(data: list): print (output) return output + +def _show_raw(data: list, intf_name: str): + if intf_name is not None and len(data) <= 1: + try: + return data[0] + except IndexError: + raise vyos.opmode.UnconfiguredObject( + f"Interface {intf_name} does not exist") + else: + return data + + def show(raw: bool, intf_name: typing.Optional[str], intf_type: typing.Optional[str], vif: bool, vrrp: bool): data = _get_raw_data(intf_name, intf_type, vif, vrrp) if raw: - return data + return _show_raw(data, intf_name) return _format_show_data(data) def show_summary(raw: bool, intf_name: typing.Optional[str], @@ -458,7 +470,7 @@ def show_summary(raw: bool, intf_name: typing.Optional[str], vif: bool, vrrp: bool): data = _get_summary_data(intf_name, intf_type, vif, vrrp) if raw: - return data + return _show_raw(data, intf_name) return _format_show_summary(data) def show_summary_extended(raw: bool, intf_name: typing.Optional[str], @@ -466,7 +478,7 @@ def show_summary_extended(raw: bool, intf_name: typing.Optional[str], vif: bool, vrrp: bool): data = _get_summary_data(intf_name, intf_type, vif, vrrp) if raw: - return data + return _show_raw(data, intf_name) return _format_show_summary_extended(data) def show_counters(raw: bool, intf_name: typing.Optional[str], @@ -474,7 +486,7 @@ def show_counters(raw: bool, intf_name: typing.Optional[str], vif: bool, vrrp: bool): data = _get_counter_data(intf_name, intf_type, vif, vrrp) if raw: - return data + return _show_raw(data, intf_name) return _format_show_counters(data) def clear_counters(intf_name: typing.Optional[str], diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py index 44d41219e..02ba126b4 100755 --- a/src/op_mode/ipsec.py +++ b/src/op_mode/ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# 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 @@ -25,6 +25,7 @@ from vyos.utils.convert import convert_data from vyos.utils.convert import seconds_to_human from vyos.utils.process import cmd from vyos.configquery import ConfigTreeQuery +from vyos.base import Warning import vyos.opmode import vyos.ipsec @@ -43,7 +44,7 @@ def _get_raw_data_sas(): get_sas = vyos.ipsec.get_vici_sas() sas = convert_data(get_sas) return sas - except (vyos.ipsec.ViciInitiateError) as err: + except vyos.ipsec.ViciInitiateError as err: raise vyos.opmode.UnconfiguredSubsystem(err) @@ -56,11 +57,10 @@ def _get_output_swanctl_sas_from_list(ra_output_list: list) -> str: :return: formatted string :rtype: str """ - output = ''; + output = '' for sa_val in ra_output_list: for sa in sa_val.values(): - swanctl_output: str = cmd( - f'sudo swanctl -l --ike-id {sa["uniqueid"]}') + swanctl_output: str = cmd(f'sudo swanctl -l --ike-id {sa["uniqueid"]}') output = f'{output}{swanctl_output}\n\n' return output @@ -72,7 +72,9 @@ def _get_formatted_output_sas(sas): # create an item for each child-sa for child_sa in parent_sa.get('child-sas', {}).values(): # prepare a list for output data - sa_out_name = sa_out_state = sa_out_uptime = sa_out_bytes = sa_out_packets = sa_out_remote_addr = sa_out_remote_id = sa_out_proposal = 'N/A' + sa_out_name = sa_out_state = sa_out_uptime = sa_out_bytes = ( + sa_out_packets + ) = sa_out_remote_addr = sa_out_remote_id = sa_out_proposal = 'N/A' # collect raw data sa_name = child_sa.get('name') @@ -104,10 +106,8 @@ def _get_formatted_output_sas(sas): bytes_out = filesize.size(int(sa_bytes_out)) sa_out_bytes = f'{bytes_in}/{bytes_out}' if sa_packets_in and sa_packets_out: - packets_in = filesize.size(int(sa_packets_in), - system=filesize.si) - packets_out = filesize.size(int(sa_packets_out), - system=filesize.si) + packets_in = filesize.size(int(sa_packets_in), system=filesize.si) + packets_out = filesize.size(int(sa_packets_out), system=filesize.si) packets_str = f'{packets_in}/{packets_out}' sa_out_packets = re.sub(r'B', r'', packets_str) if sa_remote_addr: @@ -119,7 +119,9 @@ def _get_formatted_output_sas(sas): sa_out_proposal = sa_proposal_encr_alg if sa_proposal_encr_keysize: sa_proposal_encr_keysize_str = sa_proposal_encr_keysize - sa_out_proposal = f'{sa_out_proposal}_{sa_proposal_encr_keysize_str}' + sa_out_proposal = ( + f'{sa_out_proposal}_{sa_proposal_encr_keysize_str}' + ) if sa_proposal_integ_alg: sa_proposal_integ_alg_str = sa_proposal_integ_alg sa_out_proposal = f'{sa_out_proposal}/{sa_proposal_integ_alg_str}' @@ -128,15 +130,28 @@ def _get_formatted_output_sas(sas): sa_out_proposal = f'{sa_out_proposal}/{sa_proposal_dh_group_str}' # add a new item to output data - sa_data.append([ - sa_out_name, sa_out_state, sa_out_uptime, sa_out_bytes, - sa_out_packets, sa_out_remote_addr, sa_out_remote_id, - sa_out_proposal - ]) + sa_data.append( + [ + sa_out_name, + sa_out_state, + sa_out_uptime, + sa_out_bytes, + sa_out_packets, + sa_out_remote_addr, + sa_out_remote_id, + sa_out_proposal, + ] + ) headers = [ - "Connection", "State", "Uptime", "Bytes In/Out", "Packets In/Out", - "Remote address", "Remote ID", "Proposal" + 'Connection', + 'State', + 'Uptime', + 'Bytes In/Out', + 'Packets In/Out', + 'Remote address', + 'Remote ID', + 'Proposal', ] sa_data = sorted(sa_data, key=_alphanum_key) output = tabulate(sa_data, headers) @@ -145,14 +160,16 @@ def _get_formatted_output_sas(sas): # Connections block + def _get_convert_data_connections(): try: get_connections = vyos.ipsec.get_vici_connections() connections = convert_data(get_connections) return connections - except (vyos.ipsec.ViciInitiateError) as err: + except vyos.ipsec.ViciInitiateError as err: raise vyos.opmode.UnconfiguredSubsystem(err) + def _get_parent_sa_proposal(connection_name: str, data: list) -> dict: """Get parent SA proposals by connection name if connections not in the 'down' state @@ -184,7 +201,7 @@ def _get_parent_sa_proposal(connection_name: str, data: list) -> dict: 'mode': mode, 'key_size': encr_keysize, 'hash': integ_alg, - 'dh': dh_group + 'dh': dh_group, } return proposal return {} @@ -213,8 +230,7 @@ def _get_parent_sa_state(connection_name: str, data: list) -> str: return ike_state -def _get_child_sa_state(connection_name: str, tunnel_name: str, - data: list) -> str: +def _get_child_sa_state(connection_name: str, tunnel_name: str, data: list) -> str: """Get child SA state by connection and tunnel name Args: @@ -236,14 +252,12 @@ def _get_child_sa_state(connection_name: str, tunnel_name: str, # Get all child SA states # there can be multiple SAs per tunnel child_sa_states = [ - v['state'] for k, v in child_sas.items() if - v['name'] == tunnel_name + v['state'] for k, v in child_sas.items() if v['name'] == tunnel_name ] return 'up' if 'INSTALLED' in child_sa_states else child_sa -def _get_child_sa_info(connection_name: str, tunnel_name: str, - data: list) -> dict: +def _get_child_sa_info(connection_name: str, tunnel_name: str, data: list) -> dict: """Get child SA installed info by connection and tunnel name Args: @@ -264,8 +278,9 @@ def _get_child_sa_info(connection_name: str, tunnel_name: str, # {'OFFICE-B-tunnel-0-46': {'name': 'OFFICE-B-tunnel-0'}...} # i.e get all data after 'OFFICE-B-tunnel-0-46' child_sa_info = [ - v for k, v in child_sas.items() if 'name' in v and - v['name'] == tunnel_name and v['state'] == 'INSTALLED' + v + for k, v in child_sas.items() + if 'name' in v and v['name'] == tunnel_name and v['state'] == 'INSTALLED' ] return child_sa_info[-1] if child_sa_info else {} @@ -283,7 +298,7 @@ def _get_child_sa_proposal(child_sa_data: dict) -> dict: 'mode': mode, 'key_size': key_size, 'hash': integ_alg, - 'dh': dh_group + 'dh': dh_group, } return proposal return {} @@ -305,10 +320,10 @@ def _get_raw_data_connections(list_connections: list, list_sas: list) -> list: for connection, conn_conf in connections.items(): base_list['ike_connection_name'] = connection base_list['ike_connection_state'] = _get_parent_sa_state( - connection, list_sas) + connection, list_sas + ) base_list['ike_remote_address'] = conn_conf['remote_addrs'] - base_list['ike_proposal'] = _get_parent_sa_proposal( - connection, list_sas) + base_list['ike_proposal'] = _get_parent_sa_proposal(connection, list_sas) base_list['local_id'] = conn_conf.get('local-1', '').get('id') base_list['remote_id'] = conn_conf.get('remote-1', '').get('id') base_list['version'] = conn_conf.get('version', 'IKE') @@ -322,22 +337,25 @@ def _get_raw_data_connections(list_connections: list, list_sas: list) -> list: close_action = tun_options.get('close_action') sa_info = _get_child_sa_info(connection, tunnel, list_sas) esp_proposal = _get_child_sa_proposal(sa_info) - base_list['children'].append({ - 'name': tunnel, - 'state': state, - 'local_ts': local_ts, - 'remote_ts': remote_ts, - 'dpd_action': dpd_action, - 'close_action': close_action, - 'sa': sa_info, - 'esp_proposal': esp_proposal - }) + base_list['children'].append( + { + 'name': tunnel, + 'state': state, + 'local_ts': local_ts, + 'remote_ts': remote_ts, + 'dpd_action': dpd_action, + 'close_action': close_action, + 'sa': sa_info, + 'esp_proposal': esp_proposal, + } + ) base_dict.append(base_list) return base_dict def _get_raw_connections_summary(list_conn, list_sas): import jmespath + data = _get_raw_data_connections(list_conn, list_sas) match = '[*].children[]' child = jmespath.search(match, data) @@ -347,17 +365,16 @@ def _get_raw_connections_summary(list_conn, list_sas): 'tunnels': child, 'total': len(child), 'down': tunnels_down, - 'up': tunnels_up + 'up': tunnels_up, } return tun_dict def _get_formatted_output_conections(data): from tabulate import tabulate - data_entries = '' + connections = [] for entry in data: - tunnels = [] ike_name = entry['ike_connection_name'] ike_state = entry['ike_connection_state'] conn_type = entry.get('version', 'IKE') @@ -367,15 +384,26 @@ def _get_formatted_output_conections(data): remote_id = entry['remote_id'] proposal = '-' if entry.get('ike_proposal'): - proposal = (f'{entry["ike_proposal"]["cipher"]}_' - f'{entry["ike_proposal"]["mode"]}/' - f'{entry["ike_proposal"]["key_size"]}/' - f'{entry["ike_proposal"]["hash"]}/' - f'{entry["ike_proposal"]["dh"]}') - connections.append([ - ike_name, ike_state, conn_type, remote_addrs, local_ts, remote_ts, - local_id, remote_id, proposal - ]) + proposal = ( + f'{entry["ike_proposal"]["cipher"]}_' + f'{entry["ike_proposal"]["mode"]}/' + f'{entry["ike_proposal"]["key_size"]}/' + f'{entry["ike_proposal"]["hash"]}/' + f'{entry["ike_proposal"]["dh"]}' + ) + connections.append( + [ + ike_name, + ike_state, + conn_type, + remote_addrs, + local_ts, + remote_ts, + local_id, + remote_id, + proposal, + ] + ) for tun in entry['children']: tun_name = tun.get('name') tun_state = tun.get('state') @@ -384,18 +412,36 @@ def _get_formatted_output_conections(data): remote_ts = '\n'.join(tun.get('remote_ts')) proposal = '-' if tun.get('esp_proposal'): - proposal = (f'{tun["esp_proposal"]["cipher"]}_' - f'{tun["esp_proposal"]["mode"]}/' - f'{tun["esp_proposal"]["key_size"]}/' - f'{tun["esp_proposal"]["hash"]}/' - f'{tun["esp_proposal"]["dh"]}') - connections.append([ - tun_name, tun_state, conn_type, remote_addrs, local_ts, - remote_ts, local_id, remote_id, proposal - ]) + proposal = ( + f'{tun["esp_proposal"]["cipher"]}_' + f'{tun["esp_proposal"]["mode"]}/' + f'{tun["esp_proposal"]["key_size"]}/' + f'{tun["esp_proposal"]["hash"]}/' + f'{tun["esp_proposal"]["dh"]}' + ) + connections.append( + [ + tun_name, + tun_state, + conn_type, + remote_addrs, + local_ts, + remote_ts, + local_id, + remote_id, + proposal, + ] + ) connection_headers = [ - 'Connection', 'State', 'Type', 'Remote address', 'Local TS', - 'Remote TS', 'Local id', 'Remote id', 'Proposal' + 'Connection', + 'State', + 'Type', + 'Remote address', + 'Local TS', + 'Remote TS', + 'Local id', + 'Remote id', + 'Proposal', ] output = tabulate(connections, connection_headers, numalign='left') return output @@ -421,6 +467,31 @@ def _get_childsa_id_list(ike_sas: list) -> list: return list_childsa_id +def _get_con_childsa_name_list( + ike_sas: list, filter_dict: typing.Optional[dict] = None +) -> list: + """ + Generate list of CHILD SA ids based on list of OrderingDict + wich is returned by vici + :param ike_sas: list of IKE SAs connections generated by vici + :type ike_sas: list + :param filter_dict: dict of filter options + :type filter_dict: dict + :return: list of IKE SAs name + :rtype: list + """ + list_childsa_name: list = [] + for ike in ike_sas: + for ike_name, ike_values in ike.items(): + for sa, sa_values in ike_values['children'].items(): + if filter_dict: + if filter_dict.items() <= sa_values.items(): + list_childsa_name.append(sa) + else: + list_childsa_name.append(sa) + return list_childsa_name + + def _get_all_sitetosite_peers_name_list() -> list: """ Return site-to-site peers configuration @@ -429,53 +500,142 @@ def _get_all_sitetosite_peers_name_list() -> list: """ conf: ConfigTreeQuery = ConfigTreeQuery() config_path = ['vpn', 'ipsec', 'site-to-site', 'peer'] - peers_config = conf.get_config_dict(config_path, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) + peers_config = conf.get_config_dict( + config_path, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) peers_list: list = [] for name in peers_config: peers_list.append(name) return peers_list -def reset_peer(peer: str, tunnel: typing.Optional[str] = None): - # Convert tunnel to Strongwan format of CHILD_SA +def _get_tunnel_sw_format(peer: str, tunnel: str) -> str: + """ + Convert tunnel to Strongwan format of CHILD_SA + :param peer: Peer name (IKE_SA) + :type peer: str + :param tunnel: tunnel number (CHILD_SA) + :type tunnel: str + :return: Converted tunnel name (CHILD_SA) + :rtype: str + """ tunnel_sw = None if tunnel: if tunnel.isnumeric(): tunnel_sw = f'{peer}-tunnel-{tunnel}' elif tunnel == 'vti': tunnel_sw = f'{peer}-vti' + return tunnel_sw + + +def _initiate_peer_with_childsas( + peer: str, tunnel: typing.Optional[str] = None +) -> None: + """ + Initiate IPSEC peer SAs by vici. + If tunnel is None it initiates all peers tunnels + :param peer: Peer name (IKE_SA) + :type peer: str + :param tunnel: tunnel number (CHILD_SA) + :type tunnel: str + """ + tunnel_sw = _get_tunnel_sw_format(peer, tunnel) try: - sa_list: list = vyos.ipsec.get_vici_sas_by_name(peer, tunnel_sw) - if not sa_list: + con_list: list = vyos.ipsec.get_vici_connection_by_name(peer) + if not con_list: raise vyos.opmode.IncorrectValue( - f'Peer\'s {peer} SA(s) not found, aborting') - if tunnel and sa_list: - childsa_id_list: list = _get_childsa_id_list(sa_list) - if not childsa_id_list: - raise vyos.opmode.IncorrectValue( - f'Peer {peer} tunnel {tunnel} SA(s) not found, aborting') - vyos.ipsec.terminate_vici_by_name(peer, tunnel_sw) - print(f'Peer {peer} reset result: success') - except (vyos.ipsec.ViciInitiateError) as err: + f"Peer's {peer} SA(s) not loaded. Initiation was failed" + ) + childsa_name_list: list = _get_con_childsa_name_list(con_list) + + if not tunnel_sw: + vyos.ipsec.vici_initiate_all_child_sa_by_ike(peer, childsa_name_list) + print(f'Peer {peer} initiate result: success') + return + + if tunnel_sw in childsa_name_list: + vyos.ipsec.vici_initiate_all_child_sa_by_ike(peer, [tunnel_sw]) + print(f'Peer {peer} tunnel {tunnel} initiate result: success') + return + + raise vyos.opmode.IncorrectValue(f'Peer {peer} SA {tunnel} not found, aborting') + + except vyos.ipsec.ViciInitiateError as err: raise vyos.opmode.UnconfiguredSubsystem(err) - except (vyos.ipsec.ViciCommandError) as err: + except vyos.ipsec.ViciCommandError as err: raise vyos.opmode.IncorrectValue(err) -def reset_all_peers(): +def _terminate_peer(peer: str, tunnel: typing.Optional[str] = None) -> None: + """ + Terminate IPSEC peer SAs by vici. + If tunnel is None it terminates all peers tunnels + :param peer: Peer name (IKE_SA) + :type peer: str + :param tunnel: tunnel number (CHILD_SA) + :type tunnel: str + """ + # Convert tunnel to Strongwan format of CHILD_SA + tunnel_sw = _get_tunnel_sw_format(peer, tunnel) + try: + sa_list: list = vyos.ipsec.get_vici_sas_by_name(peer, tunnel_sw) + if sa_list: + if tunnel: + childsa_id_list: list = _get_childsa_id_list(sa_list) + if childsa_id_list: + vyos.ipsec.terminate_vici_by_name(peer, tunnel_sw) + print(f'Peer {peer} tunnel {tunnel} terminate result: success') + else: + Warning( + f'Peer {peer} tunnel {tunnel} SA is not initiated. Nothing to terminate' + ) + else: + vyos.ipsec.terminate_vici_by_name(peer, tunnel_sw) + print(f'Peer {peer} terminate result: success') + else: + Warning(f"Peer's {peer} SAs are not initiated. Nothing to terminate") + + except vyos.ipsec.ViciInitiateError as err: + raise vyos.opmode.UnconfiguredSubsystem(err) + except vyos.ipsec.ViciCommandError as err: + raise vyos.opmode.IncorrectValue(err) + + +def reset_peer(peer: str, tunnel: typing.Optional[str] = None) -> None: + """ + Reset IPSEC peer SAs. + If tunnel is None it resets all peers tunnels + :param peer: Peer name (IKE_SA) + :type peer: str + :param tunnel: tunnel number (CHILD_SA) + :type tunnel: str + """ + _terminate_peer(peer, tunnel) + peer_config = _get_sitetosite_peer_config(peer) + # initiate SAs only if 'connection-type=initiate' + if ( + 'connection_type' in peer_config + and peer_config['connection_type'] == 'initiate' + ): + _initiate_peer_with_childsas(peer, tunnel) + + +def reset_all_peers() -> None: sitetosite_list = _get_all_sitetosite_peers_name_list() if sitetosite_list: for peer_name in sitetosite_list: try: reset_peer(peer_name) - except (vyos.opmode.IncorrectValue) as err: + except vyos.opmode.IncorrectValue as err: print(err) print('Peers reset result: success') else: raise vyos.opmode.UnconfiguredSubsystem( - 'VPN IPSec site-to-site is not configured, aborting') + 'VPN IPSec site-to-site is not configured, aborting' + ) def _get_ra_session_list_by_username(username: typing.Optional[str] = None): @@ -500,7 +660,7 @@ def _get_ra_session_list_by_username(username: typing.Optional[str] = None): def reset_ra(username: typing.Optional[str] = None): - #Reset remote-access ipsec sessions + # Reset remote-access ipsec sessions if username: list_sa_id = _get_ra_session_list_by_username(username) else: @@ -514,32 +674,47 @@ def reset_profile_dst(profile: str, tunnel: str, nbma_dst: str): ike_sa_name = f'dmvpn-{profile}-{tunnel}' try: # Get IKE SAs - sa_list = convert_data( - vyos.ipsec.get_vici_sas_by_name(ike_sa_name, None)) + sa_list = convert_data(vyos.ipsec.get_vici_sas_by_name(ike_sa_name, None)) if not sa_list: raise vyos.opmode.IncorrectValue( - f'SA(s) for profile {profile} tunnel {tunnel} not found, aborting') - sa_nbma_list = list([x for x in sa_list if - ike_sa_name in x and x[ike_sa_name][ - 'remote-host'] == nbma_dst]) + f'SA(s) for profile {profile} tunnel {tunnel} not found, aborting' + ) + sa_nbma_list = list( + [ + x + for x in sa_list + if ike_sa_name in x and x[ike_sa_name]['remote-host'] == nbma_dst + ] + ) if not sa_nbma_list: raise vyos.opmode.IncorrectValue( - f'SA(s) for profile {profile} tunnel {tunnel} remote-host {nbma_dst} not found, aborting') + f'SA(s) for profile {profile} tunnel {tunnel} remote-host {nbma_dst} not found, aborting' + ) # terminate IKE SAs - vyos.ipsec.terminate_vici_ikeid_list(list( - [x[ike_sa_name]['uniqueid'] for x in sa_nbma_list if - ike_sa_name in x])) + vyos.ipsec.terminate_vici_ikeid_list( + list( + [ + x[ike_sa_name]['uniqueid'] + for x in sa_nbma_list + if ike_sa_name in x + ] + ) + ) # initiate IKE SAs for ike in sa_nbma_list: if ike_sa_name in ike: - vyos.ipsec.vici_initiate(ike_sa_name, 'dmvpn', - ike[ike_sa_name]['local-host'], - ike[ike_sa_name]['remote-host']) + vyos.ipsec.vici_initiate( + ike_sa_name, + 'dmvpn', + ike[ike_sa_name]['local-host'], + ike[ike_sa_name]['remote-host'], + ) print( - f'Profile {profile} tunnel {tunnel} remote-host {nbma_dst} reset result: success') - except (vyos.ipsec.ViciInitiateError) as err: + f'Profile {profile} tunnel {tunnel} remote-host {nbma_dst} reset result: success' + ) + except vyos.ipsec.ViciInitiateError as err: raise vyos.opmode.UnconfiguredSubsystem(err) - except (vyos.ipsec.ViciCommandError) as err: + except vyos.ipsec.ViciCommandError as err: raise vyos.opmode.IncorrectValue(err) @@ -549,24 +724,30 @@ def reset_profile_all(profile: str, tunnel: str): try: # Get IKE SAs sa_list: list = convert_data( - vyos.ipsec.get_vici_sas_by_name(ike_sa_name, None)) + vyos.ipsec.get_vici_sas_by_name(ike_sa_name, None) + ) if not sa_list: raise vyos.opmode.IncorrectValue( - f'SA(s) for profile {profile} tunnel {tunnel} not found, aborting') + f'SA(s) for profile {profile} tunnel {tunnel} not found, aborting' + ) # terminate IKE SAs vyos.ipsec.terminate_vici_by_name(ike_sa_name, None) # initiate IKE SAs for ike in sa_list: if ike_sa_name in ike: - vyos.ipsec.vici_initiate(ike_sa_name, 'dmvpn', - ike[ike_sa_name]['local-host'], - ike[ike_sa_name]['remote-host']) + vyos.ipsec.vici_initiate( + ike_sa_name, + 'dmvpn', + ike[ike_sa_name]['local-host'], + ike[ike_sa_name]['remote-host'], + ) print( - f'Profile {profile} tunnel {tunnel} remote-host {ike[ike_sa_name]["remote-host"]} reset result: success') + f'Profile {profile} tunnel {tunnel} remote-host {ike[ike_sa_name]["remote-host"]} reset result: success' + ) print(f'Profile {profile} tunnel {tunnel} reset result: success') - except (vyos.ipsec.ViciInitiateError) as err: + except vyos.ipsec.ViciInitiateError as err: raise vyos.opmode.UnconfiguredSubsystem(err) - except (vyos.ipsec.ViciCommandError) as err: + except vyos.ipsec.ViciCommandError as err: raise vyos.opmode.IncorrectValue(err) @@ -734,36 +915,56 @@ def _get_formatted_output_ra_summary(ra_output_list: list): if child_sa_key: child_sa = sa['child-sas'][child_sa_key] sa_ipsec_proposal = _get_formatted_ipsec_proposal(child_sa) - sa_state = "UP" + sa_state = 'UP' sa_uptime = seconds_to_human(sa['established']) else: sa_ipsec_proposal = '' - sa_state = "DOWN" + sa_state = 'DOWN' sa_uptime = '' sa_data.append( - [sa_id, sa_username, sa_protocol, sa_state, sa_uptime, - sa_tunnel_ip, - sa_remotehost, sa_remoteid, sa_ike_proposal, - sa_ipsec_proposal]) - - headers = ["Connection ID", "Username", "Protocol", "State", "Uptime", - "Tunnel IP", "Remote Host", "Remote ID", "IKE Proposal", - "IPSec Proposal"] + [ + sa_id, + sa_username, + sa_protocol, + sa_state, + sa_uptime, + sa_tunnel_ip, + sa_remotehost, + sa_remoteid, + sa_ike_proposal, + sa_ipsec_proposal, + ] + ) + + headers = [ + 'Connection ID', + 'Username', + 'Protocol', + 'State', + 'Uptime', + 'Tunnel IP', + 'Remote Host', + 'Remote ID', + 'IKE Proposal', + 'IPSec Proposal', + ] sa_data = sorted(sa_data, key=_alphanum_key) output = tabulate(sa_data, headers) return output -def show_ra_detail(raw: bool, username: typing.Optional[str] = None, - conn_id: typing.Optional[str] = None): +def show_ra_detail( + raw: bool, + username: typing.Optional[str] = None, + conn_id: typing.Optional[str] = None, +): list_sa: list = _get_ra_sessions() if username: list_sa = _filter_ikesas(list_sa, 'remote-eap-id', username) elif conn_id: list_sa = _filter_ikesas(list_sa, 'uniqueid', conn_id) if not list_sa: - raise vyos.opmode.IncorrectValue( - f'No active connections found, aborting') + raise vyos.opmode.IncorrectValue('No active connections found, aborting') if raw: return list_sa return _get_output_ra_sas_detail(list_sa) @@ -772,8 +973,7 @@ def show_ra_detail(raw: bool, username: typing.Optional[str] = None, def show_ra_summary(raw: bool): list_sa: list = _get_ra_sessions() if not list_sa: - raise vyos.opmode.IncorrectValue( - f'No active connections found, aborting') + raise vyos.opmode.IncorrectValue('No active connections found, aborting') if raw: return list_sa return _get_formatted_output_ra_summary(list_sa) @@ -783,9 +983,12 @@ def show_ra_summary(raw: bool): def _get_raw_psk(): conf: ConfigTreeQuery = ConfigTreeQuery() config_path = ['vpn', 'ipsec', 'authentication', 'psk'] - psk_config = conf.get_config_dict(config_path, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) + psk_config = conf.get_config_dict( + config_path, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) psk_list = [] for psk, psk_data in psk_config.items(): @@ -796,11 +999,13 @@ def _get_raw_psk(): def _get_formatted_psk(psk_list): - headers = ["PSK", "Id", "Secret"] + headers = ['PSK', 'Id', 'Secret'] formatted_data = [] for psk_data in psk_list: - formatted_data.append([psk_data["psk"], "\n".join(psk_data["id"]), psk_data["secret"]]) + formatted_data.append( + [psk_data['psk'], '\n'.join(psk_data['id']), psk_data['secret']] + ) return tabulate(formatted_data, headers=headers) @@ -808,16 +1013,36 @@ def _get_formatted_psk(psk_list): def show_psk(raw: bool): config = ConfigTreeQuery() if not config.exists('vpn ipsec authentication psk'): - raise vyos.opmode.UnconfiguredSubsystem('VPN ipsec psk authentication is not configured') + raise vyos.opmode.UnconfiguredSubsystem( + 'VPN ipsec psk authentication is not configured' + ) psk = _get_raw_psk() if raw: return psk return _get_formatted_psk(psk) + # PSK block end +def _get_sitetosite_peer_config(peer: str): + """ + Return site-to-site peers configuration + :return: site-to-site peers configuration + :rtype: list + """ + conf: ConfigTreeQuery = ConfigTreeQuery() + config_path = ['vpn', 'ipsec', 'site-to-site', 'peer', peer] + peers_config = conf.get_config_dict( + config_path, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + return peers_config + + if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index 9ce166c7d..84b080023 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -844,7 +844,8 @@ def import_openvpn_secret(name, path): key_version = '1' with open(path) as f: - key_lines = f.read().split("\n") + key_lines = f.read().strip().split("\n") + key_lines = list(filter(lambda line: not line.strip().startswith('#'), key_lines)) # Remove commented lines key_data = "".join(key_lines[1:-1]) # Remove wrapper tags and line endings version_search = re.search(r'BEGIN OpenVPN Static key V(\d+)', key_lines[0]) # Future-proofing (hopefully) diff --git a/src/op_mode/serial.py b/src/op_mode/serial.py new file mode 100644 index 000000000..a5864872b --- /dev/null +++ b/src/op_mode/serial.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 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 sys, typing + +import vyos.opmode +from vyos.utils.serial import restart_login_consoles as _restart_login_consoles + +def restart_console(device_name: typing.Optional[str]): + # Service control moved to vyos.utils.serial to unify checks and prompts. + # If users are connected, we want to show an informational message and a prompt + # to continue, verifying that the user acknowledges possible interruptions. + if device_name: + _restart_login_consoles(prompt_user=True, quiet=False, devices=[device_name]) + else: + _restart_login_consoles(prompt_user=True, quiet=False) + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/services/vyos-configd b/src/services/vyos-configd index 87f7c0e25..a4b839a7f 100755 --- a/src/services/vyos-configd +++ b/src/services/vyos-configd @@ -143,9 +143,8 @@ def run_script(script_name, config, args) -> int: script.generate(c) script.apply(c) except ConfigError as e: - s = f'{script_name}: {repr(e)}' - logger.error(s) - explicit_print(session_out, session_mode, s) + logger.error(e) + explicit_print(session_out, session_mode, str(e)) return R_ERROR_COMMIT except Exception as e: logger.critical(e) diff --git a/src/services/vyos-conntrack-logger b/src/services/vyos-conntrack-logger new file mode 100755 index 000000000..9c31b465f --- /dev/null +++ b/src/services/vyos-conntrack-logger @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 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 argparse +import grp +import logging +import multiprocessing +import os +import queue +import signal +import socket +import threading +from datetime import timedelta +from pathlib import Path +from time import sleep +from typing import Dict, AnyStr + +from pyroute2 import conntrack +from pyroute2.netlink import nfnetlink +from pyroute2.netlink.nfnetlink import NFNL_SUBSYS_CTNETLINK +from pyroute2.netlink.nfnetlink.nfctsocket import nfct_msg, \ + IPCTNL_MSG_CT_DELETE, IPCTNL_MSG_CT_NEW, IPS_SEEN_REPLY, \ + IPS_OFFLOAD, IPS_ASSURED + +from vyos.utils.file import read_json + + +shutdown_event = multiprocessing.Event() + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +class DebugFormatter(logging.Formatter): + def format(self, record): + self._style._fmt = '[%(asctime)s] %(levelname)s: %(message)s' + return super().format(record) + + +def set_log_level(level: str) -> None: + if level == 'debug': + logger.setLevel(logging.DEBUG) + logger.parent.handlers[0].setFormatter(DebugFormatter()) + else: + logger.setLevel(logging.INFO) + + +EVENT_NAME_TO_GROUP = { + 'new': nfnetlink.NFNLGRP_CONNTRACK_NEW, + 'update': nfnetlink.NFNLGRP_CONNTRACK_UPDATE, + 'destroy': nfnetlink.NFNLGRP_CONNTRACK_DESTROY +} + +# https://github.com/torvalds/linux/blob/1dfe225e9af5bd3399a1dbc6a4df6a6041ff9c23/include/uapi/linux/netfilter/nf_conntrack_tcp.h#L9 +TCP_CONNTRACK_SYN_SENT = 1 +TCP_CONNTRACK_SYN_RECV = 2 +TCP_CONNTRACK_ESTABLISHED = 3 +TCP_CONNTRACK_FIN_WAIT = 4 +TCP_CONNTRACK_CLOSE_WAIT = 5 +TCP_CONNTRACK_LAST_ACK = 6 +TCP_CONNTRACK_TIME_WAIT = 7 +TCP_CONNTRACK_CLOSE = 8 +TCP_CONNTRACK_LISTEN = 9 +TCP_CONNTRACK_MAX = 10 +TCP_CONNTRACK_IGNORE = 11 +TCP_CONNTRACK_RETRANS = 12 +TCP_CONNTRACK_UNACK = 13 +TCP_CONNTRACK_TIMEOUT_MAX = 14 + +TCP_CONNTRACK_TO_NAME = { + TCP_CONNTRACK_SYN_SENT: "SYN_SENT", + TCP_CONNTRACK_SYN_RECV: "SYN_RECV", + TCP_CONNTRACK_ESTABLISHED: "ESTABLISHED", + TCP_CONNTRACK_FIN_WAIT: "FIN_WAIT", + TCP_CONNTRACK_CLOSE_WAIT: "CLOSE_WAIT", + TCP_CONNTRACK_LAST_ACK: "LAST_ACK", + TCP_CONNTRACK_TIME_WAIT: "TIME_WAIT", + TCP_CONNTRACK_CLOSE: "CLOSE", + TCP_CONNTRACK_LISTEN: "LISTEN", + TCP_CONNTRACK_MAX: "MAX", + TCP_CONNTRACK_IGNORE: "IGNORE", + TCP_CONNTRACK_RETRANS: "RETRANS", + TCP_CONNTRACK_UNACK: "UNACK", + TCP_CONNTRACK_TIMEOUT_MAX: "TIMEOUT_MAX", +} + +# https://github.com/torvalds/linux/blob/1dfe225e9af5bd3399a1dbc6a4df6a6041ff9c23/include/uapi/linux/netfilter/nf_conntrack_sctp.h#L8 +SCTP_CONNTRACK_CLOSED = 1 +SCTP_CONNTRACK_COOKIE_WAIT = 2 +SCTP_CONNTRACK_COOKIE_ECHOED = 3 +SCTP_CONNTRACK_ESTABLISHED = 4 +SCTP_CONNTRACK_SHUTDOWN_SENT = 5 +SCTP_CONNTRACK_SHUTDOWN_RECD = 6 +SCTP_CONNTRACK_SHUTDOWN_ACK_SENT = 7 +SCTP_CONNTRACK_HEARTBEAT_SENT = 8 +SCTP_CONNTRACK_HEARTBEAT_ACKED = 9 # no longer used +SCTP_CONNTRACK_MAX = 10 + +SCTP_CONNTRACK_TO_NAME = { + SCTP_CONNTRACK_CLOSED: 'CLOSED', + SCTP_CONNTRACK_COOKIE_WAIT: 'COOKIE_WAIT', + SCTP_CONNTRACK_COOKIE_ECHOED: 'COOKIE_ECHOED', + SCTP_CONNTRACK_ESTABLISHED: 'ESTABLISHED', + SCTP_CONNTRACK_SHUTDOWN_SENT: 'SHUTDOWN_SENT', + SCTP_CONNTRACK_SHUTDOWN_RECD: 'SHUTDOWN_RECD', + SCTP_CONNTRACK_SHUTDOWN_ACK_SENT: 'SHUTDOWN_ACK_SENT', + SCTP_CONNTRACK_HEARTBEAT_SENT: 'HEARTBEAT_SENT', + SCTP_CONNTRACK_HEARTBEAT_ACKED: 'HEARTBEAT_ACKED', + SCTP_CONNTRACK_MAX: 'MAX', +} + +PROTO_CONNTRACK_TO_NAME = { + 'TCP': TCP_CONNTRACK_TO_NAME, + 'SCTP': SCTP_CONNTRACK_TO_NAME +} + +SUPPORTED_PROTO_TO_NAME = { + socket.IPPROTO_ICMP: 'icmp', + socket.IPPROTO_TCP: 'tcp', + socket.IPPROTO_UDP: 'udp', +} + +PROTO_TO_NAME = { + socket.IPPROTO_ICMPV6: 'icmpv6', + socket.IPPROTO_SCTP: 'sctp', + socket.IPPROTO_GRE: 'gre', +} + +PROTO_TO_NAME.update(SUPPORTED_PROTO_TO_NAME) + + +def sig_handler(signum, frame): + process_name = multiprocessing.current_process().name + logger.debug(f'[{process_name}]: {"Shutdown" if signum == signal.SIGTERM else "Reload"} signal received...') + shutdown_event.set() + + +def format_flow_data(data: Dict) -> AnyStr: + """ + Formats the flow event data into a string suitable for logging. + """ + key_format = { + 'SRC_PORT': 'sport', + 'DST_PORT': 'dport' + } + message = f"src={data['ADDR'].get('SRC')} dst={data['ADDR'].get('DST')}" + + for key in ['SRC_PORT', 'DST_PORT', 'TYPE', 'CODE', 'ID']: + tmp = data['PROTO'].get(key) + if tmp is not None: + key = key_format.get(key, key) + message += f" {key.lower()}={tmp}" + + if 'COUNTERS' in data: + for key in ['PACKETS', 'BYTES']: + tmp = data['COUNTERS'].get(key) + if tmp is not None: + message += f" {key.lower()}={tmp}" + + return message + + +def format_event_message(event: Dict) -> AnyStr: + """ + Formats the internal parsed event data into a string suitable for logging. + """ + event_type = f"[{event['COMMON']['EVENT_TYPE'].upper()}]" + message = f"{event_type:<{9}} {event['COMMON']['ID']} " \ + f"{event['ORIG']['PROTO'].get('NAME'):<{8}} " \ + f"{event['ORIG']['PROTO'].get('NUMBER')} " + + tmp = event['COMMON']['TIME_OUT'] + if tmp is not None: message += f"{tmp} " + + if proto_info := event['COMMON'].get('PROTO_INFO'): + message += f"{proto_info.get('STATE_NAME')} " + + for key in ['ORIG', 'REPLY']: + message += f"{format_flow_data(event[key])} " + if key == 'ORIG' and not (event['COMMON']['STATUS'] & IPS_SEEN_REPLY): + message += f"[UNREPLIED] " + + tmp = event['COMMON']['MARK'] + if tmp is not None: message += f"mark={tmp} " + + if event['COMMON']['STATUS'] & IPS_OFFLOAD: message += f" [OFFLOAD] " + elif event['COMMON']['STATUS'] & IPS_ASSURED: message += f" [ASSURED] " + + if tmp := event['COMMON']['PORTID']: message += f"portid={tmp} " + if tstamp := event['COMMON'].get('TIMESTAMP'): + message += f"start={tstamp['START']} stop={tstamp['STOP']} " + delta_ns = tstamp['STOP'] - tstamp['START'] + delta_s = delta_ns // 1e9 + remaining_ns = delta_ns % 1e9 + delta = timedelta(seconds=delta_s, microseconds=remaining_ns / 1000) + message += f"delta={delta.total_seconds()} " + + return message + + +def parse_event_type(header: Dict) -> AnyStr: + """ + Extract event type from nfct_msg. new, update, destroy + """ + event_type = 'unknown' + if header['type'] == IPCTNL_MSG_CT_DELETE | (NFNL_SUBSYS_CTNETLINK << 8): + event_type = 'destroy' + elif header['type'] == IPCTNL_MSG_CT_NEW | (NFNL_SUBSYS_CTNETLINK << 8): + event_type = 'update' + if header['flags']: + event_type = 'new' + return event_type + + +def parse_proto(cta: nfct_msg.cta_tuple) -> Dict: + """ + Extract proto info from nfct_msg. src/dst port, code, type, id + """ + data = dict() + + cta_proto = cta.get_attr('CTA_TUPLE_PROTO') + proto_num = cta_proto.get_attr('CTA_PROTO_NUM') + + data['NUMBER'] = proto_num + data['NAME'] = PROTO_TO_NAME.get(proto_num, 'unknown') + + if proto_num in (socket.IPPROTO_ICMP, socket.IPPROTO_ICMPV6): + pref = 'CTA_PROTO_ICMP' + if proto_num == socket.IPPROTO_ICMPV6: pref += 'V6' + keys = ['TYPE', 'CODE', 'ID'] + else: + pref = 'CTA_PROTO' + keys = ['SRC_PORT', 'DST_PORT'] + + for key in keys: + data[key] = cta_proto.get_attr(f'{pref}_{key}') + + return data + + +def parse_proto_info(cta: nfct_msg.cta_protoinfo) -> Dict: + """ + Extract proto state and state name from nfct_msg + """ + data = dict() + if not cta: + return data + + for proto in ['TCP', 'SCTP']: + if proto_info := cta.get_attr(f'CTA_PROTOINFO_{proto}'): + data['STATE'] = proto_info.get_attr(f'CTA_PROTOINFO_{proto}_STATE') + data['STATE_NAME'] = PROTO_CONNTRACK_TO_NAME.get(proto, {}).get(data['STATE'], 'unknown') + return data + + +def parse_timestamp(cta: nfct_msg.cta_timestamp) -> Dict: + """ + Extract timestamp from nfct_msg + """ + data = dict() + if not cta: + return data + data['START'] = cta.get_attr('CTA_TIMESTAMP_START') + data['STOP'] = cta.get_attr('CTA_TIMESTAMP_STOP') + + return data + + +def parse_ip_addr(family: int, cta: nfct_msg.cta_tuple) -> Dict: + """ + Extract ip adr from nfct_msg + """ + data = dict() + cta_ip = cta.get_attr('CTA_TUPLE_IP') + + if family == socket.AF_INET: + pref = 'CTA_IP_V4' + elif family == socket.AF_INET6: + pref = 'CTA_IP_V6' + else: + logger.error(f'Undefined INET: {family}') + raise NotImplementedError(family) + + for direct in ['SRC', 'DST']: + data[direct] = cta_ip.get_attr(f'{pref}_{direct}') + + return data + + +def parse_counters(cta: nfct_msg.cta_counters) -> Dict: + """ + Extract counters from nfct_msg + """ + data = dict() + if not cta: + return data + + for key in ['PACKETS', 'BYTES']: + tmp = cta.get_attr(f'CTA_COUNTERS_{key}') + if tmp is None: + tmp = cta.get_attr(f'CTA_COUNTERS32_{key}') + data['key'] = tmp + + return data + + +def is_need_to_log(event_type: AnyStr, proto_num: int, conf_event: Dict): + """ + Filter message by event type and protocols + """ + conf = conf_event.get(event_type) + if conf == {} or conf.get(SUPPORTED_PROTO_TO_NAME.get(proto_num, 'other')) is not None: + return True + return False + + +def parse_conntrack_event(msg: nfct_msg, conf_event: Dict) -> Dict: + """ + Convert nfct_msg to internal data dict. + """ + data = dict() + event_type = parse_event_type(msg['header']) + proto_num = msg.get_nested('CTA_TUPLE_ORIG', 'CTA_TUPLE_PROTO', 'CTA_PROTO_NUM') + + if not is_need_to_log(event_type, proto_num, conf_event): + return data + + data = { + 'COMMON': { + 'ID': msg.get_attr('CTA_ID'), + 'EVENT_TYPE': event_type, + 'TIME_OUT': msg.get_attr('CTA_TIMEOUT'), + 'MARK': msg.get_attr('CTA_MARK'), + 'PORTID': msg['header'].get('pid'), + 'PROTO_INFO': parse_proto_info(msg.get_attr('CTA_PROTOINFO')), + 'STATUS': msg.get_attr('CTA_STATUS'), + 'TIMESTAMP': parse_timestamp(msg.get_attr('CTA_TIMESTAMP')) + }, + 'ORIG': {}, + 'REPLY': {}, + } + + for direct in ['ORIG', 'REPLY']: + data[direct]['ADDR'] = parse_ip_addr(msg['nfgen_family'], msg.get_attr(f'CTA_TUPLE_{direct}')) + data[direct]['PROTO'] = parse_proto(msg.get_attr(f'CTA_TUPLE_{direct}')) + data[direct]['COUNTERS'] = parse_counters(msg.get_attr(f'CTA_COUNTERS_{direct}')) + + return data + + +def worker(ct: conntrack.Conntrack, shutdown_event: multiprocessing.Event, conf_event: Dict): + """ + Main function of parser worker process + """ + process_name = multiprocessing.current_process().name + logger.debug(f'[{process_name}] started') + timeout = 0.1 + while not shutdown_event.is_set(): + if not ct.buffer_queue.empty(): + try: + for msg in ct.get(): + parsed_event = parse_conntrack_event(msg, conf_event) + if parsed_event: + message = format_event_message(parsed_event) + if logger.level == logging.DEBUG: + logger.debug(f"[{process_name}]: {message} raw: {msg}") + else: + logger.info(message) + except queue.Full: + logger.error("Conntrack message queue if full.") + except Exception as e: + logger.error(f"Error in queue: {e.__class__} {e}") + else: + sleep(timeout) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('-c', + '--config', + action='store', + help='Path to vyos-conntrack-logger configuration', + required=True, + type=Path) + + args = parser.parse_args() + try: + config = read_json(args.config) + except Exception as err: + logger.error(f'Configuration file "{args.config}" does not exist or malformed: {err}') + exit(1) + + set_log_level(config.get('log_level', 'info')) + + signal.signal(signal.SIGHUP, sig_handler) + signal.signal(signal.SIGTERM, sig_handler) + + if 'event' in config: + event_groups = list(config.get('event').keys()) + else: + logger.error(f'Configuration is wrong. Event filter is empty.') + exit(1) + + conf_event = config['event'] + qsize = config.get('queue_size') + ct = conntrack.Conntrack(async_qsize=int(qsize) if qsize else None) + ct.buffer_queue = multiprocessing.Queue(ct.async_qsize) + ct.bind(async_cache=True) + + for name in event_groups: + if group := EVENT_NAME_TO_GROUP.get(name): + ct.add_membership(group) + else: + logger.error(f'Unexpected event group {name}') + processes = list() + try: + for _ in range(multiprocessing.cpu_count()): + p = multiprocessing.Process(target=worker, args=(ct, + shutdown_event, + conf_event)) + processes.append(p) + p.start() + logger.info('Conntrack socket bound and listening for messages.') + + while not shutdown_event.is_set(): + if not ct.pthread.is_alive(): + if ct.buffer_queue.qsize()/ct.async_qsize < 0.9: + if not shutdown_event.is_set(): + logger.debug('Restart listener thread') + # restart listener thread after queue overloaded when queue size low than 90% + ct.pthread = threading.Thread( + name="Netlink async cache", target=ct.async_recv + ) + ct.pthread.daemon = True + ct.pthread.start() + else: + sleep(0.1) + finally: + for p in processes: + p.join() + if not p.is_alive(): + logger.debug(f"[{p.name}]: finished") + ct.close() + logging.info("Conntrack socket closed.") + exit() diff --git a/src/systemd/vyos-conntrack-logger.service b/src/systemd/vyos-conntrack-logger.service new file mode 100644 index 000000000..9bc1d857b --- /dev/null +++ b/src/systemd/vyos-conntrack-logger.service @@ -0,0 +1,21 @@ +[Unit] +Description=VyOS conntrack logger daemon + +# Seemingly sensible way to say "as early as the system is ready" +# All vyos-configd needs is read/write mounted root +After=conntrackd.service + +[Service] +ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-conntrack-logger -c /run/vyos-conntrack-logger.conf +Type=idle + +SyslogIdentifier=vyos-conntrack-logger +SyslogFacility=daemon + +Restart=on-failure + +User=root +Group=vyattacfg + +[Install] +WantedBy=multi-user.target |