diff options
Diffstat (limited to 'src')
23 files changed, 1318 insertions, 118 deletions
diff --git a/src/completion/list_openconnect_users.py b/src/completion/list_openconnect_users.py new file mode 100755 index 000000000..a266fd893 --- /dev/null +++ b/src/completion/list_openconnect_users.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from vyos.config import Config +from vyos.util import dict_search + +def get_user_from_ocserv(): + config = Config() + base = ['vpn', 'openconnect', 'authentication', 'local-users', 'username'] + openconnect = config.get_config_dict(base, effective=True, key_mangling=('-', '_')) + users = [] + try: + for user in (dict_search('username', openconnect) or []): + users.append(user) + except: + pass + return users + +if __name__ == "__main__": + users = [] + users = get_user_from_ocserv() + print(" ".join(users)) + diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py index 7f7a98b04..7750c1247 100755 --- a/src/conf_mode/flow_accounting_conf.py +++ b/src/conf_mode/flow_accounting_conf.py @@ -22,6 +22,7 @@ import ipaddress from ipaddress import ip_address +from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge from vyos.ifconfig import Section @@ -109,6 +110,9 @@ def _nftables_config(configured_ifaces, direction, length=None): iface_prefix = "o" if direction == "egress" else "i" rule_definition = f'{iface_prefix}ifname "{iface}" counter log group 2 snaplen {length} queue-threshold 100 comment "FLOW_ACCOUNTING_RULE"' nftable_commands.append(f'nft insert rule {nftables_table} {nftables_chain} {rule_definition}') + # Also add IPv6 ingres logging + if nftables_table == nftables_nflog_table: + nftable_commands.append(f'nft insert rule ip6 {nftables_table} {nftables_chain} {rule_definition}') # change nftables for command in nftable_commands: @@ -172,7 +176,7 @@ def verify(flow_config): if interface not in Section.interfaces(): # Changed from error to warning to allow adding dynamic interfaces # and interface templates - print(f'Warning: Interface "{interface}" is not presented in the system') + Warning(f'Interface "{interface}" is not presented in the system') # check sFlow configuration if 'sflow' in flow_config: @@ -200,7 +204,13 @@ def verify(flow_config): if 'agent_address' in flow_config['sflow']: tmp = flow_config['sflow']['agent_address'] if not is_addr_assigned(tmp): - print(f'Warning: Configured "sflow agent-address {tmp}" does not exist in the system!') + raise ConfigError(f'Configured "sflow agent-address {tmp}" does not exist in the system!') + + # Check if configured netflow source-address exist in the system + if 'source_address' in flow_config['sflow']: + if not is_addr_assigned(flow_config['sflow']['source_address']): + tmp = flow_config['sflow']['source_address'] + raise ConfigError(f'Configured "sflow source-address {tmp}" does not exist on the system!') # check NetFlow configuration if 'netflow' in flow_config: @@ -212,7 +222,7 @@ def verify(flow_config): if 'source_address' in flow_config['netflow']: if not is_addr_assigned(flow_config['netflow']['source_address']): tmp = flow_config['netflow']['source_address'] - print(f'Warning: Configured "netflow source-address {tmp}" does not exist on the system!') + raise ConfigError(f'Configured "netflow source-address {tmp}" does not exist on the system!') # Check if engine-id compatible with selected protocol version if 'engine_id' in flow_config['netflow']: diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index efa3578b4..29ed7b1b7 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -29,12 +29,60 @@ from vyos.pki import load_private_key from vyos.pki import load_crl from vyos.pki import load_dh_parameters from vyos.util import ask_input +from vyos.util import call +from vyos.util import dict_search_args from vyos.util import dict_search_recursive from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() +# keys to recursively search for under specified path, script to call if update required +sync_search = [ + { + 'keys': ['certificate'], + 'path': ['service', 'https'], + 'script': '/usr/libexec/vyos/conf_mode/https.py' + }, + { + 'keys': ['certificate', 'ca_certificate'], + 'path': ['interfaces', 'ethernet'], + 'script': '/usr/libexec/vyos/conf_mode/interfaces-ethernet.py' + }, + { + 'keys': ['certificate', 'ca_certificate', 'dh_params', 'shared_secret_key', 'auth_key', 'crypt_key'], + 'path': ['interfaces', 'openvpn'], + 'script': '/usr/libexec/vyos/conf_mode/interfaces-openvpn.py' + }, + { + 'keys': ['certificate', 'ca_certificate', 'local_key', 'remote_key'], + 'path': ['vpn', 'ipsec'], + 'script': '/usr/libexec/vyos/conf_mode/vpn_ipsec.py' + }, + { + 'keys': ['certificate', 'ca_certificate'], + 'path': ['vpn', 'openconnect'], + 'script': '/usr/libexec/vyos/conf_mode/vpn_openconnect.py' + }, + { + 'keys': ['certificate', 'ca_certificate'], + 'path': ['vpn', 'sstp'], + 'script': '/usr/libexec/vyos/conf_mode/vpn_sstp.py' + } +] + +# key from other config nodes -> key in pki['changed'] and pki +sync_translate = { + 'certificate': 'certificate', + 'ca_certificate': 'ca', + 'dh_params': 'dh', + 'local_key': 'key_pair', + 'remote_key': 'key_pair', + 'shared_secret_key': 'openvpn', + 'auth_key': 'openvpn', + 'crypt_key': 'openvpn' +} + def get_config(config=None): if config: conf = config @@ -47,12 +95,21 @@ def get_config(config=None): no_tag_node_value_mangle=True) pki['changed'] = {} - tmp = node_changed(conf, base + ['ca'], key_mangling=('-', '_')) + tmp = node_changed(conf, base + ['ca'], key_mangling=('-', '_'), recursive=True) if tmp: pki['changed'].update({'ca' : tmp}) - tmp = node_changed(conf, base + ['certificate'], key_mangling=('-', '_')) + tmp = node_changed(conf, base + ['certificate'], key_mangling=('-', '_'), recursive=True) if tmp: pki['changed'].update({'certificate' : tmp}) + tmp = node_changed(conf, base + ['dh'], key_mangling=('-', '_'), recursive=True) + if tmp: pki['changed'].update({'dh' : tmp}) + + tmp = node_changed(conf, base + ['key-pair'], key_mangling=('-', '_'), recursive=True) + if tmp: pki['changed'].update({'key_pair' : tmp}) + + tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], key_mangling=('-', '_'), recursive=True) + if tmp: pki['changed'].update({'openvpn' : tmp}) + # We only merge on the defaults of there is a configuration at all if conf.exists(base): default_values = defaults(base) @@ -164,17 +221,30 @@ def verify(pki): if 'changed' in pki: # if the list is getting longer, we can move to a dict() and also embed the # search key as value from line 173 or 176 - for cert_type in ['ca', 'certificate']: - if not cert_type in pki['changed']: - continue - for certificate in pki['changed'][cert_type]: - if cert_type not in pki or certificate not in pki['changed'][cert_type]: - if cert_type == 'ca': - if certificate in dict_search_recursive(pki['system'], 'ca_certificate'): - raise ConfigError(f'CA certificate "{certificate}" is still in use!') - elif cert_type == 'certificate': - if certificate in dict_search_recursive(pki['system'], 'certificate'): - raise ConfigError(f'Certificate "{certificate}" is still in use!') + for search in sync_search: + for key in search['keys']: + changed_key = sync_translate[key] + + if changed_key not in pki['changed']: + continue + + for item_name in pki['changed'][changed_key]: + node_present = False + if changed_key == 'openvpn': + node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) + else: + node_present = dict_search_args(pki, changed_key, item_name) + + if not node_present: + search_dict = dict_search_args(pki['system'], *search['path']) + + if not search_dict: + continue + + for found_name, found_path in dict_search_recursive(search_dict, key): + if found_name == item_name: + path_str = " ".join(search['path'] + found_path) + raise ConfigError(f'PKI object "{item_name}" still in use by "{path_str}"') return None @@ -188,7 +258,38 @@ def apply(pki): if not pki: return None - # XXX: restart services if the content of a certificate changes + if 'changed' in pki: + for search in sync_search: + for key in search['keys']: + changed_key = sync_translate[key] + + if changed_key not in pki['changed']: + continue + + for item_name in pki['changed'][changed_key]: + node_present = False + if changed_key == 'openvpn': + node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) + else: + node_present = dict_search_args(pki, changed_key, item_name) + + if node_present: + search_dict = dict_search_args(pki['system'], *search['path']) + + if not search_dict: + continue + + for found_name, found_path in dict_search_recursive(search_dict, key): + if found_name == item_name: + path_str = ' '.join(search['path'] + found_path) + print(f'pki: Updating config: {path_str} {found_name}') + + script = search['script'] + if found_path[0] == 'interfaces': + ifname = found_path[2] + call(f'VYOS_TAGNODE_VALUE={ifname} {script}') + else: + call(script) return None diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index ef6008140..3008a20e0 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -150,6 +150,16 @@ def verify(policy): tmp = dict_search('match.ipv6.address.prefix_list', rule_config) if tmp and tmp not in policy.get('prefix_list6', []): raise ConfigError(f'prefix-list6 {tmp} does not exist!') + + # Specified access_list6 in nexthop must exist + tmp = dict_search('match.ipv6.nexthop.access_list', rule_config) + if tmp and tmp not in policy.get('access_list6', []): + raise ConfigError(f'access_list6 {tmp} does not exist!') + + # Specified prefix-list6 in nexthop must exist + tmp = dict_search('match.ipv6.nexthop.prefix_list', rule_config) + if tmp and tmp not in policy.get('prefix_list6', []): + raise ConfigError(f'prefix-list6 {tmp} does not exist!') # When routing protocols are active some use prefix-lists, route-maps etc. # to apply the systems routing policy to the learned or redistributed routes. diff --git a/src/conf_mode/protocols_eigrp.py b/src/conf_mode/protocols_eigrp.py new file mode 100755 index 000000000..c1a1a45e1 --- /dev/null +++ b/src/conf_mode/protocols_eigrp.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sys import exit +from sys import argv + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.template import render_to_string +from vyos import ConfigError +from vyos import frr +from vyos import airbag +airbag.enable() + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + vrf = None + if len(argv) > 1: + vrf = argv[1] + + base_path = ['protocols', 'eigrp'] + + # eqivalent of the C foo ? 'a' : 'b' statement + base = vrf and ['vrf', 'name', vrf, 'protocols', 'eigrp'] or base_path + eigrp = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + # Assign the name of our VRF context. This MUST be done before the return + # statement below, else on deletion we will delete the default instance + # instead of the VRF instance. + if vrf: eigrp.update({'vrf' : vrf}) + + if not conf.exists(base): + eigrp.update({'deleted' : ''}) + if not vrf: + # We are running in the default VRF context, thus we can not delete + # our main EIGRP instance if there are dependent EIGRP VRF instances. + eigrp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + return eigrp + + # We also need some additional information from the config, prefix-lists + # and route-maps for instance. They will be used in verify(). + # + # XXX: one MUST always call this without the key_mangling() option! See + # vyos.configverify.verify_common_route_maps() for more information. + tmp = conf.get_config_dict(['policy']) + # Merge policy dict into "regular" config dict + eigrp = dict_merge(tmp, eigrp) + + import pprint + pprint.pprint(eigrp) + return eigrp + +def verify(eigrp): + pass + +def generate(eigrp): + if not eigrp or 'deleted' in eigrp: + return None + + eigrp['protocol'] = 'eigrp' # required for frr/vrf.route-map.frr.j2 + eigrp['frr_zebra_config'] = render_to_string('frr/vrf.route-map.frr.j2', eigrp) + eigrp['frr_eigrpd_config'] = render_to_string('frr/eigrpd.frr.j2', eigrp) + +def apply(eigrp): + eigrp_daemon = 'eigrpd' + zebra_daemon = 'zebra' + + # Save original configuration prior to starting any commit actions + frr_cfg = frr.FRRConfig() + + # The route-map used for the FIB (zebra) is part of the zebra daemon + frr_cfg.load_configuration(zebra_daemon) + frr_cfg.modify_section(r'(\s+)?ip protocol eigrp route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)') + if 'frr_zebra_config' in eigrp: + frr_cfg.add_before(frr.default_add_before, eigrp['frr_zebra_config']) + frr_cfg.commit_configuration(zebra_daemon) + + # Generate empty helper string which can be ammended to FRR commands, it + # will be either empty (default VRF) or contain the "vrf <name" statement + vrf = '' + if 'vrf' in eigrp: + vrf = ' vrf ' + eigrp['vrf'] + + frr_cfg.load_configuration(eigrp_daemon) + frr_cfg.modify_section(f'^router eigrp \d+{vrf}', stop_pattern='^exit', remove_stop_mark=True) + if 'frr_eigrpd_config' in eigrp: + frr_cfg.add_before(frr.default_add_before, eigrp['frr_eigrpd_config']) + frr_cfg.commit_configuration(eigrp_daemon) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index b6371d09f..56939955d 100755 --- a/src/conf_mode/protocols_nhrp.py +++ b/src/conf_mode/protocols_nhrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -81,6 +81,11 @@ def verify(nhrp): for map_name, map_conf in nhrp_conf['dynamic_map'].items(): if 'nbma_domain_name' not in map_conf: raise ConfigError(f'nbma-domain-name missing on dynamic-map {map_name} on tunnel {name}') + + if 'cisco_authentication' in nhrp_conf: + if len(nhrp_conf['cisco_authentication']) > 8: + raise ConfigError('Maximum length of the secret is 8 characters!') + return None def generate(nhrp): @@ -105,7 +110,7 @@ def apply(nhrp): remove_nftables_rule('ip filter', 'VYOS_FW_OUTPUT', rule_handle) action = 'restart' if nhrp and 'tunnel' in nhrp else 'stop' - run(f'systemctl {action} opennhrp') + run(f'systemctl {action} opennhrp.service') return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py index a76c1ce76..c78d90396 100755 --- a/src/conf_mode/protocols_rip.py +++ b/src/conf_mode/protocols_rip.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/src/conf_mode/service_event_handler.py b/src/conf_mode/service_event_handler.py new file mode 100755 index 000000000..5440d1056 --- /dev/null +++ b/src/conf_mode/service_event_handler.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import json +from pathlib import Path + +from vyos.config import Config +from vyos.util import call, dict_search +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + +service_name = 'vyos-event-handler' +service_conf = Path(f'/run/{service_name}.conf') + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'event-handler', 'event'] + config = conf.get_config_dict(base, + get_first_key=True, + no_tag_node_value_mangle=True) + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if not config: + return None + + for name, event_config in config.items(): + if not dict_search('filter.pattern', event_config) or not dict_search( + 'script.path', event_config): + raise ConfigError( + 'Event-handler: both pattern and script path items are mandatory' + ) + + if dict_search('script.environment.message', event_config): + raise ConfigError( + 'Event-handler: "message" environment variable is reserved for log message text' + ) + + +def generate(config): + if not config: + # Remove old config and return + service_conf.unlink(missing_ok=True) + return None + + # Write configuration file + conf_json = json.dumps(config, indent=4) + service_conf.write_text(conf_json) + + return None + + +def apply(config): + if config: + call(f'systemctl restart {service_name}.service') + else: + call(f'systemctl stop {service_name}.service') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py index 102a87318..daf75d740 100755 --- a/src/conf_mode/service_monitoring_telegraf.py +++ b/src/conf_mode/service_monitoring_telegraf.py @@ -103,11 +103,28 @@ def get_config(config=None): 'url' in monitoring: monitoring['influxdb_configured'] = True + # Redefine azure group-metrics 'single-table' and 'table-per-metric' + if 'azure_data_explorer' in monitoring: + if 'single-table' in monitoring['azure_data_explorer']['group_metrics']: + monitoring['azure_data_explorer']['group_metrics'] = 'SingleTable' + else: + monitoring['azure_data_explorer']['group_metrics'] = 'TablePerMetric' + # Set azure env + if 'authentication' in monitoring['azure_data_explorer']: + auth_config = monitoring['azure_data_explorer']['authentication'] + if {'client_id', 'client_secret', 'tenant_id'} <= set(auth_config): + os.environ['AZURE_CLIENT_ID'] = auth_config['client_id'] + os.environ['AZURE_CLIENT_SECRET'] = auth_config['client_secret'] + os.environ['AZURE_TENANT_ID'] = auth_config['tenant_id'] + # Ignore default XML values if config doesn't exists # Delete key from dict if not conf.exists(base + ['prometheus-client']): del monitoring['prometheus_client'] + if not conf.exists(base + ['azure-data-explorer']): + del monitoring['azure_data_explorer'] + return monitoring def verify(monitoring): @@ -124,6 +141,24 @@ def verify(monitoring): if 'url' not in monitoring: raise ConfigError(f'Monitoring "url" is mandatory!') + # Verify azure-data-explorer + if 'azure_data_explorer' in monitoring: + if 'authentication' not in monitoring['azure_data_explorer'] or \ + 'client_id' not in monitoring['azure_data_explorer']['authentication'] or \ + 'client_secret' not in monitoring['azure_data_explorer']['authentication'] or \ + 'tenant_id' not in monitoring['azure_data_explorer']['authentication']: + raise ConfigError(f'Authentication "client-id, client-secret and tenant-id" are mandatory!') + + if 'database' not in monitoring['azure_data_explorer']: + raise ConfigError(f'Monitoring "database" is mandatory!') + + if 'url' not in monitoring['azure_data_explorer']: + raise ConfigError(f'Monitoring "url" is mandatory!') + + if monitoring['azure_data_explorer']['group_metrics'] == 'SingleTable' and \ + 'table' not in monitoring['azure_data_explorer']: + raise ConfigError(f'Monitoring "table" name for single-table mode is mandatory!') + # Verify Splunk if 'splunk' in monitoring: if 'authentication' not in monitoring['splunk'] or \ diff --git a/src/conf_mode/service_sla.py b/src/conf_mode/service_sla.py new file mode 100755 index 000000000..e7c3ca59c --- /dev/null +++ b/src/conf_mode/service_sla.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.template import render +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + + +owamp_config_dir = '/etc/owamp-server' +owamp_config_file = f'{owamp_config_dir}/owamp-server.conf' +systemd_override_owamp = r'/etc/systemd/system/owamp-server.d/20-override.conf' + +twamp_config_dir = '/etc/twamp-server' +twamp_config_file = f'{twamp_config_dir}/twamp-server.conf' +systemd_override_twamp = r'/etc/systemd/system/twamp-server.d/20-override.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'sla'] + if not conf.exists(base): + return None + + sla = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base) + sla = dict_merge(default_values, sla) + + # Ignore default XML values if config doesn't exists + # Delete key from dict + if not conf.exists(base + ['owamp-server']): + del sla['owamp_server'] + if not conf.exists(base + ['twamp-server']): + del sla['twamp_server'] + + return sla + +def verify(sla): + if not sla: + return None + +def generate(sla): + if not sla: + return None + + render(owamp_config_file, 'sla/owamp-server.conf.j2', sla) + render(systemd_override_owamp, 'sla/owamp-override.conf.j2', sla) + + render(twamp_config_file, 'sla/twamp-server.conf.j2', sla) + render(systemd_override_twamp, 'sla/twamp-override.conf.j2', sla) + + return None + +def apply(sla): + owamp_service = 'owamp-server.service' + twamp_service = 'twamp-server.service' + + call('systemctl daemon-reload') + + if not sla or 'owamp_server' not in sla: + call(f'systemctl stop {owamp_service}') + + if os.path.exists(owamp_config_file): + os.unlink(owamp_config_file) + + if not sla or 'twamp_server' not in sla: + call(f'systemctl stop {twamp_service}') + if os.path.exists(twamp_config_file): + os.unlink(twamp_config_file) + + if sla and 'owamp_server' in sla: + call(f'systemctl reload-or-restart {owamp_service}') + + if sla and 'twamp_server' in sla: + call(f'systemctl reload-or-restart {twamp_service}') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/snmp.py b/src/conf_mode/snmp.py index ae060580d..5cd24db32 100755 --- a/src/conf_mode/snmp.py +++ b/src/conf_mode/snmp.py @@ -293,7 +293,15 @@ def apply(snmp): call(f'systemctl restart {systemd_service}') # Enable AgentX in FRR - call('vtysh -c "configure terminal" -c "agentx" >/dev/null') + # This should be done for each daemon individually because common command + # works only if all the daemons started with SNMP support + frr_daemons_list = [ + 'bgpd', 'ospf6d', 'ospfd', 'ripd', 'ripngd', 'isisd', 'ldpd', 'zebra' + ] + for frr_daemon in frr_daemons_list: + call( + f'vtysh -c "configure terminal" -d {frr_daemon} -c "agentx" >/dev/null' + ) return None diff --git a/src/conf_mode/system_frr.py b/src/conf_mode/system_frr.py new file mode 100755 index 000000000..1af0055f6 --- /dev/null +++ b/src/conf_mode/system_frr.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 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 pathlib import Path +from sys import exit + +from vyos import ConfigError +from vyos import airbag +from vyos.config import Config +from vyos.logger import syslog +from vyos.template import render_to_string +from vyos.util import read_file, write_file, run +airbag.enable() + +# path to daemons config and config status files +config_file = '/etc/frr/daemons' +vyos_status_file = '/tmp/vyos-config-status' +# path to watchfrr for FRR control +watchfrr = '/usr/lib/frr/watchfrr.sh' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['system', 'frr'] + frr_config = conf.get_config_dict(base, get_first_key=True) + + return frr_config + + +def verify(frr_config): + # Nothing to verify here + pass + + +def generate(frr_config): + # read daemons config file + daemons_config_current = read_file(config_file) + # generate new config file + daemons_config_new = render_to_string('frr/daemons.frr.tmpl', frr_config) + # update configuration file if this is necessary + if daemons_config_new != daemons_config_current: + syslog.warning('FRR daemons configuration file need to be changed') + write_file(config_file, daemons_config_new) + frr_config['config_file_changed'] = True + + +def apply(frr_config): + # check if this is initial commit during boot or intiated by CLI + # if the file exists, this must be CLI commit + commit_type_cli = Path(vyos_status_file).exists() + # display warning to user + if commit_type_cli and frr_config.get('config_file_changed'): + # Since FRR restart is not safe thing, better to give + # control over this to users + print(''' + You need to reboot a router (preferred) or restart FRR + to apply changes in modules settings + ''') + # restart FRR automatically. DUring the initial boot this should be + # safe in most cases + if not commit_type_cli and frr_config.get('config_file_changed'): + syslog.warning('Restarting FRR to apply changes in modules') + run(f'{watchfrr} restart') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/migration-scripts/ipsec/5-to-6 b/src/migration-scripts/ipsec/5-to-6 index e9adee01b..3a8b3926d 100755 --- a/src/migration-scripts/ipsec/5-to-6 +++ b/src/migration-scripts/ipsec/5-to-6 @@ -78,7 +78,7 @@ if config.exists(log_mode): base_interfaces = base + ['ipsec-interfaces', 'interface'] if config.exists(base_interfaces): config.copy(base_interfaces, base + ['interface']) - config.delete(base_interfaces) + config.delete(base + ['ipsec-interfaces']) # Remove deprecated "auto-update" option tmp = base + ['auto-update'] diff --git a/src/migration-scripts/policy/2-to-3 b/src/migration-scripts/policy/2-to-3 new file mode 100755 index 000000000..84cb1ff4a --- /dev/null +++ b/src/migration-scripts/policy/2-to-3 @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T3976: change cli +# from: set policy route-map FOO rule 10 match ipv6 nexthop 'h:h:h:h:h:h:h:h' +# to: set policy route-map FOO rule 10 match ipv6 nexthop address 'h:h:h:h:h:h:h:h' + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if (len(argv) < 1): + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['policy', 'route-map'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) + +for route_map in config.list_nodes(base): + if not config.exists(base + [route_map, 'rule']): + continue + for rule in config.list_nodes(base + [route_map, 'rule']): + base_rule = base + [route_map, 'rule', rule] + + if config.exists(base_rule + ['match', 'ipv6', 'nexthop']): + tmp = config.return_value(base_rule + ['match', 'ipv6', 'nexthop']) + config.delete(base_rule + ['match', 'ipv6', 'nexthop']) + config.set(base_rule + ['match', 'ipv6', 'nexthop', 'address'], value=tmp) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print(f'Failed to save the modified config: {e}') + exit(1)
\ No newline at end of file diff --git a/src/migration-scripts/system/23-to-24 b/src/migration-scripts/system/23-to-24 index 5ea71d51a..97fe82462 100755 --- a/src/migration-scripts/system/23-to-24 +++ b/src/migration-scripts/system/23-to-24 @@ -20,6 +20,7 @@ from ipaddress import ip_interface from ipaddress import ip_address from sys import exit, argv from vyos.configtree import ConfigTree +from vyos.template import is_ipv4 if (len(argv) < 1): print("Must specify file name!") @@ -37,6 +38,9 @@ def fixup_cli(config, path, interface): if config.exists(path + ['address']): for address in config.return_values(path + ['address']): tmp = ip_interface(address) + # ARP is only available for IPv4 ;-) + if not is_ipv4(tmp): + continue if ip_address(host) in tmp.network.hosts(): mac = config.return_value(tmp_base + [host, 'hwaddr']) iface_path = ['protocols', 'static', 'arp', 'interface'] diff --git a/src/migration-scripts/vrf/0-to-1 b/src/migration-scripts/vrf/0-to-1 index 2b41ef3c7..5df751113 100755 --- a/src/migration-scripts/vrf/0-to-1 +++ b/src/migration-scripts/vrf/0-to-1 @@ -114,6 +114,16 @@ for vrf in config.list_nodes(base): if config.exists(vrf_path): config.rename(vrf_path, 'vrf') + next_hop = route_path + [route, 'interface'] + if config.exists(next_hop): + for interface in config.list_nodes(next_hop): + interface_path = next_hop + [interface, 'next-hop-interface'] + if config.exists(interface_path): + config.rename(interface_path, 'interface') + vrf_path = next_hop + [interface, 'next-hop-vrf'] + if config.exists(vrf_path): + config.rename(vrf_path, 'vrf') + try: with open(file_name, 'w') as f: f.write(config.to_string()) diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index bc7813052..1e78c3a03 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -17,6 +17,7 @@ import argparse import ipaddress import os +import re import sys import tabulate @@ -30,7 +31,8 @@ from vyos.pki import encode_certificate, encode_public_key, encode_private_key, from vyos.pki import create_certificate, create_certificate_request, create_certificate_revocation_list from vyos.pki import create_private_key from vyos.pki import create_dh_parameters -from vyos.pki import load_certificate, load_certificate_request, load_private_key, load_crl +from vyos.pki import load_certificate, load_certificate_request, load_private_key +from vyos.pki import load_crl, load_dh_parameters, load_public_key from vyos.pki import verify_certificate from vyos.xml import defaults from vyos.util import ask_input, ask_yes_no @@ -183,13 +185,13 @@ def install_ssh_key(name, public_key, private_key, passphrase=None): ]) print(encode_private_key(private_key, encoding='PEM', key_format='OpenSSH', passphrase=passphrase)) -def install_keypair(name, key_type, private_key=None, public_key=None, passphrase=None): +def install_keypair(name, key_type, private_key=None, public_key=None, passphrase=None, prompt=True): # Show/install conf commands for key-pair config_paths = [] if public_key: - install_public_key = ask_yes_no('Do you want to install the public key?', default=True) + install_public_key = not prompt or ask_yes_no('Do you want to install the public key?', default=True) public_key_pem = encode_public_key(public_key) if install_public_key: @@ -200,7 +202,7 @@ def install_keypair(name, key_type, private_key=None, public_key=None, passphras print(public_key_pem) if private_key: - install_private_key = ask_yes_no('Do you want to install the private key?', default=True) + install_private_key = not prompt or ask_yes_no('Do you want to install the private key?', default=True) private_key_pem = encode_private_key(private_key, passphrase=passphrase) if install_private_key: @@ -214,6 +216,13 @@ def install_keypair(name, key_type, private_key=None, public_key=None, passphras install_into_config(conf, config_paths) +def install_openvpn_key(name, key_data, key_version='1'): + config_paths = [ + f"pki openvpn shared-secret {name} key '{key_data}'", + f"pki openvpn shared-secret {name} version '{key_version}'" + ] + install_into_config(conf, config_paths) + def install_wireguard_key(interface, private_key, public_key): # Show conf commands for installing wireguard key pairs from vyos.ifconfig import Section @@ -640,15 +649,11 @@ def generate_openvpn_key(name, install=False, file=False): key_data = "".join(key_lines[1:-1]) # Remove wrapper tags and line endings key_version = '1' - import re version_search = re.search(r'BEGIN OpenVPN Static key V(\d+)', result) # Future-proofing (hopefully) if version_search: key_version = version_search[1] - base = f"set pki openvpn shared-secret {name}" - print("Configure mode commands to install OpenVPN key:") - print(f"{base} key '{key_data}'") - print(f"{base} version '{key_version}'") + install_openvpn_key(name, key_data, key_version) if file: write_file(f'{name}.key', result) @@ -670,6 +675,167 @@ def generate_wireguard_psk(interface=None, peer=None, install=False): else: print(f'Pre-shared key: {psk}') +# Import functions +def import_ca_certificate(name, path=None, key_path=None): + if path: + if not os.path.exists(path): + print(f'File not found: {path}') + return + + cert = None + + with open(path) as f: + cert_data = f.read() + cert = load_certificate(cert_data, wrap_tags=False) + + if not cert: + print(f'Invalid certificate: {path}') + return + + install_certificate(name, cert, is_ca=True) + + if key_path: + if not os.path.exists(key_path): + print(f'File not found: {key_path}') + return + + key = None + passphrase = ask_input('Enter private key passphrase: ') or None + + with open(key_path) as f: + key_data = f.read() + key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False) + + if not key: + print(f'Invalid private key or passphrase: {path}') + return + + install_certificate(name, private_key=key, is_ca=True) + +def import_certificate(name, path=None, key_path=None): + if path: + if not os.path.exists(path): + print(f'File not found: {path}') + return + + cert = None + + with open(path) as f: + cert_data = f.read() + cert = load_certificate(cert_data, wrap_tags=False) + + if not cert: + print(f'Invalid certificate: {path}') + return + + install_certificate(name, cert, is_ca=False) + + if key_path: + if not os.path.exists(key_path): + print(f'File not found: {key_path}') + return + + key = None + passphrase = ask_input('Enter private key passphrase: ') or None + + with open(key_path) as f: + key_data = f.read() + key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False) + + if not key: + print(f'Invalid private key or passphrase: {path}') + return + + install_certificate(name, private_key=key, is_ca=False) + +def import_crl(name, path): + if not os.path.exists(path): + print(f'File not found: {path}') + return + + crl = None + + with open(path) as f: + crl_data = f.read() + crl = load_crl(crl_data, wrap_tags=False) + + if not crl: + print(f'Invalid certificate: {path}') + return + + install_crl(name, crl) + +def import_dh_parameters(name, path): + if not os.path.exists(path): + print(f'File not found: {path}') + return + + dh = None + + with open(path) as f: + dh_data = f.read() + dh = load_dh_parameters(dh_data, wrap_tags=False) + + if not dh: + print(f'Invalid DH parameters: {path}') + return + + install_dh_parameters(name, dh) + +def import_keypair(name, path=None, key_path=None): + if path: + if not os.path.exists(path): + print(f'File not found: {path}') + return + + key = None + + with open(path) as f: + key_data = f.read() + key = load_public_key(key_data, wrap_tags=False) + + if not key: + print(f'Invalid public key: {path}') + return + + install_keypair(name, None, public_key=key, prompt=False) + + if key_path: + if not os.path.exists(key_path): + print(f'File not found: {key_path}') + return + + key = None + passphrase = ask_input('Enter private key passphrase: ') or None + + with open(key_path) as f: + key_data = f.read() + key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False) + + if not key: + print(f'Invalid private key or passphrase: {path}') + return + + install_keypair(name, None, private_key=key, prompt=False) + +def import_openvpn_secret(name, path): + if not os.path.exists(path): + print(f'File not found: {path}') + return + + key_data = None + key_version = '1' + + with open(path) as f: + key_lines = f.read().split("\n") + 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) + if version_search: + key_version = version_search[1] + + install_openvpn_key(name, key_data, key_version) + # Show functions def show_certificate_authority(name=None): headers = ['Name', 'Subject', 'Issuer CN', 'Issued', 'Expiry', 'Private Key', 'Parent'] @@ -799,6 +965,9 @@ if __name__ == '__main__': parser.add_argument('--file', help='Write generated keys into specified filename', action='store_true') parser.add_argument('--install', help='Install generated keys into running-config', action='store_true') + parser.add_argument('--filename', help='Write certificate into specified filename', action='store') + parser.add_argument('--key-filename', help='Write key into specified filename', action='store') + args = parser.parse_args() try: @@ -840,7 +1009,19 @@ if __name__ == '__main__': generate_wireguard_key(args.interface, install=args.install) if args.psk: generate_wireguard_psk(args.interface, peer=args.peer, install=args.install) - + elif args.action == 'import': + if args.ca: + import_ca_certificate(args.ca, path=args.filename, key_path=args.key_filename) + elif args.certificate: + import_certificate(args.certificate, path=args.filename, key_path=args.key_filename) + elif args.crl: + import_crl(args.crl, args.filename) + elif args.dh: + import_dh_parameters(args.dh, args.filename) + elif args.keypair: + import_keypair(args.keypair, path=args.filename, key_path=args.key_filename) + elif args.openvpn: + import_openvpn_secret(args.openvpn, args.filename) elif args.action == 'show': if args.ca: ca_name = None if args.ca == 'all' else args.ca diff --git a/src/op_mode/show_neigh.py b/src/op_mode/show_neigh.py index 94e745493..d874bd544 100755 --- a/src/op_mode/show_neigh.py +++ b/src/op_mode/show_neigh.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,83 +14,89 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -#ip -j -f inet neigh list | jq -#[ - #{ - #"dst": "192.168.101.8", - #"dev": "enp0s25", - #"lladdr": "78:d2:94:72:77:7e", - #"state": [ - #"STALE" - #] - #}, - #{ - #"dst": "192.168.101.185", - #"dev": "enp0s25", - #"lladdr": "34:46:ec:76:f8:9b", - #"state": [ - #"STALE" - #] - #}, - #{ - #"dst": "192.168.101.225", - #"dev": "enp0s25", - #"lladdr": "c2:cb:fa:bf:a0:35", - #"state": [ - #"STALE" - #] - #}, - #{ - #"dst": "192.168.101.1", - #"dev": "enp0s25", - #"lladdr": "00:98:2b:f8:3f:11", - #"state": [ - #"REACHABLE" - #] - #}, - #{ - #"dst": "192.168.101.181", - #"dev": "enp0s25", - #"lladdr": "d8:9b:3b:d5:88:22", - #"state": [ - #"STALE" - #] - #} -#] +# Sample output of `ip --json neigh list`: +# +# [ +# { +# "dst": "192.168.1.1", +# "dev": "eth0", # Missing if `dev ...` option is used +# "lladdr": "00:aa:bb:cc:dd:ee", # May be missing for failed entries +# "state": [ +# "REACHABLE" +# ] +# }, +# ] import sys -import argparse -import json -from vyos.util import cmd - -def main(): - #parese args - parser = argparse.ArgumentParser() - parser.add_argument('--family', help='Protocol family', required=True) - args = parser.parse_args() - - neigh_raw_json = cmd(f'ip -j -f {args.family} neigh list') - neigh_raw_json = neigh_raw_json.lower() - neigh_json = json.loads(neigh_raw_json) - - format_neigh = '%-50s %-10s %-20s %s' - print(format_neigh % ("IP Address", "Device", "State", "LLADDR")) - print(format_neigh % ("----------", "------", "-----", "------")) - - if neigh_json is not None: - for neigh_item in neigh_json: - dev = neigh_item['dev'] - dst = neigh_item['dst'] - lladdr = neigh_item['lladdr'] if 'lladdr' in neigh_item else '' - state = neigh_item['state'] - - i = 0 - for state_item in state: - if i == 0: - print(format_neigh % (dst, dev, state_item, lladdr)) - else: - print(format_neigh % ('', '', state_item, '')) - i+=1 - + + +def get_raw_data(family, device=None, state=None): + from json import loads + from vyos.util import cmd + + if device: + device = f"dev {device}" + else: + device = "" + + if state: + state = f"nud {state}" + else: + state = "" + + neigh_cmd = f"ip --family {family} --json neighbor list {device} {state}" + + data = loads(cmd(neigh_cmd)) + + return data + +def get_formatted_output(family, device=None, state=None): + from tabulate import tabulate + + def entry_to_list(e, intf=None): + dst = e["dst"] + + # State is always a list in the iproute2 output + state = ", ".join(e["state"]) + + # Link layer address is absent from e.g. FAILED entries + if "lladdr" in e: + lladdr = e["lladdr"] + else: + lladdr = None + + # Device field is absent from outputs of `ip neigh list dev ...` + if "dev" in e: + dev = e["dev"] + elif device: + dev = device + else: + raise ValueError("interface is not defined") + + return [dst, dev, lladdr, state] + + neighs = get_raw_data(family, device=device, state=state) + neighs = map(entry_to_list, neighs) + + headers = ["Address", "Interface", "Link layer address", "State"] + return tabulate(neighs, headers) + if __name__ == '__main__': - main() + from argparse import ArgumentParser + + parser = ArgumentParser() + parser.add_argument("-f", "--family", type=str, default="inet", help="Address family") + parser.add_argument("-i", "--interface", type=str, help="Network interface") + parser.add_argument("-s", "--state", type=str, help="Neighbor table entry state") + + args = parser.parse_args() + + if args.state: + if args.state not in ["reachable", "failed", "stale", "permanent"]: + raise ValueError(f"""Incorrect state "{args.state}"! Must be one of: reachable, stale, failed, permanent""") + + try: + print(get_formatted_output(args.family, device=args.interface, state=args.state)) + except ValueError as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/show_openconnect_otp.py b/src/op_mode/show_openconnect_otp.py new file mode 100755 index 000000000..ae532ccc9 --- /dev/null +++ b/src/op_mode/show_openconnect_otp.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 + +# Copyright 2017, 2022 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/>. + +import argparse +import os + +from vyos.config import Config +from vyos.xml import defaults +from vyos.configdict import dict_merge +from vyos.util import popen +from base64 import b32encode + +otp_file = '/run/ocserv/users.oath' + +def check_uname_otp(username): + """ + Check if "username" exists and have an OTP key + """ + config = Config() + base_key = ['vpn', 'openconnect', 'authentication', 'local-users', 'username', username, 'otp', 'key'] + if not config.exists(base_key): + return None + return True + +def get_otp_ocserv(username): + config = Config() + base = ['vpn', 'openconnect'] + if not config.exists(base): + return None + ocserv = config.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base) + ocserv = dict_merge(default_values, ocserv) + # workaround a "know limitation" - https://phabricator.vyos.net/T2665 + del ocserv['authentication']['local_users']['username']['otp'] + if not ocserv["authentication"]["local_users"]["username"]: + return None + default_ocserv_usr_values = default_values['authentication']['local_users']['username']['otp'] + for user, params in ocserv['authentication']['local_users']['username'].items(): + # Not every configuration requires OTP settings + if ocserv['authentication']['local_users']['username'][user].get('otp'): + ocserv['authentication']['local_users']['username'][user]['otp'] = dict_merge(default_ocserv_usr_values, ocserv['authentication']['local_users']['username'][user]['otp']) + result = ocserv['authentication']['local_users']['username'][username] + return result + +def display_otp_ocserv(username, params, info): + hostname = os.uname()[1] + key_hex = params['otp']['key'] + otp_length = params['otp']['otp_length'] + interval = params['otp']['interval'] + token_type = params['otp']['token_type'] + if token_type == 'hotp-time': + token_type_acrn = 'totp' + key_base32 = b32encode(bytes.fromhex(key_hex)).decode() + otp_url = ''.join(["otpauth://",token_type_acrn,"/",username,"@",hostname,"?secret=",key_base32,"&digits=",otp_length,"&period=",interval]) + qrcode,err = popen('qrencode -t ansiutf8', input=otp_url) + + if info == 'full': + print("# You can share it with the user, he just needs to scan the QR in his OTP app") + print("# username: ", username) + print("# OTP KEY: ", key_base32) + print("# OTP URL: ", otp_url) + print(qrcode) + print('# To add this OTP key to configuration, run the following commands:') + print(f"set vpn openconnect authentication local-users username {username} otp key '{key_hex}'") + if interval != "30": + print(f"set vpn openconnect authentication local-users username {username} otp interval '{interval}'") + if otp_length != "6": + print(f"set vpn openconnect authentication local-users username {username} otp otp-length '{otp_length}'") + elif info == 'key-hex': + print("# OTP key in hexadecimal: ") + print(key_hex) + elif info == 'key-b32': + print("# OTP key in Base32: ") + print(key_base32) + elif info == 'qrcode': + print(f"# QR code for OpenConnect user '{username}'") + print(qrcode) + elif info == 'uri': + print(f"# URI for OpenConnect user '{username}'") + print(otp_url) + +if __name__ == '__main__': + parser = argparse.ArgumentParser(add_help=False, description='Show OTP authentication information for selected user') + parser.add_argument('--user', action="store", type=str, default='', help='Username') + parser.add_argument('--info', action="store", type=str, default='full', help='Wich information to display') + + args = parser.parse_args() + check_otp = check_uname_otp(args.user) + if check_otp: + user_otp_params = get_otp_ocserv(args.user) + display_otp_ocserv(args.user, user_otp_params, args.info) + else: + print(f'There is no such user ("{args.user}") with an OTP key configured') diff --git a/src/op_mode/show_uptime.py b/src/op_mode/show_uptime.py index 1b5e33fa9..b70c60cf8 100755 --- a/src/op_mode/show_uptime.py +++ b/src/op_mode/show_uptime.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -26,14 +26,17 @@ def get_uptime_seconds(): def get_load_averages(): from re import search from vyos.util import cmd + from vyos.cpu import get_core_count data = cmd("uptime") matches = search(r"load average:\s*(?P<one>[0-9\.]+)\s*,\s*(?P<five>[0-9\.]+)\s*,\s*(?P<fifteen>[0-9\.]+)\s*", data) + core_count = get_core_count() + res = {} - res[1] = float(matches["one"]) - res[5] = float(matches["five"]) - res[15] = float(matches["fifteen"]) + res[1] = float(matches["one"]) / core_count + res[5] = float(matches["five"]) / core_count + res[15] = float(matches["fifteen"]) / core_count return res @@ -53,9 +56,9 @@ def get_formatted_output(): out = "Uptime: {}\n\n".format(data["uptime"]) avgs = data["load_average"] out += "Load averages:\n" - out += "1 minute: {:.02f}%\n".format(avgs[1]*100) - out += "5 minutes: {:.02f}%\n".format(avgs[5]*100) - out += "15 minutes: {:.02f}%\n".format(avgs[15]*100) + out += "1 minute: {:.01f}%\n".format(avgs[1]*100) + out += "5 minutes: {:.01f}%\n".format(avgs[5]*100) + out += "15 minutes: {:.01f}%\n".format(avgs[15]*100) return out diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index c1b595412..e9b904ba8 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -201,6 +201,20 @@ class ShowModel(ApiModel): } } +class ResetModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "reset", + "path": ["op", "mode", "path"], + } + } + + class Success(BaseModel): success: bool data: Union[str, bool, Dict] @@ -372,7 +386,7 @@ class MultipartRoute(APIRoute): return error(400, "Malformed command \"{0}\": \"value\" field must be a string".format(json.dumps(request.offending_command))) if request.ERR_PATH_NOT_LIST_OF_STR: return error(400, "Malformed command \"{0}\": \"path\" field must be a list of strings".format(json.dumps(request.offending_command))) - if endpoint in ('/retrieve','/generate','/show'): + if endpoint in ('/retrieve','/generate','/show','/reset'): if request.ERR_NO_OP or request.ERR_NO_PATH: return error(400, "Missing required field. \"op\" and \"path\" fields are required") if endpoint in ('/config-file', '/image'): @@ -607,6 +621,27 @@ def show_op(data: ShowModel): return success(res) +@app.post('/reset') +def reset_op(data: ResetModel): + session = app.state.vyos_session + + op = data.op + path = data.path + + try: + if op == 'reset': + res = session.reset(path) + else: + return error(400, "\"{0}\" is not a valid operation".format(op)) + except ConfigSessionError as e: + return error(400, str(e)) + except Exception as e: + logger.critical(traceback.format_exc()) + return error(500, "An internal error occured. Check the logs for details.") + + return success(res) + + ### # GraphQL integration ### diff --git a/src/system/vyos-event-handler.py b/src/system/vyos-event-handler.py new file mode 100755 index 000000000..691f674b2 --- /dev/null +++ b/src/system/vyos-event-handler.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import select +import re +import json +from os import getpid, environ +from pathlib import Path +from signal import signal, SIGTERM, SIGINT +from systemd import journal +from sys import exit +from vyos.util import run, dict_search + +# Identify this script +my_pid = getpid() +my_name = Path(__file__).stem + + +# handle termination signal +def handle_signal(signal_type, frame): + if signal_type == SIGTERM: + journal.send('Received SIGTERM signal, stopping normally', + SYSLOG_IDENTIFIER=my_name) + if signal_type == SIGINT: + journal.send('Received SIGINT signal, stopping normally', + SYSLOG_IDENTIFIER=my_name) + exit(0) + + +# Class for analyzing and process messages +class Analyzer: + # Initialize settings + def __init__(self, config: dict) -> None: + self.config = {} + # Prepare compiled regex objects + for event_id, event_config in config.items(): + script = dict_search('script.path', event_config) + # Check for arguments + if dict_search('script.arguments', event_config): + script_arguments = dict_search('script.arguments', event_config) + script = f'{script} {script_arguments}' + # Prepare environment + environment = environ + # Check for additional environment options + if dict_search('script.environment', event_config): + for env_variable, env_value in dict_search( + 'script.environment', event_config).items(): + environment[env_variable] = env_value.get('value') + # Create final config dictionary + pattern_raw = event_config['filter']['pattern'] + pattern_compiled = re.compile( + rf'{event_config["filter"]["pattern"]}') + pattern_config = { + pattern_compiled: { + 'pattern_raw': + pattern_raw, + 'syslog_id': + dict_search('filter.syslog_identifier', event_config), + 'pattern_script': { + 'path': script, + 'environment': environment + } + } + } + self.config.update(pattern_config) + + # Execute script safely + def script_run(self, pattern: str, script_path: str, + script_env: dict) -> None: + try: + run(script_path, env=script_env) + journal.send( + f'Pattern found: "{pattern}", script executed: "{script_path}"', + SYSLOG_IDENTIFIER=my_name) + except Exception as err: + journal.send( + f'Pattern found: "{pattern}", failed to execute script "{script_path}": {err}', + SYSLOG_IDENTIFIER=my_name) + + # Analyze a message + def process_message(self, message: dict) -> None: + for pattern_compiled, pattern_config in self.config.items(): + # Check if syslog id is presented in config and matches + syslog_id = pattern_config.get('syslog_id') + if syslog_id and message['SYSLOG_IDENTIFIER'] != syslog_id: + continue + if pattern_compiled.fullmatch(message['MESSAGE']): + # Add message to environment variables + pattern_config['pattern_script']['environment'][ + 'message'] = message['MESSAGE'] + # Run script + self.script_run( + pattern=pattern_config['pattern_raw'], + script_path=pattern_config['pattern_script']['path'], + script_env=pattern_config['pattern_script']['environment']) + + +if __name__ == '__main__': + # Parse command arguments and get config + parser = argparse.ArgumentParser() + parser.add_argument('-c', + '--config', + action='store', + help='Path to even-handler configuration', + required=True, + type=Path) + + args = parser.parse_args() + try: + config_path = Path(args.config) + config = json.loads(config_path.read_text()) + # Create an object for analazyng messages + analyzer = Analyzer(config) + except Exception as err: + print( + f'Configuration file "{config_path}" does not exist or malformed: {err}' + ) + exit(1) + + # Prepare for proper exitting + signal(SIGTERM, handle_signal) + signal(SIGINT, handle_signal) + + # Set up journal connection + data = journal.Reader() + data.seek_tail() + data.get_previous() + p = select.poll() + p.register(data, data.get_events()) + + journal.send(f'Started with configuration: {config}', + SYSLOG_IDENTIFIER=my_name) + + while p.poll(): + if data.process() != journal.APPEND: + continue + for entry in data: + message = entry['MESSAGE'] + pid = entry['_PID'] + # Skip empty messages and messages from this process + if message and pid != my_pid: + try: + analyzer.process_message(entry) + except Exception as err: + journal.send(f'Unable to process message: {err}', + SYSLOG_IDENTIFIER=my_name) diff --git a/src/systemd/vyos-event-handler.service b/src/systemd/vyos-event-handler.service new file mode 100644 index 000000000..6afe4f95b --- /dev/null +++ b/src/systemd/vyos-event-handler.service @@ -0,0 +1,11 @@ +[Unit] +Description=VyOS event handler +After=network.target vyos-router.service + +[Service] +Type=simple +Restart=always +ExecStart=/usr/bin/python3 /usr/libexec/vyos/system/vyos-event-handler.py --config /run/vyos-event-handler.conf + +[Install] +WantedBy=multi-user.target |