diff options
Diffstat (limited to 'src')
236 files changed, 10599 insertions, 4144 deletions
diff --git a/src/completion/list_ddclient_protocols.sh b/src/completion/list_ddclient_protocols.sh index 3b4eff4d6..634981660 100755 --- a/src/completion/list_ddclient_protocols.sh +++ b/src/completion/list_ddclient_protocols.sh @@ -14,4 +14,4 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -echo -n $(ddclient -list-protocols | grep -vE 'nsupdate|cloudns') +echo -n $(ddclient -list-protocols | grep -vE 'cloudns|porkbun') diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index daad9186e..321d00abf 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -142,11 +142,17 @@ def verify(container): for address in container_config['network'][network_name]['address']: network = None if is_ipv4(address): - network = [x for x in container['network'][network_name]['prefix'] if is_ipv4(x)][0] - cnt_ipv4 += 1 + try: + network = [x for x in container['network'][network_name]['prefix'] if is_ipv4(x)][0] + cnt_ipv4 += 1 + except: + raise ConfigError(f'Network "{network_name}" does not contain an IPv4 prefix!') elif is_ipv6(address): - network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0] - cnt_ipv6 += 1 + try: + network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0] + cnt_ipv6 += 1 + except: + raise ConfigError(f'Network "{network_name}" does not contain an IPv6 prefix!') # Specified container IP address must belong to network prefix if ip_address(address) not in ip_network(network): @@ -208,6 +214,10 @@ def verify(container): if {'allow_host_networks', 'network'} <= set(container_config): raise ConfigError(f'"allow-host-networks" and "network" for "{name}" cannot be both configured at the same time!') + # gid cannot be set without uid + if 'gid' in container_config and 'uid' not in container_config: + raise ConfigError(f'Cannot set "gid" without "uid" for container') + # Add new network if 'network' in container: for network, network_config in container['network'].items(): @@ -232,9 +242,9 @@ def verify(container): # A network attached to a container can not be deleted if {'network_remove', 'name'} <= set(container): for network in container['network_remove']: - for container, container_config in container['name'].items(): - if 'network' in container_config and network in container_config['network']: - raise ConfigError(f'Can not remove network "{network}", used by container "{container}"!') + for c, c_config in container['name'].items(): + if 'network' in c_config and network in c_config['network']: + raise ConfigError(f'Can not remove network "{network}", used by container "{c}"!') if 'registry' in container: for registry, registry_config in container['registry'].items(): @@ -302,6 +312,14 @@ def generate_run_arguments(name, container_config): # If listen_addresses is empty, just include the standard publish command port += f' --publish {sport}:{dport}/{protocol}' + # Set uid and gid + uid = '' + if 'uid' in container_config: + uid = container_config['uid'] + if 'gid' in container_config: + uid += ':' + container_config['gid'] + uid = f'--user {uid}' + # Bind volume volume = '' if 'volume' in container_config: @@ -314,7 +332,7 @@ def generate_run_arguments(name, container_config): container_base_cmd = f'--detach --interactive --tty --replace {cap_add} ' \ f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ - f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label}' + f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label} {uid}' entrypoint = '' if 'entrypoint' in container_config: @@ -349,7 +367,7 @@ def generate_run_arguments(name, container_config): else: ip_param += f' --ip {address}' - return f'{container_base_cmd} --net {networks} {ip_param} {entrypoint} {image} {command} {command_arguments}'.strip() + return f'{container_base_cmd} --no-healthcheck --net {networks} {ip_param} {entrypoint} {image} {command} {command_arguments}'.strip() def generate(container): # bail out early - looks like removal from running config diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py deleted file mode 100755 index 874c4b689..000000000 --- a/src/conf_mode/dns_dynamic.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import os - -from sys import exit - -from vyos.config import Config -from vyos.configverify import verify_interface_exists -from vyos.template import render -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/ddclient/ddclient.conf' -systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf' - -# Protocols that require zone -zone_necessary = ['cloudflare', 'godaddy', 'hetzner', 'gandi', 'nfsn'] - -# Protocols that do not require username -username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla'] - -# Protocols that support TTL -ttl_supported = ['cloudflare', 'gandi', 'hetzner', 'dnsexit', 'godaddy', 'nfsn'] - -# Protocols that support both IPv4 and IPv6 -dualstack_supported = ['cloudflare', 'dyndns2', 'freedns', 'njalla'] - -# dyndns2 protocol in ddclient honors dual stack for selective servers -# because of the way it is implemented in ddclient -dyndns_dualstack_servers = ['members.dyndns.org', 'dynv6.com'] - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['service', 'dns', 'dynamic'] - if not conf.exists(base): - return None - - dyndns = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - dyndns['config_file'] = config_file - return dyndns - -def verify(dyndns): - # bail out early - looks like removal from running config - if not dyndns or 'address' not in dyndns: - return None - - for address in dyndns['address']: - # If dyndns address is an interface, ensure it exists - if address != 'web': - verify_interface_exists(address) - - # RFC2136 - configuration validation - if 'rfc2136' in dyndns['address'][address]: - for config in dyndns['address'][address]['rfc2136'].values(): - for field in ['host_name', 'zone', 'server', 'key']: - if field not in config: - raise ConfigError(f'"{field.replace("_", "-")}" is required for RFC2136 ' - f'based Dynamic DNS service on "{address}"') - - # Dynamic DNS service provider - configuration validation - if 'service' in dyndns['address'][address]: - for service, config in dyndns['address'][address]['service'].items(): - error_msg = f'is required for Dynamic DNS service "{service}" on "{address}"' - - for field in ['host_name', 'password', 'protocol']: - if field not in config: - raise ConfigError(f'"{field.replace("_", "-")}" {error_msg}') - - if config['protocol'] in zone_necessary and 'zone' not in config: - raise ConfigError(f'"zone" {error_msg}') - - if config['protocol'] not in zone_necessary and 'zone' in config: - raise ConfigError(f'"{config["protocol"]}" does not support "zone"') - - if config['protocol'] not in username_unnecessary and 'username' not in config: - raise ConfigError(f'"username" {error_msg}') - - if config['protocol'] not in ttl_supported and 'ttl' in config: - raise ConfigError(f'"{config["protocol"]}" does not support "ttl"') - - if config['ip_version'] == 'both': - if config['protocol'] not in dualstack_supported: - raise ConfigError(f'"{config["protocol"]}" does not support ' - f'both IPv4 and IPv6 at the same time') - # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org) - if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers: - raise ConfigError(f'"{config["protocol"]}" does not support ' - f'both IPv4 and IPv6 at the same time for "{config["server"]}"') - - if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']): - raise ConfigError(f'"expiry-time" must be greater than "wait-time"') - - return None - -def generate(dyndns): - # bail out early - looks like removal from running config - if not dyndns or 'address' not in dyndns: - return None - - render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns, permission=0o600) - render(systemd_override, 'dns-dynamic/override.conf.j2', dyndns) - return None - -def apply(dyndns): - systemd_service = 'ddclient.service' - # Reload systemd manager configuration - call('systemctl daemon-reload') - - # bail out early - looks like removal from running config - if not dyndns or 'address' not in dyndns: - call(f'systemctl stop {systemd_service}') - if os.path.exists(config_file): - os.unlink(config_file) - else: - call(f'systemctl reload-or-restart {systemd_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/firewall.py b/src/conf_mode/firewall.py index f6480ab0a..acb7dfa41 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -23,7 +23,7 @@ from sys import exit from vyos.base import Warning from vyos.config import Config -from vyos.configdict import node_changed +from vyos.configdict import is_node_changed from vyos.configdiff import get_config_diff, Diff from vyos.configdep import set_dependents, call_dependents from vyos.configverify import verify_interface_exists @@ -42,9 +42,6 @@ from vyos import airbag airbag.enable() -nat_conf_script = 'nat.py' -policy_route_conf_script = 'policy-route.py' - nftables_conf = '/run/nftables.conf' sysfs_config = { @@ -133,7 +130,7 @@ def get_config(config=None): with_recursive_defaults=True) - firewall['group_resync'] = bool('group' in firewall or node_changed(conf, base + ['group'])) + firewall['group_resync'] = bool('group' in firewall or is_node_changed(conf, base + ['group'])) if firewall['group_resync']: # Update nat and policy-route as firewall groups were updated set_dependents('group_resync', conf) @@ -272,7 +269,7 @@ def verify_rule(firewall, rule_conf, ipv6): raise ConfigError(f'{side} port-group and port cannot both be defined') if 'log_options' in rule_conf: - if 'log' not in rule_conf or 'enable' not in rule_conf['log']: + if 'log' not in rule_conf: raise ConfigError('log-options defined, but log is not enable') if 'snapshot_length' in rule_conf['log_options'] and 'group' not in rule_conf['log_options']: @@ -283,8 +280,8 @@ def verify_rule(firewall, rule_conf, ipv6): for direction in ['inbound_interface','outbound_interface']: if direction in rule_conf: - if 'interface_name' in rule_conf[direction] and 'interface_group' in rule_conf[direction]: - raise ConfigError(f'Cannot specify both interface-group and interface-name for {direction}') + if 'name' in rule_conf[direction] and 'group' in rule_conf[direction]: + raise ConfigError(f'Cannot specify both interface group and interface name for {direction}') def verify_nested_group(group_name, group, groups, seen): if 'include' not in group: @@ -374,12 +371,82 @@ def verify(firewall): for rule_id, rule_conf in name_conf['rule'].items(): verify_rule(firewall, rule_conf, True) + #### ZONESSSS + local_zone = False + zone_interfaces = [] + + if 'zone' in firewall: + for zone, zone_conf in firewall['zone'].items(): + if 'local_zone' not in zone_conf and 'interface' not in zone_conf: + raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone') + + if 'local_zone' in zone_conf: + if local_zone: + raise ConfigError('There cannot be multiple local zones') + if 'interface' in zone_conf: + raise ConfigError('Local zone cannot have interfaces assigned') + if 'intra_zone_filtering' in zone_conf: + raise ConfigError('Local zone cannot use intra-zone-filtering') + local_zone = True + + if 'interface' in zone_conf: + found_duplicates = [intf for intf in zone_conf['interface'] if intf in zone_interfaces] + + if found_duplicates: + raise ConfigError(f'Interfaces cannot be assigned to multiple zones') + + zone_interfaces += zone_conf['interface'] + + if 'intra_zone_filtering' in zone_conf: + intra_zone = zone_conf['intra_zone_filtering'] + + if len(intra_zone) > 1: + raise ConfigError('Only one intra-zone-filtering action must be specified') + + if 'firewall' in intra_zone: + v4_name = dict_search_args(intra_zone, 'firewall', 'name') + if v4_name and not dict_search_args(firewall, 'ipv4', 'name', v4_name): + raise ConfigError(f'Firewall name "{v4_name}" does not exist') + + v6_name = dict_search_args(intra_zone, 'firewall', 'ipv6_name') + if v6_name and not dict_search_args(firewall, 'ipv6', 'name', v6_name): + raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + + if not v4_name and not v6_name: + raise ConfigError('No firewall names specified for intra-zone-filtering') + + if 'from' in zone_conf: + for from_zone, from_conf in zone_conf['from'].items(): + if from_zone not in firewall['zone']: + raise ConfigError(f'Zone "{zone}" refers to a non-existent or deleted zone "{from_zone}"') + + v4_name = dict_search_args(from_conf, 'firewall', 'name') + if v4_name and not dict_search_args(firewall, 'ipv4', 'name', v4_name): + raise ConfigError(f'Firewall name "{v4_name}" does not exist') + + v6_name = dict_search_args(from_conf, 'firewall', 'ipv6_name') + if v6_name and not dict_search_args(firewall, 'ipv6', 'name', v6_name): + raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + return None def generate(firewall): if not os.path.exists(nftables_conf): firewall['first_install'] = True + if 'zone' in firewall: + for local_zone, local_zone_conf in firewall['zone'].items(): + if 'local_zone' not in local_zone_conf: + continue + + local_zone_conf['from_local'] = {} + + for zone, zone_conf in firewall['zone'].items(): + if zone == local_zone or 'from' not in zone_conf: + continue + if local_zone in zone_conf['from']: + local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone] + render(nftables_conf, 'firewall/nftables.j2', firewall) return None diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py deleted file mode 100755 index 793a90d88..000000000 --- a/src/conf_mode/http-api.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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/>. - -import sys -import os -import json - -from time import sleep -from copy import deepcopy - -import vyos.defaults - -from vyos.config import Config -from vyos.configdep import set_dependents, call_dependents -from vyos.template import render -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -api_conf_file = '/etc/vyos/http-api.conf' -systemd_service = '/run/systemd/system/vyos-http-api.service' - -vyos_conf_scripts_dir=vyos.defaults.directories['conf_mode'] - -def _translate_values_to_boolean(d: dict) -> dict: - for k in list(d): - if d[k] == {}: - d[k] = True - elif isinstance(d[k], dict): - _translate_values_to_boolean(d[k]) - else: - pass - -def get_config(config=None): - http_api = deepcopy(vyos.defaults.api_data) - x = http_api.get('api_keys') - if x is None: - default_key = None - else: - default_key = x[0] - keys_added = False - - if config: - conf = config - else: - conf = Config() - - # reset on creation/deletion of 'api' node - https_base = ['service', 'https'] - if conf.exists(https_base): - set_dependents("https", conf) - - base = ['service', 'https', 'api'] - if not conf.exists(base): - return None - - api_dict = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - # One needs to 'flatten' the keys dict from the config into the - # http-api.conf format for api_keys: - if 'keys' in api_dict: - api_dict['api_keys'] = [] - for el in list(api_dict['keys'].get('id', {})): - key = api_dict['keys']['id'][el].get('key', '') - if key: - api_dict['api_keys'].append({'id': el, 'key': key}) - del api_dict['keys'] - - # Do we run inside a VRF context? - vrf_path = ['service', 'https', 'vrf'] - if conf.exists(vrf_path): - http_api['vrf'] = conf.return_value(vrf_path) - - if 'api_keys' in api_dict: - keys_added = True - - if api_dict.from_defaults(['graphql']): - del api_dict['graphql'] - - http_api.update(api_dict) - - if keys_added and default_key: - if default_key in http_api['api_keys']: - http_api['api_keys'].remove(default_key) - - # Finally, translate entries in http_api into boolean settings for - # backwards compatability of JSON http-api.conf file - _translate_values_to_boolean(http_api) - - return http_api - -def verify(http_api): - return None - -def generate(http_api): - if http_api is None: - if os.path.exists(systemd_service): - os.unlink(systemd_service) - return None - - if not os.path.exists('/etc/vyos'): - os.mkdir('/etc/vyos') - - with open(api_conf_file, 'w') as f: - json.dump(http_api, f, indent=2) - - render(systemd_service, 'https/vyos-http-api.service.j2', http_api) - return None - -def apply(http_api): - # Reload systemd manager configuration - call('systemctl daemon-reload') - service_name = 'vyos-http-api.service' - - if http_api is not None: - call(f'systemctl restart {service_name}') - else: - call(f'systemctl stop {service_name}') - - # Let uvicorn settle before restarting Nginx - sleep(1) - - call_dependents() - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py deleted file mode 100755 index 010490c7e..000000000 --- a/src/conf_mode/https.py +++ /dev/null @@ -1,266 +0,0 @@ -#!/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/>. - -import os -import sys - -from copy import deepcopy - -import vyos.defaults -import vyos.certbot_util - -from vyos.config import Config -from vyos.configverify import verify_vrf -from vyos import ConfigError -from vyos.pki import wrap_certificate -from vyos.pki import wrap_private_key -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.network import check_port_availability -from vyos.utils.network import is_listen_port_bind_service -from vyos.utils.file import write_file - -from vyos import airbag -airbag.enable() - -config_file = '/etc/nginx/sites-available/default' -systemd_override = r'/run/systemd/system/nginx.service.d/override.conf' -cert_dir = '/etc/ssl/certs' -key_dir = '/etc/ssl/private' -certbot_dir = vyos.defaults.directories['certbot'] - -# https config needs to coordinate several subsystems: api, certbot, -# self-signed certificate, as well as the virtual hosts defined within the -# https config definition itself. Consequently, one needs a general dict, -# encompassing the https and other configs, and a list of such virtual hosts -# (server blocks in nginx terminology) to pass to the jinja2 template. -default_server_block = { - 'id' : '', - 'address' : '*', - 'port' : '443', - 'name' : ['_'], - 'api' : {}, - 'vyos_cert' : {}, - 'certbot' : False -} - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['service', 'https'] - if not conf.exists(base): - return None - - https = conf.get_config_dict(base, get_first_key=True) - - if https: - https['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - return https - -def verify(https): - if https is None: - return None - - if 'certificates' in https: - certificates = https['certificates'] - - if 'certificate' in certificates: - if not https['pki']: - raise ConfigError("PKI is not configured") - - cert_name = certificates['certificate'] - - if cert_name not in https['pki']['certificate']: - raise ConfigError("Invalid certificate on https configuration") - - pki_cert = https['pki']['certificate'][cert_name] - - if 'certificate' not in pki_cert: - raise ConfigError("Missing certificate on https configuration") - - if 'private' not in pki_cert or 'key' not in pki_cert['private']: - raise ConfigError("Missing certificate private key on https configuration") - - if 'certbot' in https['certificates']: - vhost_names = [] - for vh, vh_conf in https.get('virtual-host', {}).items(): - vhost_names += vh_conf.get('server-name', []) - domains = https['certificates']['certbot'].get('domain-name', []) - domains_found = [domain for domain in domains if domain in vhost_names] - if not domains_found: - raise ConfigError("At least one 'virtual-host <id> server-name' " - "matching the 'certbot domain-name' is required.") - - server_block_list = [] - - # organize by vhosts - vhost_dict = https.get('virtual-host', {}) - - if not vhost_dict: - # no specified virtual hosts (server blocks); use default - server_block_list.append(default_server_block) - else: - for vhost in list(vhost_dict): - server_block = deepcopy(default_server_block) - data = vhost_dict.get(vhost, {}) - server_block['address'] = data.get('listen-address', '*') - server_block['port'] = data.get('listen-port', '443') - server_block_list.append(server_block) - - for entry in server_block_list: - _address = entry.get('address') - _address = '0.0.0.0' if _address == '*' else _address - _port = entry.get('port') - proto = 'tcp' - if check_port_availability(_address, int(_port), proto) is not True and \ - not is_listen_port_bind_service(int(_port), 'nginx'): - raise ConfigError(f'"{proto}" port "{_port}" is used by another service') - - verify_vrf(https) - return None - -def generate(https): - if https is None: - return None - - server_block_list = [] - - # organize by vhosts - - vhost_dict = https.get('virtual-host', {}) - - if not vhost_dict: - # no specified virtual hosts (server blocks); use default - server_block_list.append(default_server_block) - else: - for vhost in list(vhost_dict): - server_block = deepcopy(default_server_block) - server_block['id'] = vhost - data = vhost_dict.get(vhost, {}) - server_block['address'] = data.get('listen-address', '*') - server_block['port'] = data.get('listen-port', '443') - name = data.get('server-name', ['_']) - server_block['name'] = name - allow_client = data.get('allow-client', {}) - server_block['allow_client'] = allow_client.get('address', []) - server_block_list.append(server_block) - - # get certificate data - - cert_dict = https.get('certificates', {}) - - if 'certificate' in cert_dict: - cert_name = cert_dict['certificate'] - pki_cert = https['pki']['certificate'][cert_name] - - cert_path = os.path.join(cert_dir, f'{cert_name}.pem') - key_path = os.path.join(key_dir, f'{cert_name}.pem') - - server_cert = str(wrap_certificate(pki_cert['certificate'])) - if 'ca-certificate' in cert_dict: - ca_cert = cert_dict['ca-certificate'] - server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate'])) - - write_file(cert_path, server_cert) - write_file(key_path, wrap_private_key(pki_cert['private']['key'])) - - vyos_cert_data = { - 'crt': cert_path, - 'key': key_path - } - - for block in server_block_list: - block['vyos_cert'] = vyos_cert_data - - # letsencrypt certificate using certbot - - certbot = False - cert_domains = cert_dict.get('certbot', {}).get('domain-name', []) - if cert_domains: - certbot = True - for domain in cert_domains: - sub_list = vyos.certbot_util.choose_server_block(server_block_list, - domain) - if sub_list: - for sb in sub_list: - sb['certbot'] = True - sb['certbot_dir'] = certbot_dir - # certbot organizes certificates by first domain - sb['certbot_domain_dir'] = cert_domains[0] - - # get api data - - api_set = False - api_data = {} - if 'api' in list(https): - api_set = True - api_data = vyos.defaults.api_data - api_settings = https.get('api', {}) - if api_settings: - port = api_settings.get('port', '') - if port: - api_data['port'] = port - vhosts = https.get('api-restrict', {}).get('virtual-host', []) - if vhosts: - api_data['vhost'] = vhosts[:] - if 'socket' in list(api_settings): - api_data['socket'] = True - - if api_data: - vhost_list = api_data.get('vhost', []) - if not vhost_list: - for block in server_block_list: - block['api'] = api_data - else: - for block in server_block_list: - if block['id'] in vhost_list: - block['api'] = api_data - - if 'server_block_list' not in https or not https['server_block_list']: - https['server_block_list'] = [default_server_block] - - data = { - 'server_block_list': server_block_list, - 'api_set': api_set, - 'certbot': certbot - } - - render(config_file, 'https/nginx.default.j2', data) - render(systemd_override, 'https/override.conf.j2', https) - return None - -def apply(https): - # Reload systemd manager configuration - call('systemctl daemon-reload') - if https is not None: - call('systemctl restart nginx.service') - else: - call('systemctl stop nginx.service') - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py deleted file mode 100755 index f3e65ad5e..000000000 --- a/src/conf_mode/interfaces-ethernet.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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/>. - -import os - -from glob import glob -from sys import exit - -from vyos.base import Warning -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import is_node_changed -from vyos.configverify import verify_address -from vyos.configverify import verify_dhcpv6 -from vyos.configverify import verify_eapol -from vyos.configverify import verify_interface_exists -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_mtu -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_vlan_config -from vyos.configverify import verify_vrf -from vyos.configverify import verify_bond_bridge_member -from vyos.ethtool import Ethtool -from vyos.ifconfig import EthernetIf -from vyos.pki import find_chain -from vyos.pki import encode_certificate -from vyos.pki import load_certificate -from vyos.pki import wrap_private_key -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.dict import dict_search -from vyos.utils.file import write_file -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -# XXX: wpa_supplicant works on the source interface -cfg_dir = '/run/wpa_supplicant' -wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf' - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - - # This must be called prior to get_interface_dict(), as this function will - # alter the config level (config.set_level()) - pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - base = ['interfaces', 'ethernet'] - ifname, ethernet = get_interface_dict(conf, base) - - if 'deleted' not in ethernet: - if pki: ethernet['pki'] = pki - - tmp = is_node_changed(conf, base + [ifname, 'speed']) - if tmp: ethernet.update({'speed_duplex_changed': {}}) - - tmp = is_node_changed(conf, base + [ifname, 'duplex']) - if tmp: ethernet.update({'speed_duplex_changed': {}}) - - return ethernet - -def verify(ethernet): - if 'deleted' in ethernet: - return None - - ifname = ethernet['ifname'] - verify_interface_exists(ifname) - verify_mtu(ethernet) - verify_mtu_ipv6(ethernet) - verify_dhcpv6(ethernet) - verify_address(ethernet) - verify_vrf(ethernet) - verify_bond_bridge_member(ethernet) - verify_eapol(ethernet) - verify_mirror_redirect(ethernet) - - ethtool = Ethtool(ifname) - # No need to check speed and duplex keys as both have default values. - if ((ethernet['speed'] == 'auto' and ethernet['duplex'] != 'auto') or - (ethernet['speed'] != 'auto' and ethernet['duplex'] == 'auto')): - raise ConfigError('Speed/Duplex missmatch. Must be both auto or manually configured') - - if ethernet['speed'] != 'auto' and ethernet['duplex'] != 'auto': - # We need to verify if the requested speed and duplex setting is - # supported by the underlaying NIC. - speed = ethernet['speed'] - duplex = ethernet['duplex'] - if not ethtool.check_speed_duplex(speed, duplex): - raise ConfigError(f'Adapter does not support changing speed and duplex '\ - f'settings to: {speed}/{duplex}!') - - if 'disable_flow_control' in ethernet: - if not ethtool.check_flow_control(): - raise ConfigError('Adapter does not support changing flow-control settings!') - - if 'ring_buffer' in ethernet: - max_rx = ethtool.get_ring_buffer_max('rx') - if not max_rx: - raise ConfigError('Driver does not support RX ring-buffer configuration!') - - max_tx = ethtool.get_ring_buffer_max('tx') - if not max_tx: - raise ConfigError('Driver does not support TX ring-buffer configuration!') - - rx = dict_search('ring_buffer.rx', ethernet) - if rx and int(rx) > int(max_rx): - raise ConfigError(f'Driver only supports a maximum RX ring-buffer '\ - f'size of "{max_rx}" bytes!') - - tx = dict_search('ring_buffer.tx', ethernet) - if tx and int(tx) > int(max_tx): - raise ConfigError(f'Driver only supports a maximum TX ring-buffer '\ - f'size of "{max_tx}" bytes!') - - # verify offloading capabilities - if dict_search('offload.rps', ethernet) != None: - if not os.path.exists(f'/sys/class/net/{ifname}/queues/rx-0/rps_cpus'): - raise ConfigError('Interface does not suport RPS!') - - driver = ethtool.get_driver_name() - # T3342 - Xen driver requires special treatment - if driver == 'vif': - if int(ethernet['mtu']) > 1500 and dict_search('offload.sg', ethernet) == None: - raise ConfigError('Xen netback drivers requires scatter-gatter offloading '\ - 'for MTU size larger then 1500 bytes') - - if {'is_bond_member', 'mac'} <= set(ethernet): - Warning(f'changing mac address "{mac}" will be ignored as "{ifname}" ' \ - f'is a member of bond "{is_bond_member}"'.format(**ethernet)) - - # use common function to verify VLAN configuration - verify_vlan_config(ethernet) - return None - -def generate(ethernet): - # render real configuration file once - wpa_supplicant_conf = wpa_suppl_conf.format(**ethernet) - - if 'deleted' in ethernet: - # delete configuration on interface removal - if os.path.isfile(wpa_supplicant_conf): - os.unlink(wpa_supplicant_conf) - return None - - if 'eapol' in ethernet: - ifname = ethernet['ifname'] - - render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', ethernet) - - cert_file_path = os.path.join(cfg_dir, f'{ifname}_cert.pem') - cert_key_path = os.path.join(cfg_dir, f'{ifname}_cert.key') - - cert_name = ethernet['eapol']['certificate'] - pki_cert = ethernet['pki']['certificate'][cert_name] - - loaded_pki_cert = load_certificate(pki_cert['certificate']) - loaded_ca_certs = {load_certificate(c['certificate']) - for c in ethernet['pki']['ca'].values()} if 'ca' in ethernet['pki'] else {} - - cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) - - write_file(cert_file_path, - '\n'.join(encode_certificate(c) for c in cert_full_chain)) - write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) - - if 'ca_certificate' in ethernet['eapol']: - ca_cert_file_path = os.path.join(cfg_dir, f'{ifname}_ca.pem') - ca_chains = [] - - for ca_cert_name in ethernet['eapol']['ca_certificate']: - pki_ca_cert = ethernet['pki']['ca'][ca_cert_name] - loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) - ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) - ca_chains.append('\n'.join(encode_certificate(c) for c in ca_full_chain)) - - write_file(ca_cert_file_path, '\n'.join(ca_chains)) - - return None - -def apply(ethernet): - ifname = ethernet['ifname'] - # take care about EAPoL supplicant daemon - eapol_action='stop' - - e = EthernetIf(ifname) - if 'deleted' in ethernet: - # delete interface - e.remove() - else: - e.update(ethernet) - if 'eapol' in ethernet: - eapol_action='reload-or-restart' - - call(f'systemctl {eapol_action} wpa_supplicant-wired@{ifname}') - -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/interfaces-bonding.py b/src/conf_mode/interfaces_bonding.py index 0bd306ed0..8184d8415 100755 --- a/src/conf_mode/interfaces-bonding.py +++ b/src/conf_mode/interfaces_bonding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2022 VyOS maintainers and contributors +# Copyright (C) 2019-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -18,7 +18,6 @@ import os from sys import exit from netifaces import interfaces - from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed @@ -34,11 +33,16 @@ from vyos.configverify import verify_source_interface from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf from vyos.ifconfig import BondIf +from vyos.ifconfig.ethernet import EthernetIf from vyos.ifconfig import Section +from vyos.template import render_to_string from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_to_paths_values from vyos.configdict import has_address_configured from vyos.configdict import has_vrf_configured +from vyos.configdep import set_dependents, call_dependents from vyos import ConfigError +from vyos import frr from vyos import airbag airbag.enable() @@ -90,7 +94,6 @@ def get_config(config=None): # determine which members have been removed interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface']) - # Reset config level to interfaces old_level = conf.get_level() conf.set_level(['interfaces']) @@ -102,6 +105,10 @@ def get_config(config=None): tmp = {} for interface in interfaces_removed: + # if member is deleted from bond, add dependencies to call + # ethernet commit again in apply function + # to apply options under ethernet section + set_dependents('ethernet', conf, interface) section = Section.section(interface) # this will be 'ethernet' for 'eth0' if conf.exists([section, interface, 'disable']): tmp[interface] = {'disable': ''} @@ -116,9 +123,21 @@ def get_config(config=None): if dict_search('member.interface', bond): for interface, interface_config in bond['member']['interface'].items(): + + interface_ethernet_config = conf.get_config_dict( + ['interfaces', 'ethernet', interface], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=False, + with_recursive_defaults=False) + + interface_config['config_paths'] = dict_to_paths_values(interface_ethernet_config) + # Check if member interface is a new member if not conf.exists_effective(base + [ifname, 'member', 'interface', interface]): bond['shutdown_required'] = {} + interface_config['new_added'] = {} # Check if member interface is disabled conf.set_level(['interfaces']) @@ -151,7 +170,6 @@ def get_config(config=None): # bond members must not have a VRF attached tmp = has_vrf_configured(conf, interface) if tmp: interface_config['has_vrf'] = {} - return bond @@ -212,6 +230,14 @@ def verify(bond): if 'has_vrf' in interface_config: raise ConfigError(error_msg + 'it has a VRF assigned!') + if 'new_added' in interface_config and 'config_paths' in interface_config: + for option_path, option_value in interface_config['config_paths'].items(): + if option_path in EthernetIf.get_bond_member_allowed_options() : + continue + if option_path in BondIf.get_inherit_bond_options(): + continue + raise ConfigError(error_msg + f'it has a "{option_path.replace(".", " ")}" assigned!') + if 'primary' in bond: if bond['primary'] not in bond['member']['interface']: raise ConfigError(f'Primary interface of bond "{bond_name}" must be a member interface') @@ -223,17 +249,38 @@ def verify(bond): return None def generate(bond): + bond['frr_zebra_config'] = '' + if 'deleted' not in bond: + bond['frr_zebra_config'] = render_to_string('frr/evpn.mh.frr.j2', bond) return None def apply(bond): - b = BondIf(bond['ifname']) - + ifname = bond['ifname'] + b = BondIf(ifname) if 'deleted' in bond: # delete interface b.remove() else: b.update(bond) + if dict_search('member.interface_remove', bond): + try: + call_dependents() + except ConfigError: + raise ConfigError('Error in updating ethernet interface ' + 'after deleting it from bond') + + 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(f'^interface {ifname}', stop_pattern='^exit', remove_stop_mark=True) + if 'frr_zebra_config' in bond: + frr_cfg.add_before(frr.default_add_before, bond['frr_zebra_config']) + frr_cfg.commit_configuration(zebra_daemon) + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces_bridge.py index c82f01e53..29991e2da 100755 --- a/src/conf_mode/interfaces-bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# Copyright (C) 2019-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -28,7 +28,8 @@ from vyos.configverify import verify_vrf from vyos.ifconfig import BridgeIf from vyos.configdict import has_address_configured from vyos.configdict import has_vrf_configured - +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.utils.dict import dict_search from vyos import ConfigError @@ -48,7 +49,7 @@ def get_config(config=None): ifname, bridge = get_interface_dict(conf, base) # determine which members have been removed - tmp = node_changed(conf, base + [ifname, 'member', 'interface'], key_mangling=('-', '_')) + tmp = node_changed(conf, base + [ifname, 'member', 'interface']) if tmp: if 'member' in bridge: bridge['member'].update({'interface_remove' : tmp }) @@ -83,6 +84,12 @@ def get_config(config=None): if 'enable_vlan' in bridge and tmp: bridge['member']['interface'][interface].update({'has_vlan' : ''}) + # When using VXLAN member interfaces that are configured for Single + # VXLAN Device (SVD) we need to call the VXLAN conf-mode script to re-create + # VLAN to VNI mappings if required + if interface.startswith('vxlan'): + set_dependents('vxlan', conf, interface) + # delete empty dictionary keys - no need to run code paths if nothing is there to do if 'member' in bridge: if 'interface' in bridge['member'] and len(bridge['member']['interface']) == 0: @@ -159,6 +166,13 @@ def apply(bridge): else: br.update(bridge) + for interface in dict_search('member.interface', bridge) or []: + if interface.startswith('vxlan'): + try: + call_dependents() + except ConfigError: + raise ConfigError('Error in updating VXLAN interface after changing bridge!') + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces-dummy.py b/src/conf_mode/interfaces_dummy.py index db768b94d..db768b94d 100755 --- a/src/conf_mode/interfaces-dummy.py +++ b/src/conf_mode/interfaces_dummy.py diff --git a/src/conf_mode/interfaces_ethernet.py b/src/conf_mode/interfaces_ethernet.py new file mode 100755 index 000000000..2c0f846c3 --- /dev/null +++ b/src/conf_mode/interfaces_ethernet.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import pprint + +from glob import glob +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configverify import verify_address +from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_eapol +from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_mtu +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_vlan_config +from vyos.configverify import verify_vrf +from vyos.configverify import verify_bond_bridge_member +from vyos.ethtool import Ethtool +from vyos.ifconfig import EthernetIf +from vyos.ifconfig import BondIf +from vyos.pki import find_chain +from vyos.pki import encode_certificate +from vyos.pki import load_certificate +from vyos.pki import wrap_private_key +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_to_paths_values +from vyos.utils.dict import dict_set +from vyos.utils.dict import dict_delete +from vyos.utils.file import write_file +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +# XXX: wpa_supplicant works on the source interface +cfg_dir = '/run/wpa_supplicant' +wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf' + +def update_bond_options(conf: Config, eth_conf: dict) -> list: + """ + Return list of blocked options if interface is a bond member + :param conf: Config object + :type conf: Config + :param eth_conf: Ethernet config dictionary + :type eth_conf: dict + :return: List of blocked options + :rtype: list + """ + blocked_list = [] + bond_name = list(eth_conf['is_bond_member'].keys())[0] + config_without_defaults = conf.get_config_dict( + ['interfaces', 'ethernet', eth_conf['ifname']], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=False, + with_recursive_defaults=False) + config_with_defaults = conf.get_config_dict( + ['interfaces', 'ethernet', eth_conf['ifname']], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True) + bond_config_with_defaults = conf.get_config_dict( + ['interfaces', 'bonding', bond_name], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True) + eth_dict_paths = dict_to_paths_values(config_without_defaults) + eth_path_base = ['interfaces', 'ethernet', eth_conf['ifname']] + + #if option is configured under ethernet section + for option_path, option_value in eth_dict_paths.items(): + bond_option_value = dict_search(option_path, bond_config_with_defaults) + + #If option is allowed for changing then continue + if option_path in EthernetIf.get_bond_member_allowed_options(): + continue + # if option is inherited from bond then set valued from bond interface + if option_path in BondIf.get_inherit_bond_options(): + # If option equals to bond option then do nothing + if option_value == bond_option_value: + continue + else: + # if ethernet has option and bond interface has + # then copy it from bond + if bond_option_value is not None: + if is_node_changed(conf, eth_path_base + option_path.split('.')): + Warning( + f'Cannot apply "{option_path.replace(".", " ")}" to "{option_value}".' \ + f' Interface "{eth_conf["ifname"]}" is a bond member.' \ + f' Option is inherited from bond "{bond_name}"') + dict_set(option_path, bond_option_value, eth_conf) + continue + # if ethernet has option and bond interface does not have + # then delete it form dict and do not apply it + else: + if is_node_changed(conf, eth_path_base + option_path.split('.')): + Warning( + f'Cannot apply "{option_path.replace(".", " ")}".' \ + f' Interface "{eth_conf["ifname"]}" is a bond member.' \ + f' Option is inherited from bond "{bond_name}"') + dict_delete(option_path, eth_conf) + blocked_list.append(option_path) + + # if inherited option is not configured under ethernet section but configured under bond section + for option_path in BondIf.get_inherit_bond_options(): + bond_option_value = dict_search(option_path, bond_config_with_defaults) + if bond_option_value is not None: + if option_path not in eth_dict_paths: + if is_node_changed(conf, eth_path_base + option_path.split('.')): + Warning( + f'Cannot apply "{option_path.replace(".", " ")}" to "{dict_search(option_path, config_with_defaults)}".' \ + f' Interface "{eth_conf["ifname"]}" is a bond member. ' \ + f'Option is inherited from bond "{bond_name}"') + dict_set(option_path, bond_option_value, eth_conf) + eth_conf['bond_blocked_changes'] = blocked_list + return None + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'ethernet'] + ifname, ethernet = get_interface_dict(conf, base, with_pki=True) + + if 'is_bond_member' in ethernet: + update_bond_options(conf, ethernet) + + tmp = is_node_changed(conf, base + [ifname, 'speed']) + if tmp: ethernet.update({'speed_duplex_changed': {}}) + + tmp = is_node_changed(conf, base + [ifname, 'duplex']) + if tmp: ethernet.update({'speed_duplex_changed': {}}) + + return ethernet + +def verify_speed_duplex(ethernet: dict, ethtool: Ethtool): + """ + Verify speed and duplex + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if ((ethernet['speed'] == 'auto' and ethernet['duplex'] != 'auto') or + (ethernet['speed'] != 'auto' and ethernet['duplex'] == 'auto')): + raise ConfigError( + 'Speed/Duplex missmatch. Must be both auto or manually configured') + + if ethernet['speed'] != 'auto' and ethernet['duplex'] != 'auto': + # We need to verify if the requested speed and duplex setting is + # supported by the underlaying NIC. + speed = ethernet['speed'] + duplex = ethernet['duplex'] + if not ethtool.check_speed_duplex(speed, duplex): + raise ConfigError( + f'Adapter does not support changing speed ' \ + f'and duplex settings to: {speed}/{duplex}!') + + +def verify_flow_control(ethernet: dict, ethtool: Ethtool): + """ + Verify flow control + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if 'disable_flow_control' in ethernet: + if not ethtool.check_flow_control(): + raise ConfigError( + 'Adapter does not support changing flow-control settings!') + + +def verify_ring_buffer(ethernet: dict, ethtool: Ethtool): + """ + Verify ring buffer + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if 'ring_buffer' in ethernet: + max_rx = ethtool.get_ring_buffer_max('rx') + if not max_rx: + raise ConfigError( + 'Driver does not support RX ring-buffer configuration!') + + max_tx = ethtool.get_ring_buffer_max('tx') + if not max_tx: + raise ConfigError( + 'Driver does not support TX ring-buffer configuration!') + + rx = dict_search('ring_buffer.rx', ethernet) + if rx and int(rx) > int(max_rx): + raise ConfigError(f'Driver only supports a maximum RX ring-buffer ' \ + f'size of "{max_rx}" bytes!') + + tx = dict_search('ring_buffer.tx', ethernet) + if tx and int(tx) > int(max_tx): + raise ConfigError(f'Driver only supports a maximum TX ring-buffer ' \ + f'size of "{max_tx}" bytes!') + + +def verify_offload(ethernet: dict, ethtool: Ethtool): + """ + Verify offloading capabilities + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if dict_search('offload.rps', ethernet) != None: + if not os.path.exists(f'/sys/class/net/{ethernet["ifname"]}/queues/rx-0/rps_cpus'): + raise ConfigError('Interface does not suport RPS!') + driver = ethtool.get_driver_name() + # T3342 - Xen driver requires special treatment + if driver == 'vif': + if int(ethernet['mtu']) > 1500 and dict_search('offload.sg', ethernet) == None: + raise ConfigError('Xen netback drivers requires scatter-gatter offloading '\ + 'for MTU size larger then 1500 bytes') + + +def verify_allowedbond_changes(ethernet: dict): + """ + Verify changed options if interface is in bonding + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + """ + if 'bond_blocked_changes' in ethernet: + for option in ethernet['bond_blocked_changes']: + raise ConfigError(f'Cannot configure "{option.replace(".", " ")}"' \ + f' on interface "{ethernet["ifname"]}".' \ + f' Interface is a bond member') + + +def verify(ethernet): + if 'deleted' in ethernet: + return None + if 'is_bond_member' in ethernet: + verify_bond_member(ethernet) + else: + verify_ethernet(ethernet) + + +def verify_bond_member(ethernet): + """ + Verification function for ethernet interface which is in bonding + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + """ + ifname = ethernet['ifname'] + verify_interface_exists(ifname) + verify_eapol(ethernet) + verify_mirror_redirect(ethernet) + ethtool = Ethtool(ifname) + verify_speed_duplex(ethernet, ethtool) + verify_flow_control(ethernet, ethtool) + verify_ring_buffer(ethernet, ethtool) + verify_offload(ethernet, ethtool) + verify_allowedbond_changes(ethernet) + +def verify_ethernet(ethernet): + """ + Verification function for simple ethernet interface + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + """ + ifname = ethernet['ifname'] + verify_interface_exists(ifname) + verify_mtu(ethernet) + verify_mtu_ipv6(ethernet) + verify_dhcpv6(ethernet) + verify_address(ethernet) + verify_vrf(ethernet) + verify_bond_bridge_member(ethernet) + verify_eapol(ethernet) + verify_mirror_redirect(ethernet) + ethtool = Ethtool(ifname) + # No need to check speed and duplex keys as both have default values. + verify_speed_duplex(ethernet, ethtool) + verify_flow_control(ethernet, ethtool) + verify_ring_buffer(ethernet, ethtool) + verify_offload(ethernet, ethtool) + # use common function to verify VLAN configuration + verify_vlan_config(ethernet) + return None + + +def generate(ethernet): + # render real configuration file once + wpa_supplicant_conf = wpa_suppl_conf.format(**ethernet) + + if 'deleted' in ethernet: + # delete configuration on interface removal + if os.path.isfile(wpa_supplicant_conf): + os.unlink(wpa_supplicant_conf) + return None + + if 'eapol' in ethernet: + ifname = ethernet['ifname'] + + render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', ethernet) + + cert_file_path = os.path.join(cfg_dir, f'{ifname}_cert.pem') + cert_key_path = os.path.join(cfg_dir, f'{ifname}_cert.key') + + cert_name = ethernet['eapol']['certificate'] + pki_cert = ethernet['pki']['certificate'][cert_name] + + loaded_pki_cert = load_certificate(pki_cert['certificate']) + loaded_ca_certs = {load_certificate(c['certificate']) + for c in ethernet['pki']['ca'].values()} if 'ca' in ethernet['pki'] else {} + + cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) + + write_file(cert_file_path, + '\n'.join(encode_certificate(c) for c in cert_full_chain)) + write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) + + if 'ca_certificate' in ethernet['eapol']: + ca_cert_file_path = os.path.join(cfg_dir, f'{ifname}_ca.pem') + ca_chains = [] + + for ca_cert_name in ethernet['eapol']['ca_certificate']: + pki_ca_cert = ethernet['pki']['ca'][ca_cert_name] + loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) + ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) + ca_chains.append( + '\n'.join(encode_certificate(c) for c in ca_full_chain)) + + write_file(ca_cert_file_path, '\n'.join(ca_chains)) + + return None + +def apply(ethernet): + ifname = ethernet['ifname'] + # take care about EAPoL supplicant daemon + eapol_action='stop' + + e = EthernetIf(ifname) + if 'deleted' in ethernet: + # delete interface + e.remove() + else: + e.update(ethernet) + if 'eapol' in ethernet: + eapol_action='reload-or-restart' + + call(f'systemctl {eapol_action} wpa_supplicant-wired@{ifname}') + +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/interfaces-geneve.py b/src/conf_mode/interfaces_geneve.py index f6694ddde..f6694ddde 100755 --- a/src/conf_mode/interfaces-geneve.py +++ b/src/conf_mode/interfaces_geneve.py diff --git a/src/conf_mode/interfaces-input.py b/src/conf_mode/interfaces_input.py index ad248843d..ad248843d 100755 --- a/src/conf_mode/interfaces-input.py +++ b/src/conf_mode/interfaces_input.py diff --git a/src/conf_mode/interfaces-l2tpv3.py b/src/conf_mode/interfaces_l2tpv3.py index e1db3206e..e1db3206e 100755 --- a/src/conf_mode/interfaces-l2tpv3.py +++ b/src/conf_mode/interfaces_l2tpv3.py diff --git a/src/conf_mode/interfaces-loopback.py b/src/conf_mode/interfaces_loopback.py index 08d34477a..08d34477a 100755 --- a/src/conf_mode/interfaces-loopback.py +++ b/src/conf_mode/interfaces_loopback.py diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces_macsec.py index 0a927ac88..0a927ac88 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces_macsec.py diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces_openvpn.py index bdeb44837..45569dd21 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright (C) 2019-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 @@ -89,16 +89,12 @@ def get_config(config=None): conf = Config() base = ['interfaces', 'openvpn'] - ifname, openvpn = get_interface_dict(conf, base) + ifname, openvpn = get_interface_dict(conf, base, with_pki=True) openvpn['auth_user_pass_file'] = '/run/openvpn/{ifname}.pw'.format(**openvpn) if 'deleted' in openvpn: return openvpn - openvpn['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - if is_node_changed(conf, base + [ifname, 'openvpn-option']): openvpn.update({'restart_required': {}}) if is_node_changed(conf, base + [ifname, 'enable-dco']): @@ -167,9 +163,10 @@ def verify_pki(openvpn): raise ConfigError(f'Invalid shared-secret on openvpn interface {interface}') # If PSK settings are correct, warn about its deprecation - DeprecationWarning("OpenVPN shared-secret support will be removed in future VyOS versions.\n\ - Please migrate your site-to-site tunnels to TLS.\n\ - You can use self-signed certificates with peer fingerprint verification, consult the documentation for details.") + DeprecationWarning('OpenVPN shared-secret support will be removed in future '\ + 'VyOS versions. Please migrate your site-to-site tunnels to '\ + 'TLS. You can use self-signed certificates with peer fingerprint '\ + 'verification, consult the documentation for details.') if tls: if (mode in ['server', 'client']) and ('ca_certificate' not in tls): @@ -729,4 +726,3 @@ if __name__ == '__main__': except ConfigError as e: print(e) exit(1) - diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces_pppoe.py index fca91253c..42f084309 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces_pppoe.py @@ -61,6 +61,12 @@ def get_config(config=None): # bail out early - no need to further process other nodes break + if 'deleted' not in pppoe: + # We always set the MRU value to the MTU size. This code path only re-creates + # the old behavior if MRU is not set on the CLI. + if 'mru' not in pppoe: + pppoe['mru'] = pppoe['mtu'] + return pppoe def verify(pppoe): @@ -77,6 +83,11 @@ def verify(pppoe): if {'connect_on_demand', 'vrf'} <= set(pppoe): raise ConfigError('On-demand dialing and VRF can not be used at the same time') + # both MTU and MRU have default values, thus we do not need to check + # if the key exists + if int(pppoe['mru']) > int(pppoe['mtu']): + raise ConfigError('PPPoE MRU needs to be lower then MTU!') + return None def generate(pppoe): diff --git a/src/conf_mode/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces_pseudo-ethernet.py index dce5c2358..dce5c2358 100755 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ b/src/conf_mode/interfaces_pseudo-ethernet.py diff --git a/src/conf_mode/interfaces-sstpc.py b/src/conf_mode/interfaces_sstpc.py index b588910dc..b9d7a74fb 100755 --- a/src/conf_mode/interfaces-sstpc.py +++ b/src/conf_mode/interfaces_sstpc.py @@ -45,7 +45,7 @@ def get_config(config=None): else: conf = Config() base = ['interfaces', 'sstpc'] - ifname, sstpc = get_interface_dict(conf, base) + ifname, sstpc = get_interface_dict(conf, base, with_pki=True) # We should only terminate the SSTP client session if critical parameters # change. All parameters that can be changed on-the-fly (like interface @@ -57,10 +57,6 @@ def get_config(config=None): # bail out early - no need to further process other nodes break - # Load PKI certificates for later processing - sstpc['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) return sstpc def verify(sstpc): diff --git a/src/conf_mode/interfaces-tunnel.py b/src/conf_mode/interfaces_tunnel.py index 91aed9cc3..efa5ebc64 100755 --- a/src/conf_mode/interfaces-tunnel.py +++ b/src/conf_mode/interfaces_tunnel.py @@ -24,7 +24,7 @@ from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_source_interface from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf @@ -166,7 +166,7 @@ def verify(tunnel): verify_mirror_redirect(tunnel) if 'source_interface' in tunnel: - verify_interface_exists(tunnel['source_interface']) + verify_source_interface(tunnel) # TTL != 0 and nopmtudisc are incompatible, parameters and ip use default # values, thus the keys are always present. diff --git a/src/conf_mode/interfaces-virtual-ethernet.py b/src/conf_mode/interfaces_virtual-ethernet.py index 8efe89c41..8efe89c41 100755 --- a/src/conf_mode/interfaces-virtual-ethernet.py +++ b/src/conf_mode/interfaces_virtual-ethernet.py diff --git a/src/conf_mode/interfaces-vti.py b/src/conf_mode/interfaces_vti.py index 9871810ae..9871810ae 100755 --- a/src/conf_mode/interfaces-vti.py +++ b/src/conf_mode/interfaces_vti.py diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces_vxlan.py index 05f68112a..4251e611b 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces_vxlan.py @@ -34,6 +34,7 @@ from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import Interface from vyos.ifconfig import VXLANIf from vyos.template import is_ipv6 +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() @@ -53,14 +54,20 @@ def get_config(config=None): # VXLAN interfaces are picky and require recreation if certain parameters # change. But a VXLAN interface should - of course - not be re-created if # it's description or IP address is adjusted. Feels somehow logic doesn't it? - for cli_option in ['parameters', 'external', 'gpe', 'group', 'port', 'remote', + for cli_option in ['parameters', 'gpe', 'group', 'port', 'remote', 'source-address', 'source-interface', 'vni']: if is_node_changed(conf, base + [ifname, cli_option]): vxlan.update({'rebuild_required': {}}) break + # When dealing with VNI filtering we need to know what VNI was actually removed, + # so build up a dict matching the vlan_to_vni structure but with removed values. tmp = node_changed(conf, base + [ifname, 'vlan-to-vni'], recursive=True) - if tmp: vxlan.update({'vlan_to_vni_removed': tmp}) + if tmp: + vxlan.update({'vlan_to_vni_removed': {}}) + for vlan in tmp: + vni = leaf_node_changed(conf, base + [ifname, 'vlan-to-vni', vlan, 'vni']) + vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}}) # We need to verify that no other VXLAN tunnel is configured when external # mode is in use - Linux Kernel limitation @@ -94,17 +101,34 @@ def verify(vxlan): if not any(tmp in ['group', 'remote', 'source_address', 'source_interface'] for tmp in vxlan): raise ConfigError('Group, remote, source-address or source-interface must be configured') - if 'vni' not in vxlan and 'external' not in vxlan: - raise ConfigError( - 'Must either configure VXLAN "vni" or use "external" CLI option!') - - if {'external', 'vni'} <= set(vxlan): - raise ConfigError('Can not specify both "external" and "VNI"!') - - if {'external', 'other_tunnels'} <= set(vxlan): - other_tunnels = ', '.join(vxlan['other_tunnels']) - raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\ - f'CLI option is used. Additional tunnels: {other_tunnels}') + if 'vni' not in vxlan and dict_search('parameters.external', vxlan) == None: + raise ConfigError('Must either configure VXLAN "vni" or use "external" CLI option!') + + if dict_search('parameters.external', vxlan) != None: + if 'vni' in vxlan: + raise ConfigError('Can not specify both "external" and "VNI"!') + + if 'other_tunnels' in vxlan: + # When multiple VXLAN interfaces are defined and "external" is used, + # all VXLAN interfaces need to have vni-filter enabled! + # See Linux Kernel commit f9c4bb0b245cee35ef66f75bf409c9573d934cf9 + other_vni_filter = False + for tunnel, tunnel_config in vxlan['other_tunnels'].items(): + if dict_search('parameters.vni_filter', tunnel_config) != None: + other_vni_filter = True + break + # eqivalent of the C foo ? 'a' : 'b' statement + vni_filter = True and (dict_search('parameters.vni_filter', vxlan) != None) or False + # If either one is enabled, so must be the other. Both can be off and both can be on + if (vni_filter and not other_vni_filter) or (not vni_filter and other_vni_filter): + raise ConfigError(f'Using multiple VXLAN interfaces with "external" '\ + 'requires all VXLAN interfaces to have "vni-filter" configured!') + + if not vni_filter and not other_vni_filter: + other_tunnels = ', '.join(vxlan['other_tunnels']) + raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\ + f'CLI option is used and "vni-filter" is unset. '\ + f'Additional tunnels: {other_tunnels}') if 'gpe' in vxlan and 'external' not in vxlan: raise ConfigError(f'VXLAN-GPE is only supported when "external" '\ @@ -164,10 +188,22 @@ def verify(vxlan): raise ConfigError(f'VNI "{vni}" is already assigned to a different VLAN!') vnis_used.append(vni) + if dict_search('parameters.neighbor_suppress', vxlan) != None: + if 'is_bridge_member' not in vxlan: + raise ConfigError('Neighbor suppression requires that VXLAN interface '\ + 'is member of a bridge interface!') + verify_mtu_ipv6(vxlan) verify_address(vxlan) verify_bond_bridge_member(vxlan) verify_mirror_redirect(vxlan) + + # We use a defaultValue for port, thus it's always safe to use + if vxlan['port'] == '8472': + Warning('Starting from VyOS 1.4, the default port for VXLAN '\ + 'has been changed to 4789. This matches the IANA assigned '\ + 'standard port number!') + return None def generate(vxlan): diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces_wireguard.py index 122d9589a..79e5d3f44 100755 --- a/src/conf_mode/interfaces-wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -51,17 +51,9 @@ def get_config(config=None): tmp = is_node_changed(conf, base + [ifname, 'port']) if tmp: wireguard['port_changed'] = {} - # Determine which Wireguard peer has been removed. - # Peers can only be removed with their public key! - if 'peer' in wireguard: - peer_remove = {} - for peer, peer_config in wireguard['peer'].items(): - # T4702: If anything on a peer changes we remove the peer first and re-add it - if is_node_changed(conf, base + [ifname, 'peer', peer]): - if 'public_key' in peer_config: - peer_remove = dict_merge({'peer_remove' : {peer : peer_config['public_key']}}, peer_remove) - if peer_remove: - wireguard.update(peer_remove) + # T4702: If anything on a peer changes we remove the peer first and re-add it + if is_node_changed(conf, base + [ifname, 'peer']): + wireguard.update({'rebuild_required': {}}) return wireguard @@ -113,12 +105,21 @@ def verify(wireguard): public_keys.append(peer['public_key']) def apply(wireguard): - tmp = WireGuardIf(wireguard['ifname']) - if 'deleted' in wireguard: - tmp.remove() - return None + if 'rebuild_required' in wireguard or 'deleted' in wireguard: + wg = WireGuardIf(**wireguard) + # WireGuard only supports peer removal based on the configured public-key, + # by deleting the entire interface this is the shortcut instead of parsing + # out all peers and removing them one by one. + # + # Peer reconfiguration will always come with a short downtime while the + # WireGuard interface is recreated (see below) + wg.remove() + + # Create the new interface if required + if 'deleted' not in wireguard: + wg = WireGuardIf(**wireguard) + wg.update(wireguard) - tmp.update(wireguard) return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces-wireless.py b/src/conf_mode/interfaces_wireless.py index 02b4a2500..02b4a2500 100755 --- a/src/conf_mode/interfaces-wireless.py +++ b/src/conf_mode/interfaces_wireless.py diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces_wwan.py index 2515dc838..2515dc838 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces_wwan.py diff --git a/src/conf_mode/le_cert.py b/src/conf_mode/le_cert.py deleted file mode 100755 index 06c7e7b72..000000000 --- a/src/conf_mode/le_cert.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2020 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 -import os - -import vyos.defaults -from vyos.config import Config -from vyos import ConfigError -from vyos.utils.process import cmd -from vyos.utils.process import call -from vyos.utils.process import is_systemd_service_running - -from vyos import airbag -airbag.enable() - -vyos_conf_scripts_dir = vyos.defaults.directories['conf_mode'] -vyos_certbot_dir = vyos.defaults.directories['certbot'] - -dependencies = [ - 'https.py', -] - -def request_certbot(cert): - email = cert.get('email') - if email is not None: - email_flag = '-m {0}'.format(email) - else: - email_flag = '' - - domains = cert.get('domains') - if domains is not None: - domain_flag = '-d ' + ' -d '.join(domains) - else: - domain_flag = '' - - certbot_cmd = f'certbot certonly --config-dir {vyos_certbot_dir} -n --nginx --agree-tos --no-eff-email --expand {email_flag} {domain_flag}' - - cmd(certbot_cmd, - raising=ConfigError, - message="The certbot request failed for the specified domains.") - -def get_config(): - conf = Config() - if not conf.exists('service https certificates certbot'): - return None - else: - conf.set_level('service https certificates certbot') - - cert = {} - - if conf.exists('domain-name'): - cert['domains'] = conf.return_values('domain-name') - - if conf.exists('email'): - cert['email'] = conf.return_value('email') - - return cert - -def verify(cert): - if cert is None: - return None - - if 'domains' not in cert: - raise ConfigError("At least one domain name is required to" - " request a letsencrypt certificate.") - - if 'email' not in cert: - raise ConfigError("An email address is required to request" - " a letsencrypt certificate.") - -def generate(cert): - if cert is None: - return None - - # certbot will attempt to reload nginx, even with 'certonly'; - # start nginx if not active - if not is_systemd_service_running('nginx.service'): - call('systemctl start nginx.service') - - request_certbot(cert) - -def apply(cert): - if cert is not None: - call('systemctl restart certbot.timer') - else: - call('systemctl stop certbot.timer') - return None - - for dep in dependencies: - cmd(f'{vyos_conf_scripts_dir}/{dep}', raising=ConfigError) - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) - diff --git a/src/conf_mode/load-balancing-haproxy.py b/src/conf_mode/load-balancing_reverse-proxy.py index 8fe429653..7338fe573 100755 --- a/src/conf_mode/load-balancing-haproxy.py +++ b/src/conf_mode/load-balancing_reverse-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright (C) 2023-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 @@ -43,17 +43,14 @@ def get_config(config=None): conf = Config() base = ['load-balancing', 'reverse-proxy'] + if not conf.exists(base): + return None lb = conf.get_config_dict(base, get_first_key=True, key_mangling=('-', '_'), - no_tag_node_value_mangle=True) - - if lb: - lb['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - if lb: - lb = conf.merge_defaults(lb, recursive=True) + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + with_pki=True) return lb @@ -94,8 +91,8 @@ def generate(lb): if os.path.isfile(file): os.unlink(file) # Delete old directories - #if os.path.isdir(load_balancing_dir): - # rmtree(load_balancing_dir, ignore_errors=True) + if os.path.isdir(load_balancing_dir): + rmtree(load_balancing_dir, ignore_errors=True) return None @@ -106,26 +103,26 @@ def generate(lb): # SSL Certificates for frontend for front, front_config in lb['service'].items(): if 'ssl' in front_config: - cert_file_path = os.path.join(load_balancing_dir, 'cert.pem') - cert_key_path = os.path.join(load_balancing_dir, 'cert.pem.key') - ca_cert_file_path = os.path.join(load_balancing_dir, 'ca.pem') if 'certificate' in front_config['ssl']: - #cert_file_path = os.path.join(load_balancing_dir, 'cert.pem') - #cert_key_path = os.path.join(load_balancing_dir, 'cert.key') - cert_name = front_config['ssl']['certificate'] - pki_cert = lb['pki']['certificate'][cert_name] + cert_names = front_config['ssl']['certificate'] + + for cert_name in cert_names: + pki_cert = lb['pki']['certificate'][cert_name] + cert_file_path = os.path.join(load_balancing_dir, f'{cert_name}.pem') + cert_key_path = os.path.join(load_balancing_dir, f'{cert_name}.pem.key') - with open(cert_file_path, 'w') as f: - f.write(wrap_certificate(pki_cert['certificate'])) + with open(cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_cert['certificate'])) - if 'private' in pki_cert and 'key' in pki_cert['private']: - with open(cert_key_path, 'w') as f: - f.write(wrap_private_key(pki_cert['private']['key'])) + if 'private' in pki_cert and 'key' in pki_cert['private']: + with open(cert_key_path, 'w') as f: + f.write(wrap_private_key(pki_cert['private']['key'])) if 'ca_certificate' in front_config['ssl']: ca_name = front_config['ssl']['ca_certificate'] pki_ca_cert = lb['pki']['ca'][ca_name] + ca_cert_file_path = os.path.join(load_balancing_dir, f'{ca_name}.pem') with open(ca_cert_file_path, 'w') as f: f.write(wrap_certificate(pki_ca_cert['certificate'])) @@ -133,11 +130,11 @@ def generate(lb): # SSL Certificates for backend for back, back_config in lb['backend'].items(): if 'ssl' in back_config: - ca_cert_file_path = os.path.join(load_balancing_dir, 'ca.pem') if 'ca_certificate' in back_config['ssl']: ca_name = back_config['ssl']['ca_certificate'] pki_ca_cert = lb['pki']['ca'][ca_name] + ca_cert_file_path = os.path.join(load_balancing_dir, f'{ca_name}.pem') with open(ca_cert_file_path, 'w') as f: f.write(wrap_certificate(pki_ca_cert['certificate'])) diff --git a/src/conf_mode/load-balancing-wan.py b/src/conf_mode/load-balancing_wan.py index 5da0b906b..5da0b906b 100755 --- a/src/conf_mode/load-balancing-wan.py +++ b/src/conf_mode/load-balancing_wan.py diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 52a7a71fd..26822b755 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -69,6 +69,10 @@ def get_config(config=None): nat['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + # Remove dynamic firewall groups if present: + if 'dynamic_group' in nat['firewall_group']: + del nat['firewall_group']['dynamic_group'] + return nat def verify_rule(config, err_msg, groups_dict): @@ -80,15 +84,8 @@ def verify_rule(config, err_msg, groups_dict): dict_search('source.port', config)): if config['protocol'] not in ['tcp', 'udp', 'tcp_udp']: - raise ConfigError(f'{err_msg}\n' \ - 'ports can only be specified when protocol is '\ - 'either tcp, udp or tcp_udp!') - - if is_ip_network(dict_search('translation.address', config)): - raise ConfigError(f'{err_msg}\n' \ - 'Cannot use ports with an IPv4 network as translation address as it\n' \ - 'statically maps a whole network of addresses onto another\n' \ - 'network of addresses') + raise ConfigError(f'{err_msg} ports can only be specified when '\ + 'protocol is either tcp, udp or tcp_udp!') for side in ['destination', 'source']: if side in config: @@ -151,8 +148,11 @@ def verify(nat): err_msg = f'Source NAT configuration error in rule {rule}:' if 'outbound_interface' in config: - if config['outbound_interface'] not in 'any' and config['outbound_interface'] not in interfaces(): - Warning(f'rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') + if 'name' in config['outbound_interface'] and 'group' in config['outbound_interface']: + raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for nat source rule "{rule}"') + elif 'name' in config['outbound_interface']: + if config['outbound_interface']['name'] not in 'any' and config['outbound_interface']['name'] not in interfaces(): + Warning(f'NAT interface "{config["outbound_interface"]["name"]}" for source NAT rule "{rule}" does not exist!') if not dict_search('translation.address', config) and not dict_search('translation.port', config): if 'exclude' not in config and 'backend' not in config['load_balance']: @@ -172,8 +172,11 @@ def verify(nat): err_msg = f'Destination NAT configuration error in rule {rule}:' if 'inbound_interface' in config: - if config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): - Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') + if 'name' in config['inbound_interface'] and 'group' in config['inbound_interface']: + raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for destination nat rule "{rule}"') + elif 'name' in config['inbound_interface']: + if config['inbound_interface']['name'] not in 'any' and config['inbound_interface']['name'] not in interfaces(): + Warning(f'NAT interface "{config["inbound_interface"]["name"]}" for destination NAT rule "{rule}" does not exist!') if not dict_search('translation.address', config) and not dict_search('translation.port', config) and 'redirect' not in config['translation']: if 'exclude' not in config and 'backend' not in config['load_balance']: @@ -187,8 +190,7 @@ def verify(nat): err_msg = f'Static NAT configuration error in rule {rule}:' if 'inbound_interface' not in config: - raise ConfigError(f'{err_msg}\n' \ - 'inbound-interface not specified') + raise ConfigError(f'{err_msg} inbound-interface not specified') # common rule verification verify_rule(config, err_msg, nat['firewall_group']) diff --git a/src/conf_mode/nat64.py b/src/conf_mode/nat64.py new file mode 100755 index 000000000..6026c61d0 --- /dev/null +++ b/src/conf_mode/nat64.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# pylint: disable=empty-docstring,missing-module-docstring + +import csv +import os +import re + +from ipaddress import IPv6Network +from json import dumps as json_write + +from vyos import ConfigError +from vyos import airbag +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configdict import is_node_changed +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file +from vyos.utils.kernel import check_kmod +from vyos.utils.process import cmd +from vyos.utils.process import run + +airbag.enable() + +INSTANCE_REGEX = re.compile(r"instance-(\d+)") +JOOL_CONFIG_DIR = "/run/jool" + + +def get_config(config: Config | None = None) -> None: + if config is None: + config = Config() + + base = ["nat64"] + nat64 = config.get_config_dict(base, key_mangling=("-", "_"), get_first_key=True) + + base_src = base + ["source", "rule"] + + # Load in existing instances so we can destroy any unknown + lines = cmd("jool instance display --csv").splitlines() + for _, instance, _ in csv.reader(lines): + match = INSTANCE_REGEX.fullmatch(instance) + if not match: + # FIXME: Instances that don't match should be ignored but WARN'ed to the user + continue + num = match.group(1) + + rules = nat64.setdefault("source", {}).setdefault("rule", {}) + # Mark it for deletion + if num not in rules: + rules[num] = {"deleted": True} + continue + + # If the user changes the mode, recreate the instance else Jool fails with: + # Jool error: Sorry; you can't change an instance's framework for now. + if is_node_changed(config, base_src + [f"instance-{num}", "mode"]): + rules[num]["recreate"] = True + + # If the user changes the pool6, recreate the instance else Jool fails with: + # Jool error: Sorry; you can't change a NAT64 instance's pool6 for now. + if dict_search("source.prefix", rules[num]) and is_node_changed( + config, + base_src + [num, "source", "prefix"], + ): + rules[num]["recreate"] = True + + return nat64 + + +def verify(nat64) -> None: + if not nat64: + # no need to verify the CLI as nat64 is going to be deactivated + return + + if dict_search("source.rule", nat64): + # Ensure only 1 netfilter instance per namespace + nf_rules = filter( + lambda i: "deleted" not in i and i.get('mode') == "netfilter", + nat64["source"]["rule"].values(), + ) + next(nf_rules, None) # Discard the first element + if next(nf_rules, None) is not None: + raise ConfigError( + "Jool permits only 1 NAT64 netfilter instance (per network namespace)" + ) + + for rule, instance in nat64["source"]["rule"].items(): + if "deleted" in instance: + continue + + # Verify that source.prefix is set and is a /96 + if not dict_search("source.prefix", instance): + raise ConfigError(f"Source NAT64 rule {rule} missing source prefix") + if IPv6Network(instance["source"]["prefix"]).prefixlen != 96: + raise ConfigError(f"Source NAT64 rule {rule} source prefix must be /96") + + pools = dict_search("translation.pool", instance) + if pools: + for num, pool in pools.items(): + if "address" not in pool: + raise ConfigError( + f"Source NAT64 rule {rule} translation pool " + f"{num} missing address/prefix" + ) + if "port" not in pool: + raise ConfigError( + f"Source NAT64 rule {rule} translation pool " + f"{num} missing port(-range)" + ) + + +def generate(nat64) -> None: + os.makedirs(JOOL_CONFIG_DIR, exist_ok=True) + + if dict_search("source.rule", nat64): + for rule, instance in nat64["source"]["rule"].items(): + if "deleted" in instance: + # Delete the unused instance file + os.unlink(os.path.join(JOOL_CONFIG_DIR, f"instance-{rule}.json")) + continue + + name = f"instance-{rule}" + config = { + "instance": name, + "framework": "netfilter", + "global": { + "pool6": instance["source"]["prefix"], + "manually-enabled": "disable" not in instance, + }, + # "bib": [], + } + + if "description" in instance: + config["comment"] = instance["description"] + + if dict_search("translation.pool", instance): + pool4 = [] + # mark + mark = '' + if dict_search("match.mark", instance): + mark = instance["match"]["mark"] + + for pool in instance["translation"]["pool"].values(): + if "disable" in pool: + continue + + protos = pool.get("protocol", {}).keys() or ("tcp", "udp", "icmp") + for proto in protos: + obj = { + "protocol": proto.upper(), + "prefix": pool["address"], + "port range": pool["port"], + } + if mark: + obj["mark"] = int(mark) + if "description" in pool: + obj["comment"] = pool["description"] + + pool4.append(obj) + + if pool4: + config["pool4"] = pool4 + + write_file(f'{JOOL_CONFIG_DIR}/{name}.json', json_write(config, indent=2)) + + +def apply(nat64) -> None: + if not nat64: + return + + if dict_search("source.rule", nat64): + # Deletions first to avoid conflicts + for rule, instance in nat64["source"]["rule"].items(): + if not any(k in instance for k in ("deleted", "recreate")): + continue + + ret = run(f"jool instance remove instance-{rule}") + if ret != 0: + raise ConfigError( + f"Failed to remove nat64 source rule {rule} (jool instance instance-{rule})" + ) + + # Now creations + for rule, instance in nat64["source"]["rule"].items(): + if "deleted" in instance: + continue + + name = f"instance-{rule}" + ret = run(f"jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json") + if ret != 0: + raise ConfigError(f"Failed to set jool instance {name}") + + +if __name__ == "__main__": + try: + check_kmod(["jool"]) + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index 46d796bc8..4c1ead258 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2021 VyOS maintainers and contributors +# Copyright (C) 2020-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -36,7 +36,6 @@ airbag.enable() k_mod = ['nft_nat', 'nft_chain_nat'] nftables_nat66_config = '/run/nftables_nat66.nft' -ndppd_config = '/run/ndppd/ndppd.conf' def get_config(config=None): if config: @@ -62,11 +61,13 @@ def verify(nat): if dict_search('source.rule', nat): for rule, config in dict_search('source.rule', nat).items(): err_msg = f'Source NAT66 configuration error in rule {rule}:' - if 'outbound_interface' not in config: - raise ConfigError(f'{err_msg} outbound-interface not specified') - if config['outbound_interface'] not in interfaces(): - raise ConfigError(f'rule "{rule}" interface "{config["outbound_interface"]}" does not exist on this system') + if 'outbound_interface' in config: + if 'name' in config['outbound_interface'] and 'group' in config['outbound_interface']: + raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for nat source rule "{rule}"') + elif 'name' in config['outbound_interface']: + if config['outbound_interface']['name'] not in 'any' and config['outbound_interface']['name'] not in interfaces(): + Warning(f'NAT66 interface "{config["outbound_interface"]["name"]}" for source NAT66 rule "{rule}" does not exist!') addr = dict_search('translation.address', config) if addr != None: @@ -85,12 +86,12 @@ def verify(nat): for rule, config in dict_search('destination.rule', nat).items(): err_msg = f'Destination NAT66 configuration error in rule {rule}:' - if 'inbound_interface' not in config: - raise ConfigError(f'{err_msg}\n' \ - 'inbound-interface not specified') - else: - if config['inbound_interface'] not in 'any' and config['inbound_interface'] not in interfaces(): - Warning(f'rule "{rule}" interface "{config["inbound_interface"]}" does not exist on this system') + if 'inbound_interface' in config: + if 'name' in config['inbound_interface'] and 'group' in config['inbound_interface']: + raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for destination nat rule "{rule}"') + elif 'name' in config['inbound_interface']: + if config['inbound_interface']['name'] not in 'any' and config['inbound_interface']['name'] not in interfaces(): + Warning(f'NAT66 interface "{config["inbound_interface"]["name"]}" for destination NAT66 rule "{rule}" does not exist!') return None @@ -99,7 +100,6 @@ def generate(nat): nat['first_install'] = True render(nftables_nat66_config, 'firewall/nftables-nat66.j2', nat, permission=0o755) - render(ndppd_config, 'ndppd/ndppd.conf.j2', nat, permission=0o755) return None def apply(nat): @@ -107,14 +107,6 @@ def apply(nat): return None cmd(f'nft -f {nftables_nat66_config}') - - if 'deleted' in nat or not dict_search('source.rule', nat): - cmd('systemctl stop ndppd') - if os.path.isfile(ndppd_config): - os.unlink(ndppd_config) - else: - cmd('systemctl restart ndppd') - call_dependents() return None diff --git a/src/conf_mode/netns.py b/src/conf_mode/netns.py index 95ab83dbc..7cee33bc6 100755 --- a/src/conf_mode/netns.py +++ b/src/conf_mode/netns.py @@ -77,8 +77,8 @@ def verify(netns): if 'netns_remove' in netns: for name, config in netns['netns_remove'].items(): if 'interface' in config: - raise ConfigError(f'Can not remove NETNS "{name}", it still has '\ - f'member interfaces!') + raise ConfigError(f'Can not remove network namespace "{name}", it '\ + f'still has member interfaces!') if 'name' in netns: for name, config in netns['name'].items(): @@ -87,7 +87,6 @@ def verify(netns): return None - def generate(netns): if not netns: return None diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 34ba2fe69..4be40e99e 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,59 +14,66 @@ # 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 argv from sys import exit from vyos.config import Config -from vyos.configdep import set_dependents, call_dependents +from vyos.config import config_dict_merge +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import node_changed +from vyos.configdiff import Diff +from vyos.defaults import directories from vyos.pki import is_ca_certificate from vyos.pki import load_certificate from vyos.pki import load_public_key from vyos.pki import load_private_key from vyos.pki import load_crl from vyos.pki import load_dh_parameters +from vyos.utils.boot import boot_configuration_complete +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 call +from vyos.utils.process import cmd +from vyos.utils.process import is_systemd_service_active from vyos import ConfigError from vyos import airbag airbag.enable() -# keys to recursively search for under specified path, script to call if update required +vyos_certbot_dir = directories['certbot'] + +# keys to recursively search for under specified path 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': ['ca_certificate'], 'path': ['interfaces', 'sstpc'], - 'script': '/usr/libexec/vyos/conf_mode/interfaces-sstpc.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' } ] @@ -82,6 +89,33 @@ sync_translate = { 'crypt_key': 'openvpn' } +def certbot_delete(certificate): + if not boot_configuration_complete(): + return + if os.path.exists(f'{vyos_certbot_dir}/renewal/{certificate}.conf'): + cmd(f'certbot delete --non-interactive --config-dir {vyos_certbot_dir} --cert-name {certificate}') + +def certbot_request(name: str, config: dict, dry_run: bool=True): + # We do not call certbot when booting the system - there is no need to do so and + # request new certificates during boot/image upgrade as the certbot configuration + # is stored persistent under /config - thus we do not open the door to transient + # errors + if not boot_configuration_complete(): + return + + domains = '--domains ' + ' --domains '.join(config['domain_name']) + tmp = f'certbot certonly --non-interactive --config-dir {vyos_certbot_dir} --cert-name {name} '\ + f'--standalone --agree-tos --no-eff-email --expand --server {config["url"]} '\ + f'--email {config["email"]} --key-type rsa --rsa-key-size {config["rsa_key_size"]} '\ + f'{domains}' + if 'listen_address' in config: + tmp += f' --http-01-address {config["listen_address"]}' + # verify() does not need to actually request a cert but only test for plausability + if dry_run: + tmp += ' --dry-run' + + cmd(tmp, raising=ConfigError, message=f'ACME certbot request failed for "{name}"!') + def get_config(config=None): if config: conf = config @@ -93,25 +127,60 @@ def get_config(config=None): get_first_key=True, no_tag_node_value_mangle=True) - pki['changed'] = {} - tmp = node_changed(conf, base + ['ca'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'ca' : tmp}) + if len(argv) > 1 and argv[1] == 'certbot_renew': + pki['certbot_renew'] = {} - tmp = node_changed(conf, base + ['certificate'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'certificate' : tmp}) + tmp = node_changed(conf, base + ['ca'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'ca' : tmp}) - tmp = node_changed(conf, base + ['dh'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'dh' : tmp}) + tmp = node_changed(conf, base + ['certificate'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'certificate' : 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 + ['dh'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'dh' : tmp}) - tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], key_mangling=('-', '_'), recursive=True) - if tmp: pki['changed'].update({'openvpn' : tmp}) + tmp = node_changed(conf, base + ['key-pair'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'key_pair' : tmp}) + + tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], recursive=True) + if tmp: + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'openvpn' : tmp}) # We only merge on the defaults of there is a configuration at all if conf.exists(base): - pki = conf.merge_defaults(pki, recursive=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 = conf.get_config_defaults(**pki.kwargs, recursive=True) + # remove ACME default configuration if unused by CLI + if 'certificate' in pki: + for name, cert_config in pki['certificate'].items(): + if 'acme' not in cert_config: + # Remove ACME default values + del default_values['certificate'][name]['acme'] + + # merge CLI and default dictionary + pki = config_dict_merge(default_values, pki) + + # Certbot triggered an external renew of the certificates. + # Mark all ACME based certificates as "changed" to trigger + # update of dependent services + if 'certificate' in pki and 'certbot_renew' in pki: + renew = [] + for name, cert_config in pki['certificate'].items(): + if 'acme' in cert_config: + renew.append(name) + # If triggered externally by certbot, certificate key is not present in changed + if 'changed' not in pki: pki.update({'changed':{}}) + pki['changed'].update({'certificate' : renew}) # We need to get the entire system configuration to verify that we are not # deleting a certificate that is still referenced somewhere! @@ -119,38 +188,34 @@ def get_config(config=None): get_first_key=True, no_tag_node_value_mangle=True) - 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 = search['path'] - path_str = ' '.join(path + found_path) - print(f'pki: Updating config: {path_str} {found_name}') - - if path[0] == 'interfaces': - ifname = found_path[0] - set_dependents(path[1], conf, ifname) - else: - set_dependents(path[1], conf) + for search in sync_search: + for key in search['keys']: + changed_key = sync_translate[key] + if 'changed' not in pki or 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 = search['path'] + path_str = ' '.join(path + found_path) + print(f'PKI: Updating config: {path_str} {found_name}') + + if path[0] == 'interfaces': + ifname = found_path[0] + set_dependents(path[1], conf, ifname) + else: + set_dependents(path[1], conf) return pki @@ -223,6 +288,22 @@ def verify(pki): if not is_valid_private_key(private['key'], protected): raise ConfigError(f'Invalid private key on certificate "{name}"') + if 'acme' in cert_conf: + if 'domain_name' not in cert_conf['acme']: + raise ConfigError(f'At least one domain-name is required to request '\ + f'certificate for "{name}" via ACME!') + + if 'email' not in cert_conf['acme']: + raise ConfigError(f'An email address is required to request '\ + f'certificate for "{name}" via ACME!') + + if 'certbot_renew' not in pki: + # Only run the ACME command if something on this entity changed, + # as this is time intensive + tmp = dict_search('changed.certificate', pki) + if tmp != None and name in tmp: + certbot_request(name, cert_conf['acme']) + if 'dh' in pki: for name, dh_conf in pki['dh'].items(): if 'parameters' in dh_conf: @@ -283,12 +364,58 @@ def generate(pki): if not pki: return None + # Certbot renewal only needs to re-trigger the services to load up the + # new PEM file + if 'certbot_renew' in pki: + return None + + certbot_list = [] + certbot_list_on_disk = [] + if os.path.exists(f'{vyos_certbot_dir}/live'): + certbot_list_on_disk = [f.path.split('/')[-1] for f in os.scandir(f'{vyos_certbot_dir}/live') if f.is_dir()] + + if 'certificate' in pki: + changed_certificates = dict_search('changed.certificate', pki) + for name, cert_conf in pki['certificate'].items(): + if 'acme' in cert_conf: + certbot_list.append(name) + # generate certificate if not found on disk + if name not in certbot_list_on_disk: + certbot_request(name, cert_conf['acme'], dry_run=False) + elif changed_certificates != None and name in changed_certificates: + # when something for the certificate changed, we should delete it + if name in certbot_list_on_disk: + certbot_delete(name) + certbot_request(name, cert_conf['acme'], dry_run=False) + + # Cleanup certbot configuration and certificates if no longer in use by CLI + # Get foldernames under vyos_certbot_dir which each represent a certbot cert + if os.path.exists(f'{vyos_certbot_dir}/live'): + for cert in certbot_list_on_disk: + if cert not in certbot_list: + # certificate is no longer active on the CLI - remove it + certbot_delete(cert) + return None def apply(pki): + systemd_certbot_name = 'certbot.timer' if not pki: + call(f'systemctl stop {systemd_certbot_name}') return None + has_certbot = False + if 'certificate' in pki: + for name, cert_conf in pki['certificate'].items(): + if 'acme' in cert_conf: + has_certbot = True + break + + if not has_certbot: + call(f'systemctl stop {systemd_certbot_name}') + elif has_certbot and not is_systemd_service_active(systemd_certbot_name): + call(f'systemctl restart {systemd_certbot_name}') + if 'changed' in pki: call_dependents() diff --git a/src/conf_mode/policy-local-route.py b/src/conf_mode/policy_local-route.py index 2e8aabb80..91e4fce2c 100755 --- a/src/conf_mode/policy-local-route.py +++ b/src/conf_mode/policy_local-route.py @@ -52,19 +52,28 @@ def get_config(config=None): if tmp: for rule in (tmp or []): src = leaf_node_changed(conf, base_rule + [rule, 'source', 'address']) + src_port = leaf_node_changed(conf, base_rule + [rule, 'source', 'port']) fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address']) + dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port']) + table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table']) proto = leaf_node_changed(conf, base_rule + [rule, 'protocol']) rule_def = {} if src: rule_def = dict_merge({'source': {'address': src}}, rule_def) + if src_port: + rule_def = dict_merge({'source': {'port': src_port}}, rule_def) if fwmk: rule_def = dict_merge({'fwmark' : fwmk}, rule_def) if iif: rule_def = dict_merge({'inbound_interface' : iif}, rule_def) if dst: rule_def = dict_merge({'destination': {'address': dst}}, rule_def) + if dst_port: + rule_def = dict_merge({'destination': {'port': dst_port}}, rule_def) + if table: + rule_def = dict_merge({'table' : table}, rule_def) if proto: rule_def = dict_merge({'protocol' : proto}, rule_def) dict = dict_merge({dict_id : {rule : rule_def}}, dict) @@ -79,9 +88,12 @@ def get_config(config=None): if 'rule' in pbr[route]: for rule, rule_config in pbr[route]['rule'].items(): src = leaf_node_changed(conf, base_rule + [rule, 'source', 'address']) + src_port = leaf_node_changed(conf, base_rule + [rule, 'source', 'port']) fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address']) + dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port']) + table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table']) proto = leaf_node_changed(conf, base_rule + [rule, 'protocol']) # keep track of changes in configuration # otherwise we might remove an existing node although nothing else has changed @@ -105,14 +117,32 @@ def get_config(config=None): if len(src) > 0: rule_def = dict_merge({'source': {'address': src}}, rule_def) + # source port + if src_port is None: + if 'source' in rule_config: + if 'port' in rule_config['source']: + tmp = rule_config['source']['port'] + if isinstance(tmp, str): + tmp = [tmp] + rule_def = dict_merge({'source': {'port': tmp}}, rule_def) + else: + changed = True + if len(src_port) > 0: + rule_def = dict_merge({'source': {'port': src_port}}, rule_def) + + # fwmark if fwmk is None: if 'fwmark' in rule_config: - rule_def = dict_merge({'fwmark': rule_config['fwmark']}, rule_def) + tmp = rule_config['fwmark'] + if isinstance(tmp, str): + tmp = [tmp] + rule_def = dict_merge({'fwmark': tmp}, rule_def) else: changed = True if len(fwmk) > 0: rule_def = dict_merge({'fwmark' : fwmk}, rule_def) + # inbound-interface if iif is None: if 'inbound_interface' in rule_config: rule_def = dict_merge({'inbound_interface': rule_config['inbound_interface']}, rule_def) @@ -121,6 +151,7 @@ def get_config(config=None): if len(iif) > 0: rule_def = dict_merge({'inbound_interface' : iif}, rule_def) + # destination address if dst is None: if 'destination' in rule_config: if 'address' in rule_config['destination']: @@ -130,9 +161,35 @@ def get_config(config=None): if len(dst) > 0: rule_def = dict_merge({'destination': {'address': dst}}, rule_def) + # destination port + if dst_port is None: + if 'destination' in rule_config: + if 'port' in rule_config['destination']: + tmp = rule_config['destination']['port'] + if isinstance(tmp, str): + tmp = [tmp] + rule_def = dict_merge({'destination': {'port': tmp}}, rule_def) + else: + changed = True + if len(dst_port) > 0: + rule_def = dict_merge({'destination': {'port': dst_port}}, rule_def) + + # table + if table is None: + if 'set' in rule_config and 'table' in rule_config['set']: + rule_def = dict_merge({'table': [rule_config['set']['table']]}, rule_def) + else: + changed = True + if len(table) > 0: + rule_def = dict_merge({'table' : table}, rule_def) + + # protocol if proto is None: if 'protocol' in rule_config: - rule_def = dict_merge({'protocol': rule_config['protocol']}, rule_def) + tmp = rule_config['protocol'] + if isinstance(tmp, str): + tmp = [tmp] + rule_def = dict_merge({'protocol': tmp}, rule_def) else: changed = True if len(proto) > 0: @@ -192,19 +249,27 @@ def apply(pbr): for rule, rule_config in pbr[rule_rm].items(): source = rule_config.get('source', {}).get('address', ['']) + source_port = rule_config.get('source', {}).get('port', ['']) destination = rule_config.get('destination', {}).get('address', ['']) + destination_port = rule_config.get('destination', {}).get('port', ['']) fwmark = rule_config.get('fwmark', ['']) inbound_interface = rule_config.get('inbound_interface', ['']) protocol = rule_config.get('protocol', ['']) + table = rule_config.get('table', ['']) - for src, dst, fwmk, iif, proto in product(source, destination, fwmark, inbound_interface, protocol): + for src, dst, src_port, dst_port, fwmk, iif, proto, table in product( + source, destination, source_port, destination_port, + fwmark, inbound_interface, protocol, table): f_src = '' if src == '' else f' from {src} ' + f_src_port = '' if src_port == '' else f' sport {src_port} ' f_dst = '' if dst == '' else f' to {dst} ' + f_dst_port = '' if dst_port == '' else f' dport {dst_port} ' f_fwmk = '' if fwmk == '' else f' fwmark {fwmk} ' f_iif = '' if iif == '' else f' iif {iif} ' f_proto = '' if proto == '' else f' ipproto {proto} ' + f_table = '' if table == '' else f' lookup {table} ' - call(f'ip{v6} rule del prio {rule} {f_src}{f_dst}{f_fwmk}{f_iif}') + call(f'ip{v6} rule del prio {rule} {f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif}{f_table}') # Generate new config for route in ['local_route', 'local_route6']: @@ -218,7 +283,9 @@ def apply(pbr): for rule, rule_config in pbr_route['rule'].items(): table = rule_config['set'].get('table', '') source = rule_config.get('source', {}).get('address', ['all']) + source_port = rule_config.get('source', {}).get('port', '') destination = rule_config.get('destination', {}).get('address', ['all']) + destination_port = rule_config.get('destination', {}).get('port', '') fwmark = rule_config.get('fwmark', '') inbound_interface = rule_config.get('inbound_interface', '') protocol = rule_config.get('protocol', '') @@ -227,11 +294,13 @@ def apply(pbr): f_src = f' from {src} ' if src else '' for dst in destination: f_dst = f' to {dst} ' if dst else '' + f_src_port = f' sport {source_port} ' if source_port else '' + f_dst_port = f' dport {destination_port} ' if destination_port else '' f_fwmk = f' fwmark {fwmark} ' if fwmark else '' f_iif = f' iif {inbound_interface} ' if inbound_interface else '' f_proto = f' ipproto {protocol} ' if protocol else '' - call(f'ip{v6} rule add prio {rule}{f_src}{f_dst}{f_proto}{f_fwmk}{f_iif} lookup {table}') + call(f'ip{v6} rule add prio {rule}{f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif} lookup {table}') return None diff --git a/src/conf_mode/policy-route.py b/src/conf_mode/policy_route.py index adad012de..6d7a06714 100755 --- a/src/conf_mode/policy-route.py +++ b/src/conf_mode/policy_route.py @@ -53,6 +53,10 @@ def get_config(config=None): policy['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + # Remove dynamic firewall groups if present: + if 'dynamic_group' in policy['firewall_group']: + del policy['firewall_group']['dynamic_group'] + return policy def verify_rule(policy, name, rule_conf, ipv6, rule_id): diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py index dab784662..37421efb4 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -72,6 +72,9 @@ def verify(bfd): if 'source' in peer_config and 'interface' in peer_config['source']: raise ConfigError('BFD multihop and source interface cannot be used together') + if 'minimum_ttl' in peer_config and 'multihop' not in peer_config: + raise ConfigError('Minimum TTL is only available for multihop BFD sessions!') + if 'profile' in peer_config: profile_name = peer_config['profile'] if 'profile' not in bfd or profile_name not in bfd['profile']: diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 00015023c..d90dfe45b 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2023 VyOS maintainers and contributors +# Copyright (C) 2020-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -30,6 +30,7 @@ from vyos.template import render_to_string from vyos.utils.dict import dict_search from vyos.utils.network import get_interface_vrf from vyos.utils.network import is_addr_assigned +from vyos.utils.process import process_named_running from vyos import ConfigError from vyos import frr from vyos import airbag @@ -49,8 +50,13 @@ def get_config(config=None): # eqivalent of the C foo ? 'a' : 'b' statement base = vrf and ['vrf', 'name', vrf, 'protocols', 'bgp'] or base_path - bgp = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) + bgp = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) bgp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'], key_mangling=('-', '_'), @@ -93,6 +99,7 @@ def get_config(config=None): tmp = conf.get_config_dict(['policy']) # Merge policy dict into "regular" config dict bgp = dict_merge(tmp, bgp) + return bgp @@ -199,6 +206,10 @@ def verify_remote_as(peer_config, bgp_config): if 'v6only' in peer_config['interface']: if 'remote_as' in peer_config['interface']['v6only']: return peer_config['interface']['v6only']['remote_as'] + if 'peer_group' in peer_config['interface']['v6only']: + peer_group_name = peer_config['interface']['v6only']['peer_group'] + tmp = dict_search(f'peer_group.{peer_group_name}.remote_as', bgp_config) + if tmp: return tmp return None @@ -209,9 +220,12 @@ def verify_afi(peer_config, bgp_config): # If address_family configured under peer-group # if neighbor interface configured - peer_group_name = '' + peer_group_name = None if dict_search('interface.peer_group', peer_config): peer_group_name = peer_config['interface']['peer_group'] + elif dict_search('interface.v6only.peer_group', peer_config): + peer_group_name = peer_config['interface']['v6only']['peer_group'] + # if neighbor IP configured. if 'peer_group' in peer_config: peer_group_name = peer_config['peer_group'] @@ -246,6 +260,19 @@ def verify(bgp): if 'system_as' not in bgp: raise ConfigError('BGP system-as number must be defined!') + # Verify BMP + if 'bmp' in bgp: + # check bmp flag "bgpd -d -F traditional --daemon -A 127.0.0.1 -M rpki -M bmp" + if not process_named_running('bgpd', 'bmp'): + raise ConfigError( + f'"bmp" flag is not found in bgpd. Configure "set system frr bmp" and restart bgp process' + ) + # check bmp target + if 'target' in bgp['bmp']: + for target, target_config in bgp['bmp']['target'].items(): + if 'address' not in target_config: + raise ConfigError(f'BMP target "{target}" address must be defined!') + # Verify vrf on interface and bgp section if 'interface' in bgp: for interface in bgp['interface']: @@ -482,6 +509,14 @@ def verify(bgp): if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']): raise ConfigError( 'Command "import vrf" conflicts with "route-target vpn both" command!') + if dict_search('route_target.vpn.export', afi_config): + raise ConfigError( + 'Command "route-target vpn export" conflicts '\ + 'with "route-target vpn both" command!') + if dict_search('route_target.vpn.import', afi_config): + raise ConfigError( + 'Command "route-target vpn import" conflicts '\ + 'with "route-target vpn both" command!') if dict_search('route_target.vpn.import', afi_config): if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']): @@ -518,6 +553,10 @@ def verify(bgp): tmp = dict_search(f'route_map.vpn.{export_import}', afi_config) if tmp: verify_route_map(tmp, bgp) + # per-vrf sid and per-af sid are mutually exclusive + if 'sid' in afi_config and 'sid' in bgp: + raise ConfigError('SID per VRF and SID per address-family are mutually exclusive!') + # Checks only required for L2VPN EVPN if afi in ['l2vpn_evpn']: if 'vni' in afi_config: diff --git a/src/conf_mode/igmp_proxy.py b/src/conf_mode/protocols_igmp-proxy.py index 40db417dd..40db417dd 100755 --- a/src/conf_mode/igmp_proxy.py +++ b/src/conf_mode/protocols_igmp-proxy.py diff --git a/src/conf_mode/protocols_igmp.py b/src/conf_mode/protocols_igmp.py deleted file mode 100755 index 435189025..000000000 --- a/src/conf_mode/protocols_igmp.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import os - -from ipaddress import IPv4Address -from sys import exit - -from vyos import ConfigError -from vyos.config import Config -from vyos.utils.process import process_named_running -from vyos.utils.process import call -from vyos.template import render -from signal import SIGTERM - -from vyos import airbag -airbag.enable() - -# Required to use the full path to pimd, in another case daemon will not be started -pimd_cmd = f'/usr/lib/frr/pimd -d -F traditional --daemon -A 127.0.0.1' - -config_file = r'/tmp/igmp.frr' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - igmp_conf = { - 'igmp_conf' : False, - 'pim_conf' : False, - 'igmp_proxy_conf' : False, - 'old_ifaces' : {}, - 'ifaces' : {} - } - if not (conf.exists('protocols igmp') or conf.exists_effective('protocols igmp')): - return None - - if conf.exists('protocols igmp-proxy'): - igmp_conf['igmp_proxy_conf'] = True - - if conf.exists('protocols pim'): - igmp_conf['pim_conf'] = True - - if conf.exists('protocols igmp'): - igmp_conf['igmp_conf'] = True - - conf.set_level('protocols igmp') - - # # Get interfaces - for iface in conf.list_effective_nodes('interface'): - igmp_conf['old_ifaces'].update({ - iface : { - 'version' : conf.return_effective_value('interface {0} version'.format(iface)), - 'query_interval' : conf.return_effective_value('interface {0} query-interval'.format(iface)), - 'query_max_resp_time' : conf.return_effective_value('interface {0} query-max-response-time'.format(iface)), - 'gr_join' : {} - } - }) - for gr_join in conf.list_effective_nodes('interface {0} join'.format(iface)): - igmp_conf['old_ifaces'][iface]['gr_join'][gr_join] = conf.return_effective_values('interface {0} join {1} source'.format(iface, gr_join)) - - for iface in conf.list_nodes('interface'): - igmp_conf['ifaces'].update({ - iface : { - 'version' : conf.return_value('interface {0} version'.format(iface)), - 'query_interval' : conf.return_value('interface {0} query-interval'.format(iface)), - 'query_max_resp_time' : conf.return_value('interface {0} query-max-response-time'.format(iface)), - 'gr_join' : {} - } - }) - for gr_join in conf.list_nodes('interface {0} join'.format(iface)): - igmp_conf['ifaces'][iface]['gr_join'][gr_join] = conf.return_values('interface {0} join {1} source'.format(iface, gr_join)) - - return igmp_conf - -def verify(igmp): - if igmp is None: - return None - - if igmp['igmp_conf']: - # Check conflict with IGMP-Proxy - if igmp['igmp_proxy_conf']: - raise ConfigError(f"IGMP proxy and PIM cannot be both configured at the same time") - - # Check interfaces - if not igmp['ifaces']: - raise ConfigError(f"IGMP require defined interfaces!") - # Check, is this multicast group - for intfc in igmp['ifaces']: - for gr_addr in igmp['ifaces'][intfc]['gr_join']: - if not IPv4Address(gr_addr).is_multicast: - raise ConfigError(gr_addr + " not a multicast group") - -def generate(igmp): - if igmp is None: - return None - - render(config_file, 'frr/igmp.frr.j2', igmp) - return None - -def apply(igmp): - if igmp is None: - return None - - pim_pid = process_named_running('pimd') - if igmp['igmp_conf'] or igmp['pim_conf']: - if not pim_pid: - call(pimd_cmd) - - if os.path.exists(config_file): - call(f'vtysh -d pimd -f {config_file}') - os.remove(config_file) - elif pim_pid: - os.kill(int(pim_pid), SIGTERM) - - 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_isis.py b/src/conf_mode/protocols_isis.py index e00c58ee4..8d594bb68 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -48,7 +48,8 @@ def get_config(config=None): # eqivalent of the C foo ? 'a' : 'b' statement base = vrf and ['vrf', 'name', vrf, 'protocols', 'isis'] or base_path isis = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True) + 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 @@ -219,6 +220,51 @@ def verify(isis): if ("explicit_null" in prefix_config['index']) and ("no_php_flag" in prefix_config['index']): raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ f'and no-php-flag configured at the same time.') + + # Check for index ranges being larger than the segment routing global block + if dict_search('segment_routing.global_block', isis): + g_high_label_value = dict_search('segment_routing.global_block.high_label_value', isis) + g_low_label_value = dict_search('segment_routing.global_block.low_label_value', isis) + g_label_difference = int(g_high_label_value) - int(g_low_label_value) + if dict_search('segment_routing.prefix', isis): + for prefix, prefix_config in isis['segment_routing']['prefix'].items(): + if 'index' in prefix_config: + index_size = isis['segment_routing']['prefix'][prefix]['index']['value'] + if int(index_size) > int(g_label_difference): + raise ConfigError(f'Segment routing prefix {prefix} cannot have an '\ + f'index base size larger than the SRGB label base.') + + # Check for LFA tiebreaker index duplication + if dict_search('fast_reroute.lfa.local.tiebreaker', isis): + comparison_dictionary = {} + for item, item_options in isis['fast_reroute']['lfa']['local']['tiebreaker'].items(): + for index, index_options in item_options.items(): + for index_value, index_value_options in index_options.items(): + if index_value not in comparison_dictionary.keys(): + comparison_dictionary[index_value] = [item] + else: + comparison_dictionary[index_value].append(item) + for index, index_length in comparison_dictionary.items(): + if int(len(index_length)) > 1: + raise ConfigError(f'LFA index {index} cannot have more than one tiebreaker configured.') + + # Check for LFA priority-limit configured multiple times per level + if dict_search('fast_reroute.lfa.local.priority_limit', isis): + comparison_dictionary = {} + for priority, priority_options in isis['fast_reroute']['lfa']['local']['priority_limit'].items(): + for level, level_options in priority_options.items(): + if level not in comparison_dictionary.keys(): + comparison_dictionary[level] = [priority] + else: + comparison_dictionary[level].append(priority) + for level, level_length in comparison_dictionary.items(): + if int(len(level_length)) > 1: + raise ConfigError(f'LFA priority-limit on {level.replace("_", "-")} cannot have more than one priority configured.') + + # Check for LFA remote prefix list configured with more than one list + if dict_search('fast_reroute.lfa.remote.prefix_list', isis): + if int(len(isis['fast_reroute']['lfa']['remote']['prefix_list'].items())) > 1: + raise ConfigError(f'LFA remote prefix-list has more than one configured. Cannot have more than one configured.') return None @@ -265,4 +311,4 @@ if __name__ == '__main__': apply(c) except ConfigError as e: print(e) - exit(1) + exit(1)
\ No newline at end of file diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index 5ec0bc9e5..c339c6391 100755 --- a/src/conf_mode/protocols_nhrp.py +++ b/src/conf_mode/protocols_nhrp.py @@ -37,7 +37,7 @@ def get_config(config=None): nhrp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) - nhrp['del_tunnels'] = node_changed(conf, base + ['tunnel'], key_mangling=('-', '_')) + nhrp['del_tunnels'] = node_changed(conf, base + ['tunnel']) if not conf.exists(base): return nhrp diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index cddd3765e..34cf49286 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -14,8 +14,6 @@ # 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 sys import argv @@ -215,6 +213,19 @@ def verify(ospf): raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\ f'and no-php-flag configured at the same time.') + # Check for index ranges being larger than the segment routing global block + if dict_search('segment_routing.global_block', ospf): + g_high_label_value = dict_search('segment_routing.global_block.high_label_value', ospf) + g_low_label_value = dict_search('segment_routing.global_block.low_label_value', ospf) + g_label_difference = int(g_high_label_value) - int(g_low_label_value) + if dict_search('segment_routing.prefix', ospf): + for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): + if 'index' in prefix_config: + index_size = ospf['segment_routing']['prefix'][prefix]['index']['value'] + if int(index_size) > int(g_label_difference): + raise ConfigError(f'Segment routing prefix {prefix} cannot have an '\ + f'index base size larger than the SRGB label base.') + # Check route summarisation if 'summary_address' in ospf: for prefix, prefix_options in ospf['summary_address'].items(): diff --git a/src/conf_mode/protocols_pim.py b/src/conf_mode/protocols_pim.py index 0aaa0d2c6..09c3be8df 100755 --- a/src/conf_mode/protocols_pim.py +++ b/src/conf_mode/protocols_pim.py @@ -16,144 +16,139 @@ import os -from ipaddress import IPv4Address +from ipaddress import IPv4Network +from signal import SIGTERM from sys import exit from vyos.config import Config -from vyos import ConfigError +from vyos.config import config_dict_merge +from vyos.configdict import node_changed +from vyos.configverify import verify_interface_exists from vyos.utils.process import process_named_running from vyos.utils.process import call -from vyos.template import render -from signal import SIGTERM - +from vyos.template import render_to_string +from vyos import ConfigError +from vyos import frr from vyos import airbag airbag.enable() -# Required to use the full path to pimd, in another case daemon will not be started -pimd_cmd = f'/usr/lib/frr/pimd -d -F traditional --daemon -A 127.0.0.1' - -config_file = r'/tmp/pimd.frr' - def get_config(config=None): if config: conf = config else: conf = Config() - pim_conf = { - 'pim_conf' : False, - 'igmp_conf' : False, - 'igmp_proxy_conf' : False, - 'old_pim' : { - 'ifaces' : {}, - 'rp' : {} - }, - 'pim' : { - 'ifaces' : {}, - 'rp' : {} - } - } - if not (conf.exists('protocols pim') or conf.exists_effective('protocols pim')): - return None - - if conf.exists('protocols igmp-proxy'): - pim_conf['igmp_proxy_conf'] = True - - if conf.exists('protocols igmp'): - pim_conf['igmp_conf'] = True - - if conf.exists('protocols pim'): - pim_conf['pim_conf'] = True - - conf.set_level('protocols pim') - - # Get interfaces - for iface in conf.list_effective_nodes('interface'): - pim_conf['old_pim']['ifaces'].update({ - iface : { - 'hello' : conf.return_effective_value('interface {0} hello'.format(iface)), - 'dr_prio' : conf.return_effective_value('interface {0} dr-priority'.format(iface)) - } - }) - for iface in conf.list_nodes('interface'): - pim_conf['pim']['ifaces'].update({ - iface : { - 'hello' : conf.return_value('interface {0} hello'.format(iface)), - 'dr_prio' : conf.return_value('interface {0} dr-priority'.format(iface)), - } - }) - - conf.set_level('protocols pim rp') - - # Get RPs addresses - for rp_addr in conf.list_effective_nodes('address'): - pim_conf['old_pim']['rp'][rp_addr] = conf.return_effective_values('address {0} group'.format(rp_addr)) - - for rp_addr in conf.list_nodes('address'): - pim_conf['pim']['rp'][rp_addr] = conf.return_values('address {0} group'.format(rp_addr)) - - # Get RP keep-alive-timer - if conf.exists_effective('rp keep-alive-timer'): - pim_conf['old_pim']['rp_keep_alive'] = conf.return_effective_value('rp keep-alive-timer') - if conf.exists('rp keep-alive-timer'): - pim_conf['pim']['rp_keep_alive'] = conf.return_value('rp keep-alive-timer') - - return pim_conf + base = ['protocols', 'pim'] + + pim = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + # We can not run both IGMP proxy and PIM at the same time - get IGMP + # proxy status + if conf.exists(['protocols', 'igmp-proxy']): + pim.update({'igmp_proxy_enabled' : {}}) + + # FRR has VRF support for different routing daemons. As interfaces belong + # to VRFs - or the global VRF, we need to check for changed interfaces so + # that they will be properly rendered for the FRR config. Also this eases + # removal of interfaces from the running configuration. + interfaces_removed = node_changed(conf, base + ['interface']) + if interfaces_removed: + pim['interface_removed'] = list(interfaces_removed) + + # Bail out early if configuration tree does no longer exist. this must + # be done after retrieving the list of interfaces to be removed. + if not conf.exists(base): + pim.update({'deleted' : ''}) + return pim + + # 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 = conf.get_config_defaults(**pim.kwargs, recursive=True) + + # We have to cleanup the default dict, as default values could enable features + # which are not explicitly enabled on the CLI. Example: default-information + # originate comes with a default metric-type of 2, which will enable the + # entire default-information originate tree, even when not set via CLI so we + # need to check this first and probably drop that key. + for interface in pim.get('interface', []): + # We need to reload the defaults on every pass b/c of + # hello-multiplier dependency on dead-interval + # If hello-multiplier is set, we need to remove the default from + # dead-interval. + if 'igmp' not in pim['interface'][interface]: + del default_values['interface'][interface]['igmp'] + + pim = config_dict_merge(default_values, pim) + return pim def verify(pim): - if pim is None: + if not pim or 'deleted' in pim: return None - if pim['pim_conf']: - # Check conflict with IGMP-Proxy - if pim['igmp_proxy_conf']: - raise ConfigError(f"IGMP proxy and PIM cannot be both configured at the same time") - - # Check interfaces - if not pim['pim']['ifaces']: - raise ConfigError(f"PIM require defined interfaces!") + if 'igmp_proxy_enabled' in pim: + raise ConfigError('IGMP proxy and PIM cannot be configured at the same time!') - if not pim['pim']['rp']: - raise ConfigError(f"RP address required") + if 'interface' not in pim: + raise ConfigError('PIM require defined interfaces!') - # Check unique multicast groups - uniq_groups = [] - for rp_addr in pim['pim']['rp']: - if not pim['pim']['rp'][rp_addr]: - raise ConfigError(f"Group should be specified for RP " + rp_addr) - for group in pim['pim']['rp'][rp_addr]: - if (group in uniq_groups): - raise ConfigError(f"Group range " + group + " specified cannot exact match another") + for interface in pim['interface']: + verify_interface_exists(interface) - # Check, is this multicast group - gr_addr = group.split('/') - if IPv4Address(gr_addr[0]) < IPv4Address('224.0.0.0'): - raise ConfigError(group + " not a multicast group") + if 'rp' in pim: + if 'address' not in pim['rp']: + raise ConfigError('PIM rendezvous point needs to be defined!') - uniq_groups.extend(pim['pim']['rp'][rp_addr]) + # Check unique multicast groups + unique = [] + pim_base_error = 'PIM rendezvous point group' + for address, address_config in pim['rp']['address'].items(): + if 'group' not in address_config: + raise ConfigError(f'{pim_base_error} should be defined for "{address}"!') + + # Check if it is a multicast group + for gr_addr in address_config['group']: + if not IPv4Network(gr_addr).is_multicast: + raise ConfigError(f'{pim_base_error} "{gr_addr}" is not a multicast group!') + if gr_addr in unique: + raise ConfigError(f'{pim_base_error} must be unique!') + unique.append(gr_addr) def generate(pim): - if pim is None: + if not pim or 'deleted' in pim: return None - - render(config_file, 'frr/pimd.frr.j2', pim) + pim['frr_pimd_config'] = render_to_string('frr/pimd.frr.j2', pim) return None def apply(pim): - if pim is None: + pim_daemon = 'pimd' + pim_pid = process_named_running(pim_daemon) + + if not pim or 'deleted' in pim: + if 'deleted' in pim: + os.kill(int(pim_pid), SIGTERM) + return None - pim_pid = process_named_running('pimd') - if pim['igmp_conf'] or pim['pim_conf']: - if not pim_pid: - call(pimd_cmd) + if not pim_pid: + call('/usr/lib/frr/pimd -d -F traditional --daemon -A 127.0.0.1') + + # Save original configuration prior to starting any commit actions + frr_cfg = frr.FRRConfig() + + frr_cfg.load_configuration(pim_daemon) + frr_cfg.modify_section(f'^ip pim') + frr_cfg.modify_section(f'^ip igmp') - if os.path.exists(config_file): - call("vtysh -d pimd -f " + config_file) - os.remove(config_file) - elif pim_pid: - os.kill(int(pim_pid), SIGTERM) + for key in ['interface', 'interface_removed']: + if key not in pim: + continue + for interface in pim[key]: + frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) + if 'frr_pimd_config' in pim: + frr_cfg.add_before(frr.default_add_before, pim['frr_pimd_config']) + frr_cfg.commit_configuration(pim_daemon) return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_pim6.py b/src/conf_mode/protocols_pim6.py index 6a1235ba5..2003a1014 100755 --- a/src/conf_mode/protocols_pim6.py +++ b/src/conf_mode/protocols_pim6.py @@ -15,18 +15,19 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from ipaddress import IPv6Address +from ipaddress import IPv6Network from sys import exit -from typing import Optional -from vyos import ConfigError, airbag, frr -from vyos.config import Config, ConfigDict +from vyos.config import Config +from vyos.config import config_dict_merge from vyos.configdict import node_changed from vyos.configverify import verify_interface_exists 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 @@ -44,11 +45,21 @@ def get_config(config=None): if interfaces_removed: pim6['interface_removed'] = list(interfaces_removed) - return pim6 + # Bail out early if configuration tree does no longer exist. this must + # be done after retrieving the list of interfaces to be removed. + if not conf.exists(base): + pim6.update({'deleted' : ''}) + return pim6 + # 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 = conf.get_config_defaults(**pim6.kwargs, recursive=True) + + pim6 = config_dict_merge(default_values, pim6) + return pim6 def verify(pim6): - if pim6 is None: + if not pim6 or 'deleted' in pim6: return for interface, interface_config in pim6.get('interface', {}).items(): @@ -60,13 +71,34 @@ def verify(pim6): if not IPv6Address(group).is_multicast: raise ConfigError(f"{group} is not a multicast group") + if 'rp' in pim6: + if 'address' not in pim6['rp']: + raise ConfigError('PIM6 rendezvous point needs to be defined!') + + # Check unique multicast groups + unique = [] + pim_base_error = 'PIM6 rendezvous point group' + + if {'address', 'prefix-list6'} <= set(pim6['rp']): + raise ConfigError(f'{pim_base_error} supports either address or a prefix-list!') + + for address, address_config in pim6['rp']['address'].items(): + if 'group' not in address_config: + raise ConfigError(f'{pim_base_error} should be defined for "{address}"!') + + # Check if it is a multicast group + for gr_addr in address_config['group']: + if not IPv6Network(gr_addr).is_multicast: + raise ConfigError(f'{pim_base_error} "{gr_addr}" is not a multicast group!') + if gr_addr in unique: + raise ConfigError(f'{pim_base_error} must be unique!') + unique.append(gr_addr) def generate(pim6): - if pim6 is None: + if not pim6 or 'deleted' in pim6: return - pim6['new_frr_config'] = render_to_string('frr/pim6d.frr.j2', pim6) - + return None def apply(pim6): if pim6 is None: @@ -83,13 +115,12 @@ def apply(pim6): if key not in pim6: continue for interface in pim6[key]: - frr_cfg.modify_section( - f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) + frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True) if 'new_frr_config' in pim6: frr_cfg.add_before(frr.default_add_before, pim6['new_frr_config']) frr_cfg.commit_configuration(pim6_daemon) - + return None if __name__ == '__main__': try: diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py index 05e876f3b..0fc14e868 100755 --- a/src/conf_mode/protocols_rpki.py +++ b/src/conf_mode/protocols_rpki.py @@ -63,11 +63,11 @@ def verify(rpki): preferences.append(preference) if 'ssh' in peer_config: - files = ['private_key_file', 'public_key_file', 'known_hosts_file'] + files = ['private_key_file', 'public_key_file'] for file in files: if file not in peer_config['ssh']: - raise ConfigError('RPKI+SSH requires username, public/private ' \ - 'keys and known-hosts file to be defined!') + raise ConfigError('RPKI+SSH requires username and public/private ' \ + 'key file to be defined!') filename = peer_config['ssh'][file] if not os.path.exists(filename): diff --git a/src/conf_mode/protocols_segment-routing.py b/src/conf_mode/protocols_segment-routing.py new file mode 100755 index 000000000..d865c2ac0 --- /dev/null +++ b/src/conf_mode/protocols_segment-routing.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.template import render_to_string +from vyos.utils.dict import dict_search +from vyos.utils.system import sysctl_write +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() + + base = ['protocols', 'segment-routing'] + sr = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True) + + # FRR has VRF support for different routing daemons. As interfaces belong + # to VRFs - or the global VRF, we need to check for changed interfaces so + # that they will be properly rendered for the FRR config. Also this eases + # removal of interfaces from the running configuration. + interfaces_removed = node_changed(conf, base + ['interface']) + if interfaces_removed: + sr['interface_removed'] = list(interfaces_removed) + + import pprint + pprint.pprint(sr) + return sr + +def verify(sr): + if 'srv6' in sr: + srv6_enable = False + if 'interface' in sr: + for interface, interface_config in sr['interface'].items(): + if 'srv6' in interface_config: + srv6_enable = True + break + if not srv6_enable: + raise ConfigError('SRv6 should be enabled on at least one interface!') + return None + +def generate(sr): + if not sr: + return None + + sr['new_frr_config'] = render_to_string('frr/zebra.segment_routing.frr.j2', sr) + return None + +def apply(sr): + zebra_daemon = 'zebra' + + if 'interface_removed' in sr: + for interface in sr['interface_removed']: + # Disable processing of IPv6-SR packets + sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0') + + if 'interface' in sr: + for interface, interface_config in sr['interface'].items(): + # Accept or drop SR-enabled IPv6 packets on this interface + if 'srv6' in interface_config: + sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '1') + # Define HMAC policy for ingress SR-enabled packets on this interface + # It's a redundant check as HMAC has a default value - but better safe + # then sorry + tmp = dict_search('srv6.hmac', interface_config) + if tmp == 'accept': + sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '0') + elif tmp == 'drop': + sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '1') + elif tmp == 'ignore': + sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '-1') + else: + sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0') + + # Save original configuration prior to starting any commit actions + frr_cfg = frr.FRRConfig() + frr_cfg.load_configuration(zebra_daemon) + frr_cfg.modify_section(r'^segment-routing') + if 'new_frr_config' in sr: + frr_cfg.add_before(frr.default_add_before, sr['new_frr_config']) + frr_cfg.commit_configuration(zebra_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/arp.py b/src/conf_mode/protocols_static_arp.py index b141f1141..b141f1141 100755 --- a/src/conf_mode/arp.py +++ b/src/conf_mode/protocols_static_arp.py diff --git a/src/conf_mode/protocols_static_neighbor-proxy.py b/src/conf_mode/protocols_static_neighbor-proxy.py new file mode 100755 index 000000000..10cc1e748 --- /dev/null +++ b/src/conf_mode/protocols_static_neighbor-proxy.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['protocols', 'static', 'neighbor-proxy'] + config = conf.get_config_dict(base, get_first_key=True) + + return config + + +def verify(config): + + if 'arp' in config: + for neighbor, neighbor_conf in config['arp'].items(): + if 'interface' not in neighbor_conf: + raise ConfigError( + f"ARP neighbor-proxy for '{neighbor}' requires an interface to be set!" + ) + + if 'nd' in config: + for neighbor, neighbor_conf in config['nd'].items(): + if 'interface' not in neighbor_conf: + raise ConfigError( + f"ARP neighbor-proxy for '{neighbor}' requires an interface to be set!" + ) + + +def generate(config): + pass + + +def apply(config): + if not config: + # Cleanup proxy + call('ip neighbor flush proxy') + call('ip -6 neighbor flush proxy') + return None + + # Add proxy ARP + if 'arp' in config: + # Cleanup entries before config + call('ip neighbor flush proxy') + for neighbor, neighbor_conf in config['arp'].items(): + for interface in neighbor_conf.get('interface'): + call(f'ip neighbor add proxy {neighbor} dev {interface}') + + # Add proxy NDP + if 'nd' in config: + # Cleanup entries before config + call('ip -6 neighbor flush proxy') + for neighbor, neighbor_conf in config['nd'].items(): + for interface in neighbor_conf['interface']: + call(f'ip -6 neighbor add proxy {neighbor} dev {interface}') + + +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/qos.py b/src/conf_mode/qos.py index ad4121a49..40d7a6c16 100755 --- a/src/conf_mode/qos.py +++ b/src/conf_mode/qos.py @@ -149,7 +149,7 @@ def verify(qos): if 'class' in policy_config: for cls, cls_config in policy_config['class'].items(): # bandwidth is not mandatory for priority-queue - that is why this is on the exception list - if 'bandwidth' not in cls_config and policy_type not in ['priority_queue', 'round_robin']: + if 'bandwidth' not in cls_config and policy_type not in ['priority_queue', 'round_robin', 'shaper_hfsc']: raise ConfigError(f'Bandwidth must be defined for policy "{policy}" class "{cls}"!') if 'match' in cls_config: for match, match_config in cls_config['match'].items(): @@ -173,7 +173,7 @@ def verify(qos): if 'default' not in policy_config: raise ConfigError(f'Policy {policy} misses "default" class!') if 'default' in policy_config: - if 'bandwidth' not in policy_config['default'] and policy_type not in ['priority_queue', 'round_robin']: + if 'bandwidth' not in policy_config['default'] and policy_type not in ['priority_queue', 'round_robin', 'shaper_hfsc']: raise ConfigError('Bandwidth not defined for default traffic!') # we should check interface ingress/egress configuration after verifying that diff --git a/src/conf_mode/bcast_relay.py b/src/conf_mode/service_broadcast-relay.py index 31c552f5a..31c552f5a 100755 --- a/src/conf_mode/bcast_relay.py +++ b/src/conf_mode/service_broadcast-relay.py diff --git a/src/conf_mode/service_config_sync.py b/src/conf_mode/service_config-sync.py index 4b8a7f6ee..4b8a7f6ee 100755 --- a/src/conf_mode/service_config_sync.py +++ b/src/conf_mode/service_config-sync.py diff --git a/src/conf_mode/conntrack_sync.py b/src/conf_mode/service_conntrack-sync.py index 4fb2ce27f..4fb2ce27f 100755 --- a/src/conf_mode/conntrack_sync.py +++ b/src/conf_mode/service_conntrack-sync.py diff --git a/src/conf_mode/dhcp_relay.py b/src/conf_mode/service_dhcp-relay.py index 37d708847..37d708847 100755 --- a/src/conf_mode/dhcp_relay.py +++ b/src/conf_mode/service_dhcp-relay.py diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/service_dhcp-server.py index ac7d95632..91ea354b6 100755 --- a/src/conf_mode/dhcp_server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -18,23 +18,36 @@ import os from ipaddress import ip_address from ipaddress import ip_network -from netaddr import IPAddress from netaddr import IPRange from sys import exit from vyos.config import Config +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key from vyos.template import render from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_args +from vyos.utils.file import chmod_775 +from vyos.utils.file import makedir +from vyos.utils.file import write_file from vyos.utils.process import call -from vyos.utils.process import run +from vyos.utils.network import interface_exists from vyos.utils.network import is_subnet_connected from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag airbag.enable() -config_file = '/run/dhcp-server/dhcpd.conf' -systemd_override = r'/run/systemd/system/isc-dhcp-server.service.d/10-override.conf' +ctrl_config_file = '/run/kea/kea-ctrl-agent.conf' +ctrl_socket = '/run/kea/dhcp4-ctrl-socket' +config_file = '/run/kea/kea-dhcp4.conf' +lease_file = '/config/dhcp/dhcp4-leases.csv' +systemd_override = r'/run/systemd/system/kea-ctrl-agent.service.d/10-override.conf' +user_group = '_kea' + +ca_cert_file = '/run/kea/kea-failover-ca.pem' +cert_file = '/run/kea/kea-failover.pem' +cert_key_file = '/run/kea/kea-failover-key.pem' def dhcp_slice_range(exclude_list, range_dict): """ @@ -130,6 +143,9 @@ def get_config(config=None): dhcp['shared_network_name'][network]['subnet'][subnet].update( {'range' : new_range_dict}) + if dict_search('failover.certificate', dhcp): + dhcp['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + return dhcp def verify(dhcp): @@ -149,6 +165,7 @@ def verify(dhcp): shared_networks = len(dhcp['shared_network_name']) disabled_shared_networks = 0 + subnet_ids = [] # A shared-network requires a subnet definition for network, network_config in dhcp['shared_network_name'].items(): @@ -160,19 +177,20 @@ def verify(dhcp): 'lease subnet must be configured.') for subnet, subnet_config in network_config['subnet'].items(): + if 'subnet_id' not in subnet_config: + raise ConfigError(f'Unique subnet ID not specified for subnet "{subnet}"') + + if subnet_config['subnet_id'] in subnet_ids: + raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique') + + subnet_ids.append(subnet_config['subnet_id']) + # All delivered static routes require a next-hop to be set if 'static_route' in subnet_config: for route, route_option in subnet_config['static_route'].items(): if 'next_hop' not in route_option: raise ConfigError(f'DHCP static-route "{route}" requires router to be defined!') - # DHCP failover needs at least one subnet that uses it - if 'enable_failover' in subnet_config: - if 'failover' not in dhcp: - raise ConfigError(f'Can not enable failover for "{subnet}" in "{network}".\n' \ - 'Failover is not configured globally!') - failover_ok = True - # Check if DHCP address range is inside configured subnet declaration if 'range' in subnet_config: networks = [] @@ -214,15 +232,35 @@ def verify(dhcp): if 'static_mapping' in subnet_config: # Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set) + used_ips = [] + used_mac = [] + used_duid = [] for mapping, mapping_config in subnet_config['static_mapping'].items(): if 'ip_address' in mapping_config: if ip_address(mapping_config['ip_address']) not in ip_network(subnet): raise ConfigError(f'Configured static lease address for mapping "{mapping}" is\n' \ f'not within shared-network "{network}, {subnet}"!') - if 'mac_address' not in mapping_config: - raise ConfigError(f'MAC address required for static mapping "{mapping}"\n' \ - f'within shared-network "{network}, {subnet}"!') + if ('mac' not in mapping_config and 'duid' not in mapping_config) or \ + ('mac' in mapping_config and 'duid' in mapping_config): + raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for ' + f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') + + if 'disable' not in mapping_config: + if mapping_config['ip_address'] in used_ips: + raise ConfigError(f'Configured IP address for static mapping "{mapping}" already exists on another static mapping') + used_ips.append(mapping_config['ip_address']) + + if 'disable' not in mapping_config: + if 'mac' in mapping_config: + if mapping_config['mac'] in used_mac: + raise ConfigError(f'Configured MAC address for static mapping "{mapping}" already exists on another static mapping') + used_mac.append(mapping_config['mac']) + + if 'duid' in mapping_config: + if mapping_config['duid'] in used_duid: + raise ConfigError(f'Configured DUID for static mapping "{mapping}" already exists on another static mapping') + used_duid.append(mapping_config['duid']) # There must be one subnet connected to a listen interface. # This only counts if the network itself is not disabled! @@ -249,14 +287,34 @@ def verify(dhcp): raise ConfigError(f'At least one shared network must be active!') if 'failover' in dhcp: - if not failover_ok: - raise ConfigError('DHCP failover must be enabled for at least one subnet!') - for key in ['name', 'remote', 'source_address', 'status']: if key not in dhcp['failover']: tmp = key.replace('_', '-') raise ConfigError(f'DHCP failover requires "{tmp}" to be specified!') + if len({'certificate', 'ca_certificate'} & set(dhcp['failover'])) == 1: + raise ConfigError(f'DHCP secured failover requires both certificate and CA certificate') + + if 'certificate' in dhcp['failover']: + cert_name = dhcp['failover']['certificate'] + + if cert_name not in dhcp['pki']['certificate']: + raise ConfigError(f'Invalid certificate specified for DHCP failover') + + if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'certificate'): + raise ConfigError(f'Invalid certificate specified for DHCP failover') + + if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'private', 'key'): + raise ConfigError(f'Missing private key on certificate specified for DHCP failover') + + if 'ca_certificate' in dhcp['failover']: + ca_cert_name = dhcp['failover']['ca_certificate'] + if ca_cert_name not in dhcp['pki']['ca']: + raise ConfigError(f'Invalid CA certificate specified for DHCP failover') + + if not dict_search_args(dhcp['pki']['ca'], ca_cert_name, 'certificate'): + raise ConfigError(f'Invalid CA certificate specified for DHCP failover') + for address in (dict_search('listen_address', dhcp) or []): if is_addr_assigned(address): listen_ok = True @@ -265,12 +323,18 @@ def verify(dhcp): else: raise ConfigError(f'listen-address "{address}" not configured on any interface') - if not listen_ok: raise ConfigError('None of the configured subnets have an appropriate primary IP address on any\n' 'broadcast interface configured, nor was there an explicit listen-address\n' 'configured for serving DHCP relay packets!') + if 'listen_address' in dhcp and 'listen_interface' in dhcp: + raise ConfigError(f'Cannot define listen-address and listen-interface at the same time') + + for interface in (dict_search('listen_interface', dhcp) or []): + if not interface_exists(interface): + raise ConfigError(f'listen-interface "{interface}" does not exist') + return None def generate(dhcp): @@ -278,43 +342,71 @@ def generate(dhcp): if not dhcp or 'disable' in dhcp: return None - # Please see: https://vyos.dev/T1129 for quoting of the raw - # parameters we can pass to ISC DHCPd - tmp_file = '/tmp/dhcpd.conf' - render(tmp_file, 'dhcp-server/dhcpd.conf.j2', dhcp, - formater=lambda _: _.replace(""", '"')) - # XXX: as we have the ability for a user to pass in "raw" options via VyOS - # CLI (see T3544) we now ask ISC dhcpd to test the newly rendered - # configuration - tmp = run(f'/usr/sbin/dhcpd -4 -q -t -cf {tmp_file}') - if tmp > 0: - if os.path.exists(tmp_file): - os.unlink(tmp_file) - raise ConfigError('Configuration file errors encountered - check your options!') - - # Now that we know that the newly rendered configuration is "good" we can - # render the "real" configuration - render(config_file, 'dhcp-server/dhcpd.conf.j2', dhcp, - formater=lambda _: _.replace(""", '"')) - render(systemd_override, 'dhcp-server/10-override.conf.j2', dhcp) - - # Clean up configuration test file - if os.path.exists(tmp_file): - os.unlink(tmp_file) + dhcp['lease_file'] = lease_file + dhcp['machine'] = os.uname().machine + + # Create directory for lease file if necessary + lease_dir = os.path.dirname(lease_file) + if not os.path.isdir(lease_dir): + makedir(lease_dir, group='vyattacfg') + chmod_775(lease_dir) + + # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way + if not os.path.exists(lease_file): + write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) + + for f in [cert_file, cert_key_file, ca_cert_file]: + if os.path.exists(f): + os.unlink(f) + + if 'failover' in dhcp: + if 'certificate' in dhcp['failover']: + cert_name = dhcp['failover']['certificate'] + cert_data = dhcp['pki']['certificate'][cert_name]['certificate'] + key_data = dhcp['pki']['certificate'][cert_name]['private']['key'] + write_file(cert_file, wrap_certificate(cert_data), user=user_group, mode=0o600) + write_file(cert_key_file, wrap_private_key(key_data), user=user_group, mode=0o600) + + dhcp['failover']['cert_file'] = cert_file + dhcp['failover']['cert_key_file'] = cert_key_file + + if 'ca_certificate' in dhcp['failover']: + ca_cert_name = dhcp['failover']['ca_certificate'] + ca_cert_data = dhcp['pki']['ca'][ca_cert_name]['certificate'] + write_file(ca_cert_file, wrap_certificate(ca_cert_data), user=user_group, mode=0o600) + + dhcp['failover']['ca_cert_file'] = ca_cert_file + + render(systemd_override, 'dhcp-server/10-override.conf.j2', dhcp) + + render(ctrl_config_file, 'dhcp-server/kea-ctrl-agent.conf.j2', dhcp, user=user_group, group=user_group) + render(config_file, 'dhcp-server/kea-dhcp4.conf.j2', dhcp, user=user_group, group=user_group) return None def apply(dhcp): - call('systemctl daemon-reload') - # bail out early - looks like removal from running config + services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server'] + if not dhcp or 'disable' in dhcp: - call('systemctl stop isc-dhcp-server.service') + for service in services: + call(f'systemctl stop {service}.service') + if os.path.exists(config_file): os.unlink(config_file) return None - call('systemctl restart isc-dhcp-server.service') + for service in services: + action = 'restart' + + if service == 'kea-dhcp-ddns-server' and 'dynamic_dns_update' not in dhcp: + action = 'stop' + + if service == 'kea-ctrl-agent' and 'failover' not in dhcp: + action = 'stop' + + call(f'systemctl {action} {service}.service') + return None if __name__ == '__main__': diff --git a/src/conf_mode/dhcpv6_relay.py b/src/conf_mode/service_dhcpv6-relay.py index 6537ca3c2..6537ca3c2 100755 --- a/src/conf_mode/dhcpv6_relay.py +++ b/src/conf_mode/service_dhcpv6-relay.py diff --git a/src/conf_mode/dhcpv6_server.py b/src/conf_mode/service_dhcpv6-server.py index 427001609..add83eb0d 100755 --- a/src/conf_mode/dhcpv6_server.py +++ b/src/conf_mode/service_dhcpv6-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -22,15 +22,20 @@ from sys import exit from vyos.config import Config from vyos.template import render -from vyos.template import is_ipv6 from vyos.utils.process import call +from vyos.utils.file import chmod_775 +from vyos.utils.file import makedir +from vyos.utils.file import write_file from vyos.utils.dict import dict_search from vyos.utils.network import is_subnet_connected from vyos import ConfigError from vyos import airbag airbag.enable() -config_file = '/run/dhcp-server/dhcpdv6.conf' +config_file = '/run/kea/kea-dhcp6.conf' +ctrl_socket = '/run/kea/dhcp6-ctrl-socket' +lease_file = '/config/dhcp/dhcp6-leases.csv' +user_group = '_kea' def get_config(config=None): if config: @@ -58,6 +63,7 @@ def verify(dhcpv6): # Inspect shared-network/subnet subnets = [] + subnet_ids = [] listen_ok = False for network, network_config in dhcpv6['shared_network_name'].items(): # A shared-network requires a subnet definition @@ -67,26 +73,37 @@ def verify(dhcpv6): 'each shared network!') for subnet, subnet_config in network_config['subnet'].items(): - if 'address_range' in subnet_config: - if 'start' in subnet_config['address_range']: - range6_start = [] - range6_stop = [] - for start, start_config in subnet_config['address_range']['start'].items(): - if 'stop' not in start_config: - raise ConfigError(f'address-range stop address for start "{start}" is not defined!') - stop = start_config['stop'] + if 'subnet_id' not in subnet_config: + raise ConfigError(f'Unique subnet ID not specified for subnet "{subnet}"') + + if subnet_config['subnet_id'] in subnet_ids: + raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique') + + subnet_ids.append(subnet_config['subnet_id']) + + if 'range' in subnet_config: + range6_start = [] + range6_stop = [] + + for num, range_config in subnet_config['range'].items(): + if 'start' in range_config: + start = range_config['start'] + + if 'stop' not in range_config: + raise ConfigError(f'Range stop address for start "{start}" is not defined!') + stop = range_config['stop'] # Start address must be inside network if not ip_address(start) in ip_network(subnet): - raise ConfigError(f'address-range start address "{start}" is not in subnet "{subnet}"!') + raise ConfigError(f'Range start address "{start}" is not in subnet "{subnet}"!') # Stop address must be inside network if not ip_address(stop) in ip_network(subnet): - raise ConfigError(f'address-range stop address "{stop}" is not in subnet "{subnet}"!') + raise ConfigError(f'Range stop address "{stop}" is not in subnet "{subnet}"!') # Stop address must be greater or equal to start address if not ip_address(stop) >= ip_address(start): - raise ConfigError(f'address-range stop address "{stop}" must be greater then or equal ' \ + raise ConfigError(f'Range stop address "{stop}" must be greater then or equal ' \ f'to the range start address "{start}"!') # DHCPv6 range start address must be unique - two ranges can't @@ -94,6 +111,7 @@ def verify(dhcpv6): if start in range6_start: raise ConfigError(f'Conflicting DHCPv6 lease range: '\ f'Pool start address "{start}" defined multipe times!') + range6_start.append(start) # DHCPv6 range stop address must be unique - two ranges can't @@ -101,26 +119,48 @@ def verify(dhcpv6): if stop in range6_stop: raise ConfigError(f'Conflicting DHCPv6 lease range: '\ f'Pool stop address "{stop}" defined multipe times!') + range6_stop.append(stop) - if 'prefix' in subnet_config: - for prefix in subnet_config['prefix']: - if ip_network(prefix) not in ip_network(subnet): - raise ConfigError(f'address-range prefix "{prefix}" is not in subnet "{subnet}""') + if 'prefix' in range_config: + prefix = range_config['prefix'] + + if not ip_network(prefix).subnet_of(ip_network(subnet)): + raise ConfigError(f'Range prefix "{prefix}" is not in subnet "{subnet}"') # Prefix delegation sanity checks if 'prefix_delegation' in subnet_config: - if 'start' not in subnet_config['prefix_delegation']: - raise ConfigError('prefix-delegation start address not defined!') + if 'prefix' not in subnet_config['prefix_delegation']: + raise ConfigError('prefix-delegation prefix not defined!') - for prefix, prefix_config in subnet_config['prefix_delegation']['start'].items(): - if 'stop' not in prefix_config: - raise ConfigError(f'Stop address of delegated IPv6 prefix range "{prefix}" '\ + for prefix, prefix_config in subnet_config['prefix_delegation']['prefix'].items(): + if 'delegated_length' not in prefix_config: + raise ConfigError(f'Delegated IPv6 prefix length for "{prefix}" '\ f'must be configured') if 'prefix_length' not in prefix_config: raise ConfigError('Length of delegated IPv6 prefix must be configured') + if prefix_config['prefix_length'] > prefix_config['delegated_length']: + raise ConfigError('Length of delegated IPv6 prefix must be within parent prefix') + + if 'excluded_prefix' in prefix_config: + if 'excluded_prefix_length' not in prefix_config: + raise ConfigError('Length of excluded IPv6 prefix must be configured') + + prefix_len = prefix_config['prefix_length'] + prefix_obj = ip_network(f'{prefix}/{prefix_len}') + + excluded_prefix = prefix_config['excluded_prefix'] + excluded_len = prefix_config['excluded_prefix_length'] + excluded_obj = ip_network(f'{excluded_prefix}/{excluded_len}') + + if excluded_len <= prefix_config['delegated_length']: + raise ConfigError('Excluded IPv6 prefix must be smaller than delegated prefix') + + if not excluded_obj.subnet_of(prefix_obj): + raise ConfigError(f'Excluded prefix "{excluded_prefix}" does not exist in the prefix') + # Static mappings don't require anything (but check if IP is in subnet if it's set) if 'static_mapping' in subnet_config: for mapping, mapping_config in subnet_config['static_mapping'].items(): @@ -129,13 +169,20 @@ def verify(dhcpv6): if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet): raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') - if 'vendor_option' in subnet_config: - if len(dict_search('vendor_option.cisco.tftp_server', subnet_config)) > 2: - raise ConfigError(f'No more then two Cisco tftp-servers should be defined for subnet "{subnet}"!') + if ('mac' not in mapping_config and 'duid' not in mapping_config) or \ + ('mac' in mapping_config and 'duid' in mapping_config): + raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for ' + f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') + + if 'option' in subnet_config: + if 'vendor_option' in subnet_config['option']: + if len(dict_search('option.vendor_option.cisco.tftp_server', subnet_config)) > 2: + raise ConfigError(f'No more then two Cisco tftp-servers should be defined for subnet "{subnet}"!') # Subnets must be unique if subnet in subnets: raise ConfigError(f'DHCPv6 subnets must be unique! Subnet {subnet} defined multiple times!') + subnets.append(subnet) # DHCPv6 requires at least one configured address range or one static mapping @@ -168,12 +215,25 @@ def generate(dhcpv6): if not dhcpv6 or 'disable' in dhcpv6: return None - render(config_file, 'dhcp-server/dhcpdv6.conf.j2', dhcpv6) + dhcpv6['lease_file'] = lease_file + dhcpv6['machine'] = os.uname().machine + + # Create directory for lease file if necessary + lease_dir = os.path.dirname(lease_file) + if not os.path.isdir(lease_dir): + makedir(lease_dir, group='vyattacfg') + chmod_775(lease_dir) + + # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way + if not os.path.exists(lease_file): + write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) + + render(config_file, 'dhcp-server/kea-dhcp6.conf.j2', dhcpv6, user=user_group, group=user_group) return None def apply(dhcpv6): # bail out early - looks like removal from running config - service_name = 'isc-dhcp-server6.service' + service_name = 'kea-dhcp6-server.service' if not dhcpv6 or 'disable' in dhcpv6: # DHCP server is removed in the commit call(f'systemctl stop {service_name}') @@ -182,6 +242,7 @@ def apply(dhcpv6): return None call(f'systemctl restart {service_name}') + return None if __name__ == '__main__': diff --git a/src/conf_mode/service_dns_dynamic.py b/src/conf_mode/service_dns_dynamic.py new file mode 100755 index 000000000..a551a9891 --- /dev/null +++ b/src/conf_mode/service_dns_dynamic.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import re +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.configverify import verify_interface_exists +from vyos.configverify import dynamic_interface_pattern +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.network import interface_exists +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/ddclient/ddclient.conf' +systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf' + +# Protocols that require zone +zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi', + 'nfsn', 'nsupdate'] +zone_supported = zone_necessary + ['dnsexit2', 'zoneedit1'] + +# Protocols that do not require username +username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'digitalocean', 'dnsexit2', + 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla', + 'nsupdate', 'regfishde'] + +# Protocols that support TTL +ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn', + 'nsupdate'] + +# Protocols that support both IPv4 and IPv6 +dualstack_supported = ['cloudflare', 'digitalocean', 'dnsexit2', 'duckdns', + 'dyndns2', 'easydns', 'freedns', 'hetzner', 'infomaniak', + 'njalla'] + +# dyndns2 protocol in ddclient honors dual stack for selective servers +# because of the way it is implemented in ddclient +dyndns_dualstack_servers = ['members.dyndns.org', 'dynv6.com'] + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'dns', 'dynamic'] + if not conf.exists(base): + return None + + dyndns = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + dyndns['config_file'] = config_file + return dyndns + +def verify(dyndns): + # bail out early - looks like removal from running config + if not dyndns or 'name' not in dyndns: + return None + + # Dynamic DNS service provider - configuration validation + for service, config in dyndns['name'].items(): + error_msg_req = f'is required for Dynamic DNS service "{service}"' + error_msg_uns = f'is not supported for Dynamic DNS service "{service}"' + + for field in ['protocol', 'address', 'host_name']: + if field not in config: + raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}') + + if not any(x in config['address'] for x in ['interface', 'web']): + raise ConfigError(f'Either "interface" or "web" {error_msg_req} ' + f'with protocol "{config["protocol"]}"') + if all(x in config['address'] for x in ['interface', 'web']): + raise ConfigError(f'Both "interface" and "web" at the same time {error_msg_uns} ' + f'with protocol "{config["protocol"]}"') + + # If dyndns address is an interface, ensure that the interface exists + # and warn if a non-active dynamic interface is used + if 'interface' in config['address']: + tmp = re.compile(dynamic_interface_pattern) + # exclude check interface for dynamic interfaces + if tmp.match(config['address']['interface']): + if not interface_exists(config['address']['interface']): + Warning(f'Interface "{config["address"]["interface"]}" does not exist yet and ' + f'cannot be used for Dynamic DNS service "{service}" until it is up!') + else: + verify_interface_exists(config['address']['interface']) + + if 'web' in config['address']: + # If 'skip' is specified, 'url' is required as well + if 'skip' in config['address']['web'] and 'url' not in config['address']['web']: + raise ConfigError(f'"url" along with "skip" {error_msg_req} ' + f'with protocol "{config["protocol"]}"') + if 'url' in config['address']['web']: + # Warn if using checkip.dyndns.org, as it does not support HTTPS + # See: https://github.com/ddclient/ddclient/issues/597 + if re.search("^(https?://)?checkip\.dyndns\.org", config['address']['web']['url']): + Warning(f'"checkip.dyndns.org" does not support HTTPS requests for IP address ' + f'lookup. Please use a different IP address lookup service.') + + # RFC2136 uses 'key' instead of 'password' + if config['protocol'] != 'nsupdate' and 'password' not in config: + raise ConfigError(f'"password" {error_msg_req}') + + # Other RFC2136 specific configuration validation + if config['protocol'] == 'nsupdate': + if 'password' in config: + raise ConfigError(f'"password" {error_msg_uns} with protocol "{config["protocol"]}"') + for field in ['server', 'key']: + if field not in config: + raise ConfigError(f'"{field}" {error_msg_req} with protocol "{config["protocol"]}"') + + if config['protocol'] in zone_necessary and 'zone' not in config: + raise ConfigError(f'"zone" {error_msg_req} with protocol "{config["protocol"]}"') + + if config['protocol'] not in zone_supported and 'zone' in config: + raise ConfigError(f'"zone" {error_msg_uns} with protocol "{config["protocol"]}"') + + if config['protocol'] not in username_unnecessary and 'username' not in config: + raise ConfigError(f'"username" {error_msg_req} with protocol "{config["protocol"]}"') + + if config['protocol'] not in ttl_supported and 'ttl' in config: + raise ConfigError(f'"ttl" {error_msg_uns} with protocol "{config["protocol"]}"') + + if config['ip_version'] == 'both': + if config['protocol'] not in dualstack_supported: + raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} ' + f'with protocol "{config["protocol"]}"') + # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org) + if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers: + raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} ' + f'for "{config["server"]}" with protocol "{config["protocol"]}"') + + if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']): + raise ConfigError(f'"expiry-time" must be greater than "wait-time" for ' + f'Dynamic DNS service "{service}"') + + return None + +def generate(dyndns): + # bail out early - looks like removal from running config + if not dyndns or 'name' not in dyndns: + return None + + render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns, permission=0o600) + render(systemd_override, 'dns-dynamic/override.conf.j2', dyndns) + return None + +def apply(dyndns): + systemd_service = 'ddclient.service' + # Reload systemd manager configuration + call('systemctl daemon-reload') + + # bail out early - looks like removal from running config + if not dyndns or 'name' not in dyndns: + call(f'systemctl stop {systemd_service}') + if os.path.exists(config_file): + os.unlink(config_file) + else: + call(f'systemctl reload-or-restart {systemd_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/dns_forwarding.py b/src/conf_mode/service_dns_forwarding.py index c186f47af..ecad765f4 100755 --- a/src/conf_mode/dns_forwarding.py +++ b/src/conf_mode/service_dns_forwarding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -26,18 +26,18 @@ from vyos.template import render from vyos.template import bracketize_ipv6 from vyos.utils.process import call from vyos.utils.permission import chown -from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag airbag.enable() -pdns_rec_user = pdns_rec_group = 'pdns' -pdns_rec_run_dir = '/run/powerdns' +pdns_rec_user_group = 'pdns' +pdns_rec_run_dir = '/run/pdns-recursor' pdns_rec_lua_conf_file = f'{pdns_rec_run_dir}/recursor.conf.lua' pdns_rec_hostsd_lua_conf_file = f'{pdns_rec_run_dir}/recursor.vyos-hostsd.conf.lua' pdns_rec_hostsd_zones_file = f'{pdns_rec_run_dir}/recursor.forward-zones.conf' pdns_rec_config_file = f'{pdns_rec_run_dir}/recursor.conf' +pdns_rec_systemd_override = '/run/systemd/system/pdns-recursor.service.d/override.conf' hostsd_tag = 'static' @@ -55,6 +55,9 @@ def get_config(config=None): get_first_key=True, with_recursive_defaults=True) + dns['config_file'] = pdns_rec_config_file + dns['config_dir'] = os.path.dirname(pdns_rec_config_file) + # some additions to the default dictionary if 'system' in dns: base_nameservers = ['system', 'name-server'] @@ -251,11 +254,16 @@ def generate(dns): if not dns: return None - render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', - dns, user=pdns_rec_user, group=pdns_rec_group) + render(pdns_rec_systemd_override, 'dns-forwarding/override.conf.j2', dns) + + render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', dns, + user=pdns_rec_user_group, group=pdns_rec_user_group) - render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.j2', - dns, user=pdns_rec_user, group=pdns_rec_group) + render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', dns, + user=pdns_rec_user_group, group=pdns_rec_user_group) + + render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.j2', dns, + user=pdns_rec_user_group, group=pdns_rec_user_group) for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): os.unlink(zone_filename) @@ -263,21 +271,25 @@ def generate(dns): if 'authoritative_zones' in dns: for zone in dns['authoritative_zones']: render(zone['file'], 'dns-forwarding/recursor.zone.conf.j2', - zone, user=pdns_rec_user, group=pdns_rec_group) + zone, user=pdns_rec_user_group, group=pdns_rec_user_group) # if vyos-hostsd didn't create its files yet, create them (empty) for file in [pdns_rec_hostsd_lua_conf_file, pdns_rec_hostsd_zones_file]: with open(file, 'a'): pass - chown(file, user=pdns_rec_user, group=pdns_rec_group) + chown(file, user=pdns_rec_user_group, group=pdns_rec_user_group) return None def apply(dns): + systemd_service = 'pdns-recursor.service' + # Reload systemd manager configuration + call('systemctl daemon-reload') + if not dns: # DNS forwarding is removed in the commit - call('systemctl stop pdns-recursor.service') + call(f'systemctl stop {systemd_service}') if os.path.isfile(pdns_rec_config_file): os.unlink(pdns_rec_config_file) @@ -345,7 +357,7 @@ def apply(dns): hc.apply() ### finally (re)start pdns-recursor - call('systemctl restart pdns-recursor.service') + call(f'systemctl reload-or-restart {systemd_service}') if __name__ == '__main__': try: diff --git a/src/conf_mode/service_event_handler.py b/src/conf_mode/service_event-handler.py index 5028ef52f..5028ef52f 100755 --- a/src/conf_mode/service_event_handler.py +++ b/src/conf_mode/service_event-handler.py diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py new file mode 100755 index 000000000..46efc3c93 --- /dev/null +++ b/src/conf_mode/service_https.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import socket +import sys +import json + +from time import sleep + +from vyos.base import Warning +from vyos.config import Config +from vyos.config import config_dict_merge +from vyos.configdiff import get_config_diff +from vyos.configverify import verify_vrf +from vyos.defaults import api_config_state +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key +from vyos.pki import wrap_dh_parameters +from vyos.pki import load_dh_parameters +from vyos.template import render +from vyos.utils.dict import dict_search +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.file import write_file +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = '/etc/nginx/sites-enabled/default' +systemd_override = r'/run/systemd/system/nginx.service.d/override.conf' +cert_dir = '/run/nginx/certs' + +user = 'www-data' +group = 'www-data' + +systemd_service_api = '/run/systemd/system/vyos-http-api.service' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'https'] + if not conf.exists(base): + return None + + https = conf.get_config_dict(base, get_first_key=True, + key_mangling=('-', '_'), + with_pki=True) + + # store path to API config file for later use in templates + https['api_config_state'] = api_config_state + # get fully qualified system hsotname + https['hostname'] = socket.getfqdn() + + # 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 = conf.get_config_defaults(**https.kwargs, recursive=True) + if 'api' not in https or 'graphql' not in https['api']: + del default_values['api'] + + # merge CLI and default dictionary + https = config_dict_merge(default_values, https) + return https + +def verify(https): + if https is None: + return None + + if 'certificates' in https and 'certificate' in https['certificates']: + cert_name = https['certificates']['certificate'] + if 'pki' not in https: + raise ConfigError('PKI is not configured!') + + if cert_name not in https['pki']['certificate']: + raise ConfigError('Invalid certificate in configuration!') + + pki_cert = https['pki']['certificate'][cert_name] + + if 'certificate' not in pki_cert: + raise ConfigError('Missing certificate in configuration!') + + if 'private' not in pki_cert or 'key' not in pki_cert['private']: + raise ConfigError('Missing certificate private key in configuration!') + + if 'dh_params' in https['certificates']: + dh_name = https['certificates']['dh_params'] + if dh_name not in https['pki']['dh']: + raise ConfigError('Invalid DH parameter in configuration!') + + pki_dh = https['pki']['dh'][dh_name] + dh_params = load_dh_parameters(pki_dh['parameters']) + dh_numbers = dh_params.parameter_numbers() + dh_bits = dh_numbers.p.bit_length() + if dh_bits < 2048: + raise ConfigError(f'Minimum DH key-size is 2048 bits') + + else: + Warning('No certificate specified, using build-in self-signed certificates. '\ + 'Do not use them in a production environment!') + + # Check if server port is already in use by a different appliaction + listen_address = ['0.0.0.0'] + port = int(https['port']) + if 'listen_address' in https: + listen_address = https['listen_address'] + + for address in listen_address: + if not check_port_availability(address, port, 'tcp') and not is_listen_port_bind_service(port, 'nginx'): + raise ConfigError(f'TCP port "{port}" is used by another service!') + + verify_vrf(https) + + # Verify API server settings, if present + if 'api' in https: + keys = dict_search('api.keys.id', https) + gql_auth_type = dict_search('api.graphql.authentication.type', https) + + # If "api graphql" is not defined and `gql_auth_type` is None, + # there's certainly no JWT auth option, and keys are required + jwt_auth = (gql_auth_type == "token") + + # Check for incomplete key configurations in every case + valid_keys_exist = False + if keys: + for k in keys: + if 'key' not in keys[k]: + raise ConfigError(f'Missing HTTPS API key string for key id "{k}"') + else: + valid_keys_exist = True + + # If only key-based methods are enabled, + # fail the commit if no valid key configurations are found + if (not valid_keys_exist) and (not jwt_auth): + raise ConfigError('At least one HTTPS API key is required unless GraphQL token authentication is enabled!') + + if (not valid_keys_exist) and jwt_auth: + Warning(f'API keys are not configured: classic (non-GraphQL) API will be unavailable!') + + return None + +def generate(https): + if https is None: + for file in [systemd_service_api, config_file, systemd_override]: + if os.path.exists(file): + os.unlink(file) + return None + + if 'api' in https: + render(systemd_service_api, 'https/vyos-http-api.service.j2', https) + with open(api_config_state, 'w') as f: + json.dump(https['api'], f, indent=2) + else: + if os.path.exists(systemd_service_api): + os.unlink(systemd_service_api) + + # get certificate data + if 'certificates' in https and 'certificate' in https['certificates']: + cert_name = https['certificates']['certificate'] + pki_cert = https['pki']['certificate'][cert_name] + + cert_path = os.path.join(cert_dir, f'{cert_name}_cert.pem') + key_path = os.path.join(cert_dir, f'{cert_name}_key.pem') + + server_cert = str(wrap_certificate(pki_cert['certificate'])) + + # Append CA certificate if specified to form a full chain + if 'ca_certificate' in https['certificates']: + ca_cert = https['certificates']['ca_certificate'] + server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate'])) + + write_file(cert_path, server_cert, user=user, group=group, mode=0o644) + write_file(key_path, wrap_private_key(pki_cert['private']['key']), + user=user, group=group, mode=0o600) + + tmp_path = {'cert_path': cert_path, 'key_path': key_path} + + if 'dh_params' in https['certificates']: + dh_name = https['certificates']['dh_params'] + pki_dh = https['pki']['dh'][dh_name] + if 'parameters' in pki_dh: + dh_path = os.path.join(cert_dir, f'{dh_name}_dh.pem') + write_file(dh_path, wrap_dh_parameters(pki_dh['parameters']), + user=user, group=group, mode=0o600) + tmp_path.update({'dh_file' : dh_path}) + + https['certificates'].update(tmp_path) + + render(config_file, 'https/nginx.default.j2', https) + render(systemd_override, 'https/override.conf.j2', https) + return None + +def apply(https): + # Reload systemd manager configuration + call('systemctl daemon-reload') + http_api_service_name = 'vyos-http-api.service' + https_service_name = 'nginx.service' + + if https is None: + call(f'systemctl stop {http_api_service_name}') + call(f'systemctl stop {https_service_name}') + return + + if 'api' in https: + call(f'systemctl reload-or-restart {http_api_service_name}') + # Let uvicorn settle before (possibly) restarting nginx + sleep(1) + elif is_systemd_service_active(http_api_service_name): + call(f'systemctl stop {http_api_service_name}') + + call(f'systemctl reload-or-restart {https_service_name}') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/service_ids_fastnetmon.py b/src/conf_mode/service_ids_ddos-protection.py index 276a71fcb..276a71fcb 100755 --- a/src/conf_mode/service_ids_fastnetmon.py +++ b/src/conf_mode/service_ids_ddos-protection.py diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index b70e32373..6df6f3dc7 100755 --- a/src/conf_mode/service_ipoe-server.py +++ b/src/conf_mode/service_ipoe-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -15,17 +15,17 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os -import jmespath from sys import exit from vyos.config import Config from vyos.configdict import get_accel_dict -from vyos.configverify import verify_accel_ppp_base_service from vyos.configverify import verify_interface_exists from vyos.template import render from vyos.utils.process import call from vyos.utils.dict import dict_search +from vyos.accel_ppp_util import get_pools_in_order +from vyos.accel_ppp_util import verify_accel_ppp_ip_pool from vyos import ConfigError from vyos import airbag airbag.enable() @@ -35,87 +35,6 @@ ipoe_conf = '/run/accel-pppd/ipoe.conf' ipoe_chap_secrets = '/run/accel-pppd/ipoe.chap-secrets' -def get_pools_in_order(data: dict) -> list: - """Return a list of dictionaries representing pool data in the order - in which they should be allocated. Pool must be defined before we can - use it with 'next-pool' option. - - Args: - data: A dictionary of pool data, where the keys are pool names and the - values are dictionaries containing the 'subnet' key and the optional - 'next_pool' key. - - Returns: - list: A list of dictionaries - - Raises: - ValueError: If a 'next_pool' key references a pool name that - has not been defined. - ValueError: If a circular reference is found in the 'next_pool' keys. - - Example: - config_data = { - ... 'first-pool': { - ... 'next_pool': 'second-pool', - ... 'subnet': '192.0.2.0/25' - ... }, - ... 'second-pool': { - ... 'next_pool': 'third-pool', - ... 'subnet': '203.0.113.0/25' - ... }, - ... 'third-pool': { - ... 'subnet': '198.51.100.0/24' - ... }, - ... 'foo': { - ... 'subnet': '100.64.0.0/24', - ... 'next_pool': 'second-pool' - ... } - ... } - - % get_pools_in_order(config_data) - [{'third-pool': {'subnet': '198.51.100.0/24'}}, - {'second-pool': {'next_pool': 'third-pool', 'subnet': '203.0.113.0/25'}}, - {'first-pool': {'next_pool': 'second-pool', 'subnet': '192.0.2.0/25'}}, - {'foo': {'next_pool': 'second-pool', 'subnet': '100.64.0.0/24'}}] - """ - pools = [] - unresolved_pools = {} - - for pool, pool_config in data.items(): - if 'next_pool' not in pool_config: - pools.insert(0, {pool: pool_config}) - else: - unresolved_pools[pool] = pool_config - - while unresolved_pools: - resolved_pools = [] - - for pool, pool_config in unresolved_pools.items(): - next_pool_name = pool_config['next_pool'] - - if any(p for p in pools if next_pool_name in p): - index = next( - (i for i, p in enumerate(pools) if next_pool_name in p), - None) - pools.insert(index + 1, {pool: pool_config}) - resolved_pools.append(pool) - elif next_pool_name in unresolved_pools: - # next pool not yet resolved - pass - else: - raise ValueError( - f"Pool '{next_pool_name}' not defined in configuration data" - ) - - if not resolved_pools: - raise ValueError("Circular reference in configuration data") - - for pool in resolved_pools: - unresolved_pools.pop(pool) - - return pools - - def get_config(config=None): if config: conf = config @@ -128,18 +47,11 @@ def get_config(config=None): # retrieve common dictionary keys ipoe = get_accel_dict(conf, base, ipoe_chap_secrets) - if jmespath.search('client_ip_pool.name', ipoe): - dict_named_pools = jmespath.search('client_ip_pool.name', ipoe) + if dict_search('client_ip_pool', ipoe): # Multiple named pools require ordered values T5099 - ipoe['ordered_named_pools'] = get_pools_in_order(dict_named_pools) - # T5099 'next-pool' option - if jmespath.search('client_ip_pool.name.*.next_pool', ipoe): - for pool, pool_config in ipoe['client_ip_pool']['name'].items(): - if 'next_pool' in pool_config: - ipoe['first_named_pool'] = pool - ipoe['first_named_pool_subnet'] = pool_config - break + ipoe['ordered_named_pools'] = get_pools_in_order(dict_search('client_ip_pool', ipoe)) + ipoe['server_type'] = 'ipoe' return ipoe @@ -156,9 +68,7 @@ def verify(ipoe): raise ConfigError('Option "client-subnet" incompatible with "vlan"!' 'Use "ipoe client-ip-pool" instead.') - #verify_accel_ppp_base_service(ipoe, local_users=False) - # IPoE server does not have 'gateway' option in the CLI - # we cannot use configverify.py verify_accel_ppp_base_service for ipoe-server + verify_accel_ppp_ip_pool(ipoe) if dict_search('authentication.mode', ipoe) == 'radius': if not dict_search('authentication.radius.server', ipoe): @@ -169,9 +79,6 @@ def verify(ipoe): if 'key' not in radius_config: raise ConfigError(f'Missing RADIUS secret key for server "{server}"') - if 'client_ipv6_pool' in ipoe: - if 'delegate' in ipoe['client_ipv6_pool'] and 'prefix' not in ipoe['client_ipv6_pool']: - raise ConfigError('IPoE IPv6 deletate-prefix requires IPv6 prefix to be configured!') return None diff --git a/src/conf_mode/lldp.py b/src/conf_mode/service_lldp.py index c2e87d171..3c647a0e8 100755 --- a/src/conf_mode/lldp.py +++ b/src/conf_mode/service_lldp.py @@ -86,9 +86,9 @@ def verify(lldp): raise ConfigError(f'Must define both longitude and latitude for "{interface}" location!') # check options - if 'snmp' in lldp and 'enable' in lldp['snmp']: + if 'snmp' in lldp: if 'system_snmp_enabled' not in lldp: - raise ConfigError('SNMP must be configured to enable LLDP SNMP') + raise ConfigError('SNMP must be configured to enable LLDP SNMP!') def generate(lldp): @@ -121,4 +121,3 @@ if __name__ == '__main__': except ConfigError as e: print(e) exit(1) - diff --git a/src/conf_mode/service_mdns-repeater.py b/src/conf_mode/service_mdns_repeater.py index 6909731ff..6526c23d1 100755 --- a/src/conf_mode/service_mdns-repeater.py +++ b/src/conf_mode/service_mdns_repeater.py @@ -29,6 +29,7 @@ from vyos import airbag airbag.enable() config_file = '/run/avahi-daemon/avahi-daemon.conf' +systemd_override = r'/run/systemd/system/avahi-daemon.service.d/override.conf' vrrp_running_file = '/run/mdns_vrrp_active' def get_config(config=None): @@ -48,6 +49,8 @@ def get_config(config=None): if mdns: mdns['vrrp_exists'] = conf.exists('high-availability vrrp') + mdns['config_file'] = config_file + return mdns def verify(mdns): @@ -101,11 +104,16 @@ def generate(mdns): return None render(config_file, 'mdns-repeater/avahi-daemon.conf.j2', mdns) + render(systemd_override, 'mdns-repeater/override.conf.j2', mdns) return None def apply(mdns): + systemd_service = 'avahi-daemon.service' + # Reload systemd manager configuration + call('systemctl daemon-reload') + if not mdns or 'disable' in mdns: - call('systemctl stop avahi-daemon.service') + call(f'systemctl stop {systemd_service}') if os.path.exists(config_file): os.unlink(config_file) @@ -120,10 +128,10 @@ def apply(mdns): os.mknod(vrrp_running_file) # vrrp script looks for this file to update mdns repeater if len(mdns['interface']) < 2: - call('systemctl stop avahi-daemon.service') + call(f'systemctl stop {systemd_service}') return None - call('systemctl restart avahi-daemon.service') + call(f'systemctl restart {systemd_service}') return None diff --git a/src/conf_mode/service_ndp-proxy.py b/src/conf_mode/service_ndp-proxy.py new file mode 100755 index 000000000..aa2374f4c --- /dev/null +++ b/src/conf_mode/service_ndp-proxy.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configverify import verify_interface_exists +from vyos.utils.process import call +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +systemd_service = 'ndppd.service' +ndppd_config = '/run/ndppd/ndppd.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'ndp-proxy'] + if not conf.exists(base): + return None + + ndpp = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + + return ndpp + +def verify(ndpp): + if not ndpp: + return None + + if 'interface' in ndpp: + for interface, interface_config in ndpp['interface'].items(): + verify_interface_exists(interface) + + if 'rule' in interface_config: + for rule, rule_config in interface_config['rule'].items(): + if rule_config['mode'] == 'interface' and 'interface' not in rule_config: + raise ConfigError(f'Rule "{rule}" uses interface mode but no interface defined!') + + if rule_config['mode'] != 'interface' and 'interface' in rule_config: + if interface_config['mode'] != 'interface' and 'interface' in interface_config: + raise ConfigError(f'Rule "{rule}" does not use interface mode, thus interface can not be defined!') + + return None + +def generate(ndpp): + if not ndpp: + return None + + render(ndppd_config, 'ndppd/ndppd.conf.j2', ndpp) + return None + +def apply(ndpp): + if not ndpp: + call(f'systemctl stop {systemd_service}') + if os.path.isfile(ndppd_config): + os.unlink(ndppd_config) + return None + + call(f'systemctl reload-or-restart {systemd_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/ntp.py b/src/conf_mode/service_ntp.py index 1cc23a7df..f11690ee6 100755 --- a/src/conf_mode/ntp.py +++ b/src/conf_mode/service_ntp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -42,7 +42,7 @@ def get_config(config=None): if not conf.exists(base): return None - ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, with_defaults=True) ntp['config_file'] = config_file ntp['user'] = user_group diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index aace267a7..31299a15c 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -21,13 +21,16 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_accel_dict from vyos.configdict import is_node_changed -from vyos.configverify import verify_accel_ppp_base_service from vyos.configverify import verify_interface_exists from vyos.template import render from vyos.utils.process import call from vyos.utils.dict import dict_search +from vyos.accel_ppp_util import verify_accel_ppp_base_service +from vyos.accel_ppp_util import verify_accel_ppp_ip_pool +from vyos.accel_ppp_util import get_pools_in_order from vyos import ConfigError from vyos import airbag + airbag.enable() pppoe_conf = r'/run/accel-pppd/pppoe.conf' @@ -45,12 +48,19 @@ def get_config(config=None): # retrieve common dictionary keys pppoe = get_accel_dict(conf, base, pppoe_chap_secrets) + if dict_search('client_ip_pool', pppoe): + # Multiple named pools require ordered values T5099 + pppoe['ordered_named_pools'] = get_pools_in_order(dict_search('client_ip_pool', pppoe)) + # reload-or-restart does not implemented in accel-ppp # use this workaround until it will be implemented # https://phabricator.accel-ppp.org/T3 - if is_node_changed(conf, base + ['client-ip-pool']) or is_node_changed( - conf, base + ['client-ipv6-pool']): + conditions = [is_node_changed(conf, base + ['client-ip-pool']), + is_node_changed(conf, base + ['client-ipv6-pool']), + is_node_changed(conf, base + ['interface'])] + if any(conditions): pppoe.update({'restart_required': {}}) + pppoe['server_type'] = 'pppoe' return pppoe def verify(pppoe): @@ -69,17 +79,13 @@ def verify(pppoe): for interface in pppoe['interface']: verify_interface_exists(interface) - # local ippool and gateway settings config checks - if not (dict_search('client_ip_pool.subnet', pppoe) or - (dict_search('client_ip_pool.name', pppoe) or - (dict_search('client_ip_pool.start', pppoe) and - dict_search('client_ip_pool.stop', pppoe)))): - print('Warning: No PPPoE client pool defined') + verify_accel_ppp_ip_pool(pppoe) if dict_search('authentication.radius.dynamic_author.server', pppoe): if not dict_search('authentication.radius.dynamic_author.key', pppoe): raise ConfigError('DA/CoE server key required!') + return None diff --git a/src/conf_mode/salt-minion.py b/src/conf_mode/service_salt-minion.py index a8fce8e01..a8fce8e01 100755 --- a/src/conf_mode/salt-minion.py +++ b/src/conf_mode/service_salt-minion.py diff --git a/src/conf_mode/snmp.py b/src/conf_mode/service_snmp.py index d2ed5414f..6565ffd60 100755 --- a/src/conf_mode/snmp.py +++ b/src/conf_mode/service_snmp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2021 VyOS maintainers and contributors +# Copyright (C) 2018-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -54,7 +54,7 @@ def get_config(config=None): if not conf.exists(base): snmp.update({'deleted' : ''}) - if conf.exists(['service', 'lldp', 'snmp', 'enable']): + if conf.exists(['service', 'lldp', 'snmp']): snmp.update({'lldp_snmp' : ''}) if 'deleted' in snmp: @@ -86,7 +86,7 @@ def get_config(config=None): return snmp def verify(snmp): - if not snmp: + if 'deleted' in snmp: return None if {'deleted', 'lldp_snmp'} <= set(snmp): @@ -178,8 +178,6 @@ def verify(snmp): return None def generate(snmp): - - # # As we are manipulating the snmpd user database we have to stop it first! # This is even save if service is going to be removed call(f'systemctl stop {systemd_service}') @@ -190,7 +188,7 @@ def generate(snmp): if os.path.isfile(file): os.unlink(file) - if not snmp: + if 'deleted' in snmp: return None if 'v3' in snmp: @@ -244,7 +242,7 @@ def apply(snmp): # Always reload systemd manager configuration call('systemctl daemon-reload') - if not snmp: + if 'deleted' in snmp: return None # start SNMP daemon @@ -256,9 +254,7 @@ def apply(snmp): # Following daemons from FRR 9.0/stable have SNMP module compiled in VyOS frr_daemons_list = ['zebra', 'bgpd', 'ospf6d', 'ospfd', 'ripd', 'isisd', 'ldpd'] for frr_daemon in frr_daemons_list: - call( - f'vtysh -c "configure terminal" -d {frr_daemon} -c "agentx" >/dev/null' - ) + call(f'vtysh -c "configure terminal" -d {frr_daemon} -c "agentx" >/dev/null') return None diff --git a/src/conf_mode/ssh.py b/src/conf_mode/service_ssh.py index ee5e1eca2..ee5e1eca2 100755 --- a/src/conf_mode/ssh.py +++ b/src/conf_mode/service_ssh.py diff --git a/src/conf_mode/tftp_server.py b/src/conf_mode/service_tftp-server.py index 3ad346e2e..3ad346e2e 100755 --- a/src/conf_mode/tftp_server.py +++ b/src/conf_mode/service_tftp-server.py diff --git a/src/conf_mode/intel_qat.py b/src/conf_mode/system_acceleration.py index e4b248675..e4b248675 100755 --- a/src/conf_mode/intel_qat.py +++ b/src/conf_mode/system_acceleration.py diff --git a/src/conf_mode/config_mgmt.py b/src/conf_mode/system_config-management.py index c681a8405..c681a8405 100755 --- a/src/conf_mode/config_mgmt.py +++ b/src/conf_mode/system_config-management.py diff --git a/src/conf_mode/conntrack.py b/src/conf_mode/system_conntrack.py index 4cece6921..7f6c71440 100755 --- a/src/conf_mode/conntrack.py +++ b/src/conf_mode/system_conntrack.py @@ -159,6 +159,13 @@ def verify(conntrack): if not group_obj: Warning(f'{error_group} "{group_name}" has no members!') + if dict_search_args(conntrack, 'timeout', 'custom', inet, 'rule') != None: + for rule, rule_config in conntrack['timeout']['custom'][inet]['rule'].items(): + if 'protocol' not in rule_config: + raise ConfigError(f'Conntrack custom timeout rule {rule} requires protocol tcp or udp') + else: + if 'tcp' in rule_config['protocol'] and 'udp' in rule_config['protocol']: + raise ConfigError(f'conntrack custom timeout rule {rule} - Cant use both tcp and udp protocol') return None def generate(conntrack): diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index ebf9a113b..a888b125e 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -22,6 +22,7 @@ from vyos.config import Config from vyos.utils.process import call from vyos.utils.file import read_file from vyos.utils.file import write_file +from vyos.system import grub_util from vyos.template import render from vyos import ConfigError from vyos import airbag @@ -114,30 +115,7 @@ def generate(console): return None speed = console['device']['ttyS0']['speed'] - grub_config = '/boot/grub/grub.cfg' - if not os.path.isfile(grub_config): - return None - - lines = read_file(grub_config).split('\n') - p = re.compile(r'^(.* console=ttyS0),[0-9]+(.*)$') - write = False - newlines = [] - for line in lines: - if line.startswith('serial --unit'): - newline = f'serial --unit=0 --speed={speed}' - elif p.match(line): - newline = '{},{}{}'.format(p.search(line)[1], speed, p.search(line)[2]) - else: - newline = line - - if newline != line: - write = True - - newlines.append(newline) - newlines.append('') - - if write: - write_file(grub_config, '\n'.join(newlines)) + grub_util.update_console_speed(speed) return None diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/system_flow-accounting.py index 81ee39df1..206f513c8 100755 --- a/src/conf_mode/flow_accounting_conf.py +++ b/src/conf_mode/system_flow-accounting.py @@ -28,6 +28,7 @@ from vyos.ifconfig import Section from vyos.template import render from vyos.utils.process import call from vyos.utils.process import cmd +from vyos.utils.process import run from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag @@ -116,6 +117,30 @@ def _nftables_config(configured_ifaces, direction, length=None): cmd(command, raising=ConfigError) +def _nftables_trigger_setup(operation: str) -> None: + """Add a dummy rule to unlock the main pmacct loop with a packet-trigger + + Args: + operation (str): 'add' or 'delete' a trigger + """ + # check if a chain exists + table_exists = False + if run('nft -snj list table ip pmacct') == 0: + table_exists = True + + if operation == 'delete' and table_exists: + nft_cmd: str = 'nft delete table ip pmacct' + cmd(nft_cmd, raising=ConfigError) + if operation == 'add' and not table_exists: + nft_cmds: list[str] = [ + 'nft add table ip pmacct', + 'nft add chain ip pmacct pmacct_out { type filter hook output priority raw - 50 \\; policy accept \\; }', + 'nft add rule ip pmacct pmacct_out oif lo ip daddr 127.0.254.0 counter log group 2 snaplen 1 queue-threshold 0 comment NFLOG_TRIGGER' + ] + for nft_cmd in nft_cmds: + cmd(nft_cmd, raising=ConfigError) + + def get_config(config=None): if config: conf = config @@ -252,7 +277,6 @@ def generate(flow_config): call('systemctl daemon-reload') def apply(flow_config): - action = 'restart' # Check if flow-accounting was removed and define command if not flow_config: _nftables_config([], 'ingress') @@ -262,6 +286,10 @@ def apply(flow_config): call(f'systemctl stop {systemd_service}') if os.path.exists(uacctd_conf_path): os.unlink(uacctd_conf_path) + + # must be done after systemctl + _nftables_trigger_setup('delete') + return # Start/reload flow-accounting daemon @@ -277,6 +305,10 @@ def apply(flow_config): else: _nftables_config([], 'egress') + # add a trigger for signal processing + _nftables_trigger_setup('add') + + if __name__ == '__main__': try: config = get_config() diff --git a/src/conf_mode/system_frr.py b/src/conf_mode/system_frr.py index 6727b63c2..07f291000 100755 --- a/src/conf_mode/system_frr.py +++ b/src/conf_mode/system_frr.py @@ -40,7 +40,9 @@ def get_config(config=None): conf = Config() base = ['system', 'frr'] - frr_config = conf.get_config_dict(base, get_first_key=True) + frr_config = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) return frr_config diff --git a/src/conf_mode/host_name.py b/src/conf_mode/system_host-name.py index 36d1f6493..6204cf247 100755 --- a/src/conf_mode/host_name.py +++ b/src/conf_mode/system_host-name.py @@ -61,8 +61,9 @@ def get_config(config=None): hosts['domain_name'] = conf.return_value(['system', 'domain-name']) hosts['domain_search'].append(hosts['domain_name']) - for search in conf.return_values(['system', 'domain-search', 'domain']): - hosts['domain_search'].append(search) + if conf.exists(['system', 'domain-search']): + for search in conf.return_values(['system', 'domain-search']): + hosts['domain_search'].append(search) if conf.exists(['system', 'name-server']): for ns in conf.return_values(['system', 'name-server']): diff --git a/src/conf_mode/system-ip.py b/src/conf_mode/system_ip.py index 7612e2c0d..7612e2c0d 100755 --- a/src/conf_mode/system-ip.py +++ b/src/conf_mode/system_ip.py diff --git a/src/conf_mode/system-ipv6.py b/src/conf_mode/system_ipv6.py index 90a1a8087..90a1a8087 100755 --- a/src/conf_mode/system-ipv6.py +++ b/src/conf_mode/system_ipv6.py diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system_login.py index 87a269499..3d16bdb4a 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system_login.py @@ -20,6 +20,7 @@ from passlib.hosts import linux_context from psutil import users from pwd import getpwall from pwd import getpwnam +from pwd import getpwuid from sys import exit from time import sleep @@ -29,6 +30,7 @@ from vyos.defaults import directories from vyos.template import render from vyos.template import is_ipv4 from vyos.utils.dict import dict_search +from vyos.utils.file import chown from vyos.utils.process import cmd from vyos.utils.process import call from vyos.utils.process import rc_cmd @@ -306,6 +308,7 @@ def generate(login): def apply(login): + enable_otp = False if 'user' in login: for user, user_config in login['user'].items(): # make new user using vyatta shell and make home directory (-m), @@ -330,16 +333,22 @@ def apply(login): if tmp: command += f" --home '{tmp}'" else: command += f" --home '/home/{user}'" - command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk {user}' + command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea {user}' try: cmd(command) - # we should not rely on the value stored in # user_config['home_directory'], as a crazy user will choose # username root or any other system user which will fail. # # XXX: Should we deny using root at all? home_dir = getpwnam(user).pw_dir + # T5875: ensure UID is properly set on home directory if user is re-added + # the home directory will always exist, as it's created above by --create-home, + # retrieve current owner of home directory and adjust it on demand + dir_owner = getpwuid(os.stat(home_dir).st_uid).pw_name + if dir_owner != user: + chown(home_dir, user=user, recursive=True) + render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2', user_config, permission=0o600, formater=lambda _: _.replace(""", '"'), @@ -350,6 +359,7 @@ def apply(login): # Generate 2FA/MFA One-Time-Pad configuration if dict_search('authentication.otp.key', user_config): + enable_otp = True render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', user_config, permission=0o400, user=user, group='users') else: @@ -398,6 +408,11 @@ def apply(login): pam_profile = 'tacplus-optional' cmd(f'pam-auth-update --enable {pam_profile}') + # Enable/disable Google authenticator + cmd('pam-auth-update --disable mfa-google-authenticator') + if enable_otp: + cmd(f'pam-auth-update --enable mfa-google-authenticator') + return None diff --git a/src/conf_mode/system-login-banner.py b/src/conf_mode/system_login_banner.py index 65fa04417..65fa04417 100755 --- a/src/conf_mode/system-login-banner.py +++ b/src/conf_mode/system_login_banner.py diff --git a/src/conf_mode/system-logs.py b/src/conf_mode/system_logs.py index 8ad4875d4..8ad4875d4 100755 --- a/src/conf_mode/system-logs.py +++ b/src/conf_mode/system_logs.py diff --git a/src/conf_mode/system-option.py b/src/conf_mode/system_option.py index d92121b3d..3b5b67437 100755 --- a/src/conf_mode/system-option.py +++ b/src/conf_mode/system_option.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright (C) 2019-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 @@ -22,6 +22,7 @@ from time import sleep from vyos.config import Config from vyos.configverify import verify_source_interface +from vyos.system import grub_util from vyos.template import render from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_running @@ -39,7 +40,6 @@ time_format_to_locale = { '24-hour': 'en_GB.UTF-8' } - def get_config(config=None): if config: conf = config @@ -87,6 +87,13 @@ def verify(options): def generate(options): render(curlrc_config, 'system/curlrc.j2', options) render(ssh_config, 'system/ssh_config.j2', options) + + cmdline_options = [] + if 'kernel' in options: + if 'disable_mitigations' in options['kernel']: + cmdline_options.append('mitigations=off') + grub_util.update_kernel_cmdline_options(' '.join(cmdline_options)) + return None def apply(options): diff --git a/src/conf_mode/system-proxy.py b/src/conf_mode/system_proxy.py index 079c43e7e..079c43e7e 100755 --- a/src/conf_mode/system-proxy.py +++ b/src/conf_mode/system_proxy.py diff --git a/src/conf_mode/system_sflow.py b/src/conf_mode/system_sflow.py index 2df1bbb7a..41119b494 100755 --- a/src/conf_mode/system_sflow.py +++ b/src/conf_mode/system_sflow.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright (C) 2023-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 @@ -19,6 +19,7 @@ import os from sys import exit from vyos.config import Config +from vyos.configverify import verify_vrf from vyos.template import render from vyos.utils.process import call from vyos.utils.network import is_addr_assigned @@ -46,7 +47,6 @@ def get_config(config=None): return sflow - def verify(sflow): if not sflow: return None @@ -68,9 +68,8 @@ def verify(sflow): if 'server' not in sflow: raise ConfigError('You need to configure at least one sFlow server!') - # return True if all checks were passed - return True - + verify_vrf(sflow) + return None def generate(sflow): if not sflow: @@ -81,7 +80,6 @@ def generate(sflow): # Reload systemd manager configuration call('systemctl daemon-reload') - def apply(sflow): if not sflow: # Stop flow-accounting daemon and remove configuration file @@ -93,7 +91,6 @@ def apply(sflow): # Start/reload flow-accounting daemon call(f'systemctl restart {systemd_service}') - if __name__ == '__main__': try: config = get_config() diff --git a/src/conf_mode/system-syslog.py b/src/conf_mode/system_syslog.py index 07fbb0734..07fbb0734 100755 --- a/src/conf_mode/system-syslog.py +++ b/src/conf_mode/system_syslog.py diff --git a/src/conf_mode/task_scheduler.py b/src/conf_mode/system_task-scheduler.py index 129be5d3c..129be5d3c 100755 --- a/src/conf_mode/task_scheduler.py +++ b/src/conf_mode/system_task-scheduler.py diff --git a/src/conf_mode/system-timezone.py b/src/conf_mode/system_timezone.py index cd3d4b229..cd3d4b229 100755 --- a/src/conf_mode/system-timezone.py +++ b/src/conf_mode/system_timezone.py diff --git a/src/conf_mode/system_update_check.py b/src/conf_mode/system_update-check.py index 8d641a97d..8d641a97d 100755 --- a/src/conf_mode/system_update_check.py +++ b/src/conf_mode/system_update-check.py diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 9e9385ddb..d074ed159 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright (C) 2021-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -27,6 +27,7 @@ from vyos.base import Warning from vyos.config import Config from vyos.configdict import leaf_node_changed from vyos.configverify import verify_interface_exists +from vyos.configverify import dynamic_interface_pattern from vyos.defaults import directories from vyos.ifconfig import Interface from vyos.pki import encode_certificate @@ -43,6 +44,7 @@ from vyos.template import is_ipv4 from vyos.template import is_ipv6 from vyos.template import render from vyos.utils.network import is_ipv6_link_local +from vyos.utils.network import interface_exists from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.process import call @@ -65,11 +67,11 @@ default_install_routes = 'yes' vici_socket = '/var/run/charon.vici' -CERT_PATH = f'{swanctl_dir}/x509/' +CERT_PATH = f'{swanctl_dir}/x509/' PUBKEY_PATH = f'{swanctl_dir}/pubkey/' -KEY_PATH = f'{swanctl_dir}/private/' -CA_PATH = f'{swanctl_dir}/x509ca/' -CRL_PATH = f'{swanctl_dir}/x509crl/' +KEY_PATH = f'{swanctl_dir}/private/' +CA_PATH = f'{swanctl_dir}/x509ca/' +CRL_PATH = f'{swanctl_dir}/x509crl/' DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_waiting' @@ -87,15 +89,13 @@ def get_config(config=None): ipsec = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True, - with_recursive_defaults=True) + with_recursive_defaults=True, + with_pki=True) ipsec['dhcp_no_address'] = {} ipsec['install_routes'] = 'no' if conf.exists(base + ["options", "disable-route-autoinstall"]) else default_install_routes ipsec['interface_change'] = leaf_node_changed(conf, base + ['interface']) ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel']) - ipsec['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True) tmp = conf.get_config_dict(l2tp_base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, @@ -160,9 +160,16 @@ def verify(ipsec): if 'id' not in psk_config or 'secret' not in psk_config: raise ConfigError(f'Authentication psk "{psk}" missing "id" or "secret"') - if 'interfaces' in ipsec : - for ifname in ipsec['interface']: - verify_interface_exists(ifname) + if 'interface' in ipsec: + tmp = re.compile(dynamic_interface_pattern) + for interface in ipsec['interface']: + # exclude check interface for dynamic interfaces + if tmp.match(interface): + if not interface_exists(interface): + Warning(f'Interface "{interface}" does not exist yet and cannot be used ' + f'for IPsec until it is up!') + else: + verify_interface_exists(interface) if 'l2tp' in ipsec: if 'esp_group' in ipsec['l2tp']: @@ -396,7 +403,7 @@ def verify(ipsec): if 'bind' in peer_conf['vti']: vti_interface = peer_conf['vti']['bind'] - if not os.path.exists(f'/sys/class/net/{vti_interface}'): + if not interface_exists(vti_interface): raise ConfigError(f'VTI interface {vti_interface} for site-to-site peer {peer} does not exist!') if 'vti' not in peer_conf and 'tunnel' not in peer_conf: diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index 6232ce64a..4ca717814 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright (C) 2019-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 @@ -15,321 +15,47 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os -import re -from copy import deepcopy -from stat import S_IRUSR, S_IWUSR, S_IRGRP from sys import exit -from ipaddress import ip_network - from vyos.config import Config -from vyos.template import is_ipv4 +from vyos.configdep import call_dependents, set_dependents +from vyos.configdict import get_accel_dict from vyos.template import render from vyos.utils.process import call -from vyos.utils.system import get_half_cpus -from vyos.utils.network import check_port_availability -from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.dict import dict_search +from vyos.accel_ppp_util import verify_accel_ppp_base_service +from vyos.accel_ppp_util import verify_accel_ppp_ip_pool +from vyos.accel_ppp_util import get_pools_in_order +from vyos.base import Warning from vyos import ConfigError from vyos import airbag airbag.enable() + l2tp_conf = '/run/accel-pppd/l2tp.conf' l2tp_chap_secrets = '/run/accel-pppd/l2tp.chap-secrets' -default_config_data = { - 'auth_mode': 'local', - 'auth_ppp_mppe': 'prefer', - 'auth_proto': ['auth_mschap_v2'], - 'chap_secrets_file': l2tp_chap_secrets, # used in Jinja2 template - 'client_ip_pool': None, - 'client_ip_subnets': [], - 'client_ipv6_pool': [], - 'client_ipv6_pool_configured': False, - 'client_ipv6_delegate_prefix': [], - 'dnsv4': [], - 'dnsv6': [], - 'gateway_address': '10.255.255.0', - 'local_users' : [], - 'mtu': '1436', - 'outside_addr': '', - 'ppp_mppe': 'prefer', - 'ppp_echo_failure' : '3', - 'ppp_echo_interval' : '30', - 'ppp_echo_timeout': '0', - 'ppp_ipv6_accept_peer_intf_id': False, - 'ppp_ipv6_intf_id': None, - 'ppp_ipv6_peer_intf_id': None, - 'radius_server': [], - 'radius_acct_inter_jitter': '', - 'radius_acct_interim_interval': None, - 'radius_acct_tmo': '3', - 'radius_max_try': '3', - 'radius_timeout': '3', - 'radius_nas_id': '', - 'radius_nas_ip': '', - 'radius_source_address': '', - 'radius_shaper_attr': '', - 'radius_shaper_vendor': '', - 'radius_dynamic_author': {}, - 'wins': [], - 'ip6_column': [], - 'thread_cnt': get_half_cpus() -} - def get_config(config=None): if config: conf = config else: conf = Config() - base_path = ['vpn', 'l2tp', 'remote-access'] - if not conf.exists(base_path): - return None - - conf.set_level(base_path) - l2tp = deepcopy(default_config_data) - - ### general options ### - if conf.exists(['name-server']): - for name_server in conf.return_values(['name-server']): - if is_ipv4(name_server): - l2tp['dnsv4'].append(name_server) - else: - l2tp['dnsv6'].append(name_server) - - if conf.exists(['wins-server']): - l2tp['wins'] = conf.return_values(['wins-server']) - - if conf.exists('outside-address'): - l2tp['outside_addr'] = conf.return_value('outside-address') - - if conf.exists(['authentication', 'mode']): - l2tp['auth_mode'] = conf.return_value(['authentication', 'mode']) - - if conf.exists(['authentication', 'require']): - l2tp['auth_proto'] = [] - auth_mods = { - 'pap': 'auth_pap', - 'chap': 'auth_chap_md5', - 'mschap': 'auth_mschap_v1', - 'mschap-v2': 'auth_mschap_v2' - } - - for proto in conf.return_values(['authentication', 'require']): - l2tp['auth_proto'].append(auth_mods[proto]) - - if conf.exists(['authentication', 'mppe']): - l2tp['auth_ppp_mppe'] = conf.return_value(['authentication', 'mppe']) - - # - # local auth - if conf.exists(['authentication', 'local-users']): - for username in conf.list_nodes(['authentication', 'local-users', 'username']): - user = { - 'name' : username, - 'password' : '', - 'state' : 'enabled', - 'ip' : '*', - 'upload' : None, - 'download' : None - } - - conf.set_level(base_path + ['authentication', 'local-users', 'username', username]) - - if conf.exists(['password']): - user['password'] = conf.return_value(['password']) - - if conf.exists(['disable']): - user['state'] = 'disable' - - if conf.exists(['static-ip']): - user['ip'] = conf.return_value(['static-ip']) - - if conf.exists(['rate-limit', 'download']): - user['download'] = conf.return_value(['rate-limit', 'download']) - - if conf.exists(['rate-limit', 'upload']): - user['upload'] = conf.return_value(['rate-limit', 'upload']) - - l2tp['local_users'].append(user) - - # - # RADIUS auth and settings - conf.set_level(base_path + ['authentication', 'radius']) - if conf.exists(['server']): - for server in conf.list_nodes(['server']): - radius = { - 'server' : server, - 'key' : '', - 'fail_time' : 0, - 'port' : '1812', - 'acct_port' : '1813' - } - - conf.set_level(base_path + ['authentication', 'radius', 'server', server]) - - if conf.exists(['disable-accounting']): - radius['acct_port'] = '0' - - if conf.exists(['fail-time']): - radius['fail_time'] = conf.return_value(['fail-time']) - - if conf.exists(['port']): - radius['port'] = conf.return_value(['port']) - - if conf.exists(['acct-port']): - radius['acct_port'] = conf.return_value(['acct-port']) - - if conf.exists(['key']): - radius['key'] = conf.return_value(['key']) - - if not conf.exists(['disable']): - l2tp['radius_server'].append(radius) - - # - # advanced radius-setting - conf.set_level(base_path + ['authentication', 'radius']) - - if conf.exists(['accounting-interim-interval']): - l2tp['radius_acct_interim_interval'] = conf.return_value(['accounting-interim-interval']) - - if conf.exists(['acct-interim-jitter']): - l2tp['radius_acct_inter_jitter'] = conf.return_value(['acct-interim-jitter']) - - if conf.exists(['acct-timeout']): - l2tp['radius_acct_tmo'] = conf.return_value(['acct-timeout']) - - if conf.exists(['max-try']): - l2tp['radius_max_try'] = conf.return_value(['max-try']) - - if conf.exists(['timeout']): - l2tp['radius_timeout'] = conf.return_value(['timeout']) - - if conf.exists(['nas-identifier']): - l2tp['radius_nas_id'] = conf.return_value(['nas-identifier']) - - if conf.exists(['nas-ip-address']): - l2tp['radius_nas_ip'] = conf.return_value(['nas-ip-address']) - - if conf.exists(['source-address']): - l2tp['radius_source_address'] = conf.return_value(['source-address']) - - # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) - if conf.exists(['dae-server']): - dae = { - 'port' : '', - 'server' : '', - 'key' : '' - } + base = ['vpn', 'l2tp', 'remote-access'] - if conf.exists(['dae-server', 'ip-address']): - dae['server'] = conf.return_value(['dae-server', 'ip-address']) + set_dependents('ipsec', conf) - if conf.exists(['dae-server', 'port']): - dae['port'] = conf.return_value(['dae-server', 'port']) - - if conf.exists(['dae-server', 'secret']): - dae['key'] = conf.return_value(['dae-server', 'secret']) - - l2tp['radius_dynamic_author'] = dae - - if conf.exists(['rate-limit', 'enable']): - l2tp['radius_shaper_attr'] = 'Filter-Id' - c_attr = ['rate-limit', 'enable', 'attribute'] - if conf.exists(c_attr): - l2tp['radius_shaper_attr'] = conf.return_value(c_attr) - - c_vendor = ['rate-limit', 'enable', 'vendor'] - if conf.exists(c_vendor): - l2tp['radius_shaper_vendor'] = conf.return_value(c_vendor) - - conf.set_level(base_path) - if conf.exists(['client-ip-pool']): - if conf.exists(['client-ip-pool', 'start']) and conf.exists(['client-ip-pool', 'stop']): - start = conf.return_value(['client-ip-pool', 'start']) - stop = conf.return_value(['client-ip-pool', 'stop']) - l2tp['client_ip_pool'] = start + '-' + re.search('[0-9]+$', stop).group(0) - - if conf.exists(['client-ip-pool', 'subnet']): - l2tp['client_ip_subnets'] = conf.return_values(['client-ip-pool', 'subnet']) - - if conf.exists(['client-ipv6-pool', 'prefix']): - l2tp['client_ipv6_pool_configured'] = True - l2tp['ip6_column'].append('ip6') - for prefix in conf.list_nodes(['client-ipv6-pool', 'prefix']): - tmp = { - 'prefix': prefix, - 'mask': '64' - } - - if conf.exists(['client-ipv6-pool', 'prefix', prefix, 'mask']): - tmp['mask'] = conf.return_value(['client-ipv6-pool', 'prefix', prefix, 'mask']) - - l2tp['client_ipv6_pool'].append(tmp) - - if conf.exists(['client-ipv6-pool', 'delegate']): - l2tp['ip6_column'].append('ip6-db') - for prefix in conf.list_nodes(['client-ipv6-pool', 'delegate']): - tmp = { - 'prefix': prefix, - 'mask': '' - } - - if conf.exists(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']): - tmp['mask'] = conf.return_value(['client-ipv6-pool', 'delegate', prefix, 'delegation-prefix']) - - l2tp['client_ipv6_delegate_prefix'].append(tmp) - - if conf.exists(['mtu']): - l2tp['mtu'] = conf.return_value(['mtu']) - - # gateway address - if conf.exists(['gateway-address']): - l2tp['gateway_address'] = conf.return_value(['gateway-address']) - else: - # calculate gw-ip-address - if conf.exists(['client-ip-pool', 'start']): - # use start ip as gw-ip-address - l2tp['gateway_address'] = conf.return_value(['client-ip-pool', 'start']) - - elif conf.exists(['client-ip-pool', 'subnet']): - # use first ip address from first defined pool - subnet = conf.return_values(['client-ip-pool', 'subnet'])[0] - subnet = ip_network(subnet) - l2tp['gateway_address'] = str(list(subnet.hosts())[0]) - - # LNS secret - if conf.exists(['lns', 'shared-secret']): - l2tp['lns_shared_secret'] = conf.return_value(['lns', 'shared-secret']) - if conf.exists(['lns', 'host-name']): - l2tp['lns_host_name'] = conf.return_value(['lns', 'host-name']) - - if conf.exists(['ccp-disable']): - l2tp['ccp_disable'] = True - - # PPP options - if conf.exists(['idle']): - l2tp['ppp_echo_timeout'] = conf.return_value(['idle']) - - if conf.exists(['ppp-options', 'lcp-echo-failure']): - l2tp['ppp_echo_failure'] = conf.return_value(['ppp-options', 'lcp-echo-failure']) - - if conf.exists(['ppp-options', 'lcp-echo-interval']): - l2tp['ppp_echo_interval'] = conf.return_value(['ppp-options', 'lcp-echo-interval']) - - if conf.exists(['ppp-options', 'ipv6']): - l2tp['ppp_ipv6'] = conf.return_value(['ppp-options', 'ipv6']) - - if conf.exists(['ppp-options', 'ipv6-accept-peer-intf-id']): - l2tp['ppp_ipv6_accept_peer_intf_id'] = True - - if conf.exists(['ppp-options', 'ipv6-intf-id']): - l2tp['ppp_ipv6_intf_id'] = conf.return_value(['ppp-options', 'ipv6-intf-id']) - - if conf.exists(['ppp-options', 'ipv6-peer-intf-id']): - l2tp['ppp_ipv6_peer_intf_id'] = conf.return_value(['ppp-options', 'ipv6-peer-intf-id']) + if not conf.exists(base): + return None + # retrieve common dictionary keys + l2tp = get_accel_dict(conf, base, l2tp_chap_secrets) + if dict_search('client_ip_pool', l2tp): + # Multiple named pools require ordered values T5099 + l2tp['ordered_named_pools'] = get_pools_in_order( + dict_search('client_ip_pool', l2tp)) + l2tp['server_type'] = 'l2tp' return l2tp @@ -337,56 +63,18 @@ def verify(l2tp): if not l2tp: return None - if l2tp['auth_mode'] == 'local': - if not l2tp['local_users']: - raise ConfigError('L2TP local auth mode requires local users to be configured!') + verify_accel_ppp_base_service(l2tp) - for user in l2tp['local_users']: - if not user['password']: - raise ConfigError(f"Password required for user {user['name']}") + if dict_search('authentication.radius.dynamic_author.server', l2tp): + if not dict_search('authentication.radius.dynamic_author.key', l2tp): + raise ConfigError('DA/CoE server key required!') - elif l2tp['auth_mode'] == 'radius': - if len(l2tp['radius_server']) == 0: - raise ConfigError("RADIUS authentication requires at least one server") + verify_accel_ppp_ip_pool(l2tp) - for radius in l2tp['radius_server']: - if not radius['key']: - raise ConfigError(f"Missing RADIUS secret for server { radius['key'] }") - if l2tp['radius_dynamic_author']: - if not l2tp['radius_dynamic_author']['server']: - raise ConfigError("Missing ip-address for dae-server") - if not l2tp['radius_dynamic_author']['key']: - raise ConfigError("Missing secret for dae-server") - address = l2tp['radius_dynamic_author']['server'] - port = l2tp['radius_dynamic_author']['port'] - proto = 'tcp' - # check if dae listen port is not used by another service - if check_port_availability(address, int(port), proto) is not True and \ - not is_listen_port_bind_service(int(port), 'accel-pppd'): - raise ConfigError(f'"{proto}" port "{port}" is used by another service') - - # check for the existence of a client ip pool - if not (l2tp['client_ip_pool'] or l2tp['client_ip_subnets']): + if 'wins_server' in l2tp and len(l2tp['wins_server']) > 2: raise ConfigError( - "set vpn l2tp remote-access client-ip-pool requires subnet or start/stop IP pool") - - # check ipv6 - if l2tp['client_ipv6_delegate_prefix'] and not l2tp['client_ipv6_pool']: - raise ConfigError('IPv6 prefix delegation requires client-ipv6-pool prefix') - - for prefix in l2tp['client_ipv6_delegate_prefix']: - if not prefix['mask']: - raise ConfigError('Delegation-prefix required for individual delegated networks') - - if len(l2tp['wins']) > 2: - raise ConfigError('Not more then two IPv4 WINS name-servers can be configured') - - if len(l2tp['dnsv4']) > 2: - raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') - - if len(l2tp['dnsv6']) > 3: - raise ConfigError('Not more then three IPv6 DNS name-servers can be configured') + 'Not more then two WINS name-servers can be configured') return None @@ -397,13 +85,9 @@ def generate(l2tp): render(l2tp_conf, 'accel-ppp/l2tp.config.j2', l2tp) - if l2tp['auth_mode'] == 'local': - render(l2tp_chap_secrets, 'accel-ppp/chap-secrets.j2', l2tp) - os.chmod(l2tp_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP) - - else: - if os.path.exists(l2tp_chap_secrets): - os.unlink(l2tp_chap_secrets) + if dict_search('authentication.mode', l2tp) == 'local': + render(l2tp_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2', + l2tp, permission=0o640) return None @@ -414,10 +98,11 @@ def apply(l2tp): for file in [l2tp_chap_secrets, l2tp_conf]: if os.path.exists(file): os.unlink(file) + else: + call('systemctl restart accel-ppp@l2tp.service') - return None + call_dependents() - call('systemctl restart accel-ppp@l2tp.service') if __name__ == '__main__': try: @@ -425,6 +110,7 @@ if __name__ == '__main__': verify(c) generate(c) apply(c) + except ConfigError as e: print(e) exit(1) diff --git a/src/conf_mode/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py index a039172c4..08e4fc6db 100755 --- a/src/conf_mode/vpn_openconnect.py +++ b/src/conf_mode/vpn_openconnect.py @@ -56,12 +56,8 @@ def get_config(config=None): ocserv = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, - with_recursive_defaults=True) - - if ocserv: - ocserv['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True) + with_recursive_defaults=True, + with_pki=True) return ocserv @@ -95,7 +91,7 @@ def verify(ocserv): if not ocserv["authentication"]['radius']['server']: raise ConfigError('Openconnect authentication mode radius requires at least one RADIUS server') if "local" in ocserv["authentication"]["mode"]: - if not ocserv["authentication"]["local_users"]: + if not ocserv.get("authentication", {}).get("local_users"): raise ConfigError('openconnect mode local required at least one user') if not ocserv["authentication"]["local_users"]["username"]: raise ConfigError('openconnect mode local required at least one user') diff --git a/src/conf_mode/vpn_pptp.py b/src/conf_mode/vpn_pptp.py index d542f57fe..b1d5067d5 100755 --- a/src/conf_mode/vpn_pptp.py +++ b/src/conf_mode/vpn_pptp.py @@ -15,17 +15,18 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os -import re - -from copy import deepcopy -from stat import S_IRUSR, S_IWUSR, S_IRGRP from sys import exit + from vyos.config import Config from vyos.template import render -from vyos.utils.system import get_half_cpus from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.accel_ppp_util import verify_accel_ppp_base_service +from vyos.accel_ppp_util import verify_accel_ppp_ip_pool +from vyos.accel_ppp_util import get_pools_in_order from vyos import ConfigError +from vyos.configdict import get_accel_dict from vyos import airbag airbag.enable() @@ -33,211 +34,25 @@ airbag.enable() pptp_conf = '/run/accel-pppd/pptp.conf' pptp_chap_secrets = '/run/accel-pppd/pptp.chap-secrets' -default_pptp = { - 'auth_mode' : 'local', - 'local_users' : [], - 'radius_server' : [], - 'radius_acct_inter_jitter': '', - 'radius_acct_interim_interval': None, - 'radius_acct_tmo' : '30', - 'radius_max_try' : '3', - 'radius_timeout' : '30', - 'radius_nas_id' : '', - 'radius_nas_ip' : '', - 'radius_source_address' : '', - 'radius_shaper_attr' : '', - 'radius_shaper_enable': False, - 'radius_shaper_multiplier': '', - 'radius_shaper_vendor': '', - 'radius_dynamic_author' : '', - 'chap_secrets_file': pptp_chap_secrets, # used in Jinja2 template - 'outside_addr': '', - 'dnsv4': [], - 'wins': [], - 'client_ip_pool': '', - 'mtu': '1436', - 'auth_proto' : ['auth_mschap_v2'], - 'ppp_mppe' : 'prefer', - 'thread_cnt': get_half_cpus() -} def get_config(config=None): if config: conf = config else: conf = Config() - base_path = ['vpn', 'pptp', 'remote-access'] - if not conf.exists(base_path): + base = ['vpn', 'pptp', 'remote-access'] + if not conf.exists(base): return None - pptp = deepcopy(default_pptp) - conf.set_level(base_path) - - if conf.exists(['name-server']): - pptp['dnsv4'] = conf.return_values(['name-server']) - - if conf.exists(['wins-server']): - pptp['wins'] = conf.return_values(['wins-server']) - - if conf.exists(['outside-address']): - pptp['outside_addr'] = conf.return_value(['outside-address']) - - if conf.exists(['authentication', 'mode']): - pptp['auth_mode'] = conf.return_value(['authentication', 'mode']) - - # - # local auth - if conf.exists(['authentication', 'local-users']): - for username in conf.list_nodes(['authentication', 'local-users', 'username']): - user = { - 'name': username, - 'password' : '', - 'state' : 'enabled', - 'ip' : '*', - } - - conf.set_level(base_path + ['authentication', 'local-users', 'username', username]) - - if conf.exists(['password']): - user['password'] = conf.return_value(['password']) - - if conf.exists(['disable']): - user['state'] = 'disable' - - if conf.exists(['static-ip']): - user['ip'] = conf.return_value(['static-ip']) - - if not conf.exists(['disable']): - pptp['local_users'].append(user) - - # - # RADIUS auth and settings - conf.set_level(base_path + ['authentication', 'radius']) - if conf.exists(['server']): - for server in conf.list_nodes(['server']): - radius = { - 'server' : server, - 'key' : '', - 'fail_time' : 0, - 'port' : '1812', - 'acct_port' : '1813' - } - - conf.set_level(base_path + ['authentication', 'radius', 'server', server]) - - if conf.exists(['disable-accounting']): - radius['acct_port'] = '0' - - if conf.exists(['fail-time']): - radius['fail_time'] = conf.return_value(['fail-time']) - - if conf.exists(['port']): - radius['port'] = conf.return_value(['port']) - - if conf.exists(['acct-port']): - radius['acct_port'] = conf.return_value(['acct-port']) - - if conf.exists(['key']): - radius['key'] = conf.return_value(['key']) - - if not conf.exists(['disable']): - pptp['radius_server'].append(radius) - - # - # advanced radius-setting - conf.set_level(base_path + ['authentication', 'radius']) - - if conf.exists(['accounting-interim-interval']): - pptp['radius_acct_interim_interval'] = conf.return_value(['accounting-interim-interval']) - - if conf.exists(['acct-interim-jitter']): - pptp['radius_acct_inter_jitter'] = conf.return_value(['acct-interim-jitter']) - - if conf.exists(['acct-timeout']): - pptp['radius_acct_tmo'] = conf.return_value(['acct-timeout']) - - if conf.exists(['max-try']): - pptp['radius_max_try'] = conf.return_value(['max-try']) - - if conf.exists(['timeout']): - pptp['radius_timeout'] = conf.return_value(['timeout']) - - if conf.exists(['nas-identifier']): - pptp['radius_nas_id'] = conf.return_value(['nas-identifier']) - - if conf.exists(['nas-ip-address']): - pptp['radius_nas_ip'] = conf.return_value(['nas-ip-address']) - - if conf.exists(['source-address']): - pptp['radius_source_address'] = conf.return_value(['source-address']) - - # Dynamic Authorization Extensions (DOA)/Change Of Authentication (COA) - if conf.exists(['dae-server']): - dae = { - 'port' : '', - 'server' : '', - 'key' : '' - } - - if conf.exists(['dynamic-author', 'ip-address']): - dae['server'] = conf.return_value(['dynamic-author', 'ip-address']) - - if conf.exists(['dynamic-author', 'port']): - dae['port'] = conf.return_value(['dynamic-author', 'port']) - - if conf.exists(['dynamic-author', 'key']): - dae['key'] = conf.return_value(['dynamic-author', 'key']) - - pptp['radius_dynamic_author'] = dae - - # Rate limit - if conf.exists(['rate-limit', 'attribute']): - pptp['radius_shaper_attr'] = conf.return_value(['rate-limit', 'attribute']) - - if conf.exists(['rate-limit', 'enable']): - pptp['radius_shaper_enable'] = True - - if conf.exists(['rate-limit', 'multiplier']): - pptp['radius_shaper_multiplier'] = conf.return_value(['rate-limit', 'multiplier']) - - if conf.exists(['rate-limit', 'vendor']): - pptp['radius_shaper_vendor'] = conf.return_value(['rate-limit', 'vendor']) - - conf.set_level(base_path) - if conf.exists(['client-ip-pool']): - if conf.exists(['client-ip-pool', 'start']) and conf.exists(['client-ip-pool', 'stop']): - start = conf.return_value(['client-ip-pool', 'start']) - stop = conf.return_value(['client-ip-pool', 'stop']) - pptp['client_ip_pool'] = start + '-' + re.search('[0-9]+$', stop).group(0) - - if conf.exists(['mtu']): - pptp['mtu'] = conf.return_value(['mtu']) - - # gateway address - if conf.exists(['gateway-address']): - pptp['gw_ip'] = conf.return_value(['gateway-address']) - else: - # calculate gw-ip-address - if conf.exists(['client-ip-pool', 'start']): - # use start ip as gw-ip-address - pptp['gateway_address'] = conf.return_value(['client-ip-pool', 'start']) - - if conf.exists(['authentication', 'require']): - # clear default list content, now populate with actual CLI values - pptp['auth_proto'] = [] - auth_mods = { - 'pap': 'auth_pap', - 'chap': 'auth_chap_md5', - 'mschap': 'auth_mschap_v1', - 'mschap-v2': 'auth_mschap_v2' - } - - for proto in conf.return_values(['authentication', 'require']): - pptp['auth_proto'].append(auth_mods[proto]) - - if conf.exists(['authentication', 'mppe']): - pptp['ppp_mppe'] = conf.return_value(['authentication', 'mppe']) + # retrieve common dictionary keys + pptp = get_accel_dict(conf, base, pptp_chap_secrets) + if dict_search('client_ip_pool', pptp): + # Multiple named pools require ordered values T5099 + pptp['ordered_named_pools'] = get_pools_in_order( + dict_search('client_ip_pool', pptp)) + pptp['chap_secrets_file'] = pptp_chap_secrets + pptp['server_type'] = 'pptp' return pptp @@ -245,29 +60,12 @@ def verify(pptp): if not pptp: return None - if pptp['auth_mode'] == 'local': - if not pptp['local_users']: - raise ConfigError('PPTP local auth mode requires local users to be configured!') - - for user in pptp['local_users']: - username = user['name'] - if not user['password']: - raise ConfigError(f'Password required for local user "{username}"') + verify_accel_ppp_base_service(pptp) + verify_accel_ppp_ip_pool(pptp) - elif pptp['auth_mode'] == 'radius': - if len(pptp['radius_server']) == 0: - raise ConfigError('RADIUS authentication requires at least one server') - - for radius in pptp['radius_server']: - if not radius['key']: - server = radius['server'] - raise ConfigError(f'Missing RADIUS secret key for server "{ server }"') - - if len(pptp['dnsv4']) > 2: - raise ConfigError('Not more then two IPv4 DNS name-servers can be configured') - - if len(pptp['wins']) > 2: - raise ConfigError('Not more then two IPv4 WINS name-servers can be configured') + if 'wins_server' in pptp and len(pptp['wins_server']) > 2: + raise ConfigError( + 'Not more then two WINS name-servers can be configured') def generate(pptp): @@ -276,12 +74,11 @@ def generate(pptp): render(pptp_conf, 'accel-ppp/pptp.config.j2', pptp) - if pptp['local_users']: - render(pptp_chap_secrets, 'accel-ppp/chap-secrets.j2', pptp) - os.chmod(pptp_chap_secrets, S_IRUSR | S_IWUSR | S_IRGRP) - else: - if os.path.exists(pptp_chap_secrets): - os.unlink(pptp_chap_secrets) + if dict_search('authentication.mode', pptp) == 'local': + render(pptp_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2', + pptp, permission=0o640) + + return None def apply(pptp): @@ -295,6 +92,7 @@ def apply(pptp): call('systemctl restart accel-ppp@pptp.service') + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index e98d8385b..5c229fe62 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -20,14 +20,15 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_accel_dict -from vyos.configdict import dict_merge -from vyos.configverify import verify_accel_ppp_base_service from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.utils.process import call from vyos.utils.network import check_port_availability from vyos.utils.dict import dict_search +from vyos.accel_ppp_util import verify_accel_ppp_base_service +from vyos.accel_ppp_util import verify_accel_ppp_ip_pool +from vyos.accel_ppp_util import get_pools_in_order from vyos.utils.network import is_listen_port_bind_service from vyos.utils.file import write_file from vyos import ConfigError @@ -52,14 +53,15 @@ def get_config(config=None): return None # retrieve common dictionary keys - sstp = get_accel_dict(conf, base, sstp_chap_secrets) - if sstp: - sstp['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) + sstp = get_accel_dict(conf, base, sstp_chap_secrets, with_pki=True) + if dict_search('client_ip_pool', sstp): + # Multiple named pools require ordered values T5099 + sstp['ordered_named_pools'] = get_pools_in_order(dict_search('client_ip_pool', sstp)) + sstp['server_type'] = 'sstp' return sstp + def verify(sstp): if not sstp: return None @@ -71,9 +73,7 @@ def verify(sstp): raise ConfigError(f'"{proto}" port "{port}" is used by another service') verify_accel_ppp_base_service(sstp) - - if 'client_ip_pool' not in sstp and 'client_ipv6_pool' not in sstp: - raise ConfigError('Client IP subnet required') + verify_accel_ppp_ip_pool(sstp) # # SSL certificate checks diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py deleted file mode 100755 index 82c2f236e..000000000 --- a/src/conf_mode/vpp.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import os -from psutil import virtual_memory - -from pathlib import Path -from re import search as re_search, MULTILINE as re_M - -from vyos.config import Config -from vyos.configdep import set_dependents, call_dependents -from vyos.configdict import node_changed -from vyos.ifconfig import Section -from vyos.utils.boot import boot_configuration_complete -from vyos.utils.process import call -from vyos.utils.process import rc_cmd -from vyos.utils.system import sysctl_read -from vyos.utils.system import sysctl_apply -from vyos.template import render - -from vyos import ConfigError -from vyos import airbag -from vyos.vpp import VPPControl -from vyos.vpp import HostControl - -airbag.enable() - -service_name = 'vpp' -service_conf = Path(f'/run/vpp/{service_name}.conf') -systemd_override = '/run/systemd/system/vpp.service.d/10-override.conf' - -# Free memory required for VPP -# 2 GB for hugepages + 1 GB for other services -MIN_AVAILABLE_MEMORY: int = 3 * 1024**3 - - -def _get_pci_address_by_interface(iface) -> str: - rc, out = rc_cmd(f'ethtool -i {iface}') - # if ethtool command was successful - if rc == 0 and out: - regex_filter = r'^bus-info: (?P<address>\w+:\w+:\w+\.\w+)$' - re_obj = re_search(regex_filter, out, re_M) - # if bus-info with PCI address found - if re_obj: - address = re_obj.groupdict().get('address', '') - return address - # use VPP - maybe interface already attached to it - vpp_control = VPPControl(attempts=20, interval=500) - pci_addr = vpp_control.get_pci_addr(iface) - if pci_addr: - return pci_addr - # raise error if PCI address was not found - raise ConfigError(f'Cannot find PCI address for interface {iface}') - - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['vpp'] - base_ethernet = ['interfaces', 'ethernet'] - - # find interfaces removed from VPP - removed_ifaces = [] - tmp = node_changed(conf, base + ['interface']) - if tmp: - for removed_iface in tmp: - pci_address: str = _get_pci_address_by_interface(removed_iface) - removed_ifaces.append({ - 'iface_name': removed_iface, - 'iface_pci_addr': pci_address - }) - # add an interface to a list of interfaces that need - # to be reinitialized after the commit - set_dependents('ethernet', conf, removed_iface) - - if not conf.exists(base): - return {'removed_ifaces': removed_ifaces} - - config = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - if 'interface' in config: - for iface, iface_config in config['interface'].items(): - # add an interface to a list of interfaces that need - # to be reinitialized after the commit - set_dependents('ethernet', conf, iface) - - # Get PCI address auto - if iface_config['pci'] == 'auto': - config['interface'][iface]['pci'] = _get_pci_address_by_interface(iface) - - config['other_interfaces'] = conf.get_config_dict(base_ethernet, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - if removed_ifaces: - config['removed_ifaces'] = removed_ifaces - - return config - - -def verify(config): - # bail out early - looks like removal from running config - if not config or (len(config) == 1 and 'removed_ifaces' in config): - return None - - if 'interface' not in config: - raise ConfigError('"interface" is required but not set!') - - if 'cpu' in config: - if 'corelist_workers' in config['cpu'] and 'main_core' not in config[ - 'cpu']: - raise ConfigError('"cpu main-core" is required but not set!') - - memory_available: int = virtual_memory().available - if memory_available < MIN_AVAILABLE_MEMORY: - raise ConfigError( - 'Not enough free memory to start VPP:\n' - f'available: {round(memory_available / 1024**3, 1)}GB\n' - f'required: {round(MIN_AVAILABLE_MEMORY / 1024**3, 1)}GB') - - -def generate(config): - if not config or (len(config) == 1 and 'removed_ifaces' in config): - # Remove old config and return - service_conf.unlink(missing_ok=True) - return None - - render(service_conf, 'vpp/startup.conf.j2', config) - render(systemd_override, 'vpp/override.conf.j2', config) - - # apply default sysctl values from - # https://github.com/FDio/vpp/blob/v23.06/src/vpp/conf/80-vpp.conf - sysctl_config: dict[str, str] = { - 'vm.nr_hugepages': '1024', - 'vm.max_map_count': '3096', - 'vm.hugetlb_shm_group': '0', - 'kernel.shmmax': '2147483648' - } - # we do not want to reduce `kernel.shmmax` - kernel_shmnax_current: str = sysctl_read('kernel.shmmax') - if int(kernel_shmnax_current) > int(sysctl_config['kernel.shmmax']): - sysctl_config['kernel.shmmax'] = kernel_shmnax_current - - if not sysctl_apply(sysctl_config): - raise ConfigError('Cannot configure sysctl parameters for VPP') - - return None - - -def apply(config): - if not config or (len(config) == 1 and 'removed_ifaces' in config): - call(f'systemctl stop {service_name}.service') - else: - call('systemctl daemon-reload') - call(f'systemctl restart {service_name}.service') - - # Initialize interfaces removed from VPP - for iface in config.get('removed_ifaces', []): - host_control = HostControl() - # rescan PCI to use a proper driver - host_control.pci_rescan(iface['iface_pci_addr']) - # rename to the proper name - iface_new_name: str = host_control.get_eth_name(iface['iface_pci_addr']) - host_control.rename_iface(iface_new_name, iface['iface_name']) - - if 'interface' in config: - # connect to VPP - # must be performed multiple attempts because API is not available - # immediately after the service restart - vpp_control = VPPControl(attempts=20, interval=500) - for iface, _ in config['interface'].items(): - # Create lcp - if iface not in Section.interfaces(): - vpp_control.lcp_pair_add(iface, iface) - - # reinitialize interfaces, but not during the first boot - if boot_configuration_complete(): - call_dependents() - - -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/vrf.py b/src/conf_mode/vrf.py index 37625142c..a2f4956be 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2023 VyOS maintainers and contributors +# Copyright (C) 2020-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -32,8 +32,6 @@ from vyos.utils.network import get_vrf_members from vyos.utils.network import interface_exists from vyos.utils.process import call from vyos.utils.process import cmd -from vyos.utils.process import popen -from vyos.utils.process import run from vyos.utils.system import sysctl_write from vyos import ConfigError from vyos import frr @@ -41,17 +39,29 @@ from vyos import airbag airbag.enable() config_file = '/etc/iproute2/rt_tables.d/vyos-vrf.conf' -nft_vrf_config = '/tmp/nftables-vrf-zones' - -def has_rule(af : str, priority : int, table : str): - """ Check if a given ip rule exists """ +k_mod = ['vrf'] + +def has_rule(af : str, priority : int, table : str=None): + """ + Check if a given ip rule exists + $ ip --json -4 rule show + [{'l3mdev': None, 'priority': 1000, 'src': 'all'}, + {'action': 'unreachable', 'l3mdev': None, 'priority': 2000, 'src': 'all'}, + {'priority': 32765, 'src': 'all', 'table': 'local'}, + {'priority': 32766, 'src': 'all', 'table': 'main'}, + {'priority': 32767, 'src': 'all', 'table': 'default'}] + """ if af not in ['-4', '-6']: raise ValueError() - command = f'ip -j {af} rule show' + command = f'ip --detail --json {af} rule show' for tmp in loads(cmd(command)): - if {'priority', 'table'} <= set(tmp): + if 'priority' in tmp and 'table' in tmp: if tmp['priority'] == priority and tmp['table'] == table: return True + elif 'priority' in tmp and table in tmp: + # l3mdev table has a different layout + if tmp['priority'] == priority: + return True return False def vrf_interfaces(c, match): @@ -173,8 +183,6 @@ def verify(vrf): def generate(vrf): # Render iproute2 VR helper names render(config_file, 'iproute2/vrf.conf.j2', vrf) - # Render nftables zones config - render(nft_vrf_config, 'firewall/nftables-vrf-zones.j2', vrf) # Render VRF Kernel/Zebra route-map filters vrf['frr_zebra_config'] = render_to_string('frr/zebra.vrf.route-map.frr.j2', vrf) @@ -215,14 +223,6 @@ def apply(vrf): call(f'ip link delete dev {tmp}') if 'name' in vrf: - # Separate VRFs in conntrack table - # check if table already exists - _, err = popen('nft list table inet vrf_zones') - # If not, create a table - if err and os.path.exists(nft_vrf_config): - cmd(f'nft -f {nft_vrf_config}') - os.unlink(nft_vrf_config) - # Linux routing uses rules to find tables - routing targets are then # looked up in those tables. If the lookup got a matching route, the # process ends. @@ -306,13 +306,6 @@ def apply(vrf): frr_cfg.add_before(frr.default_add_before, vrf['frr_zebra_config']) frr_cfg.commit_configuration(zebra_daemon) - # return to default lookup preference when no VRF is configured - if 'name' not in vrf: - # Remove VRF zones table from nftables - tmp = run('nft list table inet vrf_zones') - if tmp == 0: - cmd('nft delete table inet vrf_zones') - return None if __name__ == '__main__': diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/99-run-user-hooks b/src/etc/dhcp/dhclient-enter-hooks.d/99-run-user-hooks index b4b4d516d..570758be6 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/99-run-user-hooks +++ b/src/etc/dhcp/dhclient-enter-hooks.d/99-run-user-hooks @@ -1,5 +1,5 @@ #!/bin/bash DHCP_PRE_HOOKS="/config/scripts/dhcp-client/pre-hooks.d/" if [ -d "${DHCP_PRE_HOOKS}" ] ; then - run-parts "${DHCP_PRE_HOOKS}" + run_hookdir "${DHCP_PRE_HOOKS}" fi diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/03-vyos-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/03-vyos-dhclient-hook index 35721d009..d5e6462ba 100644 --- a/src/etc/dhcp/dhclient-exit-hooks.d/03-vyos-dhclient-hook +++ b/src/etc/dhcp/dhclient-exit-hooks.d/03-vyos-dhclient-hook @@ -29,6 +29,7 @@ fi if [ "$RUN" = "yes" ]; then BASE_PATH=$(python3 -c "from vyos.defaults import directories; print(directories['isc_dhclient_dir'])") + mkdir -p ${BASE_PATH} LOG=${BASE_PATH}/dhclient_"$interface"."$proto"lease echo `date` > $LOG diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks b/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks index 442419d79..910b586f0 100755 --- a/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks +++ b/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks @@ -1,5 +1,5 @@ #!/bin/bash DHCP_POST_HOOKS="/config/scripts/dhcp-client/post-hooks.d/" if [ -d "${DHCP_POST_HOOKS}" ] ; then - run-parts "${DHCP_POST_HOOKS}" + run_hookdir "${DHCP_POST_HOOKS}" fi diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook index c7a92fe26..e6edc1ac3 100755 --- a/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook +++ b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook @@ -71,10 +71,6 @@ if __name__ == '__main__': conf_lines[i] = line.replace(old_ip, new_ip) found = True - for i, line in enumerate(secrets_lines): - if line.find(to_match) > 0: - secrets_lines[i] = line.replace(old_ip, new_ip) - if found: write_file(SWANCTL_CONF, conf_lines) ipsec_down(old_ip) diff --git a/src/etc/ipsec.d/vti-up-down b/src/etc/ipsec.d/vti-up-down index 9eb6fac48..441b316c2 100755 --- a/src/etc/ipsec.d/vti-up-down +++ b/src/etc/ipsec.d/vti-up-down @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -13,8 +13,9 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -## Script called up strongswan to bring the vti interface up/down based on the state of the IPSec tunnel. -## Called as vti_up_down vti_intf_name + +# Script called up strongswan to bring the VTI interface up/down based on +# the state of the IPSec tunnel. Called as vti_up_down vti_intf_name import os import sys @@ -25,9 +26,10 @@ from syslog import LOG_PID from syslog import LOG_INFO from vyos.configquery import ConfigTreeQuery +from vyos.configdict import get_interface_dict +from vyos.ifconfig import VTIIf from vyos.utils.process import call from vyos.utils.network import get_interface_config -from vyos.utils.network import get_interface_address if __name__ == '__main__': verb = os.getenv('PLUTO_VERB') @@ -48,14 +50,13 @@ if __name__ == '__main__': vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False) - config = ConfigTreeQuery() - vti_dict = config.get_config_dict(['interfaces', 'vti', interface], - get_first_key=True) - if verb in ['up-client', 'up-host']: if not vti_link_up: - if 'disable' not in vti_dict: - call(f'sudo ip link set {interface} up') + conf = ConfigTreeQuery() + _, vti = get_interface_dict(conf.config, ['interfaces', 'vti'], interface) + if 'disable' not in vti: + tmp = VTIIf(interface) + tmp.update(vti) else: syslog(f'Interface {interface} is admin down ...') elif verb in ['down-client', 'down-host']: diff --git a/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers b/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers index 222c75f21..5157469f4 100755 --- a/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers +++ b/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers @@ -1,5 +1,4 @@ #!/bin/bash -### Autogenerated by interfaces-pppoe.py ### interface=$6 if [ -z "$interface" ]; then diff --git a/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers b/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers index 0fcedbedc..4affaeb5c 100755 --- a/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers +++ b/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers @@ -1,5 +1,4 @@ #!/bin/bash -### Autogenerated by interfaces-pppoe.py ### interface=$6 if [ -z "$interface" ]; then diff --git a/src/etc/sudoers.d/vyos b/src/etc/sudoers.d/vyos index e0fd8cb0b..63a944f41 100644 --- a/src/etc/sudoers.d/vyos +++ b/src/etc/sudoers.d/vyos @@ -42,7 +42,10 @@ Cmnd_Alias FORCE_CLUSTER = /usr/share/heartbeat/hb_takeover, \ /usr/share/heartbeat/hb_standby Cmnd_Alias DIAGNOSTICS = /bin/ip vrf exec * /bin/ping *, \ /bin/ip vrf exec * /bin/traceroute *, \ + /bin/ip vrf exec * /usr/bin/mtr *, \ /usr/libexec/vyos/op_mode/* +Cmnd_Alias KEA_IP6_ROUTES = /sbin/ip -6 route replace *,\ + /sbin/ip -6 route del * %operator ALL=NOPASSWD: DATE, IPTABLES, ETHTOOL, IPFLUSH, HWINFO, \ PPPOE_CMDS, PCAPTURE, /usr/sbin/wanpipemon, \ DMIDECODE, DISK, CONNTRACK, IP6TABLES, \ @@ -54,3 +57,4 @@ Cmnd_Alias DIAGNOSTICS = /bin/ip vrf exec * /bin/ping *, \ # Allow members of group sudo to execute any command %sudo ALL=NOPASSWD: ALL +_kea ALL=NOPASSWD: KEA_IP6_ROUTES diff --git a/src/etc/sysctl.d/30-vyos-router.conf b/src/etc/sysctl.d/30-vyos-router.conf index fcdc1b21d..c9b8ef8fe 100644 --- a/src/etc/sysctl.d/30-vyos-router.conf +++ b/src/etc/sysctl.d/30-vyos-router.conf @@ -21,7 +21,6 @@ net.ipv4.conf.all.arp_filter=0 # https://vyos.dev/T300 net.ipv4.conf.all.arp_ignore=0 - net.ipv4.conf.all.arp_announce=2 # Enable packet forwarding for IPv4 @@ -103,6 +102,11 @@ net.ipv4.igmp_max_memberships = 512 net.core.rps_sock_flow_entries = 32768 # Congestion control -net.core.default_qdisc=fq +net.core.default_qdisc=fq_codel net.ipv4.tcp_congestion_control=bbr +# Disable IPv6 Segment Routing packets by default +net.ipv6.conf.all.seg6_enabled = 0 +net.ipv6.conf.default.seg6_enabled = 0 + +net.vrf.strict_mode = 1 diff --git a/src/etc/systemd/system/avahi-daemon.service.d/override.conf b/src/etc/systemd/system/avahi-daemon.service.d/override.conf deleted file mode 100644 index a9d2085f7..000000000 --- a/src/etc/systemd/system/avahi-daemon.service.d/override.conf +++ /dev/null @@ -1,8 +0,0 @@ -[Unit] -After= -After=vyos-router.service -ConditionPathExists=/run/avahi-daemon/avahi-daemon.conf - -[Service] -ExecStart= -ExecStart=/usr/sbin/avahi-daemon --syslog --file /run/avahi-daemon/avahi-daemon.conf
\ No newline at end of file diff --git a/src/etc/systemd/system/certbot.service.d/10-override.conf b/src/etc/systemd/system/certbot.service.d/10-override.conf new file mode 100644 index 000000000..542f77eb2 --- /dev/null +++ b/src/etc/systemd/system/certbot.service.d/10-override.conf @@ -0,0 +1,7 @@ +[Unit] +After= +After=vyos-router.service + +[Service] +ExecStart= +ExecStart=/usr/bin/certbot renew --config-dir /config/auth/letsencrypt --no-random-sleep-on-renew --post-hook "/usr/libexec/vyos/vyos-certbot-renew-pki.sh" diff --git a/src/etc/systemd/system/frr.service.d/override.conf b/src/etc/systemd/system/frr.service.d/override.conf index 094f83551..614b4f7ed 100644 --- a/src/etc/systemd/system/frr.service.d/override.conf +++ b/src/etc/systemd/system/frr.service.d/override.conf @@ -1,3 +1,6 @@ +[Unit] +After=vyos-router.service + [Service] LimitNOFILE=4096 ExecStartPre=/bin/bash -c 'mkdir -p /run/frr/config; \ diff --git a/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf b/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf new file mode 100644 index 000000000..0f5bf801e --- /dev/null +++ b/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf @@ -0,0 +1,9 @@ +[Unit] +After= +After=vyos-router.service + +[Service] +ExecStart= +ExecStart=/usr/sbin/kea-ctrl-agent -c /run/kea/kea-ctrl-agent.conf +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE diff --git a/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf b/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf new file mode 100644 index 000000000..682e5bbce --- /dev/null +++ b/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf @@ -0,0 +1,7 @@ +[Unit] +After= +After=vyos-router.service + +[Service] +ExecStart= +ExecStart=/usr/sbin/kea-dhcp4 -c /run/kea/kea-dhcp4.conf diff --git a/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf b/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf new file mode 100644 index 000000000..cb33fc057 --- /dev/null +++ b/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf @@ -0,0 +1,7 @@ +[Unit] +After= +After=vyos-router.service + +[Service] +ExecStart= +ExecStart=/usr/sbin/kea-dhcp6 -c /run/kea/kea-dhcp6.conf diff --git a/src/etc/systemd/system/nginx.service.d/10-override.conf b/src/etc/systemd/system/nginx.service.d/10-override.conf new file mode 100644 index 000000000..1be5cec81 --- /dev/null +++ b/src/etc/systemd/system/nginx.service.d/10-override.conf @@ -0,0 +1,3 @@ +[Unit] +After= +After=vyos-router.service diff --git a/src/etc/systemd/system/pdns-recursor.service.d/override.conf b/src/etc/systemd/system/pdns-recursor.service.d/override.conf deleted file mode 100644 index 158bac02b..000000000 --- a/src/etc/systemd/system/pdns-recursor.service.d/override.conf +++ /dev/null @@ -1,8 +0,0 @@ -[Service] -WorkingDirectory= -WorkingDirectory=/run/powerdns -RuntimeDirectory= -RuntimeDirectory=powerdns -RuntimeDirectoryPreserve=yes -ExecStart= -ExecStart=/usr/sbin/pdns_recursor --daemon=no --write-pid=no --disable-syslog --log-timestamp=no --config-dir=/run/powerdns --socket-dir=/run/powerdns diff --git a/src/etc/udev/rules.d/99-vyos-systemd.rules b/src/etc/udev/rules.d/99-vyos-systemd.rules new file mode 100644 index 000000000..54aea668c --- /dev/null +++ b/src/etc/udev/rules.d/99-vyos-systemd.rules @@ -0,0 +1,79 @@ +# The main reason that we store this file is systemd-udevd interfaces excludes +# /lib/systemd/systemd-sysctl for dynamic interfaces (ppp|ipoe|l2tp etc) + +ACTION=="remove", GOTO="systemd_end" + +SUBSYSTEM=="tty", KERNEL=="tty[a-zA-Z]*|hvc*|xvc*|hvsi*|ttysclp*|sclp_line*|3270/tty[0-9]*", TAG+="systemd" +KERNEL=="vport*", TAG+="systemd" + +SUBSYSTEM=="ptp", TAG+="systemd" + +SUBSYSTEM=="ubi", TAG+="systemd" + +SUBSYSTEM=="block", TAG+="systemd" + +# We can't make any conclusions about suspended DM devices so let's just import previous SYSTEMD_READY state and skip other rules +SUBSYSTEM=="block", ENV{DM_SUSPENDED}=="1", IMPORT{db}="SYSTEMD_READY", GOTO="systemd_end" +SUBSYSTEM=="block", ACTION=="add", ENV{DM_UDEV_DISABLE_OTHER_RULES_FLAG}=="1", ENV{SYSTEMD_READY}="0" + +# Ignore encrypted devices with no identified superblock on it, since +# we are probably still calling mke2fs or mkswap on it. +SUBSYSTEM=="block", ENV{DM_UUID}=="CRYPT-*", ENV{ID_PART_TABLE_TYPE}=="", ENV{ID_FS_USAGE}=="", ENV{SYSTEMD_READY}="0" + +# Explicitly set SYSTEMD_READY=1 for DM devices that don't have it set yet, so that we always have something to import above +SUBSYSTEM=="block", ENV{DM_UUID}=="?*", ENV{SYSTEMD_READY}=="", ENV{SYSTEMD_READY}="1" + +# add symlink to GPT root disk +SUBSYSTEM=="block", ENV{ID_PART_GPT_AUTO_ROOT}=="1", ENV{ID_FS_TYPE}!="crypto_LUKS", SYMLINK+="gpt-auto-root" +SUBSYSTEM=="block", ENV{ID_PART_GPT_AUTO_ROOT}=="1", ENV{ID_FS_TYPE}=="crypto_LUKS", SYMLINK+="gpt-auto-root-luks" +SUBSYSTEM=="block", ENV{DM_UUID}=="CRYPT-*", ENV{DM_NAME}=="root", SYMLINK+="gpt-auto-root" + +# Ignore raid devices that are not yet assembled and started +SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="md*", TEST!="md/array_state", ENV{SYSTEMD_READY}="0" +SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="md*", ATTR{md/array_state}=="|clear|inactive", ENV{SYSTEMD_READY}="0" + +# Ignore loop devices that don't have any file attached +SUBSYSTEM=="block", KERNEL=="loop[0-9]*", ENV{DEVTYPE}=="disk", TEST!="loop/backing_file", ENV{SYSTEMD_READY}="0" + +# Ignore nbd devices until the PID file exists (which signals a connected device) +SUBSYSTEM=="block", KERNEL=="nbd*", ENV{DEVTYPE}=="disk", TEST!="pid", ENV{SYSTEMD_READY}="0" + +# We need a hardware independent way to identify network devices. We +# use the /sys/subsystem/ path for this. Kernel "bus" and "class" names +# should be treated as one namespace, like udev handles it. This is mostly +# just an identification string for systemd, so whether the path actually is +# accessible or not does not matter as long as it is unique and in the +# filesystem namespace. + +SUBSYSTEM=="net", KERNEL!="lo", TAG+="systemd", ENV{SYSTEMD_ALIAS}+="/sys/subsystem/net/devices/$name" +SUBSYSTEM=="bluetooth", TAG+="systemd", ENV{SYSTEMD_ALIAS}+="/sys/subsystem/bluetooth/devices/%k", \ + ENV{SYSTEMD_WANTS}+="bluetooth.target", ENV{SYSTEMD_USER_WANTS}+="bluetooth.target" + +ENV{ID_SMARTCARD_READER}=="?*", TAG+="systemd", ENV{SYSTEMD_WANTS}+="smartcard.target", ENV{SYSTEMD_USER_WANTS}+="smartcard.target" +SUBSYSTEM=="sound", KERNEL=="controlC*", TAG+="systemd", ENV{SYSTEMD_WANTS}+="sound.target", ENV{SYSTEMD_USER_WANTS}+="sound.target" + +SUBSYSTEM=="printer", TAG+="systemd", ENV{SYSTEMD_WANTS}+="printer.target", ENV{SYSTEMD_USER_WANTS}+="printer.target" +SUBSYSTEM=="usb", KERNEL=="lp*", TAG+="systemd", ENV{SYSTEMD_WANTS}+="printer.target", ENV{SYSTEMD_USER_WANTS}+="printer.target" +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ENV{ID_USB_INTERFACES}=="*:0701??:*", TAG+="systemd", ENV{SYSTEMD_WANTS}+="printer.target", ENV{SYSTEMD_USER_WANTS}+="printer.target" + +SUBSYSTEM=="udc", ACTION=="add", TAG+="systemd", ENV{SYSTEMD_WANTS}+="usb-gadget.target" + +# Apply sysctl variables to network devices (and only to those) as they appear. +# T5706. Exclude: lo, dummy*, ppp*, ipoe*, l2tp*, pptp*, sslvpn* and sstp*. +ACTION=="add", SUBSYSTEM=="net", KERNEL!="lo|dummy*|ppp*|ipoe*|l2tp*|pptp*|sslvpn*|sstp*", RUN+="/lib/systemd/systemd-sysctl --prefix=/net/ipv4/conf/$name --prefix=/net/ipv4/neigh/$name --prefix=/net/ipv6/conf/$name --prefix=/net/ipv6/neigh/$name" + +# Pull in backlight save/restore for all backlight devices and +# keyboard backlights +SUBSYSTEM=="backlight", TAG+="systemd", IMPORT{builtin}="path_id", ENV{SYSTEMD_WANTS}+="systemd-backlight@backlight:$name.service" +SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", TAG+="systemd", IMPORT{builtin}="path_id", ENV{SYSTEMD_WANTS}+="systemd-backlight@leds:$name.service" + +# Pull in rfkill save/restore for all rfkill devices +SUBSYSTEM=="rfkill", ENV{SYSTEMD_RFKILL}="1" +SUBSYSTEM=="rfkill", IMPORT{builtin}="path_id" +SUBSYSTEM=="misc", KERNEL=="rfkill", TAG+="systemd", ENV{SYSTEMD_WANTS}+="systemd-rfkill.socket" + +# Asynchronously mount file systems implemented by these modules as soon as they are loaded. +SUBSYSTEM=="module", KERNEL=="fuse", TAG+="systemd", ENV{SYSTEMD_WANTS}+="sys-fs-fuse-connections.mount" +SUBSYSTEM=="module", KERNEL=="configfs", TAG+="systemd", ENV{SYSTEMD_WANTS}+="sys-kernel-config.mount" + +LABEL="systemd_end" diff --git a/src/helpers/config_dependency.py b/src/helpers/config_dependency.py index 50c72956e..817bcc65a 100755 --- a/src/helpers/config_dependency.py +++ b/src/helpers/config_dependency.py @@ -18,22 +18,75 @@ import os import sys +import json from argparse import ArgumentParser from argparse import ArgumentTypeError - -try: - from vyos.configdep import check_dependency_graph - from vyos.defaults import directories -except ImportError: - # allow running during addon package build - _here = os.path.dirname(__file__) - sys.path.append(os.path.join(_here, '../../python/vyos')) - from configdep import check_dependency_graph - from defaults import directories +from graphlib import TopologicalSorter, CycleError # addon packages will need to specify the dependency directory -dependency_dir = os.path.join(directories['data'], - 'config-mode-dependencies') +data_dir = '/usr/share/vyos/' +dependency_dir = os.path.join(data_dir, 'config-mode-dependencies') + +def dict_merge(source, destination): + from copy import deepcopy + tmp = deepcopy(destination) + + for key, value in source.items(): + if key not in tmp: + tmp[key] = value + elif isinstance(source[key], dict): + tmp[key] = dict_merge(source[key], tmp[key]) + + return tmp + +def read_dependency_dict(dependency_dir: str = dependency_dir) -> dict: + res = {} + for dep_file in os.listdir(dependency_dir): + if not dep_file.endswith('.json'): + continue + path = os.path.join(dependency_dir, dep_file) + with open(path) as f: + d = json.load(f) + if dep_file == 'vyos-1x.json': + res = dict_merge(res, d) + else: + res = dict_merge(d, res) + + return res + +def graph_from_dependency_dict(d: dict) -> dict: + g = {} + for k in list(d): + g[k] = set() + # add the dependencies for every sub-case; should there be cases + # that are mutally exclusive in the future, the graphs will be + # distinguished + for el in list(d[k]): + g[k] |= set(d[k][el]) + + return g + +def is_acyclic(d: dict) -> bool: + g = graph_from_dependency_dict(d) + ts = TopologicalSorter(g) + try: + # get node iterator + order = ts.static_order() + # try iteration + _ = [*order] + except CycleError: + return False + + return True + +def check_dependency_graph(dependency_dir: str = dependency_dir, + supplement: str = None) -> bool: + d = read_dependency_dict(dependency_dir=dependency_dir) + if supplement is not None: + with open(supplement) as f: + d = dict_merge(json.load(f), d) + + return is_acyclic(d) def path_exists(s): if not os.path.exists(s): @@ -50,8 +103,10 @@ def main(): args = vars(parser.parse_args()) if not check_dependency_graph(**args): + print("dependency error: cycle exists") sys.exit(1) + print("dependency graph acyclic") sys.exit(0) if __name__ == '__main__': diff --git a/src/helpers/simple-download.py b/src/helpers/simple-download.py new file mode 100755 index 000000000..501af75f5 --- /dev/null +++ b/src/helpers/simple-download.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +import sys +from argparse import ArgumentParser +from vyos.remote import download + +parser = ArgumentParser() +parser.add_argument('--local-file', help='local file', required=True) +parser.add_argument('--remote-path', help='remote path', required=True) + +args = parser.parse_args() + +try: + download(args.local_file, args.remote_path, + check_space=True, raise_error=True) +except Exception as e: + print(e) + sys.exit(1) + +sys.exit() diff --git a/src/helpers/strip-private.py b/src/helpers/strip-private.py index eb584edaf..cb29069cf 100755 --- a/src/helpers/strip-private.py +++ b/src/helpers/strip-private.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -# Copyright 2021-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-2023 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 @@ -21,7 +21,6 @@ import sys from netaddr import IPNetwork, AddrFormatError - parser = argparse.ArgumentParser(description='strip off private information from VyOS config') strictness = parser.add_mutually_exclusive_group() @@ -119,6 +118,7 @@ if __name__ == "__main__": (True, re.compile(r'(shared-secret-key-file|ca-cert-file|cert-file|dh-file|key-file|client) (\S+)'), r'\1 xxxxxx'), # Strip IPSEC secrets (True, re.compile(r'pre-shared-secret \S+'), 'pre-shared-secret xxxxxx'), + (True, re.compile(r'secret \S+'), 'secret xxxxxx'), # Strip OSPF md5-key (True, re.compile(r'md5-key \S+'), 'md5-key xxxxxx'), # Strip WireGuard private-key diff --git a/src/helpers/vyos-boot-config-loader.py b/src/helpers/vyos-boot-config-loader.py index 01b06526d..42de696ce 100755 --- a/src/helpers/vyos-boot-config-loader.py +++ b/src/helpers/vyos-boot-config-loader.py @@ -102,7 +102,8 @@ def failsafe(config_file_name): 'authentication', 'encrypted-password']) - cmd(f"useradd -s /bin/bash -G 'users,sudo' -m -N -p '{passwd}' vyos") + cmd(f"useradd --create-home --no-user-group --shell /bin/vbash --password '{passwd}' "\ + "--groups frr,frrvty,vyattacfg,sudo,adm,dip,disk vyos") if __name__ == '__main__': if len(sys.argv) < 2: diff --git a/src/helpers/vyos-certbot-renew-pki.sh b/src/helpers/vyos-certbot-renew-pki.sh new file mode 100755 index 000000000..d0b663f7b --- /dev/null +++ b/src/helpers/vyos-certbot-renew-pki.sh @@ -0,0 +1,3 @@ +#!/bin/sh +source /opt/vyatta/etc/functions/script-template +/usr/libexec/vyos/conf_mode/pki.py certbot_renew diff --git a/src/helpers/vyos-load-config.py b/src/helpers/vyos-load-config.py index e579e81b2..4ec865454 100755 --- a/src/helpers/vyos-load-config.py +++ b/src/helpers/vyos-load-config.py @@ -66,7 +66,7 @@ def get_local_config(filename): return config_str -if any(x in file_name for x in protocols): +if any(file_name.startswith(f'{x}://') for x in protocols): config_string = vyos.remote.get_remote_config(file_name) if not config_string: sys.exit(f"No such config file at '{file_name}'") diff --git a/src/init/vyos-router b/src/init/vyos-router index 35095afe4..2b4fac5ef 100755 --- a/src/init/vyos-router +++ b/src/init/vyos-router @@ -260,6 +260,8 @@ EOF rm -f /etc/pam_radius_auth.conf pam-auth-update --disable tacplus-mandatory tacplus-optional rm -f /etc/tacplus_nss.conf /etc/tacplus_servers + # and no Google authenticator for 2FA/MFA + pam-auth-update --disable mfa-google-authenticator # Certain configuration files are re-generated by the configuration # subsystem and must reside under /etc and can not easily be moved to /run. @@ -370,11 +372,11 @@ start () # As VyOS does not execute commands that are not present in the CLI we call # the script by hand to have a single source for the login banner and MOTD ${vyos_conf_scripts_dir}/system_console.py || log_failure_msg "could not reset serial console" - ${vyos_conf_scripts_dir}/system-login-banner.py || log_failure_msg "could not reset motd and issue files" - ${vyos_conf_scripts_dir}/system-option.py || log_failure_msg "could not reset system option files" - ${vyos_conf_scripts_dir}/system-ip.py || log_failure_msg "could not reset system IPv4 options" - ${vyos_conf_scripts_dir}/system-ipv6.py || log_failure_msg "could not reset system IPv6 options" - ${vyos_conf_scripts_dir}/conntrack.py || log_failure_msg "could not reset conntrack subsystem" + ${vyos_conf_scripts_dir}/system_login_banner.py || log_failure_msg "could not reset motd and issue files" + ${vyos_conf_scripts_dir}/system_option.py || log_failure_msg "could not reset system option files" + ${vyos_conf_scripts_dir}/system_ip.py || log_failure_msg "could not reset system IPv4 options" + ${vyos_conf_scripts_dir}/system_ipv6.py || log_failure_msg "could not reset system IPv6 options" + ${vyos_conf_scripts_dir}/system_conntrack.py || log_failure_msg "could not reset conntrack subsystem" ${vyos_conf_scripts_dir}/container.py || log_failure_msg "could not reset container subsystem" clear_or_override_config_files || log_failure_msg "could not reset config files" @@ -446,6 +448,10 @@ start () restore_if_missing_postconfig_script run_postconfig_scripts + tmp=$(${vyos_libexec_dir}/read-saved-value.py --path "protocols rpki cache") + if [ ! -z $tmp ]; then + vtysh -c "rpki start" + fi } stop() diff --git a/src/migration-scripts/bgp/0-to-1 b/src/migration-scripts/bgp/0-to-1 index 03c45107b..5b8e8a163 100755 --- a/src/migration-scripts/bgp/0-to-1 +++ b/src/migration-scripts/bgp/0-to-1 @@ -14,7 +14,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/>. -# T3417: migrate IS-IS tagNode to node as we can only have one IS-IS process +# T3417: migrate BGP tagNode to node as we can only have one BGP process from sys import argv from sys import exit diff --git a/src/migration-scripts/bgp/1-to-2 b/src/migration-scripts/bgp/1-to-2 index 96b939b47..a40d86e67 100755 --- a/src/migration-scripts/bgp/1-to-2 +++ b/src/migration-scripts/bgp/1-to-2 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright (C) 2021-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # T3741: no-ipv4-unicast is now enabled by default +# T5937: Migrate IPv6 BGP Neighbor Peer Groups from sys import argv from sys import exit @@ -66,6 +67,15 @@ else: if not config.exists(afi_ipv4): config.set(afi_ipv4) +# Migrate IPv6 AFI peer-group +if config.exists(base + ['neighbor']): + for neighbor in config.list_nodes(base + ['neighbor']): + tmp_path = base + ['neighbor', neighbor, 'address-family', 'ipv6-unicast', 'peer-group'] + if config.exists(tmp_path): + peer_group = config.return_value(tmp_path) + config.set(base + ['neighbor', neighbor, 'peer-group'], value=peer_group) + config.delete(tmp_path) + try: with open(file_name, 'w') as f: f.write(config.to_string()) diff --git a/src/migration-scripts/bgp/4-to-5 b/src/migration-scripts/bgp/4-to-5 new file mode 100755 index 000000000..c4eb9ec72 --- /dev/null +++ b/src/migration-scripts/bgp/4-to-5 @@ -0,0 +1,67 @@ +#!/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/>. + +# Delete 'protocols bgp address-family ipv6-unicast route-target vpn +# import/export', if 'protocols bgp address-family ipv6-unicast +# route-target vpn both' exists + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +bgp_base = ['protocols', 'bgp'] +# Delete 'import/export' in default vrf if 'both' exists +if config.exists(bgp_base): + for address_family in ['ipv4-unicast', 'ipv6-unicast']: + rt_path = bgp_base + ['address-family', address_family, 'route-target', + 'vpn'] + if config.exists(rt_path + ['both']): + if config.exists(rt_path + ['import']): + config.delete(rt_path + ['import']) + if config.exists(rt_path + ['export']): + config.delete(rt_path + ['export']) + +# Delete import/export in vrfs if both exists +if config.exists(['vrf', 'name']): + for vrf in config.list_nodes(['vrf', 'name']): + vrf_base = ['vrf', 'name', vrf] + for address_family in ['ipv4-unicast', 'ipv6-unicast']: + rt_path = vrf_base + bgp_base + ['address-family', address_family, + 'route-target', 'vpn'] + if config.exists(rt_path + ['both']): + if config.exists(rt_path + ['import']): + config.delete(rt_path + ['import']) + if config.exists(rt_path + ['export']): + config.delete(rt_path + ['export']) + +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) diff --git a/src/migration-scripts/cluster/1-to-2 b/src/migration-scripts/cluster/1-to-2 new file mode 100755 index 000000000..a2e589155 --- /dev/null +++ b/src/migration-scripts/cluster/1-to-2 @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import re +import sys + +from vyos.configtree import ConfigTree + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + + file_name = sys.argv[1] + + with open(file_name, 'r') as f: + config_file = f.read() + + config = ConfigTree(config_file) + + if not config.exists(['cluster']): + # Cluster is not set -- nothing to do at all + sys.exit(0) + + # If at least one cluster group is defined, we have real work to do. + # If there are no groups, we remove the top-level cluster node at the end of this script anyway. + if config.exists(['cluster', 'group']): + # First, gather timer and interface settings to duplicate them in all groups, + # since in the old cluster they are global, but in VRRP they are always per-group + + global_interface = None + if config.exists(['cluster', 'interface']): + global_interface = config.return_value(['cluster', 'interface']) + else: + # Such configs shouldn't exist in practice because interface is a required option. + # But since it's possible to specify interface inside 'service' options, + # we may be able to convert such configs nonetheless. + print("Warning: incorrect cluster config: interface is not defined.", file=sys.stderr) + + # There are three timers: advertise-interval, dead-interval, and monitor-dead-interval + # Only the first one makes sense for the VRRP, we translate it to advertise-interval + advertise_interval = None + if config.exists(['cluster', 'keepalive-interval']): + advertise_interval = config.return_value(['cluster', 'keepalive-interval']) + + if advertise_interval is not None: + # Cluster had all timers in milliseconds, so we need to convert them to seconds + # And ensure they are not shorter than one second + advertise_interval = int(advertise_interval) // 1000 + if advertise_interval < 1: + advertise_interval = 1 + + # Cluster had password as a global option, in VRRP it's per-group + password = None + if config.exists(['cluster', 'pre-shared-secret']): + password = config.return_value(['cluster', 'pre-shared-secret']) + + # Set up the stage for converting cluster groups to VRRP groups + free_vrids = set(range(1,255)) + vrrp_base_path = ['high-availability', 'vrrp', 'group'] + if not config.exists(vrrp_base_path): + # If VRRP is not set up, create a node and set it to 'tag node' + # Setting it to 'tag' is not mandatory but it's better to be consistent + # with configs produced by 'save' + config.set(vrrp_base_path) + config.set_tag(vrrp_base_path) + else: + # If there are VRRP groups already, we need to find the set of unused VRID numbers to avoid conflicts + existing_vrids = set() + for vg in config.list_nodes(vrrp_base_path): + existing_vrids.add(int(config.return_value(vrrp_base_path + [vg, 'vrid']))) + free_vrids = free_vrids.difference(existing_vrids) + + # Now handle cluster groups + groups = config.list_nodes(['cluster', 'group']) + for g in groups: + base_path = ['cluster', 'group', g] + service_names = config.return_values(base_path + ['service']) + + # Cluster used to allow services other than IP addresses, at least nominally + # Whether that ever worked is a big question, but we need to consider that, + # since configs with custom services are definitely impossible to meaningfully migrate now + services = {"ip": [], "other": []} + for s in service_names: + if re.match(r'^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2})(/[a-z]+\d+)?$', s): + services["ip"].append(s) + else: + services["other"].append(s) + + if services["other"]: + print("Cluster config includes non-IP address services and cannot be migrated", file=sys.stderr) + sys.exit(1) + + # Cluster allowed virtual IPs for different interfaces within a single group. + # VRRP groups are by definition bound to interfaces, so we cannot migrate such configurations. + # Thus we need to find out if all addresses either leave the interface unspecified + # (in that case the global 'cluster interface' option is used), + # or have the same interface, or have the same interface as the global 'cluster interface'. + + # First, we collect all addresses and check if they have interface specified + # If not, we substitute the global interface option + # or throw an error if it's not in the config. + ips = [] + for ip in services["ip"]: + ip_with_intf = re.match(r'^(?P<ip>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/(?P<intf>[a-z]+\d+)$', ip) + if ip_with_intf: + ips.append({"ip": ip_with_intf.group("ip"), "interface": ip_with_intf.group("intf")}) + else: + if global_interface is not None: + ips.append({"ip": ip, "interface": global_interface}) + else: + print("Error: cluster has groups with IPs without interfaces and 'cluster interface' is not specified.", file=sys.stderr) + sys.exit(1) + + # Then we check if all addresses are for the same interface. + intfs_set = set(map(lambda i: i["interface"], ips)) + if len(intfs_set) > 1: + print("Error: cluster group has addresses for different interfaces", file=sys.stderr) + sys.exit(1) + + # If we got this far, the group is migratable. + + # Extract the interface from the set -- we know there's only a single member. + interface = intfs_set.pop() + + addresses = list(map(lambda i: i["ip"], ips)) + vrrp_path = ['high-availability', 'vrrp', 'group', g] + + # If there's already a VRRP group with exactly the same name, + # we probably shouldn't try to make up a unique name, just leave migration to the user... + if config.exists(vrrp_path): + print("Error: VRRP group with the same name as a cluster group already exists", file=sys.stderr) + sys.exit(1) + + config.set(vrrp_path + ['interface'], value=interface) + for a in addresses: + config.set(vrrp_path + ['virtual-address'], value=a, replace=False) + + # Take the next free VRID and assign it to the group + vrid = free_vrids.pop() + config.set(vrrp_path + ['vrid'], value=vrid) + + # Convert the monitor option to VRRP ping health check + if config.exists(base_path + ['monitor']): + monitor_ip = config.return_value(base_path + ['monitor']) + config.set(vrrp_path + ['health-check', 'ping'], value=monitor_ip) + + # Convert "auto-failback" to "no-preempt", if necessary + if config.exists(base_path + ['auto-failback']): + # It's a boolean node that requires "true" or "false" + # so if it exists we still need to check its value + auto_failback = config.return_value(base_path + ['auto-failback']) + if auto_failback == "false": + config.set(vrrp_path + ['no-preempt']) + else: + # It's "true" or we assume it is, which means preemption is desired, + # and in VRRP config it's the default + pass + else: + # The old default for that option is false + config.set(vrrp_path + ['no-preempt']) + + # Inject settings from the global cluster config that have to be per-group in VRRP + if advertise_interval is not None: + config.set(vrrp_path + ['advertise-interval'], value=advertise_interval) + + if password is not None: + config.set(vrrp_path + ['authentication', 'password'], value=password) + config.set(vrrp_path + ['authentication', 'type'], value='plaintext-password') + + # Finally, clean up the old cluster node + config.delete(['cluster']) + + try: + with open(file_name, 'w') as f: + f.write(config.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/conntrack/4-to-5 b/src/migration-scripts/conntrack/4-to-5 new file mode 100755 index 000000000..d2e5fc5fa --- /dev/null +++ b/src/migration-scripts/conntrack/4-to-5 @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5779: system conntrack timeout custom +# Before: +# Protocols tcp, udp and icmp allowed. When using udp it did not work +# Only ipv4 custom timeout rules +# Now: +# Valid protocols are only tcp or udp. +# Extend functionality to ipv6 and move ipv4 custom rules to new node: +# set system conntrack timeout custom [ipv4 | ipv6] rule <rule> ... + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['system', 'conntrack'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) + +if config.exists(base + ['timeout', 'custom', 'rule']): + for rule in config.list_nodes(base + ['timeout', 'custom', 'rule']): + if config.exists(base + ['timeout', 'custom', 'rule', rule, 'protocol', 'tcp']): + config.set(base + ['timeout', 'custom', 'ipv4', 'rule']) + config.copy(base + ['timeout', 'custom', 'rule', rule], base + ['timeout', 'custom', 'ipv4', 'rule', rule]) + config.delete(base + ['timeout', 'custom', 'rule']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/dhcp-server/6-to-7 b/src/migration-scripts/dhcp-server/6-to-7 new file mode 100755 index 000000000..ccf385a30 --- /dev/null +++ b/src/migration-scripts/dhcp-server/6-to-7 @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T3316: Migrate to Kea +# - global-parameters will not function +# - shared-network-parameters will not function +# - subnet-parameters will not function +# - static-mapping-parameters will not function +# - host-decl-name is on by default, option removed +# - ping-check no longer supported +# - failover is default enabled on all subnets that exist on failover servers + +import sys +from vyos.configtree import ConfigTree + +if (len(sys.argv) < 2): + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['service', 'dhcp-server'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + sys.exit(0) + +if config.exists(base + ['host-decl-name']): + config.delete(base + ['host-decl-name']) + +if config.exists(base + ['global-parameters']): + config.delete(base + ['global-parameters']) + +if config.exists(base + ['shared-network-name']): + for network in config.list_nodes(base + ['shared-network-name']): + base_network = base + ['shared-network-name', network] + + if config.exists(base_network + ['ping-check']): + config.delete(base_network + ['ping-check']) + + if config.exists(base_network + ['shared-network-parameters']): + config.delete(base_network +['shared-network-parameters']) + + if not config.exists(base_network + ['subnet']): + continue + + # Run this for every specified 'subnet' + for subnet in config.list_nodes(base_network + ['subnet']): + base_subnet = base_network + ['subnet', subnet] + + if config.exists(base_subnet + ['enable-failover']): + config.delete(base_subnet + ['enable-failover']) + + if config.exists(base_subnet + ['ping-check']): + config.delete(base_subnet + ['ping-check']) + + if config.exists(base_subnet + ['subnet-parameters']): + config.delete(base_subnet + ['subnet-parameters']) + + if config.exists(base_subnet + ['static-mapping']): + for mapping in config.list_nodes(base_subnet + ['static-mapping']): + if config.exists(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters']): + config.delete(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/dhcp-server/7-to-8 b/src/migration-scripts/dhcp-server/7-to-8 new file mode 100755 index 000000000..151aa6d7b --- /dev/null +++ b/src/migration-scripts/dhcp-server/7-to-8 @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T3316: +# - Adjust hostname to have valid FQDN characters only (underscores aren't allowed anymore) +# - Rename "service dhcp-server shared-network-name ... static-mapping <hostname> mac-address ..." +# to "service dhcp-server shared-network-name ... static-mapping <hostname> mac ..." + +import sys +import re +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['service', 'dhcp-server', 'shared-network-name'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + sys.exit(0) + +for network in config.list_nodes(base): + # Run this for every specified 'subnet' + if config.exists(base + [network, 'subnet']): + for subnet in config.list_nodes(base + [network, 'subnet']): + base_subnet = base + [network, 'subnet', subnet] + if config.exists(base_subnet + ['static-mapping']): + for hostname in config.list_nodes(base_subnet + ['static-mapping']): + base_mapping = base_subnet + ['static-mapping', hostname] + + # Rename the 'mac-address' node to 'mac' + if config.exists(base_mapping + ['mac-address']): + config.rename(base_mapping + ['mac-address'], 'mac') + + # Adjust hostname to have valid FQDN characters only + new_hostname = re.sub(r'[^a-zA-Z0-9-.]', '-', hostname) + if new_hostname != hostname: + config.rename(base_mapping, new_hostname) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/dhcp-server/8-to-9 b/src/migration-scripts/dhcp-server/8-to-9 new file mode 100755 index 000000000..810e403a6 --- /dev/null +++ b/src/migration-scripts/dhcp-server/8-to-9 @@ -0,0 +1,75 @@ +#!/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/>. + +# T3316: +# - Migrate dhcp options under new option node +# - Add subnet IDs to existing subnets + +import sys +import re +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['service', 'dhcp-server', 'shared-network-name'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + sys.exit(0) + +option_nodes = ['bootfile-name', 'bootfile-server', 'bootfile-size', 'captive-portal', + 'client-prefix-length', 'default-router', 'domain-name', 'domain-search', + 'name-server', 'ip-forwarding', 'ipv6-only-preferred', 'ntp-server', + 'pop-server', 'server-identifier', 'smtp-server', 'static-route', + 'tftp-server-name', 'time-offset', 'time-server', 'time-zone', + 'vendor-option', 'wins-server', 'wpad-url'] + +subnet_id = 1 + +for network in config.list_nodes(base): + for option in option_nodes: + if config.exists(base + [network, option]): + config.set(base + [network, 'option']) + config.copy(base + [network, option], base + [network, 'option', option]) + config.delete(base + [network, option]) + + if config.exists(base + [network, 'subnet']): + for subnet in config.list_nodes(base + [network, 'subnet']): + base_subnet = base + [network, 'subnet', subnet] + + for option in option_nodes: + if config.exists(base_subnet + [option]): + config.set(base_subnet + ['option']) + config.copy(base_subnet + [option], base_subnet + ['option', option]) + config.delete(base_subnet + [option]) + + config.set(base_subnet + ['subnet-id'], value=subnet_id) + subnet_id += 1 + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/dhcpv6-server/1-to-2 b/src/migration-scripts/dhcpv6-server/1-to-2 new file mode 100755 index 000000000..cc5a8900a --- /dev/null +++ b/src/migration-scripts/dhcpv6-server/1-to-2 @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T3316: Migrate to Kea +# - Kea was meant to have support for key "prefix-highest" under PD which would allow an address range +# However this seems to have never been implemented. A conversion to prefix length is needed (where possible). +# Ref: https://lists.isc.org/pipermail/kea-users/2022-November/003686.html +# - Remove prefix temporary value, convert to multi leafNode (https://kea.readthedocs.io/en/kea-2.2.0/arm/dhcp6-srv.html#dhcpv6-server-limitations) + +import sys +from vyos.configtree import ConfigTree +from vyos.utils.network import ipv6_prefix_length + +if (len(sys.argv) < 1): + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['service', 'dhcpv6-server', 'shared-network-name'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) + +for network in config.list_nodes(base): + if not config.exists(base + [network, 'subnet']): + continue + + for subnet in config.list_nodes(base + [network, 'subnet']): + # Delete temporary value under address-range prefix, convert tagNode to leafNode multi + if config.exists(base + [network, 'subnet', subnet, 'address-range', 'prefix']): + prefix_base = base + [network, 'subnet', subnet, 'address-range', 'prefix'] + prefixes = config.list_nodes(prefix_base) + + config.delete(prefix_base) + + for prefix in prefixes: + config.set(prefix_base, value=prefix, replace=False) + + if config.exists(base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix']): + prefix_base = base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix'] + + config.set(prefix_base) + config.set_tag(prefix_base) + + for start in config.list_nodes(base + [network, 'subnet', subnet, 'prefix-delegation', 'start']): + path = base + [network, 'subnet', subnet, 'prefix-delegation', 'start', start] + + delegated_length = config.return_value(path + ['prefix-length']) + stop = config.return_value(path + ['stop']) + + prefix_length = ipv6_prefix_length(start, stop) + + # This range could not be converted into a simple prefix length and must be skipped + if not prefix_length: + continue + + config.set(prefix_base + [start, 'delegated-length'], value=delegated_length) + config.set(prefix_base + [start, 'prefix-length'], value=prefix_length) + + config.delete(base + [network, 'subnet', subnet, 'prefix-delegation', 'start']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/dhcpv6-server/2-to-3 b/src/migration-scripts/dhcpv6-server/2-to-3 new file mode 100755 index 000000000..f4bdc1d1e --- /dev/null +++ b/src/migration-scripts/dhcpv6-server/2-to-3 @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T3316: +# - Adjust hostname to have valid FQDN characters only (underscores aren't allowed anymore) +# - Adjust duid (old identifier) to comply with duid format +# - Rename "service dhcpv6-server shared-network-name ... static-mapping <hostname> identifier ..." +# to "service dhcpv6-server shared-network-name ... static-mapping <hostname> duid ..." +# - Rename "service dhcpv6-server shared-network-name ... static-mapping <hostname> mac-address ..." +# to "service dhcpv6-server shared-network-name ... static-mapping <hostname> mac ..." + +import sys +import re +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['service', 'dhcpv6-server', 'shared-network-name'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + sys.exit(0) + +for network in config.list_nodes(base): + # Run this for every specified 'subnet' + if config.exists(base + [network, 'subnet']): + for subnet in config.list_nodes(base + [network, 'subnet']): + base_subnet = base + [network, 'subnet', subnet] + if config.exists(base_subnet + ['static-mapping']): + for hostname in config.list_nodes(base_subnet + ['static-mapping']): + base_mapping = base_subnet + ['static-mapping', hostname] + if config.exists(base_mapping + ['identifier']): + + # Adjust duid to comply with duid format (a:3:b:04:... => 0a:03:0b:04:...) + duid = config.return_value(base_mapping + ['identifier']) + new_duid = ':'.join(x.rjust(2,'0') for x in duid.split(':')) + if new_duid != duid: + config.set(base_mapping + ['identifier'], new_duid) + + # Rename the 'identifier' node to 'duid' + config.rename(base_mapping + ['identifier'], 'duid') + + # Rename the 'mac-address' node to 'mac' + if config.exists(base_mapping + ['mac-address']): + config.rename(base_mapping + ['mac-address'], 'mac') + + # Adjust hostname to have valid FQDN characters only + new_hostname = re.sub(r'[^a-zA-Z0-9-.]', '-', hostname) + if new_hostname != hostname: + config.rename(base_mapping, new_hostname) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/dhcpv6-server/3-to-4 b/src/migration-scripts/dhcpv6-server/3-to-4 new file mode 100755 index 000000000..4747ebd60 --- /dev/null +++ b/src/migration-scripts/dhcpv6-server/3-to-4 @@ -0,0 +1,90 @@ +#!/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/>. + +# T3316: +# - Add subnet IDs to existing subnets +# - Move options to option node +# - Migrate address-range to range tagNode + +import sys +import re +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['service', 'dhcpv6-server', 'shared-network-name'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + sys.exit(0) + +option_nodes = ['captive-portal', 'domain-search', 'name-server', + 'nis-domain', 'nis-server', 'nisplus-domain', 'nisplus-server', + 'sip-server', 'sntp-server', 'vendor-option'] + +subnet_id = 1 + +for network in config.list_nodes(base): + if config.exists(base + [network, 'subnet']): + for subnet in config.list_nodes(base + [network, 'subnet']): + base_subnet = base + [network, 'subnet', subnet] + + if config.exists(base_subnet + ['address-range']): + config.set(base_subnet + ['range']) + config.set_tag(base_subnet + ['range']) + + range_id = 1 + + if config.exists(base_subnet + ['address-range', 'prefix']): + for prefix in config.return_values(base_subnet + ['address-range', 'prefix']): + config.set(base_subnet + ['range', range_id, 'prefix'], value=prefix) + + range_id += 1 + + if config.exists(base_subnet + ['address-range', 'start']): + for start in config.list_nodes(base_subnet + ['address-range', 'start']): + stop = config.return_value(base_subnet + ['address-range', 'start', start, 'stop']) + + config.set(base_subnet + ['range', range_id, 'start'], value=start) + config.set(base_subnet + ['range', range_id, 'stop'], value=stop) + + range_id += 1 + + config.delete(base_subnet + ['address-range']) + + for option in option_nodes: + if config.exists(base_subnet + [option]): + config.set(base_subnet + ['option']) + config.copy(base_subnet + [option], base_subnet + ['option', option]) + config.delete(base_subnet + [option]) + + config.set(base_subnet + ['subnet-id'], value=subnet_id) + subnet_id += 1 + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/dns-dynamic/0-to-1 b/src/migration-scripts/dns-dynamic/0-to-1 index d80e8d44a..b7674a9c8 100755 --- a/src/migration-scripts/dns-dynamic/0-to-1 +++ b/src/migration-scripts/dns-dynamic/0-to-1 @@ -25,8 +25,10 @@ # to "service dns dynamic address <address> service <config> username ..." # - apply global 'ipv6-enable' to per <config> 'ip-version: ipv6' # - apply service protocol mapping upfront, they are not 'auto-detected' anymore +# - migrate web-options url to stricter format import sys +import re from vyos.configtree import ConfigTree service_protocol_mapping = { @@ -81,20 +83,42 @@ for address in config.list_nodes(new_base_path): config.rename(new_base_path + [address, 'service', svc_cfg, 'login'], 'username') # Apply global 'ipv6-enable' to per <config> 'ip-version: ipv6' if config.exists(new_base_path + [address, 'ipv6-enable']): - config.set(new_base_path + [address, 'service', svc_cfg, 'ip-version'], - value='ipv6', replace=False) + config.set(new_base_path + [address, 'service', svc_cfg, 'ip-version'], 'ipv6') config.delete(new_base_path + [address, 'ipv6-enable']) # Apply service protocol mapping upfront, they are not 'auto-detected' anymore if svc_cfg in service_protocol_mapping: config.set(new_base_path + [address, 'service', svc_cfg, 'protocol'], - value=service_protocol_mapping.get(svc_cfg), replace=False) + service_protocol_mapping.get(svc_cfg)) - # Migrate "service dns dynamic interface <interface> use-web" - # to "service dns dynamic address <address> web-options" - # Also, rename <address> to 'web' literal for backward compatibility + # If use-web is set, then: + # Move "service dns dynamic address <address> <service|rfc2136> <service> ..." + # to "service dns dynamic address web <service|rfc2136> <service>-<address> ..." + # Move "service dns dynamic address web use-web ..." + # to "service dns dynamic address web web-options ..." + # Note: The config is named <service>-<address> to avoid name conflict with old entries if config.exists(new_base_path + [address, 'use-web']): - config.rename(new_base_path + [address], 'web') - config.rename(new_base_path + ['web', 'use-web'], 'web-options') + for svc_type in ['rfc2136', 'service']: + if config.exists(new_base_path + [address, svc_type]): + config.set(new_base_path + ['web', svc_type]) + config.set_tag(new_base_path + ['web', svc_type]) + for svc_cfg in config.list_nodes(new_base_path + [address, svc_type]): + config.copy(new_base_path + [address, svc_type, svc_cfg], + new_base_path + ['web', svc_type, f'{svc_cfg}-{address}']) + + # Multiple web-options were not supported, so copy only the first one + # Also, migrate web-options url to stricter format and transition + # checkip.dyndns.org to https://domains.google.com/checkip for better + # TLS support (see: https://github.com/ddclient/ddclient/issues/597) + if not config.exists(new_base_path + ['web', 'web-options']): + config.copy(new_base_path + [address, 'use-web'], new_base_path + ['web', 'web-options']) + if config.exists(new_base_path + ['web', 'web-options', 'url']): + url = config.return_value(new_base_path + ['web', 'web-options', 'url']) + if re.search("^(https?://)?checkip\.dyndns\.org", url): + config.set(new_base_path + ['web', 'web-options', 'url'], 'https://domains.google.com/checkip') + if not url.startswith(('http://', 'https://')): + config.set(new_base_path + ['web', 'web-options', 'url'], f'https://{url}') + + config.delete(new_base_path + [address]) try: with open(file_name, 'w') as f: diff --git a/src/migration-scripts/dns-dynamic/1-to-2 b/src/migration-scripts/dns-dynamic/1-to-2 new file mode 100755 index 000000000..8b599b57a --- /dev/null +++ b/src/migration-scripts/dns-dynamic/1-to-2 @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5708: +# - migrate "service dns dynamic timeout ..." +# to "service dns dynamic interval ..." +# - remove "service dns dynamic address <interface> web-options ..." when <interface> != "web" +# - migrate "service dns dynamic address <interface> service <service> protocol dnsexit" +# to "service dns dynamic address <interface> service <service> protocol dnsexit2" + +import sys +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +base_path = ['service', 'dns', 'dynamic'] +timeout_path = base_path + ['timeout'] +address_path = base_path + ['address'] + +if not config.exists(base_path): + # Nothing to do + sys.exit(0) + +# Migrate "service dns dynamic timeout ..." +# to "service dns dynamic interval ..." +if config.exists(timeout_path): + config.rename(timeout_path, 'interval') + +# Remove "service dns dynamic address <interface> web-options ..." when <interface> != "web" +for address in config.list_nodes(address_path): + if config.exists(address_path + [address, 'web-options']) and address != 'web': + config.delete(address_path + [address, 'web-options']) + +# Migrate "service dns dynamic address <interface> service <service> protocol dnsexit" +# to "service dns dynamic address <interface> service <service> protocol dnsexit2" +for address in config.list_nodes(address_path): + for svc_cfg in config.list_nodes(address_path + [address, 'service']): + if config.exists(address_path + [address, 'service', svc_cfg, 'protocol']): + protocol = config.return_value(address_path + [address, 'service', svc_cfg, 'protocol']) + if protocol == 'dnsexit': + config.set(address_path + [address, 'service', svc_cfg, 'protocol'], 'dnsexit2') + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/dns-dynamic/2-to-3 b/src/migration-scripts/dns-dynamic/2-to-3 new file mode 100755 index 000000000..4e0aa37d5 --- /dev/null +++ b/src/migration-scripts/dns-dynamic/2-to-3 @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5791: +# - migrate "service dns dynamic address web web-options ..." +# to "service dns dynamic name <service> address web ..." (per service) +# - migrate "service dns dynamic address <address> rfc2136 <service> ..." +# to "service dns dynamic name <service> address <interface> protocol 'nsupdate'" +# - migrate "service dns dynamic address <interface> service <service> ..." +# to "service dns dynamic name <service> address <interface> ..." +# - normalize the all service names to conform with name constraints + +import sys +import re +from unicodedata import normalize +from vyos.configtree import ConfigTree + +def normalize_name(name): + """Normalize service names to conform with name constraints. + + This is necessary as part of migration because there were no constraints in + the old name format. + """ + # Normalize unicode characters to ASCII (NFKD) + # Replace all separators with hypens, strip leading and trailing hyphens + name = normalize('NFKD', name).encode('ascii', 'ignore').decode() + name = re.sub(r'(\s|_|\W)+', '-', name).strip('-') + + return name + + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +base_path = ['service', 'dns', 'dynamic'] +address_path = base_path + ['address'] +name_path = base_path + ['name'] + +if not config.exists(address_path): + # Nothing to do + sys.exit(0) + +# config.copy does not recursively create a path, so initialize the name path as tagged node +if not config.exists(name_path): + config.set(name_path) + config.set_tag(name_path) + +for address in config.list_nodes(address_path): + + address_path_tag = address_path + [address] + + # Move web-option as a configuration in each service instead of top level web-option + if config.exists(address_path_tag + ['web-options']) and address == 'web': + for svc_type in ['service', 'rfc2136']: + if config.exists(address_path_tag + [svc_type]): + for svc_cfg in config.list_nodes(address_path_tag + [svc_type]): + config.copy(address_path_tag + ['web-options'], + address_path_tag + [svc_type, svc_cfg, 'web-options']) + config.delete(address_path_tag + ['web-options']) + + for svc_type in ['service', 'rfc2136']: + if config.exists(address_path_tag + [svc_type]): + # Set protocol to 'nsupdate' for RFC2136 configuration + if svc_type == 'rfc2136': + for rfc_cfg in config.list_nodes(address_path_tag + ['rfc2136']): + config.set(address_path_tag + ['rfc2136', rfc_cfg, 'protocol'], 'nsupdate') + + # Add address as config value in each service before moving the service path + # And then copy the services from 'address <interface> service <service>' + # to 'name (service|rfc2136)-<service>-<address>' + # Note: The new service is named (service|rfc2136)-<service>-<address> + # to avoid name conflict with old entries + for svc_cfg in config.list_nodes(address_path_tag + [svc_type]): + config.set(address_path_tag + [svc_type, svc_cfg, 'address'], address) + config.copy(address_path_tag + [svc_type, svc_cfg], + name_path + ['-'.join([svc_type, svc_cfg, address])]) + +# Finally cleanup the old address path +config.delete(address_path) + +# Normalize the all service names to conform with name constraints +index = 1 +for name in config.list_nodes(name_path): + new_name = normalize_name(name) + if new_name != name: + # Append index if there is still a name conflicts after normalization + # For example, "foo-?(" and "foo-!)" both normalize to "foo-" + if config.exists(name_path + [new_name]): + new_name = f'{new_name}-{index}' + index += 1 + config.rename(name_path + [name], new_name) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/dns-dynamic/3-to-4 b/src/migration-scripts/dns-dynamic/3-to-4 new file mode 100755 index 000000000..b888a3b6b --- /dev/null +++ b/src/migration-scripts/dns-dynamic/3-to-4 @@ -0,0 +1,76 @@ +#!/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/>. + +# T5966: +# - migrate "service dns dynamic name <service> address <interface>" +# to "service dns dynamic name <service> address interface <interface>" +# when <interface> != 'web' +# - migrate "service dns dynamic name <service> web-options ..." +# to "service dns dynamic name <service> address web ..." +# when <interface> == 'web' + +import sys +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +base_path = ['service', 'dns', 'dynamic', 'name'] + +if not config.exists(base_path): + # Nothing to do + sys.exit(0) + +for service in config.list_nodes(base_path): + + service_path = base_path + [service] + + if config.exists(service_path + ['address']): + address = config.return_value(service_path + ['address']) + # 'address' is not a leaf node anymore, delete it first + config.delete(service_path + ['address']) + + # When address is an interface (not 'web'), move it to 'address interface' + if address != 'web': + config.set(service_path + ['address', 'interface'], address) + + else: # address == 'web' + # Relocate optional 'web-options' directly under 'address web' + if config.exists(service_path + ['web-options']): + # config.copy does not recursively create a path, so initialize it + config.set(service_path + ['address']) + config.copy(service_path + ['web-options'], + service_path + ['address', 'web']) + config.delete(service_path + ['web-options']) + + # ensure that valueless 'address web' still exists even if there are no 'web-options' + if not config.exists(service_path + ['address', 'web']): + config.set(service_path + ['address', 'web']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/firewall/10-to-11 b/src/migration-scripts/firewall/10-to-11 index 716c5a240..abb804a28 100755 --- a/src/migration-scripts/firewall/10-to-11 +++ b/src/migration-scripts/firewall/10-to-11 @@ -63,19 +63,11 @@ if not config.exists(base): ### Migration of state policies if config.exists(base + ['state-policy']): - for family in ['ipv4', 'ipv6']: - for hook in ['forward', 'input', 'output']: - for priority in ['filter']: - # Add default-action== accept for compatibility reasons: - config.set(base + [family, hook, priority, 'default-action'], value='accept') - position = 1 - for state in config.list_nodes(base + ['state-policy']): - action = config.return_value(base + ['state-policy', state, 'action']) - config.set(base + [family, hook, priority, 'rule']) - config.set_tag(base + [family, hook, priority, 'rule']) - config.set(base + [family, hook, priority, 'rule', position, 'state', state], value='enable') - config.set(base + [family, hook, priority, 'rule', position, 'action'], value=action) - position = position + 1 + for state in config.list_nodes(base + ['state-policy']): + action = config.return_value(base + ['state-policy', state, 'action']) + config.set(base + ['global-options', 'state-policy', state, 'action'], value=action) + if config.exists(base + ['state-policy', state, 'log']): + config.set(base + ['global-options', 'state-policy', state, 'log'], value='enable') config.delete(base + ['state-policy']) ## migration of global options: @@ -88,12 +80,27 @@ for option in ['all-ping', 'broadcast-ping', 'config-trap', 'ip-src-route', 'ipv config.delete(base + [option]) ### Migration of firewall name and ipv6-name +### Also migrate legacy 'accept' behaviour if config.exists(base + ['name']): config.set(['firewall', 'ipv4', 'name']) config.set_tag(['firewall', 'ipv4', 'name']) for ipv4name in config.list_nodes(base + ['name']): config.copy(base + ['name', ipv4name], base + ['ipv4', 'name', ipv4name]) + + if config.exists(base + ['ipv4', 'name', ipv4name, 'default-action']): + action = config.return_value(base + ['ipv4', 'name', ipv4name, 'default-action']) + + if action == 'accept': + config.set(base + ['ipv4', 'name', ipv4name, 'default-action'], value='return') + + if config.exists(base + ['ipv4', 'name', ipv4name, 'rule']): + for rule_id in config.list_nodes(base + ['ipv4', 'name', ipv4name, 'rule']): + action = config.return_value(base + ['ipv4', 'name', ipv4name, 'rule', rule_id, 'action']) + + if action == 'accept': + config.set(base + ['ipv4', 'name', ipv4name, 'rule', rule_id, 'action'], value='return') + config.delete(base + ['name']) if config.exists(base + ['ipv6-name']): @@ -102,6 +109,20 @@ if config.exists(base + ['ipv6-name']): for ipv6name in config.list_nodes(base + ['ipv6-name']): config.copy(base + ['ipv6-name', ipv6name], base + ['ipv6', 'name', ipv6name]) + + if config.exists(base + ['ipv6', 'name', ipv6name, 'default-action']): + action = config.return_value(base + ['ipv6', 'name', ipv6name, 'default-action']) + + if action == 'accept': + config.set(base + ['ipv6', 'name', ipv6name, 'default-action'], value='return') + + if config.exists(base + ['ipv6', 'name', ipv6name, 'rule']): + for rule_id in config.list_nodes(base + ['ipv6', 'name', ipv6name, 'rule']): + action = config.return_value(base + ['ipv6', 'name', ipv6name, 'rule', rule_id, 'action']) + + if action == 'accept': + config.set(base + ['ipv6', 'name', ipv6name, 'rule', rule_id, 'action'], value='return') + config.delete(base + ['ipv6-name']) ### Migration of firewall interface @@ -110,8 +131,8 @@ if config.exists(base + ['interface']): inp_ipv4_rule = 5 fwd_ipv6_rule = 5 inp_ipv6_rule = 5 - for iface in config.list_nodes(base + ['interface']): - for direction in ['in', 'out', 'local']: + for direction in ['in', 'out', 'local']: + for iface in config.list_nodes(base + ['interface']): if config.exists(base + ['interface', iface, direction]): if config.exists(base + ['interface', iface, direction, 'name']): target = config.return_value(base + ['interface', iface, direction, 'name']) @@ -181,191 +202,6 @@ if config.exists(base + ['interface']): config.delete(base + ['interface']) - -### Migration of zones: -### User interface groups -if config.exists(base + ['zone']): - inp_ipv4_rule = 101 - inp_ipv6_rule = 101 - fwd_ipv4_rule = 101 - fwd_ipv6_rule = 101 - out_ipv4_rule = 101 - out_ipv6_rule = 101 - local_zone = 'False' - - for zone in config.list_nodes(base + ['zone']): - if config.exists(base + ['zone', zone, 'local-zone']): - local_zone = 'True' - # Add default-action== accept for compatibility reasons: - config.set(base + ['ipv4', 'input', 'filter', 'default-action'], value='accept') - config.set(base + ['ipv6', 'input', 'filter', 'default-action'], value='accept') - config.set(base + ['ipv4', 'output', 'filter', 'default-action'], value='accept') - config.set(base + ['ipv6', 'output', 'filter', 'default-action'], value='accept') - for from_zone in config.list_nodes(base + ['zone', zone, 'from']): - group_name = 'IG_' + from_zone - if config.exists(base + ['zone', zone, 'from', from_zone, 'firewall', 'name']): - # ipv4 input ruleset - target_ipv4_chain = config.return_value(base + ['zone', zone, 'from', from_zone, 'firewall', 'name']) - config.set(base + ['ipv4', 'input', 'filter', 'rule']) - config.set_tag(base + ['ipv4', 'input', 'filter', 'rule']) - config.set(base + ['ipv4', 'input', 'filter', 'rule', inp_ipv4_rule, 'inbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv4', 'input', 'filter', 'rule', inp_ipv4_rule, 'action'], value='jump') - config.set(base + ['ipv4', 'input', 'filter', 'rule', inp_ipv4_rule, 'jump-target'], value=target_ipv4_chain) - inp_ipv4_rule = inp_ipv4_rule + 5 - if config.exists(base + ['zone', zone, 'from', from_zone, 'firewall', 'ipv6-name']): - # ipv6 input ruleset - target_ipv6_chain = config.return_value(base + ['zone', zone, 'from', from_zone, 'firewall', 'ipv6-name']) - config.set(base + ['ipv6', 'input', 'filter', 'rule']) - config.set_tag(base + ['ipv6', 'input', 'filter', 'rule']) - config.set(base + ['ipv6', 'input', 'filter', 'rule', inp_ipv6_rule, 'inbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv6', 'input', 'filter', 'rule', inp_ipv6_rule, 'action'], value='jump') - config.set(base + ['ipv6', 'input', 'filter', 'rule', inp_ipv6_rule, 'jump-target'], value=target_ipv6_chain) - inp_ipv6_rule = inp_ipv6_rule + 5 - - # Migrate: set firewall zone <zone> default-action <action> - # Options: drop or reject. If not specified, is drop - if config.exists(base + ['zone', zone, 'default-action']): - local_def_action = config.return_value(base + ['zone', zone, 'default-action']) - else: - local_def_action = 'drop' - config.set(base + ['ipv4', 'input', 'filter', 'rule']) - config.set_tag(base + ['ipv4', 'input', 'filter', 'rule']) - config.set(base + ['ipv4', 'input', 'filter', 'rule', inp_ipv4_rule, 'action'], value=local_def_action) - config.set(base + ['ipv6', 'input', 'filter', 'rule']) - config.set_tag(base + ['ipv6', 'input', 'filter', 'rule']) - config.set(base + ['ipv6', 'input', 'filter', 'rule', inp_ipv6_rule, 'action'], value=local_def_action) - if config.exists(base + ['zone', zone, 'enable-default-log']): - config.set(base + ['ipv4', 'input', 'filter', 'rule', inp_ipv4_rule, 'log'], value='enable') - config.set(base + ['ipv6', 'input', 'filter', 'rule', inp_ipv6_rule, 'log'], value='enable') - - else: - # It's not a local zone - group_name = 'IG_' + zone - # Add default-action== accept for compatibility reasons: - config.set(base + ['ipv4', 'forward', 'filter', 'default-action'], value='accept') - config.set(base + ['ipv6', 'forward', 'filter', 'default-action'], value='accept') - # intra-filtering migration. By default accept - intra_zone_ipv4_action = 'accept' - intra_zone_ipv6_action = 'accept' - - if config.exists(base + ['zone', zone, 'intra-zone-filtering', 'action']): - intra_zone_ipv4_action = config.return_value(base + ['zone', zone, 'intra-zone-filtering', 'action']) - intra_zone_ipv6_action = intra_zone_ipv4_action - else: - if config.exists(base + ['zone', zone, 'intra-zone-filtering', 'firewall', 'name']): - intra_zone_ipv4_target = config.return_value(base + ['zone', zone, 'intra-zone-filtering', 'firewall', 'name']) - intra_zone_ipv4_action = 'jump' - if config.exists(base + ['zone', zone, 'intra-zone-filtering', 'firewall', 'ipv6-name']): - intra_zone_ipv6_target = config.return_value(base + ['zone', zone, 'intra-zone-filtering', 'firewall', 'ipv6-name']) - intra_zone_ipv6_action = 'jump' - config.set(base + ['ipv4', 'forward', 'filter', 'rule']) - config.set_tag(base + ['ipv4', 'forward', 'filter', 'rule']) - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'outbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'inbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'action'], value=intra_zone_ipv4_action) - config.set(base + ['ipv6', 'forward', 'filter', 'rule']) - config.set_tag(base + ['ipv6', 'forward', 'filter', 'rule']) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'outbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'inbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'action'], value=intra_zone_ipv6_action) - if intra_zone_ipv4_action == 'jump': - if config.exists(base + ['zone', zone, 'intra-zone-filtering', 'firewall', 'name']): - intra_zone_ipv4_target = config.return_value(base + ['zone', zone, 'intra-zone-filtering', 'firewall', 'name']) - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'jump-target'], value=intra_zone_ipv4_target) - if intra_zone_ipv6_action == 'jump': - if config.exists(base + ['zone', zone, 'intra-zone-filtering', 'firewall', 'ipv6-name']): - intra_zone_ipv6_target = config.return_value(base + ['zone', zone, 'intra-zone-filtering', 'firewall', 'ipv6-name']) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'jump-target'], value=intra_zone_ipv6_target) - fwd_ipv4_rule = fwd_ipv4_rule + 5 - fwd_ipv6_rule = fwd_ipv6_rule + 5 - - if config.exists(base + ['zone', zone, 'interface']): - # Create interface group IG_<zone> - group_name = 'IG_' + zone - config.set(base + ['group', 'interface-group'], value=group_name) - config.set_tag(base + ['group', 'interface-group']) - for iface in config.return_values(base + ['zone', zone, 'interface']): - config.set(base + ['group', 'interface-group', group_name, 'interface'], value=iface, replace=False) - - if config.exists(base + ['zone', zone, 'from']): - for from_zone in config.list_nodes(base + ['zone', zone, 'from']): - from_group = 'IG_' + from_zone - if config.exists(base + ['zone', zone, 'from', from_zone, 'firewall', 'name']): - target_ipv4_chain = config.return_value(base + ['zone', zone, 'from', from_zone, 'firewall', 'name']) - if config.exists(base + ['zone', from_zone, 'local-zone']): - # It's from LOCAL zone -> Output filtering - config.set(base + ['ipv4', 'output', 'filter', 'rule']) - config.set_tag(base + ['ipv4', 'output', 'filter', 'rule']) - config.set(base + ['ipv4', 'output', 'filter', 'rule', out_ipv4_rule, 'outbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv4', 'output', 'filter', 'rule', out_ipv4_rule, 'action'], value='jump') - config.set(base + ['ipv4', 'output', 'filter', 'rule', out_ipv4_rule, 'jump-target'], value=target_ipv4_chain) - out_ipv4_rule = out_ipv4_rule + 5 - else: - # It's not LOCAL zone -> forward filtering - config.set(base + ['ipv4', 'forward', 'filter', 'rule']) - config.set_tag(base + ['ipv4', 'forward', 'filter', 'rule']) - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'outbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'inbound-interface', 'interface-group'], value=from_group) - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'action'], value='jump') - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'jump-target'], value=target_ipv4_chain) - fwd_ipv4_rule = fwd_ipv4_rule + 5 - if config.exists(base + ['zone', zone, 'from', from_zone, 'firewall', 'ipv6-name']): - target_ipv6_chain = config.return_value(base + ['zone', zone, 'from', from_zone, 'firewall', 'ipv6-name']) - if config.exists(base + ['zone', from_zone, 'local-zone']): - # It's from LOCAL zone -> Output filtering - config.set(base + ['ipv6', 'output', 'filter', 'rule']) - config.set_tag(base + ['ipv6', 'output', 'filter', 'rule']) - config.set(base + ['ipv6', 'output', 'filter', 'rule', out_ipv6_rule, 'outbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv6', 'output', 'filter', 'rule', out_ipv6_rule, 'action'], value='jump') - config.set(base + ['ipv6', 'output', 'filter', 'rule', out_ipv6_rule, 'jump-target'], value=target_ipv6_chain) - out_ipv6_rule = out_ipv6_rule + 5 - else: - # It's not LOCAL zone -> forward filtering - config.set(base + ['ipv6', 'forward', 'filter', 'rule']) - config.set_tag(base + ['ipv6', 'forward', 'filter', 'rule']) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'outbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'inbound-interface', 'interface-group'], value=from_group) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'action'], value='jump') - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'jump-target'], value=target_ipv6_chain) - fwd_ipv6_rule = fwd_ipv6_rule + 5 - - ## Now need to migrate: set firewall zone <zone> default-action <action> # action=drop if not specified. - if config.exists(base + ['zone', zone, 'default-action']): - def_action = config.return_value(base + ['zone', zone, 'default-action']) - else: - def_action = 'drop' - config.set(base + ['ipv4', 'forward', 'filter', 'rule']) - config.set_tag(base + ['ipv4', 'forward', 'filter', 'rule']) - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'outbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'action'], value=def_action) - description = 'zone_' + zone + ' default-action' - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'description'], value=description) - config.set(base + ['ipv6', 'forward', 'filter', 'rule']) - config.set_tag(base + ['ipv6', 'forward', 'filter', 'rule']) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'outbound-interface', 'interface-group'], value=group_name) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'action'], value=def_action) - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'description'], value=description) - - if config.exists(base + ['zone', zone, 'enable-default-log']): - config.set(base + ['ipv4', 'forward', 'filter', 'rule', fwd_ipv4_rule, 'log'], value='enable') - config.set(base + ['ipv6', 'forward', 'filter', 'rule', fwd_ipv6_rule, 'log'], value='enable') - fwd_ipv4_rule = fwd_ipv4_rule + 5 - fwd_ipv6_rule = fwd_ipv6_rule + 5 - - # Migrate default-action (force to be drop in output chain) if local zone is defined - if local_zone == 'True': - # General drop in output change if needed - config.set(base + ['ipv4', 'output', 'filter', 'rule']) - config.set_tag(base + ['ipv4', 'output', 'filter', 'rule']) - config.set(base + ['ipv4', 'output', 'filter', 'rule', out_ipv4_rule, 'action'], value=local_def_action) - config.set(base + ['ipv6', 'output', 'filter', 'rule']) - config.set_tag(base + ['ipv6', 'output', 'filter', 'rule']) - config.set(base + ['ipv6', 'output', 'filter', 'rule', out_ipv6_rule, 'action'], value=local_def_action) - - config.delete(base + ['zone']) - -###### END migration zones - try: with open(file_name, 'w') as f: f.write(config.to_string()) diff --git a/src/migration-scripts/firewall/11-to-12 b/src/migration-scripts/firewall/11-to-12 new file mode 100755 index 000000000..ba8374d66 --- /dev/null +++ b/src/migration-scripts/firewall/11-to-12 @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5681: Firewall re-writing. Simplify cli when mathcing interface +# From + # set firewall ... rule <rule> [inbound-interface | outboubd-interface] interface-name <iface> + # set firewall ... rule <rule> [inbound-interface | outboubd-interface] interface-group <iface_group> +# To + # set firewall ... rule <rule> [inbound-interface | outboubd-interface] name <iface> + # set firewall ... rule <rule> [inbound-interface | outboubd-interface] group <iface_group> + +import re + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree +from vyos.ifconfig import Section + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['firewall'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) + +## Migration from base chains +#if config.exists(base + ['interface', iface, direction]): +for family in ['ipv4', 'ipv6']: + if config.exists(base + [family]): + for hook in ['forward', 'input', 'output', 'name']: + if config.exists(base + [family, hook]): + for priority in config.list_nodes(base + [family, hook]): + if config.exists(base + [family, hook, priority, 'rule']): + for rule in config.list_nodes(base + [family, hook, priority, 'rule']): + for direction in ['inbound-interface', 'outbound-interface']: + if config.exists(base + [family, hook, priority, 'rule', rule, direction]): + if config.exists(base + [family, hook, priority, 'rule', rule, direction, 'interface-name']): + iface = config.return_value(base + [family, hook, priority, 'rule', rule, direction, 'interface-name']) + config.set(base + [family, hook, priority, 'rule', rule, direction, 'name'], value=iface) + config.delete(base + [family, hook, priority, 'rule', rule, direction, 'interface-name']) + elif config.exists(base + [family, hook, priority, 'rule', rule, direction, 'interface-group']): + group = config.return_value(base + [family, hook, priority, 'rule', rule, direction, 'interface-group']) + config.set(base + [family, hook, priority, 'rule', rule, direction, 'group'], value=group) + config.delete(base + [family, hook, priority, 'rule', rule, direction, 'interface-group']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1)
\ No newline at end of file diff --git a/src/migration-scripts/firewall/12-to-13 b/src/migration-scripts/firewall/12-to-13 new file mode 100755 index 000000000..8396dd9d1 --- /dev/null +++ b/src/migration-scripts/firewall/12-to-13 @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5729: Switch to valueless whenever is possible. +# From + # set firewall ... rule <rule> log enable + # set firewall ... rule <rule> state <state> enable + # set firewall ... rule <rule> log disable + # set firewall ... rule <rule> state <state> disable +# To + # set firewall ... rule <rule> log + # set firewall ... rule <rule> state <state> + # Remove command if log=disable or <state>=disable + +import re + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree +from vyos.ifconfig import Section + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['firewall'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) + +# State Policy logs: +if config.exists(base + ['global-options', 'state-policy']): + for state in config.list_nodes(base + ['global-options', 'state-policy']): + if config.exists(base + ['global-options', 'state-policy', state, 'log']): + log_value = config.return_value(base + ['global-options', 'state-policy', state, 'log']) + config.delete(base + ['global-options', 'state-policy', state, 'log']) + if log_value == 'enable': + config.set(base + ['global-options', 'state-policy', state, 'log']) + +for family in ['ipv4', 'ipv6', 'bridge']: + if config.exists(base + [family]): + for hook in ['forward', 'input', 'output', 'name']: + if config.exists(base + [family, hook]): + for priority in config.list_nodes(base + [family, hook]): + if config.exists(base + [family, hook, priority, 'rule']): + for rule in config.list_nodes(base + [family, hook, priority, 'rule']): + # Log + if config.exists(base + [family, hook, priority, 'rule', rule, 'log']): + log_value = config.return_value(base + [family, hook, priority, 'rule', rule, 'log']) + config.delete(base + [family, hook, priority, 'rule', rule, 'log']) + if log_value == 'enable': + config.set(base + [family, hook, priority, 'rule', rule, 'log']) + # State + if config.exists(base + [family, hook, priority, 'rule', rule, 'state']): + flag_enable = 'False' + for state in ['established', 'invalid', 'new', 'related']: + if config.exists(base + [family, hook, priority, 'rule', rule, 'state', state]): + state_value = config.return_value(base + [family, hook, priority, 'rule', rule, 'state', state]) + config.delete(base + [family, hook, priority, 'rule', rule, 'state', state]) + if state_value == 'enable': + config.set(base + [family, hook, priority, 'rule', rule, 'state'], value=state, replace=False) + flag_enable = 'True' + if flag_enable == 'False': + config.delete(base + [family, hook, priority, 'rule', rule, 'state']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1)
\ No newline at end of file diff --git a/src/migration-scripts/firewall/13-to-14 b/src/migration-scripts/firewall/13-to-14 new file mode 100755 index 000000000..f45ff0674 --- /dev/null +++ b/src/migration-scripts/firewall/13-to-14 @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5834: Rename 'enable-default-log' to 'default-log' +# From + # set firewall ... filter enable-default-log + # set firewall ... name <name> enable-default-log +# To + # set firewall ... filter default-log + # set firewall ... name <name> default-log + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['firewall'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) + +for family in ['ipv4', 'ipv6', 'bridge']: + if config.exists(base + [family]): + for hook in ['forward', 'input', 'output', 'name']: + if config.exists(base + [family, hook]): + for priority in config.list_nodes(base + [family, hook]): + if config.exists(base + [family, hook, priority, 'enable-default-log']): + config.rename(base + [family, hook, priority, 'enable-default-log'], 'default-log') + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/firewall/6-to-7 b/src/migration-scripts/firewall/6-to-7 index 9ad887acc..b918833e9 100755 --- a/src/migration-scripts/firewall/6-to-7 +++ b/src/migration-scripts/firewall/6-to-7 @@ -73,6 +73,7 @@ icmp_translations = { # Time Exceeded 'ttl-zero-during-transit': [11, 0], 'ttl-zero-during-reassembly': [11, 1], + 'ttl-exceeded': 'time-exceeded', # Parameter Problem 'ip-header-bad': [12, 0], 'required-option-missing': [12, 1] @@ -87,8 +88,14 @@ icmpv6_translations = { 'communication-prohibited': [1, 1], 'address-unreachble': [1, 3], 'port-unreachable': [1, 4], - # Redirect + # nd 'redirect': 'nd-redirect', + 'router-solicitation': 'nd-router-solicit', + 'router-advertisement': 'nd-router-advert', + 'neighbour-solicitation': 'nd-neighbor-solicit', + 'neighbor-solicitation': 'nd-neighbor-solicit', + 'neighbour-advertisement': 'nd-neighbor-advert', + 'neighbor-advertisement': 'nd-neighbor-advert', # Time Exceeded 'ttl-zero-during-transit': [3, 0], 'ttl-zero-during-reassembly': [3, 1], diff --git a/src/migration-scripts/https/1-to-2 b/src/migration-scripts/https/1-to-2 index b1cf37ea6..1a2cdc1e7 100755 --- a/src/migration-scripts/https/1-to-2 +++ b/src/migration-scripts/https/1-to-2 @@ -15,7 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # * Move 'api virtual-host' list to 'api-restrict virtual-host' so it -# is owned by https.py instead of http-api.py +# is owned by service_https.py import sys diff --git a/src/migration-scripts/https/4-to-5 b/src/migration-scripts/https/4-to-5 new file mode 100755 index 000000000..0dfb6ac19 --- /dev/null +++ b/src/migration-scripts/https/4-to-5 @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5762: http: api: smoketests fail as they can not establish IPv6 connection +# to uvicorn backend server, always make the UNIX domain socket the +# default way of communication + +import sys + +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +base = ['service', 'https'] +if not config.exists(base): + # Nothing to do + sys.exit(0) + +# Delete "socket" CLI option - we always use UNIX domain sockets for +# NGINX <-> API server communication +if config.exists(base + ['api', 'socket']): + config.delete(base + ['api', 'socket']) + +# There is no need for an API service port, as UNIX domain sockets +# are used +if config.exists(base + ['api', 'port']): + config.delete(base + ['api', 'port']) + +# rename listen-port -> port ver virtual-host +if config.exists(base + ['virtual-host']): + for vhost in config.list_nodes(base + ['virtual-host']): + if config.exists(base + ['virtual-host', vhost, 'listen-port']): + config.rename(base + ['virtual-host', vhost, 'listen-port'], 'port') + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/https/5-to-6 b/src/migration-scripts/https/5-to-6 new file mode 100755 index 000000000..0090adccb --- /dev/null +++ b/src/migration-scripts/https/5-to-6 @@ -0,0 +1,109 @@ +#!/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/>. + +# T5886: Add support for ACME protocol (LetsEncrypt), migrate https certbot +# to new "pki certificate" CLI tree +# T5902: Remove virtual-host + +import os +import sys + +from vyos.configtree import ConfigTree +from vyos.defaults import directories +from vyos.utils.process import cmd + +vyos_certbot_dir = directories['certbot'] + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +base = ['service', 'https'] +if not config.exists(base): + # Nothing to do + sys.exit(0) + +if config.exists(base + ['certificates', 'certbot']): + # both domain-name and email must be set on CLI - ensured by previous verify() + domain_names = config.return_values(base + ['certificates', 'certbot', 'domain-name']) + email = config.return_value(base + ['certificates', 'certbot', 'email']) + config.delete(base + ['certificates', 'certbot']) + + # Set default certname based on domain-name + cert_name = 'https-' + domain_names[0].split('.')[0] + # Overwrite certname from previous certbot calls if available + # We can not use python code like os.scandir due to filesystem permissions. + # This must be run as root + certbot_live = f'{vyos_certbot_dir}/live/' # we need the trailing / + if os.path.exists(certbot_live): + tmp = cmd(f'sudo find {certbot_live} -maxdepth 1 -type d') + tmp = tmp.split() # tmp = ['/config/auth/letsencrypt/live', '/config/auth/letsencrypt/live/router.vyos.net'] + tmp.remove(certbot_live) + cert_name = tmp[0].replace(certbot_live, '') + + config.set(['pki', 'certificate', cert_name, 'acme', 'email'], value=email) + config.set_tag(['pki', 'certificate']) + for domain in domain_names: + config.set(['pki', 'certificate', cert_name, 'acme', 'domain-name'], value=domain, replace=False) + + # Update Webserver certificate + config.set(base + ['certificates', 'certificate'], value=cert_name) + +if config.exists(base + ['virtual-host']): + allow_client = [] + listen_port = [] + listen_address = [] + for virtual_host in config.list_nodes(base + ['virtual-host']): + allow_path = base + ['virtual-host', virtual_host, 'allow-client', 'address'] + if config.exists(allow_path): + tmp = config.return_values(allow_path) + allow_client.extend(tmp) + + port_path = base + ['virtual-host', virtual_host, 'listen-port'] + if config.exists(port_path): + tmp = config.return_value(port_path) + listen_port.append(tmp) + + listen_address_path = base + ['virtual-host', virtual_host, 'listen-address'] + if config.exists(listen_address_path): + tmp = config.return_value(listen_address_path) + listen_address.append(tmp) + + config.delete(base + ['virtual-host']) + for client in allow_client: + config.set(base + ['allow-client', 'address'], value=client, replace=False) + + # clear listen-address if "all" were specified + if '*' in listen_address: + listen_address = [] + for address in listen_address: + config.set(base + ['listen-address'], value=address, replace=False) + + + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/interfaces/22-to-23 b/src/migration-scripts/interfaces/22-to-23 index 8b21fce51..04e023e77 100755 --- a/src/migration-scripts/interfaces/22-to-23 +++ b/src/migration-scripts/interfaces/22-to-23 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -13,133 +13,45 @@ # # 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 argv -from sys import exit +# +# Deletes Wireguard peers if they have the same public key as the router has. +import sys from vyos.configtree import ConfigTree - -def migrate_ospf(config, path, interface): - path = path + ['ospf'] - if config.exists(path): - new_base = ['protocols', 'ospf', 'interface'] - config.set(new_base) - config.set_tag(new_base) - config.copy(path, new_base + [interface]) - config.delete(path) - - # if "ip ospf" was the only setting, we can clean out the empty - # ip node afterwards - if len(config.list_nodes(path[:-1])) == 0: - config.delete(path[:-1]) - -def migrate_ospfv3(config, path, interface): - path = path + ['ospfv3'] - if config.exists(path): - new_base = ['protocols', 'ospfv3', 'interface'] - config.set(new_base) - config.set_tag(new_base) - config.copy(path, new_base + [interface]) - config.delete(path) - - # if "ipv6 ospfv3" was the only setting, we can clean out the empty - # ip node afterwards - if len(config.list_nodes(path[:-1])) == 0: - config.delete(path[:-1]) - -def migrate_rip(config, path, interface): - path = path + ['rip'] - if config.exists(path): - new_base = ['protocols', 'rip', 'interface'] - config.set(new_base) - config.set_tag(new_base) - config.copy(path, new_base + [interface]) - config.delete(path) - - # if "ip rip" was the only setting, we can clean out the empty - # ip node afterwards - if len(config.list_nodes(path[:-1])) == 0: - config.delete(path[:-1]) - -def migrate_ripng(config, path, interface): - path = path + ['ripng'] - if config.exists(path): - new_base = ['protocols', 'ripng', 'interface'] - config.set(new_base) - config.set_tag(new_base) - config.copy(path, new_base + [interface]) - config.delete(path) - - # if "ipv6 ripng" was the only setting, we can clean out the empty - # ip node afterwards - if len(config.list_nodes(path[:-1])) == 0: - config.delete(path[:-1]) +from vyos.utils.network import is_wireguard_key_pair if __name__ == '__main__': - if len(argv) < 2: + if len(sys.argv) < 2: print("Must specify file name!") - exit(1) + sys.exit(1) + + file_name = sys.argv[1] - file_name = argv[1] with open(file_name, 'r') as f: config_file = f.read() config = ConfigTree(config_file) - - # - # Migrate "interface ethernet eth0 ip ospf" to "protocols ospf interface eth0" - # - for type in config.list_nodes(['interfaces']): - for interface in config.list_nodes(['interfaces', type]): - ip_base = ['interfaces', type, interface, 'ip'] - ipv6_base = ['interfaces', type, interface, 'ipv6'] - migrate_rip(config, ip_base, interface) - migrate_ripng(config, ipv6_base, interface) - migrate_ospf(config, ip_base, interface) - migrate_ospfv3(config, ipv6_base, interface) - - vif_path = ['interfaces', type, interface, 'vif'] - if config.exists(vif_path): - for vif in config.list_nodes(vif_path): - vif_ip_base = vif_path + [vif, 'ip'] - vif_ipv6_base = vif_path + [vif, 'ipv6'] - ifname = f'{interface}.{vif}' - - migrate_rip(config, vif_ip_base, ifname) - migrate_ripng(config, vif_ipv6_base, ifname) - migrate_ospf(config, vif_ip_base, ifname) - migrate_ospfv3(config, vif_ipv6_base, ifname) - - - vif_s_path = ['interfaces', type, interface, 'vif-s'] - if config.exists(vif_s_path): - for vif_s in config.list_nodes(vif_s_path): - vif_s_ip_base = vif_s_path + [vif_s, 'ip'] - vif_s_ipv6_base = vif_s_path + [vif_s, 'ipv6'] - - # vif-c interfaces MUST be migrated before their parent vif-s - # interface as the migrate_*() functions delete the path! - vif_c_path = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c'] - if config.exists(vif_c_path): - for vif_c in config.list_nodes(vif_c_path): - vif_c_ip_base = vif_c_path + [vif_c, 'ip'] - vif_c_ipv6_base = vif_c_path + [vif_c, 'ipv6'] - ifname = f'{interface}.{vif_s}.{vif_c}' - - migrate_rip(config, vif_c_ip_base, ifname) - migrate_ripng(config, vif_c_ipv6_base, ifname) - migrate_ospf(config, vif_c_ip_base, ifname) - migrate_ospfv3(config, vif_c_ipv6_base, ifname) - - - ifname = f'{interface}.{vif_s}' - migrate_rip(config, vif_s_ip_base, ifname) - migrate_ripng(config, vif_s_ipv6_base, ifname) - migrate_ospf(config, vif_s_ip_base, ifname) - migrate_ospfv3(config, vif_s_ipv6_base, ifname) + base = ['interfaces', 'wireguard'] + if not config.exists(base): + # Nothing to do + sys.exit(0) + for interface in config.list_nodes(base): + if not config.exists(base + [interface, 'private-key']): + continue + private_key = config.return_value(base + [interface, 'private-key']) + interface_base = base + [interface] + if config.exists(interface_base + ['peer']): + for peer in config.list_nodes(interface_base + ['peer']): + peer_base = interface_base + ['peer', peer] + if not config.exists(peer_base + ['public-key']): + continue + peer_public_key = config.return_value(peer_base + ['public-key']) + if not config.exists(peer_base + ['disable']) \ + and is_wireguard_key_pair(private_key, peer_public_key): + config.set(peer_base + ['disable']) try: with open(file_name, 'w') as f: f.write(config.to_string()) except OSError as e: print("Failed to save the modified config: {}".format(e)) - exit(1) + sys.exit(1) diff --git a/src/migration-scripts/interfaces/23-to-24 b/src/migration-scripts/interfaces/23-to-24 index 8fd79ecc6..8b21fce51 100755 --- a/src/migration-scripts/interfaces/23-to-24 +++ b/src/migration-scripts/interfaces/23-to-24 @@ -14,47 +14,132 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# A VTI interface also requires an IPSec configuration - VyOS 1.2 supported -# having a VTI interface in the CLI but no IPSec configuration - drop VTI -# configuration if this is the case for VyOS 1.4 - -import sys +from sys import argv +from sys import exit from vyos.configtree import ConfigTree +def migrate_ospf(config, path, interface): + path = path + ['ospf'] + if config.exists(path): + new_base = ['protocols', 'ospf', 'interface'] + config.set(new_base) + config.set_tag(new_base) + config.copy(path, new_base + [interface]) + config.delete(path) + + # if "ip ospf" was the only setting, we can clean out the empty + # ip node afterwards + if len(config.list_nodes(path[:-1])) == 0: + config.delete(path[:-1]) + +def migrate_ospfv3(config, path, interface): + path = path + ['ospfv3'] + if config.exists(path): + new_base = ['protocols', 'ospfv3', 'interface'] + config.set(new_base) + config.set_tag(new_base) + config.copy(path, new_base + [interface]) + config.delete(path) + + # if "ipv6 ospfv3" was the only setting, we can clean out the empty + # ip node afterwards + if len(config.list_nodes(path[:-1])) == 0: + config.delete(path[:-1]) + +def migrate_rip(config, path, interface): + path = path + ['rip'] + if config.exists(path): + new_base = ['protocols', 'rip', 'interface'] + config.set(new_base) + config.set_tag(new_base) + config.copy(path, new_base + [interface]) + config.delete(path) + + # if "ip rip" was the only setting, we can clean out the empty + # ip node afterwards + if len(config.list_nodes(path[:-1])) == 0: + config.delete(path[:-1]) + +def migrate_ripng(config, path, interface): + path = path + ['ripng'] + if config.exists(path): + new_base = ['protocols', 'ripng', 'interface'] + config.set(new_base) + config.set_tag(new_base) + config.copy(path, new_base + [interface]) + config.delete(path) + + # if "ipv6 ripng" was the only setting, we can clean out the empty + # ip node afterwards + if len(config.list_nodes(path[:-1])) == 0: + config.delete(path[:-1]) + if __name__ == '__main__': - if len(sys.argv) < 2: + if len(argv) < 2: print("Must specify file name!") - sys.exit(1) - - file_name = sys.argv[1] + exit(1) + file_name = argv[1] with open(file_name, 'r') as f: config_file = f.read() config = ConfigTree(config_file) - base = ['interfaces', 'vti'] - if not config.exists(base): - # Nothing to do - sys.exit(0) - - ipsec_base = ['vpn', 'ipsec', 'site-to-site', 'peer'] - for interface in config.list_nodes(base): - found = False - if config.exists(ipsec_base): - for peer in config.list_nodes(ipsec_base): - if config.exists(ipsec_base + [peer, 'vti', 'bind']): - tmp = config.return_value(ipsec_base + [peer, 'vti', 'bind']) - if tmp == interface: - # Interface was found and we no longer need to search - # for it in our IPSec peers - found = True - break - if not found: - config.delete(base + [interface]) + + # + # Migrate "interface ethernet eth0 ip ospf" to "protocols ospf interface eth0" + # + for type in config.list_nodes(['interfaces']): + for interface in config.list_nodes(['interfaces', type]): + ip_base = ['interfaces', type, interface, 'ip'] + ipv6_base = ['interfaces', type, interface, 'ipv6'] + migrate_rip(config, ip_base, interface) + migrate_ripng(config, ipv6_base, interface) + migrate_ospf(config, ip_base, interface) + migrate_ospfv3(config, ipv6_base, interface) + + vif_path = ['interfaces', type, interface, 'vif'] + if config.exists(vif_path): + for vif in config.list_nodes(vif_path): + vif_ip_base = vif_path + [vif, 'ip'] + vif_ipv6_base = vif_path + [vif, 'ipv6'] + ifname = f'{interface}.{vif}' + + migrate_rip(config, vif_ip_base, ifname) + migrate_ripng(config, vif_ipv6_base, ifname) + migrate_ospf(config, vif_ip_base, ifname) + migrate_ospfv3(config, vif_ipv6_base, ifname) + + + vif_s_path = ['interfaces', type, interface, 'vif-s'] + if config.exists(vif_s_path): + for vif_s in config.list_nodes(vif_s_path): + vif_s_ip_base = vif_s_path + [vif_s, 'ip'] + vif_s_ipv6_base = vif_s_path + [vif_s, 'ipv6'] + + # vif-c interfaces MUST be migrated before their parent vif-s + # interface as the migrate_*() functions delete the path! + vif_c_path = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c'] + if config.exists(vif_c_path): + for vif_c in config.list_nodes(vif_c_path): + vif_c_ip_base = vif_c_path + [vif_c, 'ip'] + vif_c_ipv6_base = vif_c_path + [vif_c, 'ipv6'] + ifname = f'{interface}.{vif_s}.{vif_c}' + + migrate_rip(config, vif_c_ip_base, ifname) + migrate_ripng(config, vif_c_ipv6_base, ifname) + migrate_ospf(config, vif_c_ip_base, ifname) + migrate_ospfv3(config, vif_c_ipv6_base, ifname) + + + ifname = f'{interface}.{vif_s}' + migrate_rip(config, vif_s_ip_base, ifname) + migrate_ripng(config, vif_s_ipv6_base, ifname) + migrate_ospf(config, vif_s_ip_base, ifname) + migrate_ospfv3(config, vif_s_ipv6_base, ifname) try: with open(file_name, 'w') as f: f.write(config.to_string()) except OSError as e: print("Failed to save the modified config: {}".format(e)) - sys.exit(1) + exit(1) diff --git a/src/migration-scripts/interfaces/24-to-25 b/src/migration-scripts/interfaces/24-to-25 index 9aa6ea5e3..8fd79ecc6 100755 --- a/src/migration-scripts/interfaces/24-to-25 +++ b/src/migration-scripts/interfaces/24-to-25 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# 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 @@ -14,374 +14,47 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# Migrate Wireguard to store keys in CLI -# Migrate EAPoL to PKI configuration +# A VTI interface also requires an IPSec configuration - VyOS 1.2 supported +# having a VTI interface in the CLI but no IPSec configuration - drop VTI +# configuration if this is the case for VyOS 1.4 -import os import sys - from vyos.configtree import ConfigTree -from vyos.pki import CERT_BEGIN -from vyos.pki import load_certificate -from vyos.pki import load_crl -from vyos.pki import load_dh_parameters -from vyos.pki import load_private_key -from vyos.pki import encode_certificate -from vyos.pki import encode_dh_parameters -from vyos.pki import encode_private_key -from vyos.pki import verify_crl -from vyos.utils.process import run - -def wrapped_pem_to_config_value(pem): - out = [] - for line in pem.strip().split("\n"): - if not line or line.startswith("-----") or line[0] == '#': - continue - out.append(line) - return "".join(out) - -def read_file_for_pki(config_auth_path): - full_path = os.path.join(AUTH_DIR, config_auth_path) - output = None - - if os.path.isfile(full_path): - if not os.access(full_path, os.R_OK): - run(f'sudo chmod 644 {full_path}') - - with open(full_path, 'r') as f: - output = f.read() - - return output -if len(sys.argv) < 2: - print("Must specify file name!") - sys.exit(1) +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) -file_name = sys.argv[1] + file_name = sys.argv[1] -with open(file_name, 'r') as f: - config_file = f.read() + with open(file_name, 'r') as f: + config_file = f.read() -config = ConfigTree(config_file) + config = ConfigTree(config_file) + base = ['interfaces', 'vti'] + if not config.exists(base): + # Nothing to do + sys.exit(0) -AUTH_DIR = '/config/auth' -pki_base = ['pki'] - -# OpenVPN -base = ['interfaces', 'openvpn'] - -if config.exists(base): + ipsec_base = ['vpn', 'ipsec', 'site-to-site', 'peer'] for interface in config.list_nodes(base): - x509_base = base + [interface, 'tls'] - pki_name = f'openvpn_{interface}' - - if config.exists(base + [interface, 'shared-secret-key-file']): - if not config.exists(pki_base + ['openvpn', 'shared-secret']): - config.set(pki_base + ['openvpn', 'shared-secret']) - config.set_tag(pki_base + ['openvpn', 'shared-secret']) - - key_file = config.return_value(base + [interface, 'shared-secret-key-file']) - key = read_file_for_pki(key_file) - key_pki_name = f'{pki_name}_shared' - - if key: - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') - config.set(base + [interface, 'shared-secret-key'], value=key_pki_name) - else: - print(f'Failed to migrate shared-secret-key on openvpn interface {interface}') - - config.delete(base + [interface, 'shared-secret-key-file']) - - if not config.exists(base + [interface, 'tls']): - continue - - if config.exists(base + [interface, 'tls', 'auth-file']): - if not config.exists(pki_base + ['openvpn', 'shared-secret']): - config.set(pki_base + ['openvpn', 'shared-secret']) - config.set_tag(pki_base + ['openvpn', 'shared-secret']) - - key_file = config.return_value(base + [interface, 'tls', 'auth-file']) - key = read_file_for_pki(key_file) - key_pki_name = f'{pki_name}_auth' - - if key: - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') - config.set(base + [interface, 'tls', 'auth-key'], value=key_pki_name) - else: - print(f'Failed to migrate auth-key on openvpn interface {interface}') - - config.delete(base + [interface, 'tls', 'auth-file']) - - if config.exists(base + [interface, 'tls', 'crypt-file']): - if not config.exists(pki_base + ['openvpn', 'shared-secret']): - config.set(pki_base + ['openvpn', 'shared-secret']) - config.set_tag(pki_base + ['openvpn', 'shared-secret']) - - key_file = config.return_value(base + [interface, 'tls', 'crypt-file']) - key = read_file_for_pki(key_file) - key_pki_name = f'{pki_name}_crypt' - - if key: - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') - config.set(base + [interface, 'tls', 'crypt-key'], value=key_pki_name) - else: - print(f'Failed to migrate crypt-key on openvpn interface {interface}') - - config.delete(base + [interface, 'tls', 'crypt-file']) - - ca_certs = {} - - if config.exists(x509_base + ['ca-cert-file']): - if not config.exists(pki_base + ['ca']): - config.set(pki_base + ['ca']) - config.set_tag(pki_base + ['ca']) - - cert_file = config.return_value(x509_base + ['ca-cert-file']) - cert_path = os.path.join(AUTH_DIR, cert_file) - - if os.path.isfile(cert_path): - if not os.access(cert_path, os.R_OK): - run(f'sudo chmod 644 {cert_path}') - - with open(cert_path, 'r') as f: - certs_str = f.read() - certs_data = certs_str.split(CERT_BEGIN) - index = 1 - for cert_data in certs_data[1:]: - cert = load_certificate(CERT_BEGIN + cert_data, wrap_tags=False) - - if cert: - ca_certs[f'{pki_name}_{index}'] = cert - cert_pem = encode_certificate(cert) - config.set(pki_base + ['ca', f'{pki_name}_{index}', 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) - config.set(x509_base + ['ca-certificate'], value=f'{pki_name}_{index}', replace=False) - else: - print(f'Failed to migrate CA certificate on openvpn interface {interface}') - - index += 1 - else: - print(f'Failed to migrate CA certificate on openvpn interface {interface}') - - config.delete(x509_base + ['ca-cert-file']) - - if config.exists(x509_base + ['crl-file']): - if not config.exists(pki_base + ['ca']): - config.set(pki_base + ['ca']) - config.set_tag(pki_base + ['ca']) - - crl_file = config.return_value(x509_base + ['crl-file']) - crl_path = os.path.join(AUTH_DIR, crl_file) - crl = None - crl_ca_name = None - - if os.path.isfile(crl_path): - if not os.access(crl_path, os.R_OK): - run(f'sudo chmod 644 {crl_path}') - - with open(crl_path, 'r') as f: - crl_data = f.read() - crl = load_crl(crl_data, wrap_tags=False) - - for ca_name, ca_cert in ca_certs.items(): - if verify_crl(crl, ca_cert): - crl_ca_name = ca_name - break - - if crl and crl_ca_name: - crl_pem = encode_certificate(crl) - config.set(pki_base + ['ca', crl_ca_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem)) - else: - print(f'Failed to migrate CRL on openvpn interface {interface}') - - config.delete(x509_base + ['crl-file']) - - if config.exists(x509_base + ['cert-file']): - if not config.exists(pki_base + ['certificate']): - config.set(pki_base + ['certificate']) - config.set_tag(pki_base + ['certificate']) - - cert_file = config.return_value(x509_base + ['cert-file']) - cert_path = os.path.join(AUTH_DIR, cert_file) - cert = None - - if os.path.isfile(cert_path): - if not os.access(cert_path, os.R_OK): - run(f'sudo chmod 644 {cert_path}') - - with open(cert_path, 'r') as f: - cert_data = f.read() - cert = load_certificate(cert_data, wrap_tags=False) - - if cert: - cert_pem = encode_certificate(cert) - config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) - config.set(x509_base + ['certificate'], value=pki_name) - else: - print(f'Failed to migrate certificate on openvpn interface {interface}') - - config.delete(x509_base + ['cert-file']) - - if config.exists(x509_base + ['key-file']): - key_file = config.return_value(x509_base + ['key-file']) - key_path = os.path.join(AUTH_DIR, key_file) - key = None - - if os.path.isfile(key_path): - if not os.access(key_path, os.R_OK): - run(f'sudo chmod 644 {key_path}') - - with open(key_path, 'r') as f: - key_data = f.read() - key = load_private_key(key_data, passphrase=None, wrap_tags=False) - - if key: - key_pem = encode_private_key(key, passphrase=None) - config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem)) - else: - print(f'Failed to migrate private key on openvpn interface {interface}') - - config.delete(x509_base + ['key-file']) - - if config.exists(x509_base + ['dh-file']): - if not config.exists(pki_base + ['dh']): - config.set(pki_base + ['dh']) - config.set_tag(pki_base + ['dh']) - - dh_file = config.return_value(x509_base + ['dh-file']) - dh_path = os.path.join(AUTH_DIR, dh_file) - dh = None - - if os.path.isfile(dh_path): - if not os.access(dh_path, os.R_OK): - run(f'sudo chmod 644 {dh_path}') - - with open(dh_path, 'r') as f: - dh_data = f.read() - dh = load_dh_parameters(dh_data, wrap_tags=False) - - if dh: - dh_pem = encode_dh_parameters(dh) - config.set(pki_base + ['dh', pki_name, 'parameters'], value=wrapped_pem_to_config_value(dh_pem)) - config.set(x509_base + ['dh-params'], value=pki_name) - else: - print(f'Failed to migrate DH parameters on openvpn interface {interface}') - - config.delete(x509_base + ['dh-file']) - -# Wireguard -base = ['interfaces', 'wireguard'] - -if config.exists(base): - for interface in config.list_nodes(base): - private_key_path = base + [interface, 'private-key'] - - key_file = 'default' - if config.exists(private_key_path): - key_file = config.return_value(private_key_path) - - full_key_path = f'/config/auth/wireguard/{key_file}/private.key' - - if not os.path.exists(full_key_path): - print(f'Could not find wireguard private key for migration on interface "{interface}"') - continue - - with open(full_key_path, 'r') as f: - key_data = f.read().strip() - config.set(private_key_path, value=key_data) - - for peer in config.list_nodes(base + [interface, 'peer']): - config.rename(base + [interface, 'peer', peer, 'pubkey'], 'public-key') - -# Ethernet EAPoL -base = ['interfaces', 'ethernet'] - -if config.exists(base): - for interface in config.list_nodes(base): - if not config.exists(base + [interface, 'eapol']): - continue - - x509_base = base + [interface, 'eapol'] - pki_name = f'eapol_{interface}' - - if config.exists(x509_base + ['ca-cert-file']): - if not config.exists(pki_base + ['ca']): - config.set(pki_base + ['ca']) - config.set_tag(pki_base + ['ca']) - - cert_file = config.return_value(x509_base + ['ca-cert-file']) - cert_path = os.path.join(AUTH_DIR, cert_file) - cert = None - - if os.path.isfile(cert_path): - if not os.access(cert_path, os.R_OK): - run(f'sudo chmod 644 {cert_path}') - - with open(cert_path, 'r') as f: - cert_data = f.read() - cert = load_certificate(cert_data, wrap_tags=False) - - if cert: - cert_pem = encode_certificate(cert) - config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) - config.set(x509_base + ['ca-certificate'], value=pki_name) - else: - print(f'Failed to migrate CA certificate on eapol config for interface {interface}') - - config.delete(x509_base + ['ca-cert-file']) - - if config.exists(x509_base + ['cert-file']): - if not config.exists(pki_base + ['certificate']): - config.set(pki_base + ['certificate']) - config.set_tag(pki_base + ['certificate']) - - cert_file = config.return_value(x509_base + ['cert-file']) - cert_path = os.path.join(AUTH_DIR, cert_file) - cert = None - - if os.path.isfile(cert_path): - if not os.access(cert_path, os.R_OK): - run(f'sudo chmod 644 {cert_path}') - - with open(cert_path, 'r') as f: - cert_data = f.read() - cert = load_certificate(cert_data, wrap_tags=False) - - if cert: - cert_pem = encode_certificate(cert) - config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) - config.set(x509_base + ['certificate'], value=pki_name) - else: - print(f'Failed to migrate certificate on eapol config for interface {interface}') - - config.delete(x509_base + ['cert-file']) - - if config.exists(x509_base + ['key-file']): - key_file = config.return_value(x509_base + ['key-file']) - key_path = os.path.join(AUTH_DIR, key_file) - key = None - - if os.path.isfile(key_path): - if not os.access(key_path, os.R_OK): - run(f'sudo chmod 644 {key_path}') - - with open(key_path, 'r') as f: - key_data = f.read() - key = load_private_key(key_data, passphrase=None, wrap_tags=False) - - if key: - key_pem = encode_private_key(key, passphrase=None) - config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem)) - else: - print(f'Failed to migrate private key on eapol config for interface {interface}') - - config.delete(x509_base + ['key-file']) - -try: - with open(file_name, 'w') as f: - f.write(config.to_string()) -except OSError as e: - print("Failed to save the modified config: {}".format(e)) - sys.exit(1) + found = False + if config.exists(ipsec_base): + for peer in config.list_nodes(ipsec_base): + if config.exists(ipsec_base + [peer, 'vti', 'bind']): + tmp = config.return_value(ipsec_base + [peer, 'vti', 'bind']) + if tmp == interface: + # Interface was found and we no longer need to search + # for it in our IPSec peers + found = True + break + if not found: + config.delete(base + [interface]) + + try: + with open(file_name, 'w') as f: + f.write(config.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/interfaces/25-to-26 b/src/migration-scripts/interfaces/25-to-26 index 4967a29fa..9aa6ea5e3 100755 --- a/src/migration-scripts/interfaces/25-to-26 +++ b/src/migration-scripts/interfaces/25-to-26 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright (C) 2021-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,41 +14,374 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# T4384: pppoe: replace default-route CLI option with common CLI nodes already -# present for DHCP +# Migrate Wireguard to store keys in CLI +# Migrate EAPoL to PKI configuration -from sys import argv +import os +import sys -from vyos.ethtool import Ethtool from vyos.configtree import ConfigTree +from vyos.pki import CERT_BEGIN +from vyos.pki import load_certificate +from vyos.pki import load_crl +from vyos.pki import load_dh_parameters +from vyos.pki import load_private_key +from vyos.pki import encode_certificate +from vyos.pki import encode_dh_parameters +from vyos.pki import encode_private_key +from vyos.pki import verify_crl +from vyos.utils.process import run -if len(argv) < 2: +def wrapped_pem_to_config_value(pem): + out = [] + for line in pem.strip().split("\n"): + if not line or line.startswith("-----") or line[0] == '#': + continue + out.append(line) + return "".join(out) + +def read_file_for_pki(config_auth_path): + full_path = os.path.join(AUTH_DIR, config_auth_path) + output = None + + if os.path.isfile(full_path): + if not os.access(full_path, os.R_OK): + run(f'sudo chmod 644 {full_path}') + + with open(full_path, 'r') as f: + output = f.read() + + return output + +if len(sys.argv) < 2: print("Must specify file name!") - exit(1) + sys.exit(1) + +file_name = sys.argv[1] -file_name = argv[1] with open(file_name, 'r') as f: config_file = f.read() -base = ['interfaces', 'pppoe'] config = ConfigTree(config_file) -if not config.exists(base): - exit(0) +AUTH_DIR = '/config/auth' +pki_base = ['pki'] + +# OpenVPN +base = ['interfaces', 'openvpn'] + +if config.exists(base): + for interface in config.list_nodes(base): + x509_base = base + [interface, 'tls'] + pki_name = f'openvpn_{interface}' + + if config.exists(base + [interface, 'shared-secret-key-file']): + if not config.exists(pki_base + ['openvpn', 'shared-secret']): + config.set(pki_base + ['openvpn', 'shared-secret']) + config.set_tag(pki_base + ['openvpn', 'shared-secret']) + + key_file = config.return_value(base + [interface, 'shared-secret-key-file']) + key = read_file_for_pki(key_file) + key_pki_name = f'{pki_name}_shared' + + if key: + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') + config.set(base + [interface, 'shared-secret-key'], value=key_pki_name) + else: + print(f'Failed to migrate shared-secret-key on openvpn interface {interface}') + + config.delete(base + [interface, 'shared-secret-key-file']) + + if not config.exists(base + [interface, 'tls']): + continue + + if config.exists(base + [interface, 'tls', 'auth-file']): + if not config.exists(pki_base + ['openvpn', 'shared-secret']): + config.set(pki_base + ['openvpn', 'shared-secret']) + config.set_tag(pki_base + ['openvpn', 'shared-secret']) + + key_file = config.return_value(base + [interface, 'tls', 'auth-file']) + key = read_file_for_pki(key_file) + key_pki_name = f'{pki_name}_auth' + + if key: + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') + config.set(base + [interface, 'tls', 'auth-key'], value=key_pki_name) + else: + print(f'Failed to migrate auth-key on openvpn interface {interface}') + + config.delete(base + [interface, 'tls', 'auth-file']) + + if config.exists(base + [interface, 'tls', 'crypt-file']): + if not config.exists(pki_base + ['openvpn', 'shared-secret']): + config.set(pki_base + ['openvpn', 'shared-secret']) + config.set_tag(pki_base + ['openvpn', 'shared-secret']) + + key_file = config.return_value(base + [interface, 'tls', 'crypt-file']) + key = read_file_for_pki(key_file) + key_pki_name = f'{pki_name}_crypt' + + if key: + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') + config.set(base + [interface, 'tls', 'crypt-key'], value=key_pki_name) + else: + print(f'Failed to migrate crypt-key on openvpn interface {interface}') + + config.delete(base + [interface, 'tls', 'crypt-file']) + + ca_certs = {} + + if config.exists(x509_base + ['ca-cert-file']): + if not config.exists(pki_base + ['ca']): + config.set(pki_base + ['ca']) + config.set_tag(pki_base + ['ca']) + + cert_file = config.return_value(x509_base + ['ca-cert-file']) + cert_path = os.path.join(AUTH_DIR, cert_file) + + if os.path.isfile(cert_path): + if not os.access(cert_path, os.R_OK): + run(f'sudo chmod 644 {cert_path}') + + with open(cert_path, 'r') as f: + certs_str = f.read() + certs_data = certs_str.split(CERT_BEGIN) + index = 1 + for cert_data in certs_data[1:]: + cert = load_certificate(CERT_BEGIN + cert_data, wrap_tags=False) + + if cert: + ca_certs[f'{pki_name}_{index}'] = cert + cert_pem = encode_certificate(cert) + config.set(pki_base + ['ca', f'{pki_name}_{index}', 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) + config.set(x509_base + ['ca-certificate'], value=f'{pki_name}_{index}', replace=False) + else: + print(f'Failed to migrate CA certificate on openvpn interface {interface}') + + index += 1 + else: + print(f'Failed to migrate CA certificate on openvpn interface {interface}') + + config.delete(x509_base + ['ca-cert-file']) + + if config.exists(x509_base + ['crl-file']): + if not config.exists(pki_base + ['ca']): + config.set(pki_base + ['ca']) + config.set_tag(pki_base + ['ca']) + + crl_file = config.return_value(x509_base + ['crl-file']) + crl_path = os.path.join(AUTH_DIR, crl_file) + crl = None + crl_ca_name = None + + if os.path.isfile(crl_path): + if not os.access(crl_path, os.R_OK): + run(f'sudo chmod 644 {crl_path}') + + with open(crl_path, 'r') as f: + crl_data = f.read() + crl = load_crl(crl_data, wrap_tags=False) + + for ca_name, ca_cert in ca_certs.items(): + if verify_crl(crl, ca_cert): + crl_ca_name = ca_name + break + + if crl and crl_ca_name: + crl_pem = encode_certificate(crl) + config.set(pki_base + ['ca', crl_ca_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem)) + else: + print(f'Failed to migrate CRL on openvpn interface {interface}') + + config.delete(x509_base + ['crl-file']) + + if config.exists(x509_base + ['cert-file']): + if not config.exists(pki_base + ['certificate']): + config.set(pki_base + ['certificate']) + config.set_tag(pki_base + ['certificate']) + + cert_file = config.return_value(x509_base + ['cert-file']) + cert_path = os.path.join(AUTH_DIR, cert_file) + cert = None + + if os.path.isfile(cert_path): + if not os.access(cert_path, os.R_OK): + run(f'sudo chmod 644 {cert_path}') + + with open(cert_path, 'r') as f: + cert_data = f.read() + cert = load_certificate(cert_data, wrap_tags=False) + + if cert: + cert_pem = encode_certificate(cert) + config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) + config.set(x509_base + ['certificate'], value=pki_name) + else: + print(f'Failed to migrate certificate on openvpn interface {interface}') + + config.delete(x509_base + ['cert-file']) + + if config.exists(x509_base + ['key-file']): + key_file = config.return_value(x509_base + ['key-file']) + key_path = os.path.join(AUTH_DIR, key_file) + key = None + + if os.path.isfile(key_path): + if not os.access(key_path, os.R_OK): + run(f'sudo chmod 644 {key_path}') + + with open(key_path, 'r') as f: + key_data = f.read() + key = load_private_key(key_data, passphrase=None, wrap_tags=False) + + if key: + key_pem = encode_private_key(key, passphrase=None) + config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem)) + else: + print(f'Failed to migrate private key on openvpn interface {interface}') + + config.delete(x509_base + ['key-file']) + + if config.exists(x509_base + ['dh-file']): + if not config.exists(pki_base + ['dh']): + config.set(pki_base + ['dh']) + config.set_tag(pki_base + ['dh']) + + dh_file = config.return_value(x509_base + ['dh-file']) + dh_path = os.path.join(AUTH_DIR, dh_file) + dh = None + + if os.path.isfile(dh_path): + if not os.access(dh_path, os.R_OK): + run(f'sudo chmod 644 {dh_path}') + + with open(dh_path, 'r') as f: + dh_data = f.read() + dh = load_dh_parameters(dh_data, wrap_tags=False) + + if dh: + dh_pem = encode_dh_parameters(dh) + config.set(pki_base + ['dh', pki_name, 'parameters'], value=wrapped_pem_to_config_value(dh_pem)) + config.set(x509_base + ['dh-params'], value=pki_name) + else: + print(f'Failed to migrate DH parameters on openvpn interface {interface}') + + config.delete(x509_base + ['dh-file']) + +# Wireguard +base = ['interfaces', 'wireguard'] + +if config.exists(base): + for interface in config.list_nodes(base): + private_key_path = base + [interface, 'private-key'] + + key_file = 'default' + if config.exists(private_key_path): + key_file = config.return_value(private_key_path) + + full_key_path = f'/config/auth/wireguard/{key_file}/private.key' + + if not os.path.exists(full_key_path): + print(f'Could not find wireguard private key for migration on interface "{interface}"') + continue + + with open(full_key_path, 'r') as f: + key_data = f.read().strip() + config.set(private_key_path, value=key_data) + + for peer in config.list_nodes(base + [interface, 'peer']): + config.rename(base + [interface, 'peer', peer, 'pubkey'], 'public-key') + +# Ethernet EAPoL +base = ['interfaces', 'ethernet'] + +if config.exists(base): + for interface in config.list_nodes(base): + if not config.exists(base + [interface, 'eapol']): + continue + + x509_base = base + [interface, 'eapol'] + pki_name = f'eapol_{interface}' + + if config.exists(x509_base + ['ca-cert-file']): + if not config.exists(pki_base + ['ca']): + config.set(pki_base + ['ca']) + config.set_tag(pki_base + ['ca']) + + cert_file = config.return_value(x509_base + ['ca-cert-file']) + cert_path = os.path.join(AUTH_DIR, cert_file) + cert = None + + if os.path.isfile(cert_path): + if not os.access(cert_path, os.R_OK): + run(f'sudo chmod 644 {cert_path}') + + with open(cert_path, 'r') as f: + cert_data = f.read() + cert = load_certificate(cert_data, wrap_tags=False) + + if cert: + cert_pem = encode_certificate(cert) + config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) + config.set(x509_base + ['ca-certificate'], value=pki_name) + else: + print(f'Failed to migrate CA certificate on eapol config for interface {interface}') + + config.delete(x509_base + ['ca-cert-file']) + + if config.exists(x509_base + ['cert-file']): + if not config.exists(pki_base + ['certificate']): + config.set(pki_base + ['certificate']) + config.set_tag(pki_base + ['certificate']) + + cert_file = config.return_value(x509_base + ['cert-file']) + cert_path = os.path.join(AUTH_DIR, cert_file) + cert = None + + if os.path.isfile(cert_path): + if not os.access(cert_path, os.R_OK): + run(f'sudo chmod 644 {cert_path}') + + with open(cert_path, 'r') as f: + cert_data = f.read() + cert = load_certificate(cert_data, wrap_tags=False) + + if cert: + cert_pem = encode_certificate(cert) + config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) + config.set(x509_base + ['certificate'], value=pki_name) + else: + print(f'Failed to migrate certificate on eapol config for interface {interface}') + + config.delete(x509_base + ['cert-file']) + + if config.exists(x509_base + ['key-file']): + key_file = config.return_value(x509_base + ['key-file']) + key_path = os.path.join(AUTH_DIR, key_file) + key = None + + if os.path.isfile(key_path): + if not os.access(key_path, os.R_OK): + run(f'sudo chmod 644 {key_path}') + + with open(key_path, 'r') as f: + key_data = f.read() + key = load_private_key(key_data, passphrase=None, wrap_tags=False) + + if key: + key_pem = encode_private_key(key, passphrase=None) + config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem)) + else: + print(f'Failed to migrate private key on eapol config for interface {interface}') -for ifname in config.list_nodes(base): - tmp_config = base + [ifname, 'default-route'] - if config.exists(tmp_config): - # Retrieve current config value - value = config.return_value(tmp_config) - # Delete old Config node - config.delete(tmp_config) - if value == 'none': - config.set(base + [ifname, 'no-default-route']) + config.delete(x509_base + ['key-file']) 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) + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/interfaces/26-to-27 b/src/migration-scripts/interfaces/26-to-27 index a0d043d11..4967a29fa 100755 --- a/src/migration-scripts/interfaces/26-to-27 +++ b/src/migration-scripts/interfaces/26-to-27 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 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,8 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# T4995: pppoe, wwan, sstpc-client rename "authentication user" CLI node -# to "authentication username" +# T4384: pppoe: replace default-route CLI option with common CLI nodes already +# present for DHCP from sys import argv @@ -30,16 +30,21 @@ file_name = argv[1] with open(file_name, 'r') as f: config_file = f.read() +base = ['interfaces', 'pppoe'] config = ConfigTree(config_file) -for type in ['pppoe', 'sstpc-client', 'wwam']: - base = ['interfaces', type] - if not config.exists(base): - continue - for interface in config.list_nodes(base): - auth_base = base + [interface, 'authentication', 'user'] - if config.exists(auth_base): - config.rename(auth_base, 'username') +if not config.exists(base): + exit(0) + +for ifname in config.list_nodes(base): + tmp_config = base + [ifname, 'default-route'] + if config.exists(tmp_config): + # Retrieve current config value + value = config.return_value(tmp_config) + # Delete old Config node + config.delete(tmp_config) + if value == 'none': + config.set(base + [ifname, 'no-default-route']) try: with open(file_name, 'w') as f: diff --git a/src/migration-scripts/interfaces/27-to-28 b/src/migration-scripts/interfaces/27-to-28 index ad5bfa653..a0d043d11 100755 --- a/src/migration-scripts/interfaces/27-to-28 +++ b/src/migration-scripts/interfaces/27-to-28 @@ -14,8 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# T5034: tunnel: rename "multicast enable" CLI node to "enable-multicast" -# valueless node. +# T4995: pppoe, wwan, sstpc-client rename "authentication user" CLI node +# to "authentication username" from sys import argv @@ -30,21 +30,16 @@ file_name = argv[1] with open(file_name, 'r') as f: config_file = f.read() -base = ['interfaces', 'tunnel'] config = ConfigTree(config_file) -if not config.exists(base): - exit(0) - -for ifname in config.list_nodes(base): - multicast_base = base + [ifname, 'multicast'] - if config.exists(multicast_base): - tmp = config.return_value(multicast_base) - print(tmp) - # Delete old Config node - config.delete(multicast_base) - if tmp == 'enable': - config.set(base + [ifname, 'enable-multicast']) +for type in ['pppoe', 'sstpc-client', 'wwam']: + base = ['interfaces', type] + if not config.exists(base): + continue + for interface in config.list_nodes(base): + auth_base = base + [interface, 'authentication', 'user'] + if config.exists(auth_base): + config.rename(auth_base, 'username') try: with open(file_name, 'w') as f: diff --git a/src/migration-scripts/interfaces/28-to-29 b/src/migration-scripts/interfaces/28-to-29 index acb6ee1fb..ad5bfa653 100755 --- a/src/migration-scripts/interfaces/28-to-29 +++ b/src/migration-scripts/interfaces/28-to-29 @@ -14,7 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# T5286: remove XDP support in favour of VPP +# T5034: tunnel: rename "multicast enable" CLI node to "enable-multicast" +# valueless node. from sys import argv @@ -29,17 +30,21 @@ file_name = argv[1] with open(file_name, 'r') as f: config_file = f.read() -supports_xdp = ['bonding', 'ethernet'] +base = ['interfaces', 'tunnel'] config = ConfigTree(config_file) -for if_type in supports_xdp: - base = ['interfaces', if_type] - if not config.exists(base): - continue - for interface in config.list_nodes(base): - if_base = base + [interface] - if config.exists(if_base + ['xdp']): - config.delete(if_base + ['xdp']) +if not config.exists(base): + exit(0) + +for ifname in config.list_nodes(base): + multicast_base = base + [ifname, 'multicast'] + if config.exists(multicast_base): + tmp = config.return_value(multicast_base) + print(tmp) + # Delete old Config node + config.delete(multicast_base) + if tmp == 'enable': + config.set(base + [ifname, 'enable-multicast']) try: with open(file_name, 'w') as f: diff --git a/src/migration-scripts/interfaces/29-to-30 b/src/migration-scripts/interfaces/29-to-30 index 97e1b329c..acb6ee1fb 100755 --- a/src/migration-scripts/interfaces/29-to-30 +++ b/src/migration-scripts/interfaces/29-to-30 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright (C) 2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -13,42 +13,37 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# -# Deletes Wireguard peers if they have the same public key as the router has. -import sys + +# T5286: remove XDP support in favour of VPP + +from sys import argv + +from vyos.ethtool import Ethtool from vyos.configtree import ConfigTree -from vyos.utils.network import is_wireguard_key_pair -if __name__ == '__main__': - if len(sys.argv) < 2: - print("Must specify file name!") - sys.exit(1) +if len(argv) < 2: + print("Must specify file name!") + exit(1) - file_name = sys.argv[1] +file_name = argv[1] +with open(file_name, 'r') as f: + config_file = f.read() - with open(file_name, 'r') as f: - config_file = f.read() +supports_xdp = ['bonding', 'ethernet'] +config = ConfigTree(config_file) - config = ConfigTree(config_file) - base = ['interfaces', 'wireguard'] +for if_type in supports_xdp: + base = ['interfaces', if_type] if not config.exists(base): - # Nothing to do - sys.exit(0) + continue for interface in config.list_nodes(base): - private_key = config.return_value(base + [interface, 'private-key']) - interface_base = base + [interface] - if config.exists(interface_base + ['peer']): - for peer in config.list_nodes(interface_base + ['peer']): - peer_base = interface_base + ['peer', peer] - peer_public_key = config.return_value(peer_base + ['public-key']) - if config.exists(peer_base + ['public-key']): - if not config.exists(peer_base + ['disable']) \ - and is_wireguard_key_pair(private_key, peer_public_key): - config.set(peer_base + ['disable']) - - try: - with open(file_name, 'w') as f: - f.write(config.to_string()) - except OSError as e: - print("Failed to save the modified config: {}".format(e)) - sys.exit(1) + if_base = base + [interface] + if config.exists(if_base + ['xdp']): + config.delete(if_base + ['xdp']) + +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) diff --git a/src/migration-scripts/interfaces/30-to-31 b/src/migration-scripts/interfaces/30-to-31 new file mode 100755 index 000000000..894106ef4 --- /dev/null +++ b/src/migration-scripts/interfaces/30-to-31 @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# Deletes Wireguard peers if they have the same public key as the router has. + +import json +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree +from vyos.ifconfig import EthernetIf +from vyos.ifconfig import BondIf +from vyos.utils.dict import dict_to_paths_values + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] +with open(file_name, 'r') as f: + config_file = f.read() + base = ['interfaces', 'bonding'] + +config = ConfigTree(config_file) +if not config.exists(base): + # Nothing to do + exit(0) +for bond in config.list_nodes(base): + member_base = base + [bond, 'member', 'interface'] + if config.exists(member_base): + for interface in config.return_values(member_base): + if_base = ['interfaces', 'ethernet', interface] + if config.exists(if_base): + config_ethernet = json.loads(config.get_subtree(if_base).to_json()) + eth_dict_paths = dict_to_paths_values(config_ethernet) + for option_path, option_value in eth_dict_paths.items(): + # If option is allowed for changing then continue + converted_path = option_path.replace('-','_') + if converted_path in EthernetIf.get_bond_member_allowed_options(): + continue + # if option is inherited from bond then continue + if converted_path in BondIf.get_inherit_bond_options(): + continue + option_path_list = option_path.split('.') + config.delete(if_base + option_path_list) + del option_path_list[-1] + # delete empty node from config + while len(option_path_list) > 0: + if config.list_nodes(if_base + option_path_list): + break + config.delete(if_base + option_path_list) + del option_path_list[-1] + +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) diff --git a/src/migration-scripts/interfaces/31-to-32 b/src/migration-scripts/interfaces/31-to-32 new file mode 100755 index 000000000..0fc27b70a --- /dev/null +++ b/src/migration-scripts/interfaces/31-to-32 @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# T5671: change port to IANA assigned default port +# T5759: change default MTU 1450 -> 1500 + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['interfaces', 'vxlan'] + +config = ConfigTree(config_file) +if not config.exists(base): + # Nothing to do + exit(0) + +for vxlan in config.list_nodes(base): + if config.exists(base + [vxlan, 'external']): + config.delete(base + [vxlan, 'external']) + config.set(base + [vxlan, 'parameters', 'external']) + + if not config.exists(base + [vxlan, 'port']): + config.set(base + [vxlan, 'port'], value='8472') + + if not config.exists(base + [vxlan, 'mtu']): + config.set(base + [vxlan, 'mtu'], value='1450') + +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) diff --git a/src/migration-scripts/ipoe-server/1-to-2 b/src/migration-scripts/ipoe-server/1-to-2 new file mode 100755 index 000000000..11d7911e9 --- /dev/null +++ b/src/migration-scripts/ipoe-server/1-to-2 @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# - changed cli of all named pools +# - moved gateway-address from pool to global configuration with / netmask +# gateway can exist without pool if radius is used +# and Framed-ip-address is transmited +# - There are several gateway-addresses in ipoe +# - default-pool by migration. +# 1. The first pool that contains next-poll. +# 2. Else, the first pool in the list + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['service', 'ipoe-server'] +pool_base = base + ['client-ip-pool'] +if not config.exists(base): + exit(0) + +if not config.exists(pool_base): + exit(0) +default_pool = '' +gateway = '' + +#named pool migration +namedpools_base = pool_base + ['name'] + +for pool_name in config.list_nodes(namedpools_base): + pool_path = namedpools_base + [pool_name] + if config.exists(pool_path + ['subnet']): + subnet = config.return_value(pool_path + ['subnet']) + config.set(pool_base + [pool_name, 'range'], value=subnet, replace=False) + # Get netmask from subnet + mask = subnet.split("/")[1] + if config.exists(pool_path + ['next-pool']): + next_pool = config.return_value(pool_path + ['next-pool']) + config.set(pool_base + [pool_name, 'next-pool'], value=next_pool) + if not default_pool: + default_pool = pool_name + if config.exists(pool_path + ['gateway-address']) and mask: + gateway = f'{config.return_value(pool_path + ["gateway-address"])}/{mask}' + config.set(base + ['gateway-address'], value=gateway, replace=False) + +if not default_pool and config.list_nodes(namedpools_base): + default_pool = config.list_nodes(namedpools_base)[0] + +config.delete(namedpools_base) + +if default_pool: + config.set(base + ['default-pool'], value=default_pool) +# format as tag node +config.set_tag(pool_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/ipoe-server/2-to-3 b/src/migration-scripts/ipoe-server/2-to-3 new file mode 100755 index 000000000..d4ae0a7ba --- /dev/null +++ b/src/migration-scripts/ipoe-server/2-to-3 @@ -0,0 +1,61 @@ +#!/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/>. + +# Migrating to named ipv6 pools + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['service', 'ipoe-server'] +pool_base = base + ['client-ipv6-pool'] +if not config.exists(base): + exit(0) + +if not config.exists(pool_base): + exit(0) + +ipv6_pool_name = 'ipv6-pool' +config.copy(pool_base, pool_base + [ipv6_pool_name]) + +if config.exists(pool_base + ['prefix']): + config.delete(pool_base + ['prefix']) + config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name) +if config.exists(pool_base + ['delegate']): + config.delete(pool_base + ['delegate']) + +# format as tag node +config.set_tag(pool_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/ipsec/12-to-13 b/src/migration-scripts/ipsec/12-to-13 new file mode 100755 index 000000000..c11f708bd --- /dev/null +++ b/src/migration-scripts/ipsec/12-to-13 @@ -0,0 +1,59 @@ +#!/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/>. + +# Changed value of dead-peer-detection.action from hold to trap +# Changed value of close-action from hold to trap and from restart to start + +import re + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['vpn', 'ipsec', 'ike-group'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) +else: + for ike_group in config.list_nodes(base): + base_dpd_action = base + [ike_group, 'dead-peer-detection', 'action'] + base_close_action = base + [ike_group, 'close-action'] + if config.exists(base_dpd_action) and config.return_value(base_dpd_action) == 'hold': + config.set(base_dpd_action, 'trap', replace=True) + if config.exists(base_close_action): + if config.return_value(base_close_action) == 'hold': + config.set(base_close_action, 'trap', replace=True) + if config.return_value(base_close_action) == 'restart': + config.set(base_close_action, 'start', replace=True) + +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) diff --git a/src/migration-scripts/l2tp/4-to-5 b/src/migration-scripts/l2tp/4-to-5 new file mode 100755 index 000000000..3176f895a --- /dev/null +++ b/src/migration-scripts/l2tp/4-to-5 @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# - move all pool to named pools +# 'start-stop' migrate to namedpool 'default-range-pool' +# 'subnet' migrate to namedpool 'default-subnet-pool' +# 'default-subnet-pool' is the next pool for 'default-range-pool' + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree +from vyos.base import Warning + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['vpn', 'l2tp', 'remote-access'] +pool_base = base + ['client-ip-pool'] +if not config.exists(base): + exit(0) + +if not config.exists(pool_base): + exit(0) +default_pool = '' +range_pool_name = 'default-range-pool' + +if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']): + def is_legalrange(ip1: str, ip2: str, mask: str): + from ipaddress import IPv4Interface + interface1 = IPv4Interface(f'{ip1}/{mask}') + + interface2 = IPv4Interface(f'{ip2}/{mask}') + return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip + + start_ip = config.return_value(pool_base + ['start']) + stop_ip = config.return_value(pool_base + ['stop']) + if is_legalrange(start_ip, stop_ip,'24'): + ip_range = f'{start_ip}-{stop_ip}' + config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False) + default_pool = range_pool_name + else: + Warning( + f'L2TP client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.') + + config.delete(pool_base + ['start']) + config.delete(pool_base + ['stop']) + +if config.exists(pool_base + ['subnet']): + for subnet in config.return_values(pool_base + ['subnet']): + config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False) + + config.delete(pool_base + ['subnet']) + default_pool = range_pool_name + +if default_pool: + config.set(base + ['default-pool'], value=default_pool) +# format as tag node +config.set_tag(pool_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/l2tp/5-to-6 b/src/migration-scripts/l2tp/5-to-6 new file mode 100755 index 000000000..ca0b13dcc --- /dev/null +++ b/src/migration-scripts/l2tp/5-to-6 @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['vpn', 'l2tp', 'remote-access'] +if not config.exists(base): + exit(0) + +#migrate idle to ppp option lcp-echo-timeout +idle_path = base + ['idle'] +if config.exists(idle_path): + config.set(base + ['ppp-options', 'lcp-echo-timeout'], + value=config.return_value(idle_path)) + config.delete(idle_path) + +#migrate mppe from authentication to ppp-otion +mppe_path = base + ['authentication', 'mppe'] +if config.exists(mppe_path): + config.set(base + ['ppp-options', 'mppe'], + value=config.return_value(mppe_path)) + config.delete(mppe_path) + +#migrate require to protocol +require_path = base + ['authentication', 'require'] +if config.exists(require_path): + protocols = list(config.return_values(require_path)) + for protocol in protocols: + config.set(base + ['authentication', 'protocols'], value=protocol, + replace=False) + config.delete(require_path) +else: + config.set(base + ['authentication', 'protocols'], value='mschap-v2') + +#migrate default gateway if not exist +if not config.exists(base + ['gateway-address']): + config.set(base + ['gateway-address'], value='10.255.255.0') + +#migrate authentication radius timeout +rad_timeout_path = base + ['authentication', 'radius', 'timeout'] +if config.exists(rad_timeout_path): + if int(config.return_value(rad_timeout_path)) > 60: + config.set(rad_timeout_path, value=60) + +#migrate authentication radius acct timeout +rad_acct_timeout_path = base + ['authentication', 'radius', 'acct-timeout'] +if config.exists(rad_acct_timeout_path): + if int(config.return_value(rad_acct_timeout_path)) > 60: + config.set(rad_acct_timeout_path,value=60) + +#migrate authentication radius max-try +rad_max_try_path = base + ['authentication', 'radius', 'max-try'] +if config.exists(rad_max_try_path): + if int(config.return_value(rad_max_try_path)) > 20: + config.set(rad_max_try_path, value=20) + +#migrate dae-server to dynamic-author +dae_path_old = base + ['authentication', 'radius', 'dae-server'] +dae_path_new = base + ['authentication', 'radius', 'dynamic-author'] + +if config.exists(dae_path_old + ['ip-address']): + config.set(dae_path_new + ['server'], + value=config.return_value(dae_path_old + ['ip-address'])) + +if config.exists(dae_path_old + ['port']): + config.set(dae_path_new + ['port'], + value=config.return_value(dae_path_old + ['port'])) + +if config.exists(dae_path_old + ['secret']): + config.set(dae_path_new + ['key'], + value=config.return_value(dae_path_old + ['secret'])) + +if config.exists(dae_path_old): + config.delete(dae_path_old) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/l2tp/6-to-7 b/src/migration-scripts/l2tp/6-to-7 new file mode 100755 index 000000000..f49c4ab08 --- /dev/null +++ b/src/migration-scripts/l2tp/6-to-7 @@ -0,0 +1,60 @@ +#!/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/>. + +# Migrating to named ipv6 pools + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['vpn', 'l2tp', 'remote-access'] +pool_base = base + ['client-ipv6-pool'] +if not config.exists(base): + exit(0) + +if not config.exists(pool_base): + exit(0) + +ipv6_pool_name = 'ipv6-pool' +config.copy(pool_base, pool_base + [ipv6_pool_name]) + +if config.exists(pool_base + ['prefix']): + config.delete(pool_base + ['prefix']) + config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name) +if config.exists(pool_base + ['delegate']): + config.delete(pool_base + ['delegate']) +# format as tag node +config.set_tag(pool_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/l2tp/7-to-8 b/src/migration-scripts/l2tp/7-to-8 new file mode 100755 index 000000000..4956e1155 --- /dev/null +++ b/src/migration-scripts/l2tp/7-to-8 @@ -0,0 +1,68 @@ +#!/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/>. + +# Migrate from 'ccp-disable' to 'ppp-options.disable-ccp' +# Migration ipv6 options + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['vpn', 'l2tp', 'remote-access'] +if not config.exists(base): + exit(0) + +#CCP migration +if config.exists(base + ['ccp-disable']): + config.delete(base + ['ccp-disable']) + config.set(base + ['ppp-options', 'disable-ccp']) + +#IPV6 options migrations +if config.exists(base + ['ppp-options','ipv6-peer-intf-id']): + intf_peer_id = config.return_value(base + ['ppp-options','ipv6-peer-intf-id']) + if intf_peer_id == 'ipv4': + intf_peer_id = 'ipv4-addr' + config.set(base + ['ppp-options','ipv6-peer-interface-id'], value=intf_peer_id, replace=True) + config.delete(base + ['ppp-options','ipv6-peer-intf-id']) + +if config.exists(base + ['ppp-options','ipv6-intf-id']): + intf_id = config.return_value(base + ['ppp-options','ipv6-intf-id']) + config.set(base + ['ppp-options','ipv6-interface-id'], value=intf_id, replace=True) + config.delete(base + ['ppp-options','ipv6-intf-id']) + +if config.exists(base + ['ppp-options','ipv6-accept-peer-intf-id']): + config.set(base + ['ppp-options','ipv6-accept-peer-interface-id']) + config.delete(base + ['ppp-options','ipv6-accept-peer-intf-id']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/lldp/0-to-1 b/src/migration-scripts/lldp/0-to-1 index a936cbdfc..a99356062 100755 --- a/src/migration-scripts/lldp/0-to-1 +++ b/src/migration-scripts/lldp/0-to-1 @@ -1,4 +1,18 @@ #!/usr/bin/env python3 +# +# Copyright (C) 2020 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/>. # Delete "set service lldp interface <interface> location civic-based" option # as it was broken most of the time anyways diff --git a/src/migration-scripts/lldp/1-to-2 b/src/migration-scripts/lldp/1-to-2 new file mode 100755 index 000000000..35efb25db --- /dev/null +++ b/src/migration-scripts/lldp/1-to-2 @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5855: migrate "set service lldp snmp enable" -> `set service lldp snmp" + +import sys + +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['service', 'lldp'] +if not config.exists(base): + # Nothing to do + sys.exit(0) + +if config.exists(base + ['snmp']): + enabled = config.exists(base + ['snmp', 'enable']) + config.delete(base + ['snmp']) + if enabled: config.set(base + ['snmp']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/nat/5-to-6 b/src/migration-scripts/nat/5-to-6 new file mode 100755 index 000000000..c83b93d84 --- /dev/null +++ b/src/migration-scripts/nat/5-to-6 @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5643: move from 'set nat [source|destination] rule X [inbound-interface|outbound interface] <iface>' +# to +# 'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-name <iface>' + +from sys import argv,exit +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +if not config.exists(['nat']): + # Nothing to do + exit(0) + +for direction in ['source', 'destination']: + # If a node doesn't exist, we obviously have nothing to do. + if not config.exists(['nat', direction]): + continue + + # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist, + # but there are no rules under it. + if not config.list_nodes(['nat', direction]): + continue + + for rule in config.list_nodes(['nat', direction, 'rule']): + base = ['nat', direction, 'rule', rule] + for iface in ['inbound-interface','outbound-interface']: + if config.exists(base + [iface]): + tmp = config.return_value(base + [iface]) + if tmp: + config.delete(base + [iface]) + config.set(base + [iface, 'interface-name'], value=tmp) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/nat/6-to-7 b/src/migration-scripts/nat/6-to-7 new file mode 100755 index 000000000..a2e735394 --- /dev/null +++ b/src/migration-scripts/nat/6-to-7 @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5681: Firewall re-writing. Simplify cli when mathcing interface +# From +# 'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-name <iface>' +# 'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-group <iface_group>' +# to +# 'set nat [source|destination] rule X [inbound-interface|outbound interface] name <iface>' +# 'set nat [source|destination] rule X [inbound-interface|outbound interface] group <iface_group>' +# Also remove command if interface == any + +from sys import argv,exit +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +if not config.exists(['nat']): + # Nothing to do + exit(0) + +for direction in ['source', 'destination']: + # If a node doesn't exist, we obviously have nothing to do. + if not config.exists(['nat', direction]): + continue + + # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist, + # but there are no rules under it. + if not config.list_nodes(['nat', direction]): + continue + + for rule in config.list_nodes(['nat', direction, 'rule']): + base = ['nat', direction, 'rule', rule] + for iface in ['inbound-interface','outbound-interface']: + if config.exists(base + [iface]): + if config.exists(base + [iface, 'interface-name']): + tmp = config.return_value(base + [iface, 'interface-name']) + if tmp != 'any': + config.delete(base + [iface, 'interface-name']) + config.set(base + [iface, 'name'], value=tmp) + else: + config.delete(base + [iface]) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/nat66/1-to-2 b/src/migration-scripts/nat66/1-to-2 new file mode 100755 index 000000000..b7d4e3f6b --- /dev/null +++ b/src/migration-scripts/nat66/1-to-2 @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5681: Firewall re-writing. Simplify cli when mathcing interface +# From +# 'set nat66 [source|destination] rule X [inbound-interface|outbound interface] <iface>' +# to +# 'set nat66 [source|destination] rule X [inbound-interface|outbound interface] name <iface>' + +from sys import argv,exit +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +if not config.exists(['nat66']): + # Nothing to do + exit(0) + +for direction in ['source', 'destination']: + # If a node doesn't exist, we obviously have nothing to do. + if not config.exists(['nat66', direction]): + continue + + # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist, + # but there are no rules under it. + if not config.list_nodes(['nat66', direction]): + continue + + for rule in config.list_nodes(['nat66', direction, 'rule']): + base = ['nat66', direction, 'rule', rule] + for iface in ['inbound-interface','outbound-interface']: + if config.exists(base + [iface]): + tmp = config.return_value(base + [iface]) + config.delete(base + [iface]) + config.set(base + [iface, 'name'], value=tmp) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/nat66/2-to-3 b/src/migration-scripts/nat66/2-to-3 new file mode 100755 index 000000000..f34f170b3 --- /dev/null +++ b/src/migration-scripts/nat66/2-to-3 @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sys import argv,exit +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['nat66', 'source'] +new_base = ['service', 'ndp-proxy', 'interface'] + +config = ConfigTree(config_file) +if not config.exists(base): + # Nothing to do + exit(0) + +for rule in config.list_nodes(base + ['rule']): + base_rule = base + ['rule', rule] + + interface = None + if config.exists(base_rule + ['outbound-interface', 'name']): + interface = config.return_value(base_rule + ['outbound-interface', 'name']) + else: + continue + + prefix_base = base_rule + ['source', 'prefix'] + if config.exists(prefix_base): + prefix = config.return_value(prefix_base) + config.set(new_base + [interface, 'prefix', prefix, 'mode'], value='static') + config.set_tag(new_base) + config.set_tag(new_base + [interface, 'prefix']) + + if config.exists(base_rule + ['disable']): + config.set(new_base + [interface, 'prefix', prefix, 'disable']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/openvpn/0-to-1 b/src/migration-scripts/openvpn/0-to-1 new file mode 100755 index 000000000..24bb38d3c --- /dev/null +++ b/src/migration-scripts/openvpn/0-to-1 @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +# Removes outdated ciphers (DES and Blowfish) from OpenVPN configs + +import sys + +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +if not config.exists(['interfaces', 'openvpn']): + # Nothing to do + sys.exit(0) +else: + ovpn_intfs = config.list_nodes(['interfaces', 'openvpn']) + for i in ovpn_intfs: + # Remove DES and Blowfish from 'encryption cipher' + cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'cipher'] + if config.exists(cipher_path): + cipher = config.return_value(cipher_path) + if cipher in ['des', 'bf128', 'bf256']: + config.delete(cipher_path) + + ncp_cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers'] + if config.exists(ncp_cipher_path): + ncp_ciphers = config.return_values(['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers']) + if 'des' in ncp_ciphers: + config.delete_value(['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers'], 'des') + + # Clean up the encryption subtree if the migration procedure left it empty + if config.exists(['interfaces', 'openvpn', i, 'encryption']) and \ + (config.list_nodes(['interfaces', 'openvpn', i, 'encryption']) == []): + config.delete(['interfaces', 'openvpn', i, 'encryption']) + + try: + with open(file_name, 'w') as f: + f.write(config.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/ospf/0-to-1 b/src/migration-scripts/ospf/0-to-1 index 8f02acada..a6cb9feb8 100755 --- a/src/migration-scripts/ospf/0-to-1 +++ b/src/migration-scripts/ospf/0-to-1 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -28,6 +28,7 @@ def ospf_passive_migration(config, ospf_base): default = True continue config.set(ospf_base + ['interface', interface, 'passive']) + config.set_tag(ospf_base + ['interface']) config.delete(ospf_base + ['passive-interface']) config.set(ospf_base + ['passive-interface'], value='default') @@ -35,6 +36,7 @@ def ospf_passive_migration(config, ospf_base): if config.exists(ospf_base + ['passive-interface-exclude']): for interface in config.return_values(ospf_base + ['passive-interface-exclude']): config.set(ospf_base + ['interface', interface, 'passive', 'disable']) + config.set_tag(ospf_base + ['interface']) config.delete(ospf_base + ['passive-interface-exclude']) if len(argv) < 2: diff --git a/src/migration-scripts/pim/0-to-1 b/src/migration-scripts/pim/0-to-1 new file mode 100755 index 000000000..bf8af733c --- /dev/null +++ b/src/migration-scripts/pim/0-to-1 @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5736: igmp: migrate "protocols igmp" to "protocols pim" + +import sys +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +base = ['protocols', 'igmp'] +pim_base = ['protocols', 'pim'] +if not config.exists(base): + # Nothing to do + sys.exit(0) + +for interface in config.list_nodes(base + ['interface']): + base_igmp_iface = base + ['interface', interface] + pim_base_iface = pim_base + ['interface', interface] + + # Create IGMP note under PIM interface + if not config.exists(pim_base_iface + ['igmp']): + config.set(pim_base_iface + ['igmp']) + + if config.exists(base_igmp_iface + ['join']): + config.copy(base_igmp_iface + ['join'], pim_base_iface + ['igmp', 'join']) + config.set_tag(pim_base_iface + ['igmp', 'join']) + + new_join_base = pim_base_iface + ['igmp', 'join'] + for address in config.list_nodes(new_join_base): + if config.exists(new_join_base + [address, 'source']): + config.rename(new_join_base + [address, 'source'], 'source-address') + + if config.exists(base_igmp_iface + ['query-interval']): + config.copy(base_igmp_iface + ['query-interval'], pim_base_iface + ['igmp', 'query-interval']) + + if config.exists(base_igmp_iface + ['query-max-response-time']): + config.copy(base_igmp_iface + ['query-max-response-time'], pim_base_iface + ['igmp', 'query-max-response-time']) + + if config.exists(base_igmp_iface + ['version']): + config.copy(base_igmp_iface + ['version'], pim_base_iface + ['igmp', 'version']) + +config.delete(base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + sys.exit(1) diff --git a/src/migration-scripts/policy/4-to-5 b/src/migration-scripts/policy/4-to-5 index f6f889c35..5b8fee17e 100755 --- a/src/migration-scripts/policy/4-to-5 +++ b/src/migration-scripts/policy/4-to-5 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 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 @@ -37,7 +37,53 @@ base4 = ['policy', 'route'] base6 = ['policy', 'route6'] config = ConfigTree(config_file) + +def delete_orphaned_interface_policy(config, iftype, ifname, vif=None, vifs=None, vifc=None): + """Delete unexpected policy on interfaces in cases when + policy does not exist but inreface has a policy configuration + Example T5941: + set interfaces bonding bond0 vif 995 policy + """ + if_path = ['interfaces', iftype, ifname] + + if vif: + if_path += ['vif', vif] + elif vifs: + if_path += ['vif-s', vifs] + if vifc: + if_path += ['vif-c', vifc] + + if not config.exists(if_path + ['policy']): + return + + config.delete(if_path + ['policy']) + + if not config.exists(base4) and not config.exists(base6): + # Delete orphaned nodes on interfaces T5941 + for iftype in config.list_nodes(['interfaces']): + for ifname in config.list_nodes(['interfaces', iftype]): + delete_orphaned_interface_policy(config, iftype, ifname) + + if config.exists(['interfaces', iftype, ifname, 'vif']): + for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']): + delete_orphaned_interface_policy(config, iftype, ifname, vif=vif) + + if config.exists(['interfaces', iftype, ifname, 'vif-s']): + for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']): + delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs) + + if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']): + for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']): + delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs, vifc=vifc) + + try: + with open(file_name, 'w') as f: + f.write(config.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) + # Nothing to do exit(0) diff --git a/src/migration-scripts/policy/6-to-7 b/src/migration-scripts/policy/6-to-7 new file mode 100755 index 000000000..727b8487a --- /dev/null +++ b/src/migration-scripts/policy/6-to-7 @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5729: Switch to valueless whenever is possible. +# From + # set policy [route | route6] ... rule <rule> log enable + # set policy [route | route6] ... rule <rule> log disable +# To + # set policy [route | route6] ... rule <rule> log + # Remove command if log=disable + +import re + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree +from vyos.ifconfig import Section + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['policy'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) + +for family in ['route', 'route6']: + if config.exists(base + [family]): + + for policy_name in config.list_nodes(base + [family]): + if config.exists(base + [family, policy_name, 'rule']): + for rule in config.list_nodes(base + [family, policy_name, 'rule']): + # Log + if config.exists(base + [family, policy_name, 'rule', rule, 'log']): + log_value = config.return_value(base + [family, policy_name, 'rule', rule, 'log']) + config.delete(base + [family, policy_name, 'rule', rule, 'log']) + if log_value == 'enable': + config.set(base + [family, policy_name, 'rule', rule, 'log']) + # State + if config.exists(base + [family, policy_name, 'rule', rule, 'state']): + flag_enable = 'False' + for state in ['established', 'invalid', 'new', 'related']: + if config.exists(base + [family, policy_name, 'rule', rule, 'state', state]): + state_value = config.return_value(base + [family, policy_name, 'rule', rule, 'state', state]) + config.delete(base + [family, policy_name, 'rule', rule, 'state', state]) + if state_value == 'enable': + config.set(base + [family, policy_name, 'rule', rule, 'state'], value=state, replace=False) + flag_enable = 'True' + if flag_enable == 'False': + config.delete(base + [family, policy_name, 'rule', rule, 'state']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1)
\ No newline at end of file diff --git a/src/migration-scripts/policy/7-to-8 b/src/migration-scripts/policy/7-to-8 new file mode 100755 index 000000000..73eece1a6 --- /dev/null +++ b/src/migration-scripts/policy/7-to-8 @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5834: Rename 'enable-default-log' to 'default-log' +# From + # set policy [route | route 6] <route> enable-default-log +# To + # set policy [route | route 6] <route> default-log + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['policy'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) + +for family in ['route', 'route6']: + if config.exists(base + [family]): + + for policy_name in config.list_nodes(base + [family]): + if config.exists(base + [family, policy_name, 'enable-default-log']): + config.rename(base + [family, policy_name, 'enable-default-log'], 'default-log') + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/pppoe-server/6-to-7 b/src/migration-scripts/pppoe-server/6-to-7 new file mode 100755 index 000000000..b94ce57f9 --- /dev/null +++ b/src/migration-scripts/pppoe-server/6-to-7 @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# - move all pool to named pools +# 'start-stop' migrate to namedpool 'default-range-pool' +# 'subnet' migrate to namedpool 'default-subnet-pool' +# 'default-subnet-pool' is the next pool for 'default-range-pool' +# - There is only one gateway-address, take the first which is configured +# - default-pool by migration. +# 1. If authentication mode = 'local' then it is first named pool. +# If there are not named pools, namedless pool will be default. +# 2. If authentication mode = 'radius' then namedless pool will be default + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree +from vyos.base import Warning + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['service', 'pppoe-server'] +pool_base = base + ['client-ip-pool'] +if not config.exists(base): + exit(0) + +if not config.exists(pool_base): + exit(0) + +default_pool = '' +range_pool_name = 'default-range-pool' + +#Default nameless pools migrations +if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']): + def is_legalrange(ip1: str, ip2: str, mask: str): + from ipaddress import IPv4Interface + interface1 = IPv4Interface(f'{ip1}/{mask}') + interface2 = IPv4Interface(f'{ip2}/{mask}') + return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip + + start_ip = config.return_value(pool_base + ['start']) + stop_ip = config.return_value(pool_base + ['stop']) + if is_legalrange(start_ip, stop_ip, '24'): + ip_range = f'{start_ip}-{stop_ip}' + config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False) + default_pool = range_pool_name + else: + Warning( + f'PPPoE client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.') + config.delete(pool_base + ['start']) + config.delete(pool_base + ['stop']) + +if config.exists(pool_base + ['subnet']): + default_pool = range_pool_name + for subnet in config.return_values(pool_base + ['subnet']): + config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False) + config.delete(pool_base + ['subnet']) + +gateway = '' +if config.exists(base + ['gateway-address']): + gateway = config.return_value(base + ['gateway-address']) + +#named pool migration +namedpools_base = pool_base + ['name'] +if config.exists(namedpools_base): + if config.exists(base + ['authentication', 'mode']): + if config.return_value(base + ['authentication', 'mode']) == 'local': + if config.list_nodes(namedpools_base): + default_pool = config.list_nodes(namedpools_base)[0] + + for pool_name in config.list_nodes(namedpools_base): + pool_path = namedpools_base + [pool_name] + if config.exists(pool_path + ['subnet']): + subnet = config.return_value(pool_path + ['subnet']) + config.set(pool_base + [pool_name, 'range'], value=subnet, replace=False) + if config.exists(pool_path + ['next-pool']): + next_pool = config.return_value(pool_path + ['next-pool']) + config.set(pool_base + [pool_name, 'next-pool'], value=next_pool) + if not gateway: + if config.exists(pool_path + ['gateway-address']): + gateway = config.return_value(pool_path + ['gateway-address']) + + config.delete(namedpools_base) + +if gateway: + config.set(base + ['gateway-address'], value=gateway) +if default_pool: + config.set(base + ['default-pool'], value=default_pool) +# format as tag node +config.set_tag(pool_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/pppoe-server/7-to-8 b/src/migration-scripts/pppoe-server/7-to-8 new file mode 100755 index 000000000..b0d9bb464 --- /dev/null +++ b/src/migration-scripts/pppoe-server/7-to-8 @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# Migrating to named ipv6 pools + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['service', 'pppoe-server'] +pool_base = base + ['client-ipv6-pool'] +if not config.exists(base): + exit(0) + +if not config.exists(pool_base): + exit(0) + +ipv6_pool_name = 'ipv6-pool' +config.copy(pool_base, pool_base + [ipv6_pool_name]) + +if config.exists(pool_base + ['prefix']): + config.delete(pool_base + ['prefix']) + config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name) +if config.exists(pool_base + ['delegate']): + config.delete(pool_base + ['delegate']) + +# format as tag node +config.set_tag(pool_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/pppoe-server/8-to-9 b/src/migration-scripts/pppoe-server/8-to-9 new file mode 100755 index 000000000..ad75c28a1 --- /dev/null +++ b/src/migration-scripts/pppoe-server/8-to-9 @@ -0,0 +1,69 @@ +#!/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/>. + +# Change from 'ccp' to 'disable-ccp' in ppp-option section +# Migration ipv6 options + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['service', 'pppoe-server'] +if not config.exists(base): + exit(0) + +#CCP migration +if config.exists(base + ['ppp-options', 'ccp']): + config.delete(base + ['ppp-options', 'ccp']) +else: + config.set(base + ['ppp-options', 'disable-ccp']) + +#IPV6 options migrations +if config.exists(base + ['ppp-options','ipv6-peer-intf-id']): + intf_peer_id = config.return_value(base + ['ppp-options','ipv6-peer-intf-id']) + if intf_peer_id == 'ipv4': + intf_peer_id = 'ipv4-addr' + config.set(base + ['ppp-options','ipv6-peer-interface-id'], value=intf_peer_id, replace=True) + config.delete(base + ['ppp-options','ipv6-peer-intf-id']) + +if config.exists(base + ['ppp-options','ipv6-intf-id']): + intf_id = config.return_value(base + ['ppp-options','ipv6-intf-id']) + config.set(base + ['ppp-options','ipv6-interface-id'], value=intf_id, replace=True) + config.delete(base + ['ppp-options','ipv6-intf-id']) + +if config.exists(base + ['ppp-options','ipv6-accept-peer-intf-id']): + config.set(base + ['ppp-options','ipv6-accept-peer-interface-id']) + config.delete(base + ['ppp-options','ipv6-accept-peer-intf-id']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/pptp/2-to-3 b/src/migration-scripts/pptp/2-to-3 new file mode 100755 index 000000000..091cb68ec --- /dev/null +++ b/src/migration-scripts/pptp/2-to-3 @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# - move all pool to named pools +# 'start-stop' migrate to namedpool 'default-range-pool' +# 'default-subnet-pool' is the next pool for 'default-range-pool' + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree +from vyos.base import Warning + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['vpn', 'pptp', 'remote-access'] +pool_base = base + ['client-ip-pool'] +if not config.exists(base): + exit(0) + +if not config.exists(pool_base): + exit(0) + +range_pool_name = 'default-range-pool' + +if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']): + def is_legalrange(ip1: str, ip2: str, mask: str): + from ipaddress import IPv4Interface + interface1 = IPv4Interface(f'{ip1}/{mask}') + interface2 = IPv4Interface(f'{ip2}/{mask}') + return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip + + start_ip = config.return_value(pool_base + ['start']) + stop_ip = config.return_value(pool_base + ['stop']) + if is_legalrange(start_ip, stop_ip, '24'): + ip_range = f'{start_ip}-{stop_ip}' + config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False) + config.set(base + ['default-pool'], value=range_pool_name) + else: + Warning( + f'PPTP client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.') + + config.delete(pool_base + ['start']) + config.delete(pool_base + ['stop']) +# format as tag node +config.set_tag(pool_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/pptp/3-to-4 b/src/migration-scripts/pptp/3-to-4 new file mode 100755 index 000000000..0a8dad2f4 --- /dev/null +++ b/src/migration-scripts/pptp/3-to-4 @@ -0,0 +1,51 @@ +#!/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/>. + +# - Move 'mppe' from 'authentication' node to 'ppp-options' + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['vpn', 'pptp', 'remote-access'] + +if not config.exists(base): + exit(0) + +if config.exists(base + ['authentication','mppe']): + mppe = config.return_value(base + ['authentication','mppe']) + config.set(base + ['ppp-options', 'mppe'], value=mppe, replace=True) + config.delete(base + ['authentication','mppe']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/qos/1-to-2 b/src/migration-scripts/qos/1-to-2 index cca32d06e..666811e5a 100755 --- a/src/migration-scripts/qos/1-to-2 +++ b/src/migration-scripts/qos/1-to-2 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 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 @@ -40,7 +40,53 @@ with open(file_name, 'r') as f: base = ['traffic-policy'] config = ConfigTree(config_file) + +def delete_orphaned_interface_policy(config, iftype, ifname, vif=None, vifs=None, vifc=None): + """Delete unexpected traffic-policy on interfaces in cases when + policy does not exist but inreface has a policy configuration + Example T5941: + set interfaces bonding bond0 vif 995 traffic-policy + """ + if_path = ['interfaces', iftype, ifname] + + if vif: + if_path += ['vif', vif] + elif vifs: + if_path += ['vif-s', vifs] + if vifc: + if_path += ['vif-c', vifc] + + if not config.exists(if_path + ['traffic-policy']): + return + + config.delete(if_path + ['traffic-policy']) + + if not config.exists(base): + # Delete orphaned nodes on interfaces T5941 + for iftype in config.list_nodes(['interfaces']): + for ifname in config.list_nodes(['interfaces', iftype]): + delete_orphaned_interface_policy(config, iftype, ifname) + + if config.exists(['interfaces', iftype, ifname, 'vif']): + for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']): + delete_orphaned_interface_policy(config, iftype, ifname, vif=vif) + + if config.exists(['interfaces', iftype, ifname, 'vif-s']): + for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']): + delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs) + + if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']): + for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']): + delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs, vifc=vifc) + + try: + with open(file_name, 'w') as f: + f.write(config.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) + # Nothing to do exit(0) diff --git a/src/migration-scripts/rpki/1-to-2 b/src/migration-scripts/rpki/1-to-2 new file mode 100755 index 000000000..559440bba --- /dev/null +++ b/src/migration-scripts/rpki/1-to-2 @@ -0,0 +1,51 @@ +#!/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/>. + +# T6011: rpki: known-hosts-file is no longer supported bxy FRR CLI, +# remove VyOS CLI node + +from sys import exit +from sys import argv +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['protocols', 'rpki'] +config = ConfigTree(config_file) + +# Nothing to do +if not config.exists(base): + exit(0) + +if config.exists(base + ['cache']): + for cache in config.list_nodes(base + ['cache']): + ssh_node = base + ['cache', cache, 'ssh'] + if config.exists(ssh_node + ['known-hosts-file']): + config.delete(ssh_node + ['known-hosts-file']) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/sstp/4-to-5 b/src/migration-scripts/sstp/4-to-5 new file mode 100755 index 000000000..95e482713 --- /dev/null +++ b/src/migration-scripts/sstp/4-to-5 @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# - move all pool to named pools +# 'subnet' migrate to namedpool 'default-subnet-pool' +# 'default-subnet-pool' is the next pool for 'default-range-pool' + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['vpn', 'sstp'] +pool_base = base + ['client-ip-pool'] +if not config.exists(base): + exit(0) + +if not config.exists(pool_base): + exit(0) + +range_pool_name = 'default-range-pool' + +if config.exists(pool_base + ['subnet']): + default_pool = range_pool_name + for subnet in config.return_values(pool_base + ['subnet']): + config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False) + config.delete(pool_base + ['subnet']) + config.set(base + ['default-pool'], value=default_pool) +# format as tag node +config.set_tag(pool_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/sstp/5-to-6 b/src/migration-scripts/sstp/5-to-6 new file mode 100755 index 000000000..bac9975b2 --- /dev/null +++ b/src/migration-scripts/sstp/5-to-6 @@ -0,0 +1,62 @@ +#!/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/>. + +# Migrating to named ipv6 pools + +import os +import pprint + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['vpn', 'sstp'] +pool_base = base + ['client-ipv6-pool'] +if not config.exists(base): + exit(0) + +if not config.exists(pool_base): + exit(0) + +ipv6_pool_name = 'ipv6-pool' +config.copy(pool_base, pool_base + [ipv6_pool_name]) + +if config.exists(pool_base + ['prefix']): + config.delete(pool_base + ['prefix']) + config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name) +if config.exists(pool_base + ['delegate']): + config.delete(pool_base + ['delegate']) + +# format as tag node +config.set_tag(pool_base) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/migration-scripts/system/26-to-27 b/src/migration-scripts/system/26-to-27 new file mode 100755 index 000000000..80bb82cbd --- /dev/null +++ b/src/migration-scripts/system/26-to-27 @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# T5877: migrate 'system domain-search domain' to 'system domain-search' + +from sys import exit, argv +from vyos.configtree import ConfigTree + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['system', 'domain-search'] +config = ConfigTree(config_file) + +if not config.exists(base): + exit(0) + +if config.exists(base + ['domain']): + entries = config.return_values(base + ['domain']) + config.delete(base + ['domain']) + for entry in entries: + config.set(base, value=entry, replace=False) + +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) diff --git a/src/op_mode/bridge.py b/src/op_mode/bridge.py index 185db4f20..412a4eba8 100755 --- a/src/op_mode/bridge.py +++ b/src/op_mode/bridge.py @@ -56,6 +56,13 @@ def _get_raw_data_vlan(tunnel:bool=False): data_dict = json.loads(json_data) return data_dict +def _get_raw_data_vni() -> dict: + """ + :returns dict + """ + json_data = cmd(f'bridge --json vni show') + data_dict = json.loads(json_data) + return data_dict def _get_raw_data_fdb(bridge): """Get MAC-address for the bridge brX @@ -165,6 +172,22 @@ def _get_formatted_output_vlan_tunnel(data): output = tabulate(data_entries, headers) return output +def _get_formatted_output_vni(data): + data_entries = [] + for entry in data: + interface = entry.get('ifname') + vlans = entry.get('vnis') + for vlan_entry in vlans: + vlan = vlan_entry.get('vni') + if vlan_entry.get('vniEnd'): + vlan_end = vlan_entry.get('vniEnd') + vlan = f'{vlan}-{vlan_end}' + data_entries.append([interface, vlan]) + + headers = ["Interface", "VNI"] + output = tabulate(data_entries, headers) + return output + def _get_formatted_output_fdb(data): data_entries = [] for entry in data: @@ -228,6 +251,12 @@ def show_vlan(raw: bool, tunnel: typing.Optional[bool]): else: return _get_formatted_output_vlan(bridge_vlan) +def show_vni(raw: bool): + bridge_vni = _get_raw_data_vni() + if raw: + return bridge_vni + else: + return _get_formatted_output_vni(bridge_vni) def show_fdb(raw: bool, interface: str): fdb_data = _get_raw_data_fdb(interface) diff --git a/src/op_mode/clear_dhcp_lease.py b/src/op_mode/clear_dhcp_lease.py index f372d3af0..7d4b47104 100755 --- a/src/op_mode/clear_dhcp_lease.py +++ b/src/op_mode/clear_dhcp_lease.py @@ -1,20 +1,34 @@ #!/usr/bin/env python3 +# +# Copyright 2023 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 re -from isc_dhcp_leases import Lease -from isc_dhcp_leases import IscDhcpLeases - from vyos.configquery import ConfigTreeQuery +from vyos.kea import kea_parse_leases from vyos.utils.io import ask_yes_no from vyos.utils.process import call from vyos.utils.commit import commit_in_progress +# TODO: Update to use Kea control socket command "lease4-del" config = ConfigTreeQuery() base = ['service', 'dhcp-server'] -lease_file = '/config/dhcpd.leases' +lease_file = '/config/dhcp/dhcp4-leases.csv' def del_lease_ip(address): @@ -25,8 +39,7 @@ def del_lease_ip(address): """ with open(lease_file, encoding='utf-8') as f: data = f.read().rstrip() - lease_config_ip = '{(?P<config>[\s\S]+?)\n}' - pattern = rf"lease {address} {lease_config_ip}" + pattern = rf"^{address},[^\n]+\n" # Delete lease for ip block data = re.sub(pattern, '', data) @@ -38,15 +51,12 @@ def is_ip_in_leases(address): """ Return True if address found in the lease file """ - leases = IscDhcpLeases(lease_file) - lease_ips = [] - for lease in leases.get(): - lease_ips.append(lease.ip) - if address not in lease_ips: - print(f'Address "{address}" not found in "{lease_file}"') - return False - return True - + leases = kea_parse_leases(lease_file) + for lease in leases: + if address == lease['address']: + return True + print(f'Address "{address}" not found in "{lease_file}"') + return False if not config.exists(base): print('DHCP-server not configured!') @@ -75,4 +85,4 @@ if __name__ == '__main__': exit(1) else: del_lease_ip(address) - call('systemctl restart isc-dhcp-server.service') + call('systemctl restart kea-dhcp4-server.service') diff --git a/src/op_mode/connect_disconnect.py b/src/op_mode/connect_disconnect.py index 89f929be7..10034e499 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -55,7 +55,7 @@ def connect(interface): if is_wwan_connected(interface): print(f'Interface {interface}: already connected!') else: - call(f'VYOS_TAGNODE_VALUE={interface} /usr/libexec/vyos/conf_mode/interfaces-wwan.py') + call(f'VYOS_TAGNODE_VALUE={interface} /usr/libexec/vyos/conf_mode/interfaces_wwan.py') else: print(f'Unknown interface {interface}, can not connect. Aborting!') diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index 77f38992b..a64acec31 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -21,7 +21,6 @@ import typing from datetime import datetime from glob import glob from ipaddress import ip_address -from isc_dhcp_leases import IscDhcpLeases from tabulate import tabulate import vyos.opmode @@ -29,9 +28,9 @@ import vyos.opmode from vyos.base import Warning from vyos.configquery import ConfigTreeQuery -from vyos.utils.dict import dict_search -from vyos.utils.file import read_file -from vyos.utils.process import cmd +from vyos.kea import kea_get_active_config +from vyos.kea import kea_get_pool_from_subnet_id +from vyos.kea import kea_parse_leases from vyos.utils.process import is_systemd_service_running time_string = "%a %b %d %H:%M:%S %Z %Y" @@ -43,6 +42,7 @@ sort_valid_inet6 = ['end', 'iaid_duid', 'ip', 'last_communication', 'pool', 'rem ArgFamily = typing.Literal['inet', 'inet6'] ArgState = typing.Literal['all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup'] +ArgOrigin = typing.Literal['local', 'remote'] def _utc_to_local(utc_dt): return datetime.fromtimestamp((datetime.fromtimestamp(utc_dt) - datetime(1970, 1, 1)).total_seconds()) @@ -71,42 +71,47 @@ def _find_list_of_dict_index(lst, key='ip', value='') -> int: return idx -def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[]) -> list: +def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], origin=None) -> list: """ Get DHCP server leases :return list """ - lease_file = '/config/dhcpdv6.leases' if family == 'inet6' else '/config/dhcpd.leases' - data = [] - leases = IscDhcpLeases(lease_file).get() + inet_suffix = '6' if family == 'inet6' else '4' + lease_file = f'/config/dhcp/dhcp{inet_suffix}-leases.csv' + leases = kea_parse_leases(lease_file) if pool is None: pool = _get_dhcp_pools(family=family) else: pool = [pool] + active_config = kea_get_active_config(inet_suffix) + + data = [] for lease in leases: data_lease = {} - data_lease['ip'] = lease.ip - data_lease['state'] = lease.binding_state - data_lease['pool'] = lease.sets.get('shared-networkname', '') - data_lease['end'] = lease.end.timestamp() if lease.end else None + data_lease['ip'] = lease['address'] + lease_state_long = {'0': 'active', '1': 'rejected', '2': 'expired'} + data_lease['state'] = lease_state_long[lease['state']] + data_lease['pool'] = kea_get_pool_from_subnet_id(active_config, inet_suffix, lease['subnet_id']) if active_config else '-' + data_lease['end'] = lease['expire_timestamp'].timestamp() if lease['expire_timestamp'] else None + data_lease['origin'] = 'local' # TODO: Determine remote in HA if family == 'inet': - data_lease['mac'] = lease.ethernet - data_lease['start'] = lease.start.timestamp() - data_lease['hostname'] = lease.hostname + data_lease['mac'] = lease['hwaddr'] + data_lease['start'] = lease['start_timestamp'].timestamp() + data_lease['hostname'] = lease['hostname'] if family == 'inet6': - data_lease['last_communication'] = lease.last_communication.timestamp() - data_lease['iaid_duid'] = _format_hex_string(lease.host_identifier_string) - lease_types_long = {'na': 'non-temporary', 'ta': 'temporary', 'pd': 'prefix delegation'} - data_lease['type'] = lease_types_long[lease.type] + data_lease['last_communication'] = lease['start_timestamp'].timestamp() + data_lease['iaid_duid'] = _format_hex_string(lease['duid']) + lease_types_long = {'0': 'non-temporary', '1': 'temporary', '2': 'prefix delegation'} + data_lease['type'] = lease_types_long[lease['lease_type']] data_lease['remaining'] = '-' - if lease.end: - data_lease['remaining'] = lease.end - datetime.utcnow() + if lease['expire']: + data_lease['remaining'] = lease['expire_timestamp'] - datetime.utcnow() if data_lease['remaining'].days >= 0: # substraction gives us a timedelta object which can't be formatted with strftime @@ -115,7 +120,7 @@ def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[]) -> l # Do not add old leases if data_lease['remaining'] != '' and data_lease['pool'] in pool and data_lease['state'] != 'free': - if not state or data_lease['state'] in state: + if not state or state == 'all' or data_lease['state'] in state: data.append(data_lease) # deduplicate @@ -150,10 +155,11 @@ def _get_formatted_server_leases(raw_data, family='inet'): remain = lease.get('remaining') pool = lease.get('pool') hostname = lease.get('hostname') - data_entries.append([ipaddr, hw_addr, state, start, end, remain, pool, hostname]) + origin = lease.get('origin') + data_entries.append([ipaddr, hw_addr, state, start, end, remain, pool, hostname, origin]) headers = ['IP Address', 'MAC address', 'State', 'Lease start', 'Lease expiration', 'Remaining', 'Pool', - 'Hostname'] + 'Hostname', 'Origin'] if family == 'inet6': for lease in raw_data: @@ -188,14 +194,11 @@ def _get_pool_size(pool, family='inet'): size = 0 subnets = config.list_nodes(f'{base} subnet') for subnet in subnets: - if family == 'inet6': - ranges = config.list_nodes(f'{base} subnet {subnet} address-range start') - else: - ranges = config.list_nodes(f'{base} subnet {subnet} range') + ranges = config.list_nodes(f'{base} subnet {subnet} range') for range in ranges: if family == 'inet6': - start = config.list_nodes(f'{base} subnet {subnet} address-range start')[0] - stop = config.value(f'{base} subnet {subnet} address-range start {start} stop') + start = config.value(f'{base} subnet {subnet} range {range} start') + stop = config.value(f'{base} subnet {subnet} range {range} stop') else: start = config.value(f'{base} subnet {subnet} range {range} start') stop = config.value(f'{base} subnet {subnet} range {range} stop') @@ -267,12 +270,12 @@ def show_pool_statistics(raw: bool, family: ArgFamily, pool: typing.Optional[str @_verify def show_server_leases(raw: bool, family: ArgFamily, pool: typing.Optional[str], - sorted: typing.Optional[str], state: typing.Optional[ArgState]): + sorted: typing.Optional[str], state: typing.Optional[ArgState], + origin: typing.Optional[ArgOrigin] ): # if dhcp server is down, inactive leases may still be shown as active, so warn the user. - v = '6' if family == 'inet6' else '' - service_name = 'DHCPv6' if family == 'inet6' else 'DHCP' - if not is_systemd_service_running(f'isc-dhcp-server{v}.service'): - Warning(f'{service_name} server is configured but not started. Data may be stale.') + v = '6' if family == 'inet6' else '4' + if not is_systemd_service_running(f'kea-dhcp{v}-server.service'): + Warning('DHCP server is configured but not started. Data may be stale.') v = 'v6' if family == 'inet6' else '' if pool and pool not in _get_dhcp_pools(family=family): @@ -285,7 +288,7 @@ def show_server_leases(raw: bool, family: ArgFamily, pool: typing.Optional[str], if sorted and sorted not in sort_valid: raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!') - lease_data = _get_raw_server_leases(family=family, pool=pool, sorted=sorted, state=state) + lease_data = _get_raw_server_leases(family=family, pool=pool, sorted=sorted, state=state, origin=origin) if raw: return lease_data else: diff --git a/src/op_mode/dns.py b/src/op_mode/dns.py index 2168aef89..16c462f23 100755 --- a/src/op_mode/dns.py +++ b/src/op_mode/dns.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 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 @@ -15,17 +15,35 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. +import os import sys +import time +import typing +import vyos.opmode from tabulate import tabulate - from vyos.configquery import ConfigTreeQuery -from vyos.utils.process import cmd - -import vyos.opmode - - -def _data_to_dict(data, sep="\t") -> dict: +from vyos.utils.process import cmd, rc_cmd +from vyos.template import is_ipv4, is_ipv6 + +_dynamic_cache_file = r'/run/ddclient/ddclient.cache' + +_dynamic_status_columns = { + 'host': 'Hostname', + 'ipv4': 'IPv4 address', + 'status-ipv4': 'IPv4 status', + 'ipv6': 'IPv6 address', + 'status-ipv6': 'IPv6 status', + 'mtime': 'Last update', +} + +_forwarding_statistics_columns = { + 'cache-entries': 'Cache entries', + 'max-cache-entries': 'Max cache entries', + 'cache-size': 'Cache size', +} + +def _forwarding_data_to_dict(data, sep="\t") -> dict: """ Return dictionary from plain text separated by tab @@ -51,37 +69,135 @@ def _data_to_dict(data, sep="\t") -> dict: dictionary[key] = value return dictionary +def _get_dynamic_host_records_raw() -> dict: + + data = [] + + if os.path.isfile(_dynamic_cache_file): # A ddclient status file might not always exist + with open(_dynamic_cache_file, 'r') as f: + for line in f: + if line.startswith('#'): + continue + + props = {} + # ddclient cache rows have properties in 'key=value' format separated by comma + # we pick up the ones we are interested in + for kvraw in line.split(' ')[0].split(','): + k, v = kvraw.split('=') + if k in list(_dynamic_status_columns.keys()) + ['ip', 'status']: # ip and status are legacy keys + props[k] = v + + # Extract IPv4 and IPv6 address and status from legacy keys + # Dual-stack isn't supported in legacy format, 'ip' and 'status' are for one of IPv4 or IPv6 + if 'ip' in props: + if is_ipv4(props['ip']): + props['ipv4'] = props['ip'] + props['status-ipv4'] = props['status'] + elif is_ipv6(props['ip']): + props['ipv6'] = props['ip'] + props['status-ipv6'] = props['status'] + del props['ip'] + + # Convert mtime to human readable format + if 'mtime' in props: + props['mtime'] = time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(int(props['mtime'], base=10))) + + data.append(props) -def _get_raw_forwarding_statistics() -> dict: - command = cmd('rec_control --socket-dir=/run/powerdns get-all') - data = _data_to_dict(command) - data['cache-size'] = "{0:.2f}".format( int( - cmd('rec_control --socket-dir=/run/powerdns get cache-bytes')) / 1024 ) return data - -def _get_formatted_forwarding_statistics(data): - cache_entries = data.get('cache-entries') - max_cache_entries = data.get('max-cache-entries') - cache_size = data.get('cache-size') - data_entries = [[cache_entries, max_cache_entries, f'{cache_size} kbytes']] - headers = ["Cache entries", "Max cache entries" , "Cache size"] - output = tabulate(data_entries, headers, numalign="left") +def _get_dynamic_host_records_formatted(data): + data_entries = [] + for entry in data: + data_entries.append([entry.get(key) for key in _dynamic_status_columns.keys()]) + header = _dynamic_status_columns.values() + output = tabulate(data_entries, header, numalign='left') return output +def _get_forwarding_statistics_raw() -> dict: + command = cmd('rec_control get-all') + data = _forwarding_data_to_dict(command) + data['cache-size'] = "{0:.2f} kbytes".format( int( + cmd('rec_control get cache-bytes')) / 1024 ) + return data -def show_forwarding_statistics(raw: bool): +def _get_forwarding_statistics_formatted(data): + data_entries = [] + data_entries.append([data.get(key) for key in _forwarding_statistics_columns.keys()]) + header = _forwarding_statistics_columns.values() + output = tabulate(data_entries, header, numalign='left') + return output - config = ConfigTreeQuery() - if not config.exists('service dns forwarding'): - raise vyos.opmode.UnconfiguredSubsystem('DNS forwarding is not configured') +def _verify(target): + """Decorator checks if config for DNS related service exists""" + from functools import wraps + + if target not in ['dynamic', 'forwarding']: + raise ValueError('Invalid target') + + def _verify_target(func): + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + if not config.exists(f'service dns {target}'): + _prefix = f'Dynamic DNS' if target == 'dynamic' else 'DNS Forwarding' + raise vyos.opmode.UnconfiguredSubsystem(f'{_prefix} is not configured') + return func(*args, **kwargs) + return _wrapper + return _verify_target + +@_verify('dynamic') +def show_dynamic_status(raw: bool): + host_data = _get_dynamic_host_records_raw() + if raw: + return host_data + else: + return _get_dynamic_host_records_formatted(host_data) - dns_data = _get_raw_forwarding_statistics() +@_verify('dynamic') +def reset_dynamic(): + """ + Reset Dynamic DNS cache + """ + if os.path.exists(_dynamic_cache_file): + os.remove(_dynamic_cache_file) + rc, output = rc_cmd('systemctl restart ddclient.service') + if rc != 0: + print(output) + return None + print(f'Dynamic DNS state reset!') + +@_verify('forwarding') +def show_forwarding_statistics(raw: bool): + dns_data = _get_forwarding_statistics_raw() if raw: return dns_data else: - return _get_formatted_forwarding_statistics(dns_data) + return _get_forwarding_statistics_formatted(dns_data) + +@_verify('forwarding') +def reset_forwarding(all: bool, domain: typing.Optional[str]): + """ + Reset DNS Forwarding cache + :param all (bool): reset cache all domains + :param domain (str): reset cache for specified domain + """ + if all: + rc, output = rc_cmd('rec_control wipe-cache ".$"') + if rc != 0: + print(output) + return None + print('DNS Forwarding cache reset for all domains!') + return output + elif domain: + rc, output = rc_cmd(f'rec_control wipe-cache "{domain}$"') + if rc != 0: + print(output) + return None + print(f'DNS Forwarding cache reset for domain "{domain}"!') + return output if __name__ == '__main__': try: diff --git a/src/op_mode/dns_dynamic.py b/src/op_mode/dns_dynamic.py deleted file mode 100755 index 12aa5494a..000000000 --- a/src/op_mode/dns_dynamic.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import os -import argparse -import sys -import time -from tabulate import tabulate - -from vyos.config import Config -from vyos.template import is_ipv4, is_ipv6 -from vyos.utils.process import call - -cache_file = r'/run/ddclient/ddclient.cache' - -columns = { - 'host': 'Hostname', - 'ipv4': 'IPv4 address', - 'status-ipv4': 'IPv4 status', - 'ipv6': 'IPv6 address', - 'status-ipv6': 'IPv6 status', - 'mtime': 'Last update', -} - - -def _get_formatted_host_records(host_data): - data_entries = [] - for entry in host_data: - data_entries.append([entry.get(key) for key in columns.keys()]) - - header = columns.values() - output = tabulate(data_entries, header, numalign='left') - return output - - -def show_status(): - # A ddclient status file might not always exist - if not os.path.exists(cache_file): - sys.exit(0) - - data = [] - - with open(cache_file, 'r') as f: - for line in f: - if line.startswith('#'): - continue - - props = {} - # ddclient cache rows have properties in 'key=value' format separated by comma - # we pick up the ones we are interested in - for kvraw in line.split(' ')[0].split(','): - k, v = kvraw.split('=') - if k in list(columns.keys()) + ['ip', 'status']: # ip and status are legacy keys - props[k] = v - - # Extract IPv4 and IPv6 address and status from legacy keys - # Dual-stack isn't supported in legacy format, 'ip' and 'status' are for one of IPv4 or IPv6 - if 'ip' in props: - if is_ipv4(props['ip']): - props['ipv4'] = props['ip'] - props['status-ipv4'] = props['status'] - elif is_ipv6(props['ip']): - props['ipv6'] = props['ip'] - props['status-ipv6'] = props['status'] - del props['ip'] - - # Convert mtime to human readable format - if 'mtime' in props: - props['mtime'] = time.strftime( - "%Y-%m-%d %H:%M:%S", time.localtime(int(props['mtime'], base=10))) - - data.append(props) - - print(_get_formatted_host_records(data)) - - -def update_ddns(): - call('systemctl stop ddclient.service') - if os.path.exists(cache_file): - os.remove(cache_file) - call('systemctl start ddclient.service') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser() - group = parser.add_mutually_exclusive_group() - group.add_argument("--status", help="Show DDNS status", action="store_true") - group.add_argument("--update", help="Update DDNS on a given interface", action="store_true") - args = parser.parse_args() - - # Do nothing if service is not configured - c = Config() - if not c.exists_effective('service dns dynamic'): - print("Dynamic DNS not configured") - sys.exit(1) - - if args.status: - show_status() - elif args.update: - update_ddns() diff --git a/src/op_mode/dns_forwarding_reset.py b/src/op_mode/dns_forwarding_reset.py deleted file mode 100755 index 55e20918f..000000000 --- a/src/op_mode/dns_forwarding_reset.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018 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/>. -# -# File: vyos-show-version -# Purpose: -# Displays image version and system information. -# Used by the "run show version" command. - - -import os -import argparse - -from sys import exit -from vyos.config import Config -from vyos.utils.process import call - -PDNS_CMD='/usr/bin/rec_control --socket-dir=/run/powerdns' - -parser = argparse.ArgumentParser() -parser.add_argument("-a", "--all", action="store_true", help="Reset all cache") -parser.add_argument("domain", type=str, nargs="?", help="Domain to reset cache entries for") - -if __name__ == '__main__': - args = parser.parse_args() - - # Do nothing if service is not configured - c = Config() - if not c.exists_effective(['service', 'dns', 'forwarding']): - print("DNS forwarding is not configured") - exit(0) - - if args.all: - call(f"{PDNS_CMD} wipe-cache \'.$\'") - exit(0) - - elif args.domain: - call(f"{PDNS_CMD} wipe-cache \'{0}$\'".format(args.domain)) - - else: - parser.print_help() - exit(1) diff --git a/src/op_mode/dns_forwarding_restart.sh b/src/op_mode/dns_forwarding_restart.sh deleted file mode 100755 index 64cc92115..000000000 --- a/src/op_mode/dns_forwarding_restart.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh - -if cli-shell-api existsEffective service dns forwarding; then - echo "Restarting the DNS forwarding service" - systemctl restart pdns-recursor.service -else - echo "DNS forwarding is not configured" -fi diff --git a/src/op_mode/dns_forwarding_statistics.py b/src/op_mode/dns_forwarding_statistics.py deleted file mode 100755 index 32b5c76a7..000000000 --- a/src/op_mode/dns_forwarding_statistics.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 - -import jinja2 -from sys import exit - -from vyos.config import Config -from vyos.utils.process import cmd - -PDNS_CMD='/usr/bin/rec_control --socket-dir=/run/powerdns' - -OUT_TMPL_SRC = """ -DNS forwarding statistics: - -Cache entries: {{ cache_entries }} -Cache size: {{ cache_size }} kbytes - -""" - -if __name__ == '__main__': - # Do nothing if service is not configured - c = Config() - if not c.exists_effective('service dns forwarding'): - print("DNS forwarding is not configured") - exit(0) - - data = {} - - data['cache_entries'] = cmd(f'{PDNS_CMD} get cache-entries') - data['cache_size'] = "{0:.2f}".format( int(cmd(f'{PDNS_CMD} get cache-bytes')) / 1024 ) - - tmpl = jinja2.Template(OUT_TMPL_SRC) - print(tmpl.render(data)) diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py index 3434707ec..4dcffc412 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -24,19 +24,28 @@ from vyos.config import Config from vyos.utils.process import cmd from vyos.utils.dict import dict_search_args -def get_config_firewall(conf, family=None, hook=None, priority=None): - config_path = ['firewall'] - if family: - config_path += [family] - if hook: - config_path += [hook] - if priority: - config_path += [priority] +def get_config_node(conf, node=None, family=None, hook=None, priority=None): + if node == 'nat': + if family == 'ipv6': + config_path = ['nat66'] + else: + config_path = ['nat'] - firewall = conf.get_config_dict(config_path, key_mangling=('-', '_'), + elif node == 'policy': + config_path = ['policy'] + else: + config_path = ['firewall'] + if family: + config_path += [family] + if hook: + config_path += [hook] + if priority: + config_path += [priority] + + node_config = conf.get_config_dict(config_path, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) - return firewall + return node_config def get_nftables_details(family, hook, priority): if family == 'ipv6': @@ -102,13 +111,16 @@ def output_firewall_name(family, hook, priority, firewall_conf, single_rule_id=N row.append(rule_details['conditions']) rows.append(row) - if 'default_action' in firewall_conf and not single_rule_id: - row = ['default', firewall_conf['default_action'], 'all'] - if 'default-action' in details: - rule_details = details['default-action'] - row.append(rule_details.get('packets', 0)) - row.append(rule_details.get('bytes', 0)) - rows.append(row) + if hook in ['input', 'forward', 'output']: + def_action = firewall_conf['default_action'] if 'default_action' in firewall_conf else 'accept' + else: + def_action = firewall_conf['default_action'] if 'default_action' in firewall_conf else 'drop' + row = ['default', def_action, 'all'] + rule_details = details['default-action'] + row.append(rule_details.get('packets', 0)) + row.append(rule_details.get('bytes', 0)) + + rows.append(row) if rows: header = ['Rule', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions'] @@ -167,16 +179,16 @@ def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule dest_addr = 'any' # Get inbound interface - iiface = dict_search_args(rule_conf, 'inbound_interface', 'interface_name') + iiface = dict_search_args(rule_conf, 'inbound_interface', 'name') if not iiface: - iiface = dict_search_args(rule_conf, 'inbound_interface', 'interface_group') + iiface = dict_search_args(rule_conf, 'inbound_interface', 'group') if not iiface: iiface = 'any' # Get outbound interface - oiface = dict_search_args(rule_conf, 'outbound_interface', 'interface_name') + oiface = dict_search_args(rule_conf, 'outbound_interface', 'name') if not oiface: - oiface = dict_search_args(rule_conf, 'outbound_interface', 'interface_group') + oiface = dict_search_args(rule_conf, 'outbound_interface', 'group') if not oiface: oiface = 'any' @@ -198,8 +210,9 @@ def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule if hook in ['input', 'forward', 'output']: row = ['default'] - row.append('N/A') - row.append('N/A') + rule_details = details['default-action'] + row.append(rule_details.get('packets', 0)) + row.append(rule_details.get('bytes', 0)) if 'default_action' in prior_conf: row.append(prior_conf['default_action']) else: @@ -234,7 +247,7 @@ def show_firewall(): print('Rulesets Information') conf = Config() - firewall = get_config_firewall(conf) + firewall = get_config_node(conf) if not firewall: return @@ -249,7 +262,7 @@ def show_firewall_family(family): print(f'Rulesets {family} Information') conf = Config() - firewall = get_config_firewall(conf) + firewall = get_config_node(conf) if not firewall or family not in firewall: return @@ -262,7 +275,7 @@ def show_firewall_name(family, hook, priority): print('Ruleset Information') conf = Config() - firewall = get_config_firewall(conf, family, hook, priority) + firewall = get_config_node(conf, 'firewall', family, hook, priority) if firewall: output_firewall_name(family, hook, priority, firewall) @@ -270,17 +283,20 @@ def show_firewall_rule(family, hook, priority, rule_id): print('Rule Information') conf = Config() - firewall = get_config_firewall(conf, family, hook, priority) + firewall = get_config_node(conf, 'firewall', family, hook, priority) if firewall: output_firewall_name(family, hook, priority, firewall, rule_id) def show_firewall_group(name=None): conf = Config() - firewall = get_config_firewall(conf) + firewall = get_config_node(conf, node='firewall') if 'group' not in firewall: return + nat = get_config_node(conf, node='nat') + policy = get_config_node(conf, node='policy') + def find_references(group_type, group_name): out = [] family = [] @@ -293,9 +309,10 @@ def show_firewall_group(name=None): family = ['ipv6'] group_type = 'network_group' else: - family = ['ipv4', 'ipv6'] + family = ['ipv4', 'ipv6', 'bridge'] for item in family: + # Look references in firewall for name_type in ['name', 'ipv6_name', 'forward', 'input', 'output']: if item in firewall: if name_type not in firewall[item]: @@ -308,8 +325,10 @@ def show_firewall_group(name=None): for rule_id, rule_conf in priority_conf['rule'].items(): source_group = dict_search_args(rule_conf, 'source', 'group', group_type) dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type) - in_interface = dict_search_args(rule_conf, 'inbound_interface', 'interface_group') - out_interface = dict_search_args(rule_conf, 'outbound_interface', 'interface_group') + in_interface = dict_search_args(rule_conf, 'inbound_interface', 'group') + out_interface = dict_search_args(rule_conf, 'outbound_interface', 'group') + dyn_group_source = dict_search_args(rule_conf, 'add_address_to_group', 'source_address', group_type) + dyn_group_dst = dict_search_args(rule_conf, 'add_address_to_group', 'destination_address', group_type) if source_group: if source_group[0] == "!": source_group = source_group[1:] @@ -330,31 +349,121 @@ def show_firewall_group(name=None): out_interface = out_interface[1:] if group_name == out_interface: out.append(f'{item}-{name_type}-{priority}-{rule_id}') + + if dyn_group_source: + if group_name == dyn_group_source: + out.append(f'{item}-{name_type}-{priority}-{rule_id}') + if dyn_group_dst: + if group_name == dyn_group_dst: + out.append(f'{item}-{name_type}-{priority}-{rule_id}') + + + # Look references in route | route6 + for name_type in ['route', 'route6']: + if name_type not in policy: + continue + if name_type == 'route' and item == 'ipv6': + continue + elif name_type == 'route6' and item == 'ipv4': + continue + else: + for policy_name, policy_conf in policy[name_type].items(): + if 'rule' not in policy_conf: + continue + for rule_id, rule_conf in policy_conf['rule'].items(): + source_group = dict_search_args(rule_conf, 'source', 'group', group_type) + dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type) + in_interface = dict_search_args(rule_conf, 'inbound_interface', 'group') + out_interface = dict_search_args(rule_conf, 'outbound_interface', 'group') + if source_group: + if source_group[0] == "!": + source_group = source_group[1:] + if group_name == source_group: + out.append(f'{name_type}-{policy_name}-{rule_id}') + if dest_group: + if dest_group[0] == "!": + dest_group = dest_group[1:] + if group_name == dest_group: + out.append(f'{name_type}-{policy_name}-{rule_id}') + if in_interface: + if in_interface[0] == "!": + in_interface = in_interface[1:] + if group_name == in_interface: + out.append(f'{name_type}-{policy_name}-{rule_id}') + if out_interface: + if out_interface[0] == "!": + out_interface = out_interface[1:] + if group_name == out_interface: + out.append(f'{name_type}-{policy_name}-{rule_id}') + + ## Look references in nat table + for direction in ['source', 'destination']: + if direction in nat: + if 'rule' not in nat[direction]: + continue + for rule_id, rule_conf in nat[direction]['rule'].items(): + source_group = dict_search_args(rule_conf, 'source', 'group', group_type) + dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type) + in_interface = dict_search_args(rule_conf, 'inbound_interface', 'group') + out_interface = dict_search_args(rule_conf, 'outbound_interface', 'group') + if source_group: + if source_group[0] == "!": + source_group = source_group[1:] + if group_name == source_group: + out.append(f'nat-{direction}-{rule_id}') + if dest_group: + if dest_group[0] == "!": + dest_group = dest_group[1:] + if group_name == dest_group: + out.append(f'nat-{direction}-{rule_id}') + if in_interface: + if in_interface[0] == "!": + in_interface = in_interface[1:] + if group_name == in_interface: + out.append(f'nat-{direction}-{rule_id}') + if out_interface: + if out_interface[0] == "!": + out_interface = out_interface[1:] + if group_name == out_interface: + out.append(f'nat-{direction}-{rule_id}') + return out header = ['Name', 'Type', 'References', 'Members'] rows = [] for group_type, group_type_conf in firewall['group'].items(): - for group_name, group_conf in group_type_conf.items(): - if name and name != group_name: - continue + ## + if group_type != 'dynamic_group': + + for group_name, group_conf in group_type_conf.items(): + if name and name != group_name: + continue + + references = find_references(group_type, group_name) + row = [group_name, group_type, '\n'.join(references) or 'N/D'] + if 'address' in group_conf: + row.append("\n".join(sorted(group_conf['address']))) + elif 'network' in group_conf: + row.append("\n".join(sorted(group_conf['network'], key=ipaddress.ip_network))) + elif 'mac_address' in group_conf: + row.append("\n".join(sorted(group_conf['mac_address']))) + elif 'port' in group_conf: + row.append("\n".join(sorted(group_conf['port']))) + elif 'interface' in group_conf: + row.append("\n".join(sorted(group_conf['interface']))) + else: + row.append('N/D') + rows.append(row) - references = find_references(group_type, group_name) - row = [group_name, group_type, '\n'.join(references) or 'N/D'] - if 'address' in group_conf: - row.append("\n".join(sorted(group_conf['address']))) - elif 'network' in group_conf: - row.append("\n".join(sorted(group_conf['network'], key=ipaddress.ip_network))) - elif 'mac_address' in group_conf: - row.append("\n".join(sorted(group_conf['mac_address']))) - elif 'port' in group_conf: - row.append("\n".join(sorted(group_conf['port']))) - elif 'interface' in group_conf: - row.append("\n".join(sorted(group_conf['interface']))) - else: - row.append('N/D') - rows.append(row) + else: + for dynamic_type in ['address_group', 'ipv6_address_group']: + if dynamic_type in firewall['group']['dynamic_group']: + for dynamic_name, dynamic_conf in firewall['group']['dynamic_group'][dynamic_type].items(): + references = find_references(dynamic_type, dynamic_name) + row = [dynamic_name, dynamic_type + '(dynamic)', '\n'.join(references) or 'N/D'] + row.append('N/D') + rows.append(row) if rows: print('Firewall Groups\n') @@ -364,7 +473,7 @@ def show_summary(): print('Ruleset Summary') conf = Config() - firewall = get_config_firewall(conf) + firewall = get_config_node(conf) if not firewall: return @@ -410,7 +519,7 @@ def show_statistics(): print('Rulesets Statistics') conf = Config() - firewall = get_config_firewall(conf) + firewall = get_config_node(conf) if not firewall: return diff --git a/src/op_mode/generate_firewall_rule-resequence.py b/src/op_mode/generate_firewall_rule-resequence.py index eb82a1a0a..21441f689 100755 --- a/src/op_mode/generate_firewall_rule-resequence.py +++ b/src/op_mode/generate_firewall_rule-resequence.py @@ -41,6 +41,10 @@ def convert_to_set_commands(config_dict, parent_key=''): commands.extend( convert_to_set_commands(value, f"{current_key} ")) + elif isinstance(value, list): + for item in value: + commands.append(f"set {current_key} '{item}'") + elif isinstance(value, str): commands.append(f"set {current_key} '{value}'") diff --git a/src/op_mode/generate_ipsec_debug_archive.py b/src/op_mode/generate_ipsec_debug_archive.py index 60195d48b..ca2eeb511 100755 --- a/src/op_mode/generate_ipsec_debug_archive.py +++ b/src/op_mode/generate_ipsec_debug_archive.py @@ -24,7 +24,6 @@ from vyos.utils.process import rc_cmd # define a list of commands that needs to be executed CMD_LIST: list[str] = [ - 'ipsec status', 'swanctl -L', 'swanctl -l', 'swanctl -P', @@ -36,7 +35,7 @@ CMD_LIST: list[str] = [ 'ip route | head -100', 'ip route show table 220' ] -JOURNALCTL_CMD: str = 'journalctl -b -n 10000 /usr/lib/ipsec/charon' +JOURNALCTL_CMD: str = 'journalctl --no-hostname --boot --unit strongswan.service' # execute a command and save the output to a file def save_stdout(command: str, file: Path) -> None: diff --git a/src/op_mode/generate_tech-support_archive.py b/src/op_mode/generate_tech-support_archive.py new file mode 100755 index 000000000..c490b0137 --- /dev/null +++ b/src/op_mode/generate_tech-support_archive.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +import os +import argparse +import glob +from datetime import datetime +from pathlib import Path +from shutil import rmtree + +from socket import gethostname +from sys import exit +from tarfile import open as tar_open +from vyos.utils.process import rc_cmd +from vyos.remote import upload + +def op(cmd: str) -> str: + """Returns a command with the VyOS operational mode wrapper.""" + return f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}' + +def save_stdout(command: str, file: Path) -> None: + rc, stdout = rc_cmd(command) + body: str = f'''### {command} ### +Command: {command} +Exit code: {rc} +Stdout: +{stdout} + +''' + with file.open(mode='a') as f: + f.write(body) +def __rotate_logs(path: str, log_pattern:str): + files_list = glob.glob(f'{path}/{log_pattern}') + if len(files_list) > 5: + oldest_file = min(files_list, key=os.path.getctime) + os.remove(oldest_file) + + +def __generate_archived_files(location_path: str) -> None: + """ + Generate arhives of main directories + :param location_path: path to temporary directory + :type location_path: str + """ + # Dictionary arhive_name:directory_to_arhive + archive_dict = { + 'etc': '/etc', + 'home': '/home', + 'var-log': '/var/log', + 'root': '/root', + 'tmp': '/tmp', + 'core-dump': '/var/core', + 'config': '/opt/vyatta/etc/config' + } + # Dictionary arhive_name:excluding pattern + archive_excludes = { + # Old location of archives + 'config': 'tech-support-archive', + # New locations of arhives + 'tmp': 'tech-support-archive' + } + for archive_name, path in archive_dict.items(): + archive_file: str = f'{location_path}/{archive_name}.tar.gz' + with tar_open(name=archive_file, mode='x:gz') as tar_file: + if archive_name in archive_excludes: + tar_file.add(path, filter=lambda x: None if str(archive_excludes[archive_name]) in str(x.name) else x) + else: + tar_file.add(path) + + +def __generate_main_archive_file(archive_file: str, tmp_dir_path: str) -> None: + """ + Generate main arhive file + :param archive_file: name of arhive file + :type archive_file: str + :param tmp_dir_path: path to arhive memeber + :type tmp_dir_path: str + """ + with tar_open(name=archive_file, mode='x:gz') as tar_file: + tar_file.add(tmp_dir_path, arcname=os.path.basename(tmp_dir_path)) + + +if __name__ == '__main__': + defualt_tmp_dir = '/tmp' + parser = argparse.ArgumentParser() + parser.add_argument("path", nargs='?', default=defualt_tmp_dir) + args = parser.parse_args() + location_path = args.path[:-1] if args.path[-1] == '/' else args.path + + hostname: str = gethostname() + time_now: str = datetime.now().isoformat(timespec='seconds').replace(":", "-") + + remote = False + tmp_path = '' + tmp_dir_path = '' + if 'ftp://' in args.path or 'scp://' in args.path: + remote = True + tmp_path = defualt_tmp_dir + else: + tmp_path = location_path + archive_pattern = f'_tech-support-archive_' + archive_file_name = f'{hostname}{archive_pattern}{time_now}.tar.gz' + + # Log rotation in tmp directory + if tmp_path == defualt_tmp_dir: + __rotate_logs(tmp_path, f'*{archive_pattern}*') + + # Temporary directory creation + tmp_dir_path = f'{tmp_path}/drops-debug_{time_now}' + tmp_dir: Path = Path(tmp_dir_path) + tmp_dir.mkdir() + + report_file: Path = Path(f'{tmp_dir_path}/show_tech-support_report.txt') + report_file.touch() + try: + + save_stdout(op('show tech-support report'), report_file) + # Generate included archives + __generate_archived_files(tmp_dir_path) + + # Generate main archive + __generate_main_archive_file(f'{tmp_path}/{archive_file_name}', tmp_dir_path) + # Delete temporary directory + rmtree(tmp_dir) + # Upload to remote site if it is scpecified + if remote: + upload(f'{tmp_path}/{archive_file_name}', args.path) + print(f'Debug file is generated and located in {location_path}/{archive_file_name}') + except Exception as err: + print(f'Error during generating a debug file: {err}') + # cleanup + if tmp_dir.exists(): + rmtree(tmp_dir) + finally: + # cleanup + exit() diff --git a/src/op_mode/image_info.py b/src/op_mode/image_info.py new file mode 100755 index 000000000..791001e00 --- /dev/null +++ b/src/op_mode/image_info.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This file is part of VyOS. +# +# VyOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# VyOS 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 +# VyOS. If not, see <https://www.gnu.org/licenses/>. + +import sys +from typing import List, Union + +from tabulate import tabulate + +from vyos import opmode +from vyos.system import disk, grub, image +from vyos.utils.convert import bytes_to_human + + +def _format_show_images_summary(images_summary: image.BootDetails) -> str: + headers: list[str] = ['Name', 'Default boot', 'Running'] + table_data: list[list[str]] = list() + for image_item in images_summary.get('images_available', []): + name: str = image_item + if images_summary.get('image_default') == name: + default: str = 'Yes' + else: + default: str = '' + + if images_summary.get('image_running') == name: + running: str = 'Yes' + else: + running: str = '' + + table_data.append([name, default, running]) + tabulated: str = tabulate(table_data, headers) + + return tabulated + + +def _format_show_images_details( + images_details: list[image.ImageDetails]) -> str: + headers: list[str] = [ + 'Name', 'Version', 'Storage Read-Only', 'Storage Read-Write', + 'Storage Total' + ] + table_data: list[list[Union[str, int]]] = list() + for image_item in images_details: + name: str = image_item.get('name') + version: str = image_item.get('version') + disk_ro: str = bytes_to_human(image_item.get('disk_ro'), + precision=1, int_below_exponent=30) + disk_rw: str = bytes_to_human(image_item.get('disk_rw'), + precision=1, int_below_exponent=30) + disk_total: str = bytes_to_human(image_item.get('disk_total'), + precision=1, int_below_exponent=30) + table_data.append([name, version, disk_ro, disk_rw, disk_total]) + tabulated: str = tabulate(table_data, headers, + colalign=('left', 'left', 'right', 'right', 'right')) + + return tabulated + + +def show_images_summary(raw: bool) -> Union[image.BootDetails, str]: + images_available: list[str] = grub.version_list() + root_dir: str = disk.find_persistence() + boot_vars: dict = grub.vars_read(f'{root_dir}/{image.CFG_VYOS_VARS}') + + images_summary: image.BootDetails = dict() + + images_summary['image_default'] = image.get_default_image() + images_summary['image_running'] = image.get_running_image() + images_summary['images_available'] = images_available + images_summary['console_type'] = boot_vars.get('console_type') + images_summary['console_num'] = boot_vars.get('console_num') + + if raw: + return images_summary + else: + return _format_show_images_summary(images_summary) + + +def show_images_details(raw: bool) -> Union[list[image.ImageDetails], str]: + images_details = image.get_images_details() + + if raw: + return images_details + else: + return _format_show_images_details(images_details) + + +if __name__ == '__main__': + try: + res = opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py new file mode 100755 index 000000000..d677c2cf8 --- /dev/null +++ b/src/op_mode/image_installer.py @@ -0,0 +1,929 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This file is part of VyOS. +# +# VyOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# VyOS 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 +# VyOS. If not, see <https://www.gnu.org/licenses/>. + +from argparse import ArgumentParser, Namespace +from pathlib import Path +from shutil import copy, chown, rmtree, copytree +from glob import glob +from sys import exit +from os import environ +from time import sleep +from typing import Union +from urllib.parse import urlparse +from passlib.hosts import linux_context + +from psutil import disk_partitions + +from vyos.configtree import ConfigTree +from vyos.configquery import ConfigTreeQuery +from vyos.remote import download +from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER +from vyos.template import render +from vyos.utils.io import ask_input, ask_yes_no, select_entry +from vyos.utils.file import chmod_2775 +from vyos.utils.process import cmd, run +from vyos.version import get_remote_version + +# define text messages +MSG_ERR_NOT_LIVE: str = 'The system is already installed. Please use "add system image" instead.' +MSG_ERR_LIVE: str = 'The system is in live-boot mode. Please use "install image" instead.' +MSG_ERR_NO_DISK: str = 'No suitable disk was found. There must be at least one disk of 2GB or greater size.' +MSG_ERR_IMPROPER_IMAGE: str = 'Missing sha256sum.txt.\nEither this image is corrupted, or of era 1.2.x (md5sum) and would downgrade image tools;\ndisallowed in either case.' +MSG_INFO_INSTALL_WELCOME: str = 'Welcome to VyOS installation!\nThis command will install VyOS to your permanent storage.' +MSG_INFO_INSTALL_EXIT: str = 'Exiting from VyOS installation' +MSG_INFO_INSTALL_SUCCESS: str = 'The image installed successfully; please reboot now.' +MSG_INFO_INSTALL_DISKS_LIST: str = 'The following disks were found:' +MSG_INFO_INSTALL_DISK_SELECT: str = 'Which one should be used for installation?' +MSG_INFO_INSTALL_RAID_CONFIGURE: str = 'Would you like to configure RAID-1 mirroring?' +MSG_INFO_INSTALL_RAID_FOUND_DISKS: str = 'Would you like to configure RAID-1 mirroring on them?' +MSG_INFO_INSTALL_RAID_CHOOSE_DISKS: str = 'Would you like to choose two disks for RAID-1 mirroring?' +MSG_INFO_INSTALL_DISK_CONFIRM: str = 'Installation will delete all data on the drive. Continue?' +MSG_INFO_INSTALL_RAID_CONFIRM: str = 'Installation will delete all data on both drives. Continue?' +MSG_INFO_INSTALL_PARTITONING: str = 'Creating partition table...' +MSG_INPUT_CONFIG_FOUND: str = 'An active configuration was found. Would you like to copy it to the new image?' +MSG_INPUT_IMAGE_NAME: str = 'What would you like to name this image?' +MSG_INPUT_IMAGE_DEFAULT: str = 'Would you like to set the new image as the default one for boot?' +MSG_INPUT_PASSWORD: str = 'Please enter a password for the "vyos" user' +MSG_INPUT_ROOT_SIZE_ALL: str = 'Would you like to use all the free space on the drive?' +MSG_INPUT_ROOT_SIZE_SET: str = 'Please specify the size (in GB) of the root partition (min is 1.5 GB)?' +MSG_INPUT_CONSOLE_TYPE: str = 'What console should be used by default? (K: KVM, S: Serial, U: USB-Serial)?' +MSG_INPUT_COPY_DATA: str = 'Would you like to copy data to the new image?' +MSG_INPUT_CHOOSE_COPY_DATA: str = 'From which image would you like to save config information?' +MSG_WARN_ISO_SIGN_INVALID: str = 'Signature is not valid. Do you want to continue with installation?' +MSG_WARN_ISO_SIGN_UNAVAL: str = 'Signature is not available. Do you want to continue with installation?' +MSG_WARN_ROOT_SIZE_TOOBIG: str = 'The size is too big. Try again.' +MSG_WARN_ROOT_SIZE_TOOSMALL: str = 'The size is too small. Try again' +MSG_WARN_IMAGE_NAME_WRONG: str = 'The suggested name is unsupported!\n'\ +'It must be between 1 and 64 characters long and contains only the next characters: .+-_ a-z A-Z 0-9' +CONST_MIN_DISK_SIZE: int = 2147483648 # 2 GB +CONST_MIN_ROOT_SIZE: int = 1610612736 # 1.5 GB +# a reserved space: 2MB for header, 1 MB for BIOS partition, 256 MB for EFI +CONST_RESERVED_SPACE: int = (2 + 1 + 256) * 1024**2 + +# define directories and paths +DIR_INSTALLATION: str = '/mnt/installation' +DIR_ROOTFS_SRC: str = f'{DIR_INSTALLATION}/root_src' +DIR_ROOTFS_DST: str = f'{DIR_INSTALLATION}/root_dst' +DIR_ISO_MOUNT: str = f'{DIR_INSTALLATION}/iso_src' +DIR_DST_ROOT: str = f'{DIR_INSTALLATION}/disk_dst' +DIR_KERNEL_SRC: str = '/boot/' +FILE_ROOTFS_SRC: str = '/usr/lib/live/mount/medium/live/filesystem.squashfs' +ISO_DOWNLOAD_PATH: str = '/tmp/vyos_installation.iso' + +external_download_script = '/usr/libexec/vyos/simple-download.py' + +# default boot variables +DEFAULT_BOOT_VARS: dict[str, str] = { + 'timeout': '5', + 'console_type': 'tty', + 'console_num': '0', + 'console_speed': '115200', + 'bootmode': 'normal' +} + + +def bytes_to_gb(size: int) -> float: + """Convert Bytes to GBytes, rounded to 1 decimal number + + Args: + size (int): input size in bytes + + Returns: + float: size in GB + """ + return round(size / 1024**3, 1) + + +def gb_to_bytes(size: float) -> int: + """Convert GBytes to Bytes + + Args: + size (float): input size in GBytes + + Returns: + int: size in bytes + """ + return int(size * 1024**3) + + +def find_disks() -> dict[str, int]: + """Find a target disk for installation + + Returns: + dict[str, int]: a list of available disks by name and size + """ + # check for available disks + print('Probing disks') + disks_available: dict[str, int] = disk.disks_size() + for disk_name, disk_size in disks_available.copy().items(): + if disk_size < CONST_MIN_DISK_SIZE: + del disks_available[disk_name] + if not disks_available: + print(MSG_ERR_NO_DISK) + exit(MSG_INFO_INSTALL_EXIT) + + num_disks: int = len(disks_available) + print(f'{num_disks} disk(s) found') + + return disks_available + + +def ask_root_size(available_space: int) -> int: + """Define a size of root partition + + Args: + available_space (int): available space in bytes for a root partition + + Returns: + int: defined size + """ + if ask_yes_no(MSG_INPUT_ROOT_SIZE_ALL, default=True): + return available_space + + while True: + root_size_gb: str = ask_input(MSG_INPUT_ROOT_SIZE_SET) + root_size_kbytes: int = (gb_to_bytes(float(root_size_gb))) // 1024 + + if root_size_kbytes > available_space: + print(MSG_WARN_ROOT_SIZE_TOOBIG) + continue + if root_size_kbytes < CONST_MIN_ROOT_SIZE / 1024: + print(MSG_WARN_ROOT_SIZE_TOOSMALL) + continue + + return root_size_kbytes + +def create_partitions(target_disk: str, target_size: int, + prompt: bool = True) -> None: + """Create partitions on a target disk + + Args: + target_disk (str): a target disk + target_size (int): size of disk in bytes + """ + # define target rootfs size in KB (smallest unit acceptable by sgdisk) + available_size: int = (target_size - CONST_RESERVED_SPACE) // 1024 + if prompt: + rootfs_size: int = ask_root_size(available_size) + else: + rootfs_size: int = available_size + + print(MSG_INFO_INSTALL_PARTITONING) + raid.clear() + disk.disk_cleanup(target_disk) + disk_details: disk.DiskDetails = disk.parttable_create(target_disk, + rootfs_size) + + return disk_details + + +def search_format_selection(image: tuple[str, str]) -> str: + """Format a string for selection of image + + Args: + image (tuple[str, str]): a tuple of image name and drive + + Returns: + str: formatted string + """ + return f'{image[0]} on {image[1]}' + + +def search_previous_installation(disks: list[str]) -> None: + """Search disks for previous installation config and SSH keys + + Args: + disks (list[str]): a list of available disks + """ + mnt_config = '/mnt/config' + mnt_ssh = '/mnt/ssh' + mnt_tmp = '/mnt/tmp' + rmtree(Path(mnt_config), ignore_errors=True) + rmtree(Path(mnt_ssh), ignore_errors=True) + Path(mnt_tmp).mkdir(exist_ok=True) + + print('Searching for data from previous installations') + image_data = [] + for disk_name in disks: + for partition in disk.partition_list(disk_name): + if disk.partition_mount(partition, mnt_tmp): + if Path(mnt_tmp + '/boot').exists(): + for path in Path(mnt_tmp + '/boot').iterdir(): + if path.joinpath('rw/config/.vyatta_config').exists(): + image_data.append((path.name, partition)) + + disk.partition_umount(partition) + + if len(image_data) == 1: + image_name, image_drive = image_data[0] + print('Found data from previous installation:') + print(f'\t{image_name} on {image_drive}') + if not ask_yes_no(MSG_INPUT_COPY_DATA, default=True): + return + + elif len(image_data) > 1: + print('Found data from previous installations') + if not ask_yes_no(MSG_INPUT_COPY_DATA, default=True): + return + + image_name, image_drive = select_entry(image_data, + 'Available versions:', + MSG_INPUT_CHOOSE_COPY_DATA, + search_format_selection) + else: + print('No previous installation found') + return + + disk.partition_mount(image_drive, mnt_tmp) + + copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config) + Path(mnt_ssh).mkdir() + host_keys: list[str] = glob(f'{mnt_tmp}/boot/{image_name}/rw/etc/ssh/ssh_host*') + for host_key in host_keys: + copy(host_key, mnt_ssh) + + disk.partition_umount(image_drive) + +def copy_preserve_owner(src: str, dst: str, *, follow_symlinks=True): + if not Path(src).is_file(): + return + if Path(dst).is_dir(): + dst = Path(dst).joinpath(Path(src).name) + st = Path(src).stat() + copy(src, dst, follow_symlinks=follow_symlinks) + chown(dst, user=st.st_uid) + + +def copy_previous_installation_data(target_dir: str) -> None: + if Path('/mnt/config').exists(): + copytree('/mnt/config', f'{target_dir}/opt/vyatta/etc/config', + dirs_exist_ok=True) + if Path('/mnt/ssh').exists(): + copytree('/mnt/ssh', f'{target_dir}/etc/ssh', + dirs_exist_ok=True) + + +def ask_single_disk(disks_available: dict[str, int]) -> str: + """Ask user to select a disk for installation + + Args: + disks_available (dict[str, int]): a list of available disks + """ + print(MSG_INFO_INSTALL_DISKS_LIST) + default_disk: str = list(disks_available)[0] + for disk_name, disk_size in disks_available.items(): + disk_size_human: str = bytes_to_gb(disk_size) + print(f'Drive: {disk_name} ({disk_size_human} GB)') + disk_selected: str = ask_input(MSG_INFO_INSTALL_DISK_SELECT, + default=default_disk, + valid_responses=list(disks_available)) + + # create partitions + if not ask_yes_no(MSG_INFO_INSTALL_DISK_CONFIRM): + print(MSG_INFO_INSTALL_EXIT) + exit() + + search_previous_installation(list(disks_available)) + + disk_details: disk.DiskDetails = create_partitions(disk_selected, + disks_available[disk_selected]) + + disk.filesystem_create(disk_details.partition['efi'], 'efi') + disk.filesystem_create(disk_details.partition['root'], 'ext4') + + return disk_details + + +def check_raid_install(disks_available: dict[str, int]) -> Union[str, None]: + """Ask user to select disks for RAID installation + + Args: + disks_available (dict[str, int]): a list of available disks + """ + if len(disks_available) < 2: + return None + + if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIGURE, default=True): + return None + + def format_selection(disk_name: str) -> str: + return f'{disk_name}\t({bytes_to_gb(disks_available[disk_name])} GB)' + + disk0, disk1 = list(disks_available)[0], list(disks_available)[1] + disks_selected: dict[str, int] = { disk0: disks_available[disk0], + disk1: disks_available[disk1] } + + target_size: int = min(disks_selected[disk0], disks_selected[disk1]) + + print(MSG_INFO_INSTALL_DISKS_LIST) + for disk_name, disk_size in disks_selected.items(): + disk_size_human: str = bytes_to_gb(disk_size) + print(f'\t{disk_name} ({disk_size_human} GB)') + if not ask_yes_no(MSG_INFO_INSTALL_RAID_FOUND_DISKS, default=True): + if not ask_yes_no(MSG_INFO_INSTALL_RAID_CHOOSE_DISKS, default=True): + return None + else: + disks_selected = {} + disk0 = select_entry(list(disks_available), 'Disks available:', + 'Select first disk:', format_selection) + + disks_selected[disk0] = disks_available[disk0] + del disks_available[disk0] + disk1 = select_entry(list(disks_available), 'Remaining disks:', + 'Select second disk:', format_selection) + disks_selected[disk1] = disks_available[disk1] + + target_size: int = min(disks_selected[disk0], + disks_selected[disk1]) + + # create partitions + if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIRM): + print(MSG_INFO_INSTALL_EXIT) + exit() + + search_previous_installation(list(disks_available)) + + disks: list[disk.DiskDetails] = [] + for disk_selected in list(disks_selected): + print(f'Creating partitions on {disk_selected}') + disk_details = create_partitions(disk_selected, target_size, + prompt=False) + disk.filesystem_create(disk_details.partition['efi'], 'efi') + + disks.append(disk_details) + + print('Creating RAID array') + members = [disk.partition['root'] for disk in disks] + raid_details: raid.RaidDetails = raid.raid_create(members) + # raid init stuff + print('Updating initramfs') + raid.update_initramfs() + # end init + print('Creating filesystem on RAID array') + disk.filesystem_create(raid_details.name, 'ext4') + + return raid_details + + +def prepare_tmp_disr() -> None: + """Create temporary directories for installation + """ + print('Creating temporary directories') + for dir in [DIR_ROOTFS_SRC, DIR_ROOTFS_DST, DIR_DST_ROOT]: + dirpath = Path(dir) + dirpath.mkdir(mode=0o755, parents=True) + + +def setup_grub(root_dir: str) -> None: + """Install GRUB configurations + + Args: + root_dir (str): a path to the root of target filesystem + """ + print('Installing GRUB configuration files') + grub_cfg_main = f'{root_dir}/{grub.GRUB_DIR_MAIN}/grub.cfg' + grub_cfg_vars = f'{root_dir}/{grub.CFG_VYOS_VARS}' + grub_cfg_modules = f'{root_dir}/{grub.CFG_VYOS_MODULES}' + grub_cfg_menu = f'{root_dir}/{grub.CFG_VYOS_MENU}' + grub_cfg_options = f'{root_dir}/{grub.CFG_VYOS_OPTIONS}' + + # create new files + render(grub_cfg_main, grub.TMPL_GRUB_MAIN, {}) + grub.common_write(root_dir) + grub.vars_write(grub_cfg_vars, DEFAULT_BOOT_VARS) + grub.modules_write(grub_cfg_modules, []) + grub.write_cfg_ver(1, root_dir) + render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {}) + render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {}) + + +def configure_authentication(config_file: str, password: str) -> None: + """Write encrypted password to config file + + Args: + config_file (str): path of target config file + password (str): plaintext password + + N.B. this can not be deferred by simply setting the plaintext password + and relying on the config mode script to process at boot, as the config + will not automatically be saved in that case, thus leaving the + plaintext exposed + """ + encrypted_password = linux_context.hash(password) + + with open(config_file) as f: + config_string = f.read() + + config = ConfigTree(config_string) + config.set([ + 'system', 'login', 'user', 'vyos', 'authentication', + 'encrypted-password' + ], + value=encrypted_password, + replace=True) + config.set_tag(['system', 'login', 'user']) + + with open(config_file, 'w') as f: + f.write(config.to_string()) + +def validate_signature(file_path: str, sign_type: str) -> None: + """Validate a file by signature and delete a signature file + + Args: + file_path (str): a path to file + sign_type (str): a signature type + """ + print('Validating signature') + signature_valid: bool = False + # validate with minisig + if sign_type == 'minisig': + pub_key_list = glob('/usr/share/vyos/keys/*.minisign.pub') + for pubkey in pub_key_list: + if run(f'minisign -V -q -p {pubkey} -m {file_path} -x {file_path}.minisig' + ) == 0: + signature_valid = True + break + Path(f'{file_path}.minisig').unlink() + # validate with GPG + if sign_type == 'asc': + if run(f'gpg --verify ${file_path}.asc ${file_path}') == 0: + signature_valid = True + Path(f'{file_path}.asc').unlink() + + # warn or pass + if not signature_valid: + if not ask_yes_no(MSG_WARN_ISO_SIGN_INVALID, default=False): + exit(MSG_INFO_INSTALL_EXIT) + else: + print('Signature is valid') + +def download_file(local_file: str, remote_path: str, vrf: str, + username: str, password: str, + progressbar: bool = False, check_space: bool = False): + environ['REMOTE_USERNAME'] = username + environ['REMOTE_PASSWORD'] = password + if vrf is None: + download(local_file, remote_path, progressbar=progressbar, + check_space=check_space, raise_error=True) + else: + vrf_cmd = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password} \ + ip vrf exec {vrf} {external_download_script} \ + --local-file {local_file} --remote-path {remote_path}' + cmd(vrf_cmd) + +def image_fetch(image_path: str, vrf: str = None, + username: str = '', password: str = '', + no_prompt: bool = False) -> Path: + """Fetch an ISO image + + Args: + image_path (str): a path, remote or local + + Returns: + Path: a path to a local file + """ + # Latest version gets url from configured "system update-check url" + if image_path == 'latest': + config = ConfigTreeQuery() + if config.exists('system update-check url'): + configured_url_version = config.value('system update-check url') + remote_url_list = get_remote_version(configured_url_version) + image_path = remote_url_list[0].get('url') + + try: + # check a type of path + if urlparse(image_path).scheme: + # download an image + download_file(ISO_DOWNLOAD_PATH, image_path, vrf, + username, password, + progressbar=True, check_space=True) + + # download a signature + sign_file = (False, '') + for sign_type in ['minisig', 'asc']: + try: + download_file(f'{ISO_DOWNLOAD_PATH}.{sign_type}', + f'{image_path}.{sign_type}', vrf, + username, password) + sign_file = (True, sign_type) + break + except Exception: + print(f'{sign_type} signature is not available') + # validate a signature if it is available + if sign_file[0]: + validate_signature(ISO_DOWNLOAD_PATH, sign_file[1]) + else: + if (not no_prompt and + not ask_yes_no(MSG_WARN_ISO_SIGN_UNAVAL, default=False)): + cleanup() + exit(MSG_INFO_INSTALL_EXIT) + + return Path(ISO_DOWNLOAD_PATH) + else: + local_path: Path = Path(image_path) + if local_path.is_file(): + return local_path + else: + raise FileNotFoundError + except Exception as e: + print(f'The image cannot be fetched from: {image_path} {e}') + exit(1) + + +def migrate_config() -> bool: + """Check for active config and ask user for migration + + Returns: + bool: user's decision + """ + active_config_path: Path = Path('/opt/vyatta/etc/config/config.boot') + if active_config_path.exists(): + if ask_yes_no(MSG_INPUT_CONFIG_FOUND, default=True): + return True + return False + + +def copy_ssh_host_keys() -> bool: + """Ask user to copy SSH host keys + + Returns: + bool: user's decision + """ + if ask_yes_no('Would you like to copy SSH host keys?', default=True): + return True + return False + + +def cleanup(mounts: list[str] = [], remove_items: list[str] = []) -> None: + """Clean up after installation + + Args: + mounts (list[str], optional): List of mounts to unmount. + Defaults to []. + remove_items (list[str], optional): List of files or directories + to remove. Defaults to []. + """ + print('Cleaning up') + # clean up installation directory by default + mounts_all = disk_partitions(all=True) + for mounted_device in mounts_all: + if mounted_device.mountpoint.startswith(DIR_INSTALLATION) and not ( + mounted_device.device in mounts or + mounted_device.mountpoint in mounts): + mounts.append(mounted_device.mountpoint) + # add installation dir to cleanup list + if DIR_INSTALLATION not in remove_items: + remove_items.append(DIR_INSTALLATION) + # also delete an ISO file + if Path(ISO_DOWNLOAD_PATH).exists( + ) and ISO_DOWNLOAD_PATH not in remove_items: + remove_items.append(ISO_DOWNLOAD_PATH) + + if mounts: + print('Unmounting target filesystems') + for mountpoint in mounts: + disk.partition_umount(mountpoint) + for mountpoint in mounts: + disk.wait_for_umount(mountpoint) + if remove_items: + print('Removing temporary files') + for remove_item in remove_items: + if Path(remove_item).exists(): + if Path(remove_item).is_file(): + Path(remove_item).unlink() + if Path(remove_item).is_dir(): + rmtree(remove_item, ignore_errors=True) + + +def cleanup_raid(details: raid.RaidDetails) -> None: + efiparts = [] + for raid_disk in details.disks: + efiparts.append(raid_disk.partition['efi']) + cleanup([details.name, *efiparts], + ['/mnt/installation']) + + +def is_raid_install(install_object: Union[disk.DiskDetails, raid.RaidDetails]) -> bool: + """Check if installation target is a RAID array + + Args: + install_object (Union[disk.DiskDetails, raid.RaidDetails]): a target disk + + Returns: + bool: True if it is a RAID array + """ + if isinstance(install_object, raid.RaidDetails): + return True + return False + + +def install_image() -> None: + """Install an image to a disk + """ + if not image.is_live_boot(): + exit(MSG_ERR_NOT_LIVE) + + print(MSG_INFO_INSTALL_WELCOME) + if not ask_yes_no('Would you like to continue?'): + print(MSG_INFO_INSTALL_EXIT) + exit() + + # configure image name + running_image_name: str = image.get_running_image() + while True: + image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, + running_image_name) + if image.validate_name(image_name): + break + print(MSG_WARN_IMAGE_NAME_WRONG) + + # ask for password + user_password: str = ask_input(MSG_INPUT_PASSWORD, default='vyos', + no_echo=True) + + # ask for default console + console_type: str = ask_input(MSG_INPUT_CONSOLE_TYPE, + default='K', + valid_responses=['K', 'S', 'U']) + console_dict: dict[str, str] = {'K': 'tty', 'S': 'ttyS', 'U': 'ttyUSB'} + + disks: dict[str, int] = find_disks() + + install_target: Union[disk.DiskDetails, raid.RaidDetails, None] = None + try: + install_target = check_raid_install(disks) + if install_target is None: + install_target = ask_single_disk(disks) + + # create directories for installation media + prepare_tmp_disr() + + # mount target filesystem and create required dirs inside + print('Mounting new partitions') + if is_raid_install(install_target): + disk.partition_mount(install_target.name, DIR_DST_ROOT) + Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True) + else: + disk.partition_mount(install_target.partition['root'], DIR_DST_ROOT) + Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True) + disk.partition_mount(install_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi') + + # a config dir. It is the deepest one, so the comand will + # create all the rest in a single step + print('Creating a configuration file') + target_config_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw/opt/vyatta/etc/config/' + Path(target_config_dir).mkdir(parents=True) + chown(target_config_dir, group='vyattacfg') + chmod_2775(target_config_dir) + # copy config + copy('/opt/vyatta/etc/config/config.boot', target_config_dir) + configure_authentication(f'{target_config_dir}/config.boot', + user_password) + Path(f'{target_config_dir}/.vyatta_config').touch() + + # create a persistence.conf + Path(f'{DIR_DST_ROOT}/persistence.conf').write_text('/ union\n') + + # copy system image and kernel files + print('Copying system image files') + for file in Path(DIR_KERNEL_SRC).iterdir(): + if file.is_file(): + copy(file, f'{DIR_DST_ROOT}/boot/{image_name}/') + copy(FILE_ROOTFS_SRC, + f'{DIR_DST_ROOT}/boot/{image_name}/{image_name}.squashfs') + + # copy saved config data and SSH keys + # owner restored on copy of config data by chmod_2775, above + copy_previous_installation_data(f'{DIR_DST_ROOT}/boot/{image_name}/rw') + + if is_raid_install(install_target): + write_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw' + raid.update_default(write_dir) + + setup_grub(DIR_DST_ROOT) + # add information about version + grub.create_structure() + grub.version_add(image_name, DIR_DST_ROOT) + grub.set_default(image_name, DIR_DST_ROOT) + grub.set_console_type(console_dict[console_type], DIR_DST_ROOT) + + if is_raid_install(install_target): + # add RAID specific modules + grub.modules_write(f'{DIR_DST_ROOT}/{grub.CFG_VYOS_MODULES}', + ['part_msdos', 'part_gpt', 'diskfilter', + 'ext2','mdraid1x']) + # install GRUB + if is_raid_install(install_target): + print('Installing GRUB to the drives') + l = install_target.disks + for disk_target in l: + disk.partition_mount(disk_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi') + grub.install(disk_target.name, f'{DIR_DST_ROOT}/boot/', + f'{DIR_DST_ROOT}/boot/efi', + id=f'VyOS (RAID disk {l.index(disk_target) + 1})') + disk.partition_umount(disk_target.partition['efi']) + else: + print('Installing GRUB to the drive') + grub.install(install_target.name, f'{DIR_DST_ROOT}/boot/', + f'{DIR_DST_ROOT}/boot/efi') + + # umount filesystems and remove temporary files + if is_raid_install(install_target): + cleanup([install_target.name], + ['/mnt/installation']) + else: + cleanup([install_target.partition['efi'], + install_target.partition['root']], + ['/mnt/installation']) + + # we are done + print(MSG_INFO_INSTALL_SUCCESS) + exit() + + except Exception as err: + print(f'Unable to install VyOS: {err}') + # unmount filesystems and clenup + try: + if install_target is not None: + if is_raid_install(install_target): + cleanup_raid(install_target) + else: + cleanup([install_target.partition['efi'], + install_target.partition['root']], + ['/mnt/installation']) + except Exception as err: + print(f'Cleanup failed: {err}') + + exit(1) + + +@compat.grub_cfg_update +def add_image(image_path: str, vrf: str = None, username: str = '', + password: str = '', no_prompt: bool = False) -> None: + """Add a new image + + Args: + image_path (str): a path to an ISO image + """ + if image.is_live_boot(): + exit(MSG_ERR_LIVE) + + # fetch an image + iso_path: Path = image_fetch(image_path, vrf, username, password, no_prompt) + try: + # mount an ISO + Path(DIR_ISO_MOUNT).mkdir(mode=0o755, parents=True) + disk.partition_mount(iso_path, DIR_ISO_MOUNT, 'iso9660') + + # check sums + print('Validating image checksums') + if not Path(DIR_ISO_MOUNT).joinpath('sha256sum.txt').exists(): + cleanup() + exit(MSG_ERR_IMPROPER_IMAGE) + if run(f'cd {DIR_ISO_MOUNT} && sha256sum --status -c sha256sum.txt'): + cleanup() + exit('Image checksum verification failed.') + + # mount rootfs (to get a system version) + Path(DIR_ROOTFS_SRC).mkdir(mode=0o755, parents=True) + disk.partition_mount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs', + DIR_ROOTFS_SRC, 'squashfs') + + cfg_ver: str = image.get_image_tools_version(DIR_ROOTFS_SRC) + version_name: str = image.get_image_version(DIR_ROOTFS_SRC) + + disk.partition_umount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs') + + if cfg_ver < SYSTEM_CFG_VER: + raise compat.DowngradingImageTools( + f'Adding image would downgrade image tools to v.{cfg_ver}; disallowed') + + if not no_prompt: + while True: + image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, version_name) + if image.validate_name(image_name): + break + print(MSG_WARN_IMAGE_NAME_WRONG) + set_as_default: bool = ask_yes_no(MSG_INPUT_IMAGE_DEFAULT, default=True) + else: + image_name: str = version_name + set_as_default: bool = True + + # find target directory + root_dir: str = disk.find_persistence() + + # a config dir. It is the deepest one, so the comand will + # create all the rest in a single step + target_config_dir: str = f'{root_dir}/boot/{image_name}/rw/opt/vyatta/etc/config/' + # copy config + if no_prompt or migrate_config(): + print('Copying configuration directory') + # copytree preserves perms but not ownership: + Path(target_config_dir).mkdir(parents=True) + chown(target_config_dir, group='vyattacfg') + chmod_2775(target_config_dir) + copytree('/opt/vyatta/etc/config/', target_config_dir, + copy_function=copy_preserve_owner, dirs_exist_ok=True) + else: + Path(target_config_dir).mkdir(parents=True) + chown(target_config_dir, group='vyattacfg') + chmod_2775(target_config_dir) + Path(f'{target_config_dir}/.vyatta_config').touch() + + target_ssh_dir: str = f'{root_dir}/boot/{image_name}/rw/etc/ssh/' + if no_prompt or copy_ssh_host_keys(): + print('Copying SSH host keys') + Path(target_ssh_dir).mkdir(parents=True) + host_keys: list[str] = glob('/etc/ssh/ssh_host*') + for host_key in host_keys: + copy(host_key, target_ssh_dir) + + # copy system image and kernel files + print('Copying system image files') + for file in Path(f'{DIR_ISO_MOUNT}/live').iterdir(): + if file.is_file() and (file.match('initrd*') or + file.match('vmlinuz*')): + copy(file, f'{root_dir}/boot/{image_name}/') + copy(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs', + f'{root_dir}/boot/{image_name}/{image_name}.squashfs') + + # unmount an ISO and cleanup + cleanup([str(iso_path)]) + + # add information about version + grub.version_add(image_name, root_dir) + if set_as_default: + grub.set_default(image_name, root_dir) + + except Exception as err: + # unmount an ISO and cleanup + cleanup([str(iso_path)]) + exit(f'Error: {err}') + + +def parse_arguments() -> Namespace: + """Parse arguments + + Returns: + Namespace: a namespace with parsed arguments + """ + parser: ArgumentParser = ArgumentParser( + description='Install new system images') + parser.add_argument('--action', + choices=['install', 'add'], + required=True, + help='action to perform with an image') + parser.add_argument('--vrf', + help='vrf name for image download') + parser.add_argument('--no-prompt', action='store_true', + help='perform action non-interactively') + parser.add_argument('--username', default='', + help='username for image download') + parser.add_argument('--password', default='', + help='password for image download') + parser.add_argument('--image-path', + help='a path (HTTP or local file) to an image that needs to be installed' + ) + # parser.add_argument('--image_new_name', help='a new name for image') + args: Namespace = parser.parse_args() + # Validate arguments + if args.action == 'add' and not args.image_path: + exit('A path to image is required for add action') + + return args + + +if __name__ == '__main__': + try: + args: Namespace = parse_arguments() + if args.action == 'install': + install_image() + if args.action == 'add': + add_image(args.image_path, args.vrf, + args.username, args.password, args.no_prompt) + + exit() + + except KeyboardInterrupt: + print('Stopped by Ctrl+C') + cleanup() + exit() + + except Exception as err: + exit(f'{err}') diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py new file mode 100755 index 000000000..e64a85b95 --- /dev/null +++ b/src/op_mode/image_manager.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This file is part of VyOS. +# +# VyOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# VyOS 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 +# VyOS. If not, see <https://www.gnu.org/licenses/>. + +from argparse import ArgumentParser, Namespace +from pathlib import Path +from shutil import rmtree +from sys import exit +from typing import Optional + +from vyos.system import disk, grub, image, compat +from vyos.utils.io import ask_yes_no, select_entry + +SET_IMAGE_LIST_MSG: str = 'The following images are available:' +SET_IMAGE_PROMPT_MSG: str = 'Select an image to set as default:' +DELETE_IMAGE_LIST_MSG: str = 'The following images are installed:' +DELETE_IMAGE_PROMPT_MSG: str = 'Select an image to delete:' +MSG_DELETE_IMAGE_RUNNING: str = 'Currently running image cannot be deleted; reboot into another image first' +MSG_DELETE_IMAGE_DEFAULT: str = 'Default image cannot be deleted; set another image as default first' + +def annotated_list(images_list: list[str]) -> list[str]: + """Annotate list of images with additional info + + Args: + images_list (list[str]): a list of image names + + Returns: + list[str]: a list of image names with additional info + """ + index_running: int = None + index_default: int = None + try: + index_running = images_list.index(image.get_running_image()) + index_default = images_list.index(image.get_default_image()) + except ValueError: + pass + if index_running is not None: + images_list[index_running] += ' (running)' + if index_default is not None: + images_list[index_default] += ' (default boot)' + return images_list + +@compat.grub_cfg_update +def delete_image(image_name: Optional[str] = None, + no_prompt: bool = False) -> None: + """Remove installed image files and boot entry + + Args: + image_name (str): a name of image to delete + """ + available_images: list[str] = annotated_list(grub.version_list()) + if image_name is None: + if no_prompt: + exit('An image name is required for delete action') + else: + image_name = select_entry(available_images, + DELETE_IMAGE_LIST_MSG, + DELETE_IMAGE_PROMPT_MSG) + if image_name == image.get_running_image(): + exit(MSG_DELETE_IMAGE_RUNNING) + if image_name == image.get_default_image(): + exit(MSG_DELETE_IMAGE_DEFAULT) + if image_name not in available_images: + exit(f'The image "{image_name}" cannot be found') + persistence_storage: str = disk.find_persistence() + if not persistence_storage: + exit('Persistence storage cannot be found') + + if (not no_prompt and + not ask_yes_no(f'Do you really want to delete the image {image_name}?', + default=False)): + exit() + + # remove files and menu entry + version_path: Path = Path(f'{persistence_storage}/boot/{image_name}') + try: + rmtree(version_path) + grub.version_del(image_name, persistence_storage) + print(f'The image "{image_name}" was successfully deleted') + except Exception as err: + exit(f'Unable to remove the image "{image_name}": {err}') + + +@compat.grub_cfg_update +def set_image(image_name: Optional[str] = None, + prompt: bool = True) -> None: + """Set default boot image + + Args: + image_name (str): an image name + """ + available_images: list[str] = annotated_list(grub.version_list()) + if image_name is None: + if not prompt: + exit('An image name is required for set action') + else: + image_name = select_entry(available_images, + SET_IMAGE_LIST_MSG, + SET_IMAGE_PROMPT_MSG) + if image_name == image.get_default_image(): + exit(f'The image "{image_name}" already configured as default') + if image_name not in available_images: + exit(f'The image "{image_name}" cannot be found') + persistence_storage: str = disk.find_persistence() + if not persistence_storage: + exit('Persistence storage cannot be found') + + # set default boot image + try: + grub.set_default(image_name, persistence_storage) + print(f'The image "{image_name}" is now default boot image') + except Exception as err: + exit(f'Unable to set default image "{image_name}": {err}') + + +@compat.grub_cfg_update +def rename_image(name_old: str, name_new: str) -> None: + """Rename installed image + + Args: + name_old (str): old name + name_new (str): new name + """ + if name_old == image.get_running_image(): + exit('Currently running image cannot be renamed') + available_images: list[str] = grub.version_list() + if name_old not in available_images: + exit(f'The image "{name_old}" cannot be found') + if name_new in available_images: + exit(f'The image "{name_new}" already exists') + if not image.validate_name(name_new): + exit(f'The image name "{name_new}" is not allowed') + + persistence_storage: str = disk.find_persistence() + if not persistence_storage: + exit('Persistence storage cannot be found') + + if not ask_yes_no( + f'Do you really want to rename the image {name_old} ' + f'to the {name_new}?', + default=False): + exit() + + try: + # replace default boot item + if name_old == image.get_default_image(): + grub.set_default(name_new, persistence_storage) + + # rename files and dirs + old_path: Path = Path(f'{persistence_storage}/boot/{name_old}') + new_path: Path = Path(f'{persistence_storage}/boot/{name_new}') + old_path.rename(new_path) + + # replace boot item + grub.version_del(name_old, persistence_storage) + grub.version_add(name_new, persistence_storage) + + print(f'The image "{name_old}" was renamed to "{name_new}"') + except Exception as err: + exit(f'Unable to rename image "{name_old}" to "{name_new}": {err}') + + +def list_images() -> None: + """Print list of available images for CLI hints""" + images_list: list[str] = grub.version_list() + for image_name in images_list: + print(image_name) + + +def parse_arguments() -> Namespace: + """Parse arguments + + Returns: + Namespace: a namespace with parsed arguments + """ + parser: ArgumentParser = ArgumentParser(description='Manage system images') + parser.add_argument('--action', + choices=['delete', 'set', 'rename', 'list'], + required=True, + help='action to perform with an image') + parser.add_argument('--no-prompt', action='store_true', + help='perform action non-interactively') + parser.add_argument( + '--image-name', + help= + 'a name of an image to add, delete, install, rename, or set as default') + parser.add_argument('--image-new-name', help='a new name for image') + args: Namespace = parser.parse_args() + # Validate arguments + if args.action == 'rename' and (not args.image_name or + not args.image_new_name): + exit('Both old and new image names are required for rename action') + + return args + + +if __name__ == '__main__': + try: + args: Namespace = parse_arguments() + if args.action == 'delete': + delete_image(args.image_name, args.no_prompt) + if args.action == 'set': + set_image(args.image_name) + if args.action == 'rename': + rename_image(args.image_name, args.image_new_name) + if args.action == 'list': + list_images() + + exit() + + except KeyboardInterrupt: + print('Stopped by Ctrl+C') + exit() + + except Exception as err: + exit(f'{err}') diff --git a/src/op_mode/interfaces.py b/src/op_mode/interfaces.py index 782e178c6..14ffdca9f 100755 --- a/src/op_mode/interfaces.py +++ b/src/op_mode/interfaces.py @@ -235,6 +235,11 @@ def _get_summary_data(ifname: typing.Optional[str], if iftype is None: iftype = '' ret = [] + + def is_interface_has_mac(interface_name): + interface_no_mac = ('tun', 'wg') + return not any(interface_name.startswith(prefix) for prefix in interface_no_mac) + for interface in filtered_interfaces(ifname, iftype, vif, vrrp): res_intf = {} @@ -243,6 +248,9 @@ def _get_summary_data(ifname: typing.Optional[str], res_intf['admin_state'] = interface.get_admin_state() res_intf['addr'] = [_ for _ in interface.get_addr() if not _.startswith('fe80::')] res_intf['description'] = interface.get_alias() + res_intf['mtu'] = interface.get_mtu() + res_intf['mac'] = interface.get_mac() if is_interface_has_mac(interface.ifname) else 'n/a' + res_intf['vrf'] = interface.get_vrf() ret.append(res_intf) @@ -373,6 +381,51 @@ def _format_show_summary(data): return 0 @catch_broken_pipe +def _format_show_summary_extended(data): + headers = ["Interface", "IP Address", "MAC", "VRF", "MTU", "S/L", "Description"] + table_data = [] + + print('Codes: S - State, L - Link, u - Up, D - Down, A - Admin Down') + + for intf in data: + if 'unhandled' in intf: + continue + + ifname = intf['ifname'] + oper_state = 'u' if intf['oper_state'] in ('up', 'unknown') else 'D' + admin_state = 'u' if intf['admin_state'] in ('up', 'unknown') else 'A' + addrs = intf['addr'] or ['-'] + description = '\n'.join(_split_text(intf['description'], 0)) + mac = intf['mac'] if intf['mac'] else 'n/a' + mtu = intf['mtu'] if intf['mtu'] else 'n/a' + vrf = intf['vrf'] if intf['vrf'] else 'default' + + ip_addresses = '\n'.join(ip for ip in addrs) + + # Create a row for the table + row = [ + ifname, + ip_addresses, + mac, + vrf, + mtu, + f"{admin_state}/{oper_state}", + description, + ] + + # Append the row to the table data + table_data.append(row) + + for intf in data: + if 'unhandled' in intf: + string = {'C': 'u/D', 'D': 'A/D'}[intf['state']] + table_data.append([intf['ifname'], '', '', '', '', string, '']) + + print(tabulate(table_data, headers)) + + return 0 + +@catch_broken_pipe def _format_show_counters(data: list): data_entries = [] for entry in data: @@ -408,6 +461,14 @@ def show_summary(raw: bool, intf_name: typing.Optional[str], return data return _format_show_summary(data) +def show_summary_extended(raw: bool, intf_name: typing.Optional[str], + intf_type: typing.Optional[str], + vif: bool, vrrp: bool): + data = _get_summary_data(intf_name, intf_type, vif, vrrp) + if raw: + return data + return _format_show_summary_extended(data) + def show_counters(raw: bool, intf_name: typing.Optional[str], intf_type: typing.Optional[str], vif: bool, vrrp: bool): diff --git a/src/op_mode/interfaces_wireless.py b/src/op_mode/interfaces_wireless.py new file mode 100755 index 000000000..259fd3900 --- /dev/null +++ b/src/op_mode/interfaces_wireless.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import re +import sys +import typing +import vyos.opmode + +from copy import deepcopy +from tabulate import tabulate +from vyos.utils.process import popen +from vyos.configquery import ConfigTreeQuery + +def _verify(func): + """Decorator checks if Wireless LAN config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + if not config.exists(['interfaces', 'wireless']): + unconf_message = 'No Wireless interfaces configured' + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + return func(*args, **kwargs) + return _wrapper + +def _get_raw_info_data(): + output_data = [] + + config = ConfigTreeQuery() + raw = config.get_config_dict(['interfaces', 'wireless'], effective=True, + get_first_key=True, key_mangling=('-', '_')) + for interface, interface_config in raw.items(): + tmp = {'name' : interface} + + if 'type' in interface_config: + tmp.update({'type' : interface_config['type']}) + else: + tmp.update({'type' : '-'}) + + if 'ssid' in interface_config: + tmp.update({'ssid' : interface_config['ssid']}) + else: + tmp.update({'ssid' : '-'}) + + if 'channel' in interface_config: + tmp.update({'channel' : interface_config['channel']}) + else: + tmp.update({'channel' : '-'}) + + output_data.append(tmp) + + return output_data + +def _get_formatted_info_output(raw_data): + output=[] + for ssid in raw_data: + output.append([ssid['name'], ssid['type'], ssid['ssid'], ssid['channel']]) + + headers = ["Interface", "Type", "SSID", "Channel"] + print(tabulate(output, headers, numalign="left")) + +def _get_raw_scan_data(intf_name): + # XXX: This ignores errors + tmp, _ = popen(f'iw dev {intf_name} scan ap-force') + networks = [] + data = { + 'ssid': '', + 'mac': '', + 'channel': '', + 'signal': '' + } + re_mac = re.compile(r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})') + for line in tmp.splitlines(): + if line.startswith('BSS '): + ssid = deepcopy(data) + ssid['mac'] = re.search(re_mac, line).group() + + elif line.lstrip().startswith('SSID: '): + # SSID can be " SSID: WLAN-57 6405", thus strip all leading whitespaces + ssid['ssid'] = line.lstrip().split(':')[-1].lstrip() + + elif line.lstrip().startswith('signal: '): + # Siganl can be " signal: -67.00 dBm", thus strip all leading whitespaces + ssid['signal'] = line.lstrip().split(':')[-1].split()[0] + + elif line.lstrip().startswith('DS Parameter set: channel'): + # Channel can be " DS Parameter set: channel 6" , thus + # strip all leading whitespaces + ssid['channel'] = line.lstrip().split(':')[-1].split()[-1] + networks.append(ssid) + continue + + return networks + +def _format_scan_data(raw_data): + output=[] + for ssid in raw_data: + output.append([ssid['mac'], ssid['ssid'], ssid['channel'], ssid['signal']]) + headers = ["Address", "SSID", "Channel", "Signal (dbm)"] + return tabulate(output, headers, numalign="left") + +def _get_raw_station_data(intf_name): + # XXX: This ignores errors + tmp, _ = popen(f'iw dev {intf_name} station dump') + clients = [] + data = { + 'mac': '', + 'signal': '', + 'rx_bytes': '', + 'rx_packets': '', + 'tx_bytes': '', + 'tx_packets': '' + } + re_mac = re.compile(r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})') + for line in tmp.splitlines(): + if line.startswith('Station'): + client = deepcopy(data) + client['mac'] = re.search(re_mac, line).group() + + elif line.lstrip().startswith('signal avg:'): + client['signal'] = line.lstrip().split(':')[-1].lstrip().split()[0] + + elif line.lstrip().startswith('rx bytes:'): + client['rx_bytes'] = line.lstrip().split(':')[-1].lstrip() + + elif line.lstrip().startswith('rx packets:'): + client['rx_packets'] = line.lstrip().split(':')[-1].lstrip() + + elif line.lstrip().startswith('tx bytes:'): + client['tx_bytes'] = line.lstrip().split(':')[-1].lstrip() + + elif line.lstrip().startswith('tx packets:'): + client['tx_packets'] = line.lstrip().split(':')[-1].lstrip() + clients.append(client) + continue + + return clients + +def _format_station_data(raw_data): + output=[] + for ssid in raw_data: + output.append([ssid['mac'], ssid['signal'], ssid['rx_bytes'], ssid['rx_packets'], ssid['tx_bytes'], ssid['tx_packets']]) + headers = ["Station", "Signal", "RX bytes", "RX packets", "TX bytes", "TX packets"] + return tabulate(output, headers, numalign="left") + +@_verify +def show_info(raw: bool): + info_data = _get_raw_info_data() + if raw: + return info_data + return _get_formatted_info_output(info_data) + +def show_scan(raw: bool, intf_name: str): + data = _get_raw_scan_data(intf_name) + if raw: + return data + return _format_scan_data(data) + +@_verify +def show_stations(raw: bool, intf_name: str): + data = _get_raw_station_data(intf_name) + if raw: + return data + return _format_station_data(data) + +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/op_mode/lldp.py b/src/op_mode/lldp.py index c287b8fa6..58cfce443 100755 --- a/src/op_mode/lldp.py +++ b/src/op_mode/lldp.py @@ -114,7 +114,10 @@ def _get_formatted_output(raw_data): # Remote software platform platform = jmespath.search('chassis.[*][0][0].descr', values) - tmp.append(platform[:37]) + if platform: + tmp.append(platform[:37]) + else: + tmp.append('') # Remote interface interface = jmespath.search('port.descr', values) diff --git a/src/op_mode/mtr.py b/src/op_mode/mtr.py new file mode 100644 index 000000000..de139f2fa --- /dev/null +++ b/src/op_mode/mtr.py @@ -0,0 +1,306 @@ +#! /usr/bin/env python3 + +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import socket +import ipaddress + +from vyos.utils.network import interface_list +from vyos.utils.network import vrf_list +from vyos.utils.process import call + +options = { + 'report': { + 'mtr': '{command} --report', + 'type': 'noarg', + 'help': 'This option puts mtr into report mode. When in this mode, mtr will run for the number of cycles specified by the -c option, and then print statistics and exit.' + }, + 'report-wide': { + 'mtr': '{command} --report-wide', + 'type': 'noarg', + 'help': 'This option puts mtr into wide report mode. When in this mode, mtr will not cut hostnames in the report.' + }, + 'raw': { + 'mtr': '{command} --raw', + 'type': 'noarg', + 'help': 'Use the raw output format. This format is better suited for archival of the measurement results.' + }, + 'json': { + 'mtr': '{command} --json', + 'type': 'noarg', + 'help': 'Use this option to tell mtr to use the JSON output format.' + }, + 'split': { + 'mtr': '{command} --split', + 'type': 'noarg', + 'help': 'Use this option to set mtr to spit out a format that is suitable for a split-user interface.' + }, + 'no-dns': { + 'mtr': '{command} --no-dns', + 'type': 'noarg', + 'help': 'Use this option to force mtr to display numeric IP numbers and not try to resolve the host names.' + }, + 'show-ips': { + 'mtr': '{command} --show-ips {value}', + 'type': '<num>', + 'help': 'Use this option to tell mtr to display both the host names and numeric IP numbers.' + }, + 'ipinfo': { + 'mtr': '{command} --ipinfo {value}', + 'type': '<num>', + 'help': 'Displays information about each IP hop.' + }, + 'aslookup': { + 'mtr': '{command} --aslookup', + 'type': 'noarg', + 'help': 'Displays the Autonomous System (AS) number alongside each hop. Equivalent to --ipinfo 0.' + }, + 'interval': { + 'mtr': '{command} --interval {value}', + 'type': '<num>', + 'help': 'Use this option to specify the positive number of seconds between ICMP ECHO requests. The default value for this parameter is one second. The root user may choose values between zero and one.' + }, + 'report-cycles': { + 'mtr': '{command} --report-cycles {value}', + 'type': '<num>', + 'help': 'Use this option to set the number of pings sent to determine both the machines on the network and the reliability of those machines. Each cycle lasts one second.' + }, + 'psize': { + 'mtr': '{command} --psize {value}', + 'type': '<num>', + 'help': 'This option sets the packet size used for probing. It is in bytes, inclusive IP and ICMP headers. If set to a negative number, every iteration will use a different, random packet size up to that number.' + }, + 'bitpattern': { + 'mtr': '{command} --bitpattern {value}', + 'type': '<num>', + 'help': 'Specifies bit pattern to use in payload. Should be within range 0 - 255. If NUM is greater than 255, a random pattern is used.' + }, + 'gracetime': { + 'mtr': '{command} --gracetime {value}', + 'type': '<num>', + 'help': 'Use this option to specify the positive number of seconds to wait for responses after the final request. The default value is five seconds.' + }, + 'tos': { + 'mtr': '{command} --tos {value}', + 'type': '<tos>', + 'help': 'Specifies value for type of service field in IP header. Should be within range 0 - 255.' + }, + 'mpls': { + 'mtr': '{command} --mpls {value}', + 'type': 'noarg', + 'help': 'Use this option to tell mtr to display information from ICMP extensions for MPLS (RFC 4950) that are encoded in the response packets.' + }, + 'interface': { + 'mtr': '{command} --interface {value}', + 'type': '<interface>', + 'helpfunction': interface_list, + 'help': 'Use the network interface with a specific name for sending network probes. This can be useful when you have multiple network interfaces with routes to your destination, for example both wired Ethernet and WiFi, and wish to test a particular interface.' + }, + 'address': { + 'mtr': '{command} --address {value}', + 'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>', + 'help': 'Use this option to bind the outgoing socket to ADDRESS, so that all packets will be sent with ADDRESS as source address.' + }, + 'first-ttl': { + 'mtr': '{command} --first-ttl {value}', + 'type': '<num>', + 'help': 'Specifies with what TTL to start. Defaults to 1.' + }, + 'max-ttl': { + 'mtr': '{command} --max-ttl {value}', + 'type': '<num>', + 'help': 'Specifies the maximum number of hops or max time-to-live value mtr will probe. Default is 30.' + }, + 'max-unknown': { + 'mtr': '{command} --max-unknown {value}', + 'type': '<num>', + 'help': 'Specifies the maximum unknown host. Default is 5.' + }, + 'udp': { + 'mtr': '{command} --udp', + 'type': 'noarg', + 'help': 'Use UDP datagrams instead of ICMP ECHO.' + }, + 'tcp': { + 'mtr': '{command} --tcp', + 'type': 'noarg', + 'help': ' Use TCP SYN packets instead of ICMP ECHO. PACKETSIZE is ignored, since SYN packets can not contain data.' + }, + 'sctp': { + 'mtr': '{command} --sctp', + 'type': 'noarg', + 'help': 'Use Stream Control Transmission Protocol packets instead of ICMP ECHO.' + }, + 'port': { + 'mtr': '{command} --port {value}', + 'type': '<port>', + 'help': 'The target port number for TCP/SCTP/UDP traces.' + }, + 'localport': { + 'mtr': '{command} --localport {value}', + 'type': '<port>', + 'help': 'The source port number for UDP traces.' + }, + 'timeout': { + 'mtr': '{command} --timeout {value}', + 'type': '<num>', + 'help': ' The number of seconds to keep probe sockets open before giving up on the connection.' + }, + 'mark': { + 'mtr': '{command} --mark {value}', + 'type': '<num>', + 'help': ' Set the mark for each packet sent through this socket similar to the netfilter MARK target but socket-based. MARK is 32 unsigned integer.' + }, + 'vrf': { + 'mtr': 'sudo ip vrf exec {value} {command}', + 'type': '<vrf>', + 'help': 'Use specified VRF table', + 'helpfunction': vrf_list, + 'dflt': 'default' + } + } + +mtr = { + 4: '/bin/mtr -4', + 6: '/bin/mtr -6', +} + +class List(list): + def first(self): + return self.pop(0) if self else '' + + def last(self): + return self.pop() if self else '' + + def prepend(self, value): + self.insert(0, value) + + +def completion_failure(option: str) -> None: + """ + Shows failure message after TAB when option is wrong + :param option: failure option + :type str: + """ + sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option)) + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def expension_failure(option, completions): + reason = 'Ambiguous' if completions else 'Invalid' + sys.stderr.write( + '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv), + option)) + if completions: + sys.stderr.write(' Possible completions:\n ') + sys.stderr.write('\n '.join(completions)) + sys.stderr.write('\n') + sys.stdout.write('<nocomps>') + sys.exit(1) + + +def complete(prefix): + return [o for o in options if o.startswith(prefix)] + + +def convert(command, args): + while args: + shortname = args.first() + longnames = complete(shortname) + if len(longnames) != 1: + expension_failure(shortname, longnames) + longname = longnames[0] + if options[longname]['type'] == 'noarg': + command = options[longname]['mtr'].format( + command=command, value='') + elif not args: + sys.exit(f'mtr: missing argument for {longname} option') + else: + command = options[longname]['mtr'].format( + command=command, value=args.first()) + return command + + +if __name__ == '__main__': + args = List(sys.argv[1:]) + host = args.first() + + if not host: + sys.exit("mtr: Missing host") + + + if host == '--get-options' or host == '--get-options-nested': + if host == '--get-options-nested': + args.first() # pop monitor + args.first() # pop mtr | traceroute + args.first() # pop IP + usedoptionslist = [] + while args: + option = args.first() # pop option + matched = complete(option) # get option parameters + usedoptionslist.append(option) # list of used options + # Select options + if not args: + # remove from Possible completions used options + for o in usedoptionslist: + if o in matched: + matched.remove(o) + sys.stdout.write(' '.join(matched)) + sys.exit(0) + + if len(matched) > 1: + sys.stdout.write(' '.join(matched)) + sys.exit(0) + # If option doesn't have value + if matched: + if options[matched[0]]['type'] == 'noarg': + continue + else: + # Unexpected option + completion_failure(option) + + value = args.first() # pop option's value + if not args: + matched = complete(option) + helplines = options[matched[0]]['type'] + # Run helpfunction to get list of possible values + if 'helpfunction' in options[matched[0]]: + result = options[matched[0]]['helpfunction']() + if result: + helplines = '\n' + ' '.join(result) + sys.stdout.write(helplines) + sys.exit(0) + + for name, option in options.items(): + if 'dflt' in option and name not in args: + args.append(name) + args.append(option['dflt']) + + try: + ip = socket.gethostbyname(host) + except UnicodeError: + sys.exit(f'mtr: Unknown host: {host}') + except socket.gaierror: + ip = host + + try: + version = ipaddress.ip_address(ip).version + except ValueError: + sys.exit(f'mtr: Unknown host: {host}') + + command = convert(mtr[version], args) + call(f'{command} --curses --displaymode 0 {host}') diff --git a/src/op_mode/multicast.py b/src/op_mode/multicast.py new file mode 100755 index 000000000..0666f8af3 --- /dev/null +++ b/src/op_mode/multicast.py @@ -0,0 +1,72 @@ +#!/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 json +import sys +import typing + +from tabulate import tabulate +from vyos.utils.process import cmd + +import vyos.opmode + +ArgFamily = typing.Literal['inet', 'inet6'] + +def _get_raw_data(family, interface=None): + tmp = 'ip -4' + if family == 'inet6': + tmp = 'ip -6' + tmp = f'{tmp} -j maddr show' + if interface: + tmp = f'{tmp} dev {interface}' + output = cmd(tmp) + data = json.loads(output) + if not data: + return [] + return data + +def _get_formatted_output(raw_data): + data_entries = [] + + # sort result by interface name + for interface in sorted(raw_data, key=lambda x: x['ifname']): + for address in interface['maddr']: + tmp = [] + tmp.append(interface['ifname']) + tmp.append(address['family']) + tmp.append(address['address']) + + data_entries.append(tmp) + + headers = ["Interface", "Family", "Address"] + output = tabulate(data_entries, headers, numalign="left") + return output + +def show_group(raw: bool, family: ArgFamily, interface: typing.Optional[str]): + multicast_data = _get_raw_data(family=family, interface=interface) + if raw: + return multicast_data + else: + return _get_formatted_output(multicast_data) + +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/op_mode/nat.py b/src/op_mode/nat.py index 71a40c0e1..2bc7e24fe 100755 --- a/src/op_mode/nat.py +++ b/src/op_mode/nat.py @@ -28,9 +28,6 @@ from vyos.configquery import ConfigTreeQuery from vyos.utils.process import cmd from vyos.utils.dict import dict_search -base = 'nat' -unconf_message = 'NAT is not configured' - ArgDirection = typing.Literal['source', 'destination'] ArgFamily = typing.Literal['inet', 'inet6'] @@ -293,8 +290,9 @@ def _verify(func): @wraps(func) def _wrapper(*args, **kwargs): config = ConfigTreeQuery() + base = 'nat66' if 'inet6' in sys.argv[1:] else 'nat' if not config.exists(base): - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + raise vyos.opmode.UnconfiguredSubsystem(f'{base.upper()} is not configured') return func(*args, **kwargs) return _wrapper diff --git a/src/op_mode/ping.py b/src/op_mode/ping.py index f1d87a118..583d8792c 100755 --- a/src/op_mode/ping.py +++ b/src/op_mode/ping.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,29 +14,13 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os import sys import socket import ipaddress -from vyos.utils.network import get_all_vrfs -from vyos.ifconfig import Section - - -def interface_list() -> list: - """ - Get list of interfaces in system - :rtype: list - """ - return Section.interfaces() - - -def vrf_list() -> list: - """ - Get list of VRFs in system - :rtype: list - """ - return list(get_all_vrfs().keys()) +from vyos.utils.network import interface_list +from vyos.utils.network import vrf_list +from vyos.utils.process import call options = { 'audible': { @@ -295,6 +279,4 @@ if __name__ == '__main__': sys.exit(f'ping: Unknown host: {host}') command = convert(ping[version], args) - - # print(f'{command} {host}') - os.system(f'{command} {host}') + call(f'{command} {host}') diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index 35c7ce0e2..ad2c1ada0 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright (C) 2021-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -25,6 +25,7 @@ from cryptography import x509 from cryptography.x509.oid import ExtendedKeyUsageOID from vyos.config import Config +from vyos.config import config_dict_mangle_acme from vyos.pki import encode_certificate, encode_public_key, encode_private_key, encode_dh_parameters from vyos.pki import get_certificate_fingerprint from vyos.pki import create_certificate, create_certificate_request, create_certificate_revocation_list @@ -79,9 +80,14 @@ def get_config_certificate(name=None): if not conf.exists(base + ['private', 'key']) or not conf.exists(base + ['certificate']): return False - return conf.get_config_dict(base, key_mangling=('-', '_'), + pki = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + if pki: + for certificate in pki: + pki[certificate] = config_dict_mangle_acme(certificate, pki[certificate]) + + return pki def get_certificate_ca(cert, ca_certs): # Find CA certificate for given certificate @@ -896,11 +902,15 @@ def show_certificate(name=None, pem=False): cert_subject_cn = cert.subject.rfc4514_string().split(",")[0] cert_issuer_cn = cert.issuer.rfc4514_string().split(",")[0] cert_type = 'Unknown' - ext = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) - if ext and ExtendedKeyUsageOID.SERVER_AUTH in ext.value: - cert_type = 'Server' - elif ext and ExtendedKeyUsageOID.CLIENT_AUTH in ext.value: - cert_type = 'Client' + + try: + ext = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) + if ext and ExtendedKeyUsageOID.SERVER_AUTH in ext.value: + cert_type = 'Server' + elif ext and ExtendedKeyUsageOID.CLIENT_AUTH in ext.value: + cert_type = 'Client' + except: + pass revoked = 'Yes' if 'revoke' in cert_dict else 'No' have_private = 'Yes' if 'private' in cert_dict and 'key' in cert_dict['private'] else 'No' @@ -1069,7 +1079,9 @@ if __name__ == '__main__': show_crl(None if args.crl == 'all' else args.crl, args.pem) else: show_certificate_authority() + print('\n') show_certificate() + print('\n') show_crl() except KeyboardInterrupt: print("Aborted") diff --git a/src/op_mode/powerctrl.py b/src/op_mode/powerctrl.py index 3ac5991b4..c07d0c4bd 100755 --- a/src/op_mode/powerctrl.py +++ b/src/op_mode/powerctrl.py @@ -191,7 +191,7 @@ def main(): nargs="*", metavar="HH:MM") - action.add_argument("--reboot_in", "-i", + action.add_argument("--reboot-in", "-i", help="Reboot the system", nargs="*", metavar="Minutes") @@ -214,7 +214,7 @@ def main(): if args.reboot is not None: for r in args.reboot: if ':' not in r and '/' not in r and '.' not in r: - print("Incorrect format! Use HH:MM") + print("Incorrect format! Use HH:MM") exit(1) execute_shutdown(args.reboot, reboot=True, ask=args.yes) if args.reboot_in is not None: diff --git a/src/op_mode/restart_frr.py b/src/op_mode/restart_frr.py index 820a3846c..8841b0eca 100755 --- a/src/op_mode/restart_frr.py +++ b/src/op_mode/restart_frr.py @@ -139,9 +139,7 @@ def _reload_config(daemon): # define program arguments cmd_args_parser = argparse.ArgumentParser(description='restart frr daemons') cmd_args_parser.add_argument('--action', choices=['restart'], required=True, help='action to frr daemons') -# Full list of FRR 9.0/stable daemons for reference -#cmd_args_parser.add_argument('--daemon', choices=['zebra', 'staticd', 'bgpd', 'ospfd', 'ospf6d', 'ripd', 'ripngd', 'isisd', 'pim6d', 'ldpd', 'eigrpd', 'babeld', 'sharpd', 'bfdd', 'fabricd', 'pathd'], required=False, nargs='*', help='select single or multiple daemons') -cmd_args_parser.add_argument('--daemon', choices=['zebra', 'staticd', 'bgpd', 'ospfd', 'ospf6d', 'ripd', 'ripngd', 'isisd', 'pim6d', 'ldpd', 'babeld', 'bfdd'], required=False, nargs='*', help='select single or multiple daemons') +cmd_args_parser.add_argument('--daemon', choices=['zebra', 'staticd', 'bgpd', 'eigrpd', 'ospfd', 'ospf6d', 'ripd', 'ripngd', 'isisd', 'pimd', 'pim6d', 'ldpd', 'babeld', 'bfdd'], required=False, nargs='*', help='select single or multiple daemons') # parse arguments cmd_args = cmd_args_parser.parse_args() diff --git a/src/op_mode/show_openvpn.py b/src/op_mode/show_openvpn.py index e29e594a5..6abafc8b6 100755 --- a/src/op_mode/show_openvpn.py +++ b/src/op_mode/show_openvpn.py @@ -63,9 +63,11 @@ def get_vpn_tunnel_address(peer, interface): # filter out subnet entries lst = [l for l in lst[1:] if '/' not in l.split(',')[0]] - tunnel_ip = lst[0].split(',')[0] + if lst: + tunnel_ip = lst[0].split(',')[0] + return tunnel_ip - return tunnel_ip + return 'n/a' def get_status(mode, interface): status_file = '/var/run/openvpn/{}.status'.format(interface) diff --git a/src/op_mode/show_wireless.py b/src/op_mode/show_wireless.py deleted file mode 100755 index 340163057..000000000 --- a/src/op_mode/show_wireless.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -import argparse -import re - -from sys import exit -from copy import deepcopy - -from vyos.config import Config -from vyos.utils.process import popen - -parser = argparse.ArgumentParser() -parser.add_argument("-s", "--scan", help="Scan for Wireless APs on given interface, e.g. 'wlan0'") -parser.add_argument("-b", "--brief", action="store_true", help="Show wireless configuration") -parser.add_argument("-c", "--stations", help="Show wireless clients connected on interface, e.g. 'wlan0'") - -def show_brief(): - config = Config() - if len(config.list_effective_nodes('interfaces wireless')) == 0: - print("No Wireless interfaces configured") - exit(0) - - interfaces = [] - for intf in config.list_effective_nodes('interfaces wireless'): - config.set_level(f'interfaces wireless {intf}') - data = { 'name': intf } - data['type'] = config.return_effective_value('type') or '-' - data['ssid'] = config.return_effective_value('ssid') or '-' - data['channel'] = config.return_effective_value('channel') or '-' - interfaces.append(data) - - return interfaces - -def ssid_scan(intf): - # XXX: This ignores errors - tmp, _ = popen(f'/sbin/iw dev {intf} scan ap-force') - networks = [] - data = { - 'ssid': '', - 'mac': '', - 'channel': '', - 'signal': '' - } - re_mac = re.compile(r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})') - for line in tmp.splitlines(): - if line.startswith('BSS '): - ssid = deepcopy(data) - ssid['mac'] = re.search(re_mac, line).group() - - elif line.lstrip().startswith('SSID: '): - # SSID can be " SSID: WLAN-57 6405", thus strip all leading whitespaces - ssid['ssid'] = line.lstrip().split(':')[-1].lstrip() - - elif line.lstrip().startswith('signal: '): - # Siganl can be " signal: -67.00 dBm", thus strip all leading whitespaces - ssid['signal'] = line.lstrip().split(':')[-1].split()[0] - - elif line.lstrip().startswith('DS Parameter set: channel'): - # Channel can be " DS Parameter set: channel 6" , thus - # strip all leading whitespaces - ssid['channel'] = line.lstrip().split(':')[-1].split()[-1] - networks.append(ssid) - continue - - return networks - -def show_clients(intf): - # XXX: This ignores errors - tmp, _ = popen(f'/sbin/iw dev {intf} station dump') - clients = [] - data = { - 'mac': '', - 'signal': '', - 'rx_bytes': '', - 'rx_packets': '', - 'tx_bytes': '', - 'tx_packets': '' - } - re_mac = re.compile(r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})') - for line in tmp.splitlines(): - if line.startswith('Station'): - client = deepcopy(data) - client['mac'] = re.search(re_mac, line).group() - - elif line.lstrip().startswith('signal avg:'): - client['signal'] = line.lstrip().split(':')[-1].lstrip().split()[0] - - elif line.lstrip().startswith('rx bytes:'): - client['rx_bytes'] = line.lstrip().split(':')[-1].lstrip() - - elif line.lstrip().startswith('rx packets:'): - client['rx_packets'] = line.lstrip().split(':')[-1].lstrip() - - elif line.lstrip().startswith('tx bytes:'): - client['tx_bytes'] = line.lstrip().split(':')[-1].lstrip() - - elif line.lstrip().startswith('tx packets:'): - client['tx_packets'] = line.lstrip().split(':')[-1].lstrip() - clients.append(client) - continue - - return clients - -if __name__ == '__main__': - args = parser.parse_args() - - if args.scan: - print("Address SSID Channel Signal (dbm)") - for network in ssid_scan(args.scan): - print("{:<17} {:<32} {:>3} {}".format(network['mac'], - network['ssid'], - network['channel'], - network['signal'])) - exit(0) - - elif args.brief: - print("Interface Type SSID Channel") - for intf in show_brief(): - print("{:<9} {:<12} {:<32} {:>3}".format(intf['name'], - intf['type'], - intf['ssid'], - intf['channel'])) - exit(0) - - elif args.stations: - print("Station Signal RX: bytes packets TX: bytes packets") - for client in show_clients(args.stations): - print("{:<17} {:>3} {:>15} {:>9} {:>15} {:>10} ".format(client['mac'], - client['signal'], client['rx_bytes'], client['rx_packets'], client['tx_bytes'], client['tx_packets'])) - - exit(0) - - else: - parser.print_help() - exit(1) diff --git a/src/op_mode/ssh.py b/src/op_mode/ssh.py new file mode 100755 index 000000000..102becc55 --- /dev/null +++ b/src/op_mode/ssh.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# +# Copyright 2017-2023 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 json +import sys +import glob +import vyos.opmode +from vyos.utils.process import cmd +from vyos.configquery import ConfigTreeQuery +from tabulate import tabulate + +def show_fingerprints(raw: bool, ascii: bool): + config = ConfigTreeQuery() + if not config.exists("service ssh"): + raise vyos.opmode.UnconfiguredSubsystem("SSH server is not enabled.") + + publickeys = glob.glob("/etc/ssh/*.pub") + + if publickeys: + keys = [] + for keyfile in publickeys: + try: + if ascii: + keydata = cmd("ssh-keygen -l -v -E sha256 -f " + keyfile).splitlines() + else: + keydata = cmd("ssh-keygen -l -E sha256 -f " + keyfile).splitlines() + type = keydata[0].split(None)[-1].strip("()") + key_size = keydata[0].split(None)[0] + fingerprint = keydata[0].split(None)[1] + comment = keydata[0].split(None)[2:-1][0] + if ascii: + ascii_art = "\n".join(keydata[1:]) + keys.append({"type": type, "key_size": key_size, "fingerprint": fingerprint, "comment": comment, "ascii_art": ascii_art}) + else: + keys.append({"type": type, "key_size": key_size, "fingerprint": fingerprint, "comment": comment}) + except: + # Ignore invalid public keys + pass + if raw: + return keys + else: + headers = {"type": "Type", "key_size": "Key Size", "fingerprint": "Fingerprint", "comment": "Comment", "ascii_art": "ASCII Art"} + output = "SSH server public key fingerprints:\n\n" + tabulate(keys, headers=headers, tablefmt="simple") + return output + else: + if raw: + return [] + else: + return "No SSH server public keys are found." + +def show_dynamic_protection(raw: bool): + config = ConfigTreeQuery() + if not config.exists(['service', 'ssh', 'dynamic-protection']): + raise vyos.opmode.UnconfiguredSubsystem("SSH server dynamic-protection is not enabled.") + + attackers = [] + try: + # IPv4 + attackers = attackers + json.loads(cmd("nft -j list set ip sshguard attackers"))["nftables"][1]["set"]["elem"] + except: + pass + try: + # IPv6 + attackers = attackers + json.loads(cmd("nft -j list set ip6 sshguard attackers"))["nftables"][1]["set"]["elem"] + except: + pass + if attackers: + if raw: + return attackers + else: + output = "Blocked attackers:\n" + "\n".join(attackers) + return output + else: + if raw: + return [] + else: + return "No blocked attackers." + +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/op_mode/traceroute.py b/src/op_mode/traceroute.py index 2f0edf53a..d2bac3f7c 100755 --- a/src/op_mode/traceroute.py +++ b/src/op_mode/traceroute.py @@ -14,29 +14,13 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os import sys import socket import ipaddress -from vyos.utils.network import get_all_vrfs -from vyos.ifconfig import Section - - -def interface_list() -> list: - """ - Get list of interfaces in system - :rtype: list - """ - return Section.interfaces() - - -def vrf_list() -> list: - """ - Get list of VRFs in system - :rtype: list - """ - return list(get_all_vrfs().keys()) +from vyos.utils.network import interface_list +from vyos.utils.network import vrf_list +from vyos.utils.process import call options = { 'backward-hops': { @@ -251,6 +235,4 @@ if __name__ == '__main__': sys.exit(f'traceroute: Unknown host: {host}') command = convert(traceroute[version], args) - - # print(f'{command} {host}') - os.system(f'{command} {host}') + call(f'{command} {host}') diff --git a/src/op_mode/zone.py b/src/op_mode/zone.py new file mode 100644 index 000000000..d24b1065b --- /dev/null +++ b/src/op_mode/zone.py @@ -0,0 +1,215 @@ +#!/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 typing +import sys +import vyos.opmode + +import tabulate +from vyos.configquery import ConfigTreeQuery +from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search + + +def get_config_zone(conf, name=None): + config_path = ['firewall', 'zone'] + if name: + config_path += [name] + + zone_policy = conf.get_config_dict(config_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + return zone_policy + + +def _convert_one_zone_data(zone: str, zone_config: dict) -> dict: + """ + Convert config dictionary of one zone to API dictionary + :param zone: Zone name + :type zone: str + :param zone_config: config dictionary + :type zone_config: dict + :return: AP dictionary + :rtype: dict + """ + list_of_rules = [] + intrazone_dict = {} + if dict_search('from', zone_config): + for from_zone, from_zone_config in zone_config['from'].items(): + from_zone_dict = {'name': from_zone} + if dict_search('firewall.name', from_zone_config): + from_zone_dict['firewall'] = dict_search('firewall.name', + from_zone_config) + if dict_search('firewall.ipv6_name', from_zone_config): + from_zone_dict['firewall_v6'] = dict_search( + 'firewall.ipv6_name', from_zone_config) + list_of_rules.append(from_zone_dict) + + zone_dict = { + 'name': zone, + 'interface': dict_search('interface', zone_config), + 'type': 'LOCAL' if dict_search('local_zone', + zone_config) is not None else None, + } + if list_of_rules: + zone_dict['from'] = list_of_rules + if dict_search('intra_zone_filtering.firewall.name', zone_config): + intrazone_dict['firewall'] = dict_search( + 'intra_zone_filtering.firewall.name', zone_config) + if dict_search('intra_zone_filtering.firewall.ipv6_name', zone_config): + intrazone_dict['firewall_v6'] = dict_search( + 'intra_zone_filtering.firewall.ipv6_name', zone_config) + if intrazone_dict: + zone_dict['intrazone'] = intrazone_dict + return zone_dict + + +def _convert_zones_data(zone_policies: dict) -> list: + """ + Convert all config dictionary to API list of zone dictionaries + :param zone_policies: config dictionary + :type zone_policies: dict + :return: API list + :rtype: list + """ + zone_list = [] + for zone, zone_config in zone_policies.items(): + zone_list.append(_convert_one_zone_data(zone, zone_config)) + return zone_list + + +def _convert_config(zones_config: dict, zone: str = None) -> list: + """ + convert config to API list + :param zones_config: zones config + :type zones_config: + :param zone: zone name + :type zone: str + :return: API list + :rtype: list + """ + if zone: + if zones_config: + output = [_convert_one_zone_data(zone, zones_config)] + else: + raise vyos.opmode.DataUnavailable(f'Zone {zone} not found') + else: + if zones_config: + output = _convert_zones_data(zones_config) + else: + raise vyos.opmode.UnconfiguredSubsystem( + 'Zone entries are not configured') + return output + + +def output_zone_list(zone_conf: dict) -> list: + """ + Format one zone row + :param zone_conf: zone config + :type zone_conf: dict + :return: formatted list of zones + :rtype: list + """ + zone_info = [zone_conf['name']] + if zone_conf['type'] == 'LOCAL': + zone_info.append('LOCAL') + else: + zone_info.append("\n".join(zone_conf['interface'])) + + from_zone = [] + firewall = [] + firewall_v6 = [] + if 'intrazone' in zone_conf: + from_zone.append(zone_conf['name']) + + v4_name = dict_search_args(zone_conf['intrazone'], 'firewall') + v6_name = dict_search_args(zone_conf['intrazone'], 'firewall_v6') + if v4_name: + firewall.append(v4_name) + else: + firewall.append('') + if v6_name: + firewall_v6.append(v6_name) + else: + firewall_v6.append('') + + if 'from' in zone_conf: + for from_conf in zone_conf['from']: + from_zone.append(from_conf['name']) + + v4_name = dict_search_args(from_conf, 'firewall') + v6_name = dict_search_args(from_conf, 'firewall_v6') + if v4_name: + firewall.append(v4_name) + else: + firewall.append('') + if v6_name: + firewall_v6.append(v6_name) + else: + firewall_v6.append('') + + zone_info.append("\n".join(from_zone)) + zone_info.append("\n".join(firewall)) + zone_info.append("\n".join(firewall_v6)) + return zone_info + + +def get_formatted_output(zone_policy: list) -> str: + """ + Formatted output of all zones + :param zone_policy: list of zones + :type zone_policy: list + :return: formatted table with zones + :rtype: str + """ + headers = ["Zone", + "Interfaces", + "From Zone", + "Firewall IPv4", + "Firewall IPv6" + ] + formatted_list = [] + for zone_conf in zone_policy: + formatted_list.append(output_zone_list(zone_conf)) + tabulate.PRESERVE_WHITESPACE = True + output = tabulate.tabulate(formatted_list, headers, numalign="left") + return output + + +def show(raw: bool, zone: typing.Optional[str]): + """ + Show zone-policy command + :param raw: if API + :type raw: bool + :param zone: zone name + :type zone: str + """ + conf: ConfigTreeQuery = ConfigTreeQuery() + zones_config: dict = get_config_zone(conf, zone) + zone_policy_api: list = _convert_config(zones_config, zone) + if raw: + return zone_policy_api + else: + return get_formatted_output(zone_policy_api) + + +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)
\ No newline at end of file diff --git a/src/pam-configs/mfa-google-authenticator b/src/pam-configs/mfa-google-authenticator new file mode 100644 index 000000000..9e49e5ef9 --- /dev/null +++ b/src/pam-configs/mfa-google-authenticator @@ -0,0 +1,8 @@ +Name: Google Authenticator PAM module (2FA/MFA) +Default: no +Priority: 384 + +Auth-Type: Primary +Auth: + [default=ignore success=ok auth_err=die] pam_google_authenticator.so nullok forward_pass + diff --git a/src/services/vyos-hostsd b/src/services/vyos-hostsd index e34a4b740..1ba90471e 100755 --- a/src/services/vyos-hostsd +++ b/src/services/vyos-hostsd @@ -271,8 +271,8 @@ SOCKET_PATH = "ipc://" + os.path.join(RUN_DIR, 'vyos-hostsd.sock') RESOLV_CONF_FILE = '/etc/resolv.conf' HOSTS_FILE = '/etc/hosts' -PDNS_REC_USER = PDNS_REC_GROUP = 'pdns' -PDNS_REC_RUN_DIR = '/run/powerdns' +PDNS_REC_USER_GROUP = 'pdns' +PDNS_REC_RUN_DIR = '/run/pdns-recursor' PDNS_REC_LUA_CONF_FILE = f'{PDNS_REC_RUN_DIR}/recursor.vyos-hostsd.conf.lua' PDNS_REC_ZONES_FILE = f'{PDNS_REC_RUN_DIR}/recursor.forward-zones.conf' @@ -436,18 +436,18 @@ def make_hosts(state): def make_pdns_rec_conf(state): logger.info(f"Writing {PDNS_REC_LUA_CONF_FILE}") - # on boot, /run/powerdns does not exist, so create it - makedir(PDNS_REC_RUN_DIR, user=PDNS_REC_USER, group=PDNS_REC_GROUP) + # on boot, /run/pdns-recursor does not exist, so create it + makedir(PDNS_REC_RUN_DIR, user=PDNS_REC_USER_GROUP, group=PDNS_REC_USER_GROUP) chmod_755(PDNS_REC_RUN_DIR) render(PDNS_REC_LUA_CONF_FILE, 'dns-forwarding/recursor.vyos-hostsd.conf.lua.j2', - state, user=PDNS_REC_USER, group=PDNS_REC_GROUP) + state, user=PDNS_REC_USER_GROUP, group=PDNS_REC_USER_GROUP) logger.info(f"Writing {PDNS_REC_ZONES_FILE}") render(PDNS_REC_ZONES_FILE, 'dns-forwarding/recursor.forward-zones.conf.j2', - state, user=PDNS_REC_USER, group=PDNS_REC_GROUP) + state, user=PDNS_REC_USER_GROUP, group=PDNS_REC_USER_GROUP) def set_host_name(state, data): if data['host_name']: diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 66e80ced5..40d442e30 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -1,6 +1,6 @@ #!/usr/share/vyos-http-api-tools/bin/python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright (C) 2019-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 @@ -13,8 +13,6 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# -# import os import sys @@ -22,12 +20,15 @@ import grp import copy import json import logging +import signal import traceback import threading + +from time import sleep from typing import List, Union, Callable, Dict -import uvicorn from fastapi import FastAPI, Depends, Request, Response, HTTPException +from fastapi import BackgroundTasks from fastapi.responses import HTMLResponse from fastapi.exceptions import RequestValidationError from fastapi.routing import APIRoute @@ -36,15 +37,20 @@ from starlette.middleware.cors import CORSMiddleware from starlette.datastructures import FormData from starlette.formparsers import FormParser, MultiPartParser from multipart.multipart import parse_options_header +from uvicorn import Config as UvicornConfig +from uvicorn import Server as UvicornServer from ariadne.asgi import GraphQL -import vyos.config -from vyos.configsession import ConfigSession, ConfigSessionError +from vyos.config import Config +from vyos.configtree import ConfigTree +from vyos.configdiff import get_config_diff +from vyos.configsession import ConfigSession +from vyos.configsession import ConfigSessionError +from vyos.defaults import api_config_state import api.graphql.state -DEFAULT_CONFIG_FILE = '/etc/vyos/http-api.conf' CFG_GROUP = 'vyattacfg' debug = True @@ -62,7 +68,7 @@ else: lock = threading.Lock() def load_server_config(): - with open(DEFAULT_CONFIG_FILE) as f: + with open(api_config_state) as f: config = json.load(f) return config @@ -217,6 +223,19 @@ class ShowModel(ApiModel): } } +class RebootModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "reboot", + "path": ["op", "mode", "path"], + } + } + class ResetModel(ApiModel): op: StrictStr path: List[StrictStr] @@ -230,6 +249,19 @@ class ResetModel(ApiModel): } } +class PoweroffModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "poweroff", + "path": ["op", "mode", "path"], + } + } + class Success(BaseModel): success: bool @@ -410,12 +442,24 @@ app.router.route_class = MultipartRoute async def validation_exception_handler(request, exc): return error(400, str(exc.errors()[0])) +self_ref_msg = "Requested HTTP API server configuration change; commit will be called in the background" + +def call_commit(s: ConfigSession): + try: + s.commit() + except ConfigSessionError as e: + s.discard() + if app.state.vyos_debug: + logger.warning(f"ConfigSessionError:\n {traceback.format_exc()}") + else: + logger.warning(f"ConfigSessionError: {e}") + def _configure_op(data: Union[ConfigureModel, ConfigureListModel, ConfigSectionModel, ConfigSectionListModel], - request: Request): + request: Request, background_tasks: BackgroundTasks): session = app.state.vyos_session env = session.get_session_env() - config = vyos.config.Config(session_env=env) + config = Config(session_env=env) endpoint = request.url.path @@ -470,7 +514,15 @@ def _configure_op(data: Union[ConfigureModel, ConfigureListModel, else: raise ConfigSessionError(f"'{op}' is not a valid operation") # end for - session.commit() + config = Config(session_env=env) + d = get_config_diff(config) + + if d.is_node_changed(['service', 'https']): + background_tasks.add_task(call_commit, session) + msg = self_ref_msg + else: + session.commit() + logger.info(f"Configuration modified via HTTP API using key '{app.state.vyos_id}'") except ConfigSessionError as e: session.discard() @@ -495,21 +547,21 @@ def _configure_op(data: Union[ConfigureModel, ConfigureListModel, @app.post('/configure') def configure_op(data: Union[ConfigureModel, - ConfigureListModel], - request: Request): - return _configure_op(data, request) + ConfigureListModel], + request: Request, background_tasks: BackgroundTasks): + return _configure_op(data, request, background_tasks) @app.post('/configure-section') def configure_section_op(data: Union[ConfigSectionModel, - ConfigSectionListModel], - request: Request): - return _configure_op(data, request) + ConfigSectionListModel], + request: Request, background_tasks: BackgroundTasks): + return _configure_op(data, request, background_tasks) @app.post("/retrieve") async def retrieve_op(data: RetrieveModel): session = app.state.vyos_session env = session.get_session_env() - config = vyos.config.Config(session_env=env) + config = Config(session_env=env) op = data.op path = " ".join(data.path) @@ -528,10 +580,10 @@ async def retrieve_op(data: RetrieveModel): res = session.show_config(path=data.path) if config_format == 'json': - config_tree = vyos.configtree.ConfigTree(res) + config_tree = ConfigTree(res) res = json.loads(config_tree.to_json()) elif config_format == 'json_ast': - config_tree = vyos.configtree.ConfigTree(res) + config_tree = ConfigTree(res) res = json.loads(config_tree.to_json_ast()) elif config_format == 'raw': pass @@ -548,10 +600,11 @@ async def retrieve_op(data: RetrieveModel): return success(res) @app.post('/config-file') -def config_file_op(data: ConfigFileModel): +def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): session = app.state.vyos_session - + env = session.get_session_env() op = data.op + msg = None try: if op == 'save': @@ -559,14 +612,23 @@ def config_file_op(data: ConfigFileModel): path = data.file else: path = '/config/config.boot' - res = session.save_config(path) + msg = session.save_config(path) elif op == 'load': if data.file: path = data.file else: return error(400, "Missing required field \"file\"") - res = session.migrate_and_load_config(path) - res = session.commit() + + session.migrate_and_load_config(path) + + config = Config(session_env=env) + d = get_config_diff(config) + + if d.is_node_changed(['service', 'https']): + background_tasks.add_task(call_commit, session) + msg = self_ref_msg + else: + session.commit() else: return error(400, f"'{op}' is not a valid operation") except ConfigSessionError as e: @@ -575,7 +637,7 @@ def config_file_op(data: ConfigFileModel): logger.critical(traceback.format_exc()) return error(500, "An internal error occured. Check the logs for details.") - return success(res) + return success(msg) @app.post('/image') def image_op(data: ImageModel): @@ -607,7 +669,7 @@ def image_op(data: ImageModel): return success(res) @app.post('/container-image') -def image_op(data: ContainerImageModel): +def container_image_op(data: ContainerImageModel): session = app.state.vyos_session op = data.op @@ -677,6 +739,26 @@ def show_op(data: ShowModel): return success(res) +@app.post('/reboot') +def reboot_op(data: RebootModel): + session = app.state.vyos_session + + op = data.op + path = data.path + + try: + if op == 'reboot': + res = session.reboot(path) + else: + return error(400, f"'{op}' is not a valid operation") + 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) + @app.post('/reset') def reset_op(data: ResetModel): session = app.state.vyos_session @@ -697,12 +779,32 @@ def reset_op(data: ResetModel): return success(res) +@app.post('/poweroff') +def poweroff_op(data: PoweroffModel): + session = app.state.vyos_session + + op = data.op + path = data.path + + try: + if op == 'poweroff': + res = session.poweroff(path) + else: + return error(400, f"'{op}' is not a valid operation") + 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 ### -def graphql_init(fast_api_app): +def graphql_init(app: FastAPI = app): from api.graphql.libs.token_auth import get_user_context api.graphql.state.init() api.graphql.state.settings['app'] = app @@ -728,30 +830,60 @@ def graphql_init(fast_api_app): debug=True, introspection=in_spec)) ### +# Modify uvicorn to allow reloading server within the configsession +### -if __name__ == '__main__': - # systemd's user and group options don't work, do it by hand here, - # else no one else will be able to commit - cfg_group = grp.getgrnam(CFG_GROUP) - os.setgid(cfg_group.gr_gid) +server = None +shutdown = False - # Need to set file permissions to 775 too so that every vyattacfg group member - # has write access to the running config - os.umask(0o002) +class ApiServerConfig(UvicornConfig): + pass +class ApiServer(UvicornServer): + def install_signal_handlers(self): + pass + +def reload_handler(signum, frame): + global server + logger.debug('Reload signal received...') + if server is not None: + server.handle_exit(signum, frame) + server = None + logger.info('Server stopping for reload...') + else: + logger.warning('Reload called for non-running server...') + +def shutdown_handler(signum, frame): + global shutdown + logger.debug('Shutdown signal received...') + server.handle_exit(signum, frame) + logger.info('Server shutdown...') + shutdown = True + +def flatten_keys(d: dict) -> list[dict]: + keys_list = [] + for el in list(d['keys'].get('id', {})): + key = d['keys']['id'][el].get('key', '') + if key: + keys_list.append({'id': el, 'key': key}) + return keys_list + +def initialization(session: ConfigSession, app: FastAPI = app): + global server try: server_config = load_server_config() - except Exception as err: - logger.critical(f"Failed to load the HTTP API server config: {err}") + except Exception as e: + logger.critical(f'Failed to load the HTTP API server config: {e}') sys.exit(1) - config_session = ConfigSession(os.getpid()) + app.state.vyos_session = session + app.state.vyos_keys = [] - app.state.vyos_session = config_session - app.state.vyos_keys = server_config['api_keys'] + if 'keys' in server_config: + app.state.vyos_keys = flatten_keys(server_config) - app.state.vyos_debug = server_config['debug'] - app.state.vyos_strict = server_config['strict'] + app.state.vyos_debug = bool('debug' in server_config) + app.state.vyos_strict = bool('strict' in server_config) app.state.vyos_origins = server_config.get('cors', {}).get('allow_origin', []) if 'graphql' in server_config: app.state.vyos_graphql = True @@ -760,7 +892,7 @@ if __name__ == '__main__': app.state.vyos_introspection = True else: app.state.vyos_introspection = False - # default value is merged in conf_mode http-api.py, if not set + # default values if not set explicitly app.state.vyos_auth_type = server_config['graphql']['authentication']['type'] app.state.vyos_token_exp = server_config['graphql']['authentication']['expiration'] app.state.vyos_secret_len = server_config['graphql']['authentication']['secret_length'] @@ -770,14 +902,36 @@ if __name__ == '__main__': if app.state.vyos_graphql: graphql_init(app) + config = ApiServerConfig(app, uds="/run/api.sock", proxy_headers=True) + server = ApiServer(config) + +def run_server(): try: - if not server_config['socket']: - uvicorn.run(app, host=server_config["listen_address"], - port=int(server_config["port"]), - proxy_headers=True) - else: - uvicorn.run(app, uds="/run/api.sock", - proxy_headers=True) - except OSError as err: - logger.critical(f"OSError {err}") + server.run() + except OSError as e: + logger.critical(e) sys.exit(1) + +if __name__ == '__main__': + # systemd's user and group options don't work, do it by hand here, + # else no one else will be able to commit + cfg_group = grp.getgrnam(CFG_GROUP) + os.setgid(cfg_group.gr_gid) + + # Need to set file permissions to 775 too so that every vyattacfg group member + # has write access to the running config + os.umask(0o002) + + signal.signal(signal.SIGHUP, reload_handler) + signal.signal(signal.SIGTERM, shutdown_handler) + + config_session = ConfigSession(os.getpid()) + + while True: + logger.debug('Enter main loop...') + if shutdown: + break + if server is None: + initialization(config_session) + server.run() + sleep(1) diff --git a/src/system/grub_update.py b/src/system/grub_update.py new file mode 100644 index 000000000..5a7d8eb72 --- /dev/null +++ b/src/system/grub_update.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# +# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This file is part of VyOS. +# +# VyOS is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# VyOS 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 +# VyOS. If not, see <https://www.gnu.org/licenses/>. + +from pathlib import Path +from sys import exit + +from vyos.system import disk, grub, image, compat, SYSTEM_CFG_VER +from vyos.template import render + + +def cfg_check_update() -> bool: + """Check if GRUB structure update is required + + Returns: + bool: False if not required, True if required + """ + current_ver = grub.get_cfg_ver() + if current_ver and current_ver >= SYSTEM_CFG_VER: + return False + + return True + + +if __name__ == '__main__': + if image.is_live_boot(): + exit(0) + + if image.is_running_as_container(): + exit(0) + + # Skip everything if update is not required + if not cfg_check_update(): + exit(0) + + # find root directory of persistent storage + root_dir = disk.find_persistence() + + # read current GRUB config + grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}' + vars = grub.vars_read(grub_cfg_main) + modules = grub.modules_read(grub_cfg_main) + vyos_menuentries = compat.parse_menuentries(grub_cfg_main) + vyos_versions = compat.find_versions(vyos_menuentries) + unparsed_items = compat.filter_unparsed(grub_cfg_main) + # compatibilty for raid installs + search_root = compat.get_search_root(unparsed_items) + common_dict = {} + common_dict['search_root'] = search_root + # find default values + default_entry = vyos_menuentries[int(vars['default'])] + default_settings = { + 'default': grub.gen_version_uuid(default_entry['version']), + 'bootmode': default_entry['bootmode'], + 'console_type': default_entry['console_type'], + 'console_num': default_entry['console_num'], + 'console_speed': default_entry['console_speed'] + } + vars.update(default_settings) + + # create new files + grub_cfg_vars = f'{root_dir}/{grub.CFG_VYOS_VARS}' + grub_cfg_modules = f'{root_dir}/{grub.CFG_VYOS_MODULES}' + grub_cfg_platform = f'{root_dir}/{grub.CFG_VYOS_PLATFORM}' + grub_cfg_menu = f'{root_dir}/{grub.CFG_VYOS_MENU}' + grub_cfg_options = f'{root_dir}/{grub.CFG_VYOS_OPTIONS}' + + Path(image.GRUB_DIR_VYOS).mkdir(exist_ok=True) + grub.vars_write(grub_cfg_vars, vars) + grub.modules_write(grub_cfg_modules, modules) + grub.common_write(grub_common=common_dict) + render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {}) + render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {}) + + # create menu entries + for vyos_ver in vyos_versions: + boot_opts = None + for entry in vyos_menuentries: + if entry.get('version') == vyos_ver and entry.get( + 'bootmode') == 'normal': + boot_opts = entry.get('boot_opts') + grub.version_add(vyos_ver, root_dir, boot_opts) + + # update structure version + cfg_ver = compat.update_cfg_ver(root_dir) + grub.write_cfg_ver(cfg_ver, root_dir) + + if compat.mode(): + compat.render_grub_cfg(root_dir) + else: + render(grub_cfg_main, grub.TMPL_GRUB_MAIN, {}) + + exit(0) diff --git a/src/system/keepalived-fifo.py b/src/system/keepalived-fifo.py index 5e19bdbad..6d33e372d 100755 --- a/src/system/keepalived-fifo.py +++ b/src/system/keepalived-fifo.py @@ -41,7 +41,7 @@ logger.addHandler(logs_handler_syslog) logger.setLevel(logging.DEBUG) mdns_running_file = '/run/mdns_vrrp_active' -mdns_update_command = 'sudo /usr/libexec/vyos/conf_mode/service_mdns-repeater.py' +mdns_update_command = 'sudo /usr/libexec/vyos/conf_mode/service_mdns_repeater.py' # class for all operations class KeepalivedFifo: diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh index 49e53d7e1..47c276270 100755 --- a/src/system/on-dhcp-event.sh +++ b/src/system/on-dhcp-event.sh @@ -1,53 +1,98 @@ #!/bin/bash +# +# 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/>. +# +# -# This script came from ubnt.com forum user "bradd" in the following post -# http://community.ubnt.com/t5/EdgeMAX/Automatic-DNS-resolution-of-DHCP-client-names/td-p/651311 -# It has been modified by Ubiquiti to update the /etc/host file -# instead of adding to the CLI. -# Thanks to forum user "itsmarcos" for bug fix & improvements -# Thanks to forum user "ruudboon" for multiple domain fix -# Thanks to forum user "chibby85" for expire patch and static-mapping - -if [ $# -lt 5 ]; then +if [ $# -lt 1 ]; then echo Invalid args logger -s -t on-dhcp-event "Invalid args \"$@\"" exit 1 fi action=$1 -client_name=$2 -client_ip=$3 -client_mac=$4 -domain=$5 hostsd_client="/usr/bin/vyos-hostsd-client" +get_subnet_domain_name () { + python3 <<EOF +from vyos.kea import kea_get_active_config +from vyos.utils.dict import dict_search_args + +config = kea_get_active_config('4') +shared_networks = dict_search_args(config, 'arguments', f'Dhcp4', 'shared-networks') + +found = False + +if shared_networks: + for network in shared_networks: + for subnet in network[f'subnet4']: + if subnet['id'] == $1: + for option in subnet['option-data']: + if option['name'] == 'domain-name': + print(option['data']) + found = True + + if not found: + for option in network['option-data']: + if option['name'] == 'domain-name': + print(option['data']) +EOF +} + case "$action" in - commit) # add mapping for new lease - if [ -z "$client_name" ]; then - logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead" - client_name=$(echo "client-"$client_mac | tr : -) - fi - - if [ "$domain" == "..YYZ!" ]; then - client_fqdn_name=$client_name - client_search_expr=$client_name - else - client_fqdn_name=$client_name.$domain - client_search_expr="$client_name\\.$domain" - fi - $hostsd_client --add-hosts "$client_fqdn_name,$client_ip" --tag "dhcp-server-$client_ip" --apply + lease4_renew|lease4_recover) exit 0 ;; - release) # delete mapping for released address + lease4_release|lease4_expire|lease4_decline) # delete mapping for released/declined address + client_ip=$LEASE4_ADDRESS $hostsd_client --delete-hosts --tag "dhcp-server-$client_ip" --apply exit 0 ;; + leases4_committed) # process committed leases (added/renewed/recovered) + for ((i = 0; i < $LEASES4_SIZE; i++)); do + client_ip_var="LEASES4_AT${i}_ADDRESS" + client_mac_var="LEASES4_AT${i}_HWADDR" + client_name_var="LEASES4_AT${i}_HOSTNAME" + client_subnet_id_var="LEASES4_AT${i}_SUBNET_ID" + + client_ip=${!client_ip_var} + client_mac=${!client_mac_var} + client_name=${!client_name_var%.} + client_subnet_id=${!client_subnet_id_var} + + if [ -z "$client_name" ]; then + logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead" + client_name=$(echo "host-$client_mac" | tr : -) + fi + + client_domain=$(get_subnet_domain_name $client_subnet_id) + + if [[ -n "$client_domain" ]] && ! [[ $client_name =~ .*$client_domain$ ]]; then + client_name="$client_name.$client_domain" + fi + + $hostsd_client --add-hosts "$client_name,$client_ip" --tag "dhcp-server-$client_ip" --apply + done + + exit 0 + ;; + *) logger -s -t on-dhcp-event "Invalid command \"$1\"" exit 1 ;; esac - -exit 0 diff --git a/src/system/on-dhcpv6-event.sh b/src/system/on-dhcpv6-event.sh new file mode 100755 index 000000000..cbb370999 --- /dev/null +++ b/src/system/on-dhcpv6-event.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# 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/>. +# +# + +if [ $# -lt 1 ]; then + echo Invalid args + logger -s -t on-dhcpv6-event "Invalid args \"$@\"" + exit 1 +fi + +action=$1 + +case "$action" in + lease6_renew|lease6_recover) + exit 0 + ;; + + lease6_release|lease6_expire|lease6_decline) + ifname=$QUERY6_IFACE_NAME + lease_addr=$LEASE6_ADDRESS + lease_prefix_len=$LEASE6_PREFIX_LEN + + if [[ "$LEASE6_TYPE" != "IA_PD" ]]; then + exit 0 + fi + + logger -s -t on-dhcpv6-event "Processing route deletion for ${lease_addr}/${lease_prefix_len}" + route_cmd="sudo -n /sbin/ip -6 route del ${lease_addr}/${lease_prefix_len}" + + # the ifname is not always present, like in LEASE6_VALID_LIFETIME=0 updates, + # but 'route del' works either way. Use interface only if there is one. + if [[ "$ifname" != "" ]]; then + route_cmd+=" dev ${ifname}" + fi + route_cmd+=" proto static" + eval "$route_cmd" + + exit 0 + ;; + + leases6_committed) + for ((i = 0; i < $LEASES6_SIZE; i++)); do + ifname=$QUERY6_IFACE_NAME + requester_link_local=$QUERY6_REMOTE_ADDR + lease_type_var="LEASES6_AT${i}_TYPE" + lease_ip_var="LEASES6_AT${i}_ADDRESS" + lease_prefix_len_var="LEASES6_AT${i}_PREFIX_LEN" + + lease_type=${!lease_type_var} + + if [[ "$lease_type" != "IA_PD" ]]; then + continue + fi + + lease_ip=${!lease_ip_var} + lease_prefix_len=${!lease_prefix_len_var} + + logger -s -t on-dhcpv6-event "Processing PD route for ${lease_addr}/${lease_prefix_len}. Link local: ${requester_link_local} ifname: ${ifname}" + + sudo -n /sbin/ip -6 route replace ${lease_ip}/${lease_prefix_len} \ + via ${requester_link_local} \ + dev ${ifname} \ + proto static + done + + exit 0 + ;; + + *) + logger -s -t on-dhcpv6-event "Invalid command \"$1\"" + exit 1 + ;; +esac diff --git a/src/system/standalone_root_pw_reset b/src/system/standalone_root_pw_reset new file mode 100755 index 000000000..c82cea321 --- /dev/null +++ b/src/system/standalone_root_pw_reset @@ -0,0 +1,178 @@ +#!/bin/bash +# **** License **** +# 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 +# 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. +# +# This code was originally developed by Vyatta, Inc. +# Portions created by Vyatta are Copyright (C) 2007 Vyatta, Inc. +# All Rights Reserved. +# +# Author: Bob Gilligan <gilligan@vyatta.com> +# Description: Standalone script to set the admin passwd to new value +# value. Note: This script can ONLY be run as a standalone +# init program by grub. +# +# **** End License **** + +# The Vyatta config file: +CF=/opt/vyatta/etc/config/config.boot + +# Admin user name +ADMIN=vyos + +set_encrypted_password() { + sed -i \ + -e "/ user $1 {/,/encrypted-password/s/encrypted-password .*\$/encrypted-password \"$2\"/" $3 +} + + +# How long to wait for user to respond, in seconds +TIME_TO_WAIT=30 + +change_password() { + local user=$1 + local pwd1="1" + local pwd2="2" + + until [ "$pwd1" == "$pwd2" ] + do + read -p "Enter $user password: " -r -s pwd1 + echo + read -p "Retype $user password: " -r -s pwd2 + echo + + if [ "$pwd1" != "$pwd2" ] + then echo "Passwords do not match" + fi + done + + # set the password for the user then store it in the config + # so the user is recreated on the next full system boot. + local epwd=$(mkpasswd --method=sha-512 "$pwd1") + # escape any slashes in resulting password + local eepwd=$(sed 's:/:\\/:g' <<< $epwd) + set_encrypted_password $user $eepwd $CF +} + +# System is so messed up that doing anything would be a mistake +dead() { + echo $* + echo + echo "This tool can only recover missing admininistrator password." + echo "It is not a full system restore" + echo + echo -n "Hit return to reboot system: " + read + /sbin/reboot -f +} + +echo "Standalone root password recovery tool." +echo +# +# Check to see if we are running in standalone mode. We'll +# know that we are if our pid is 1. +# +if [ "$$" != "1" ]; then + echo "This tool can only be run in standalone mode." + exit 1 +fi + +# +# OK, now we know we are running in standalone mode. Talk to the +# user. +# +echo -n "Do you wish to reset the admin password? (y or n) " +read -t $TIME_TO_WAIT response +if [ "$?" != "0" ]; then + echo + echo "Response not received in time." + echo "The admin password will not be reset." + echo "Rebooting in 5 seconds..." + sleep 5 + echo + /sbin/reboot -f +fi + +response=${response:0:1} +if [ "$response" != "y" -a "$response" != "Y" ]; then + echo "OK, the admin password will not be reset." + echo -n "Rebooting in 5 seconds..." + sleep 5 + echo + /sbin/reboot -f +fi + +echo -en "Which admin account do you want to reset? [$ADMIN] " +read admin_user +ADMIN=${admin_user:-$ADMIN} + +echo "Starting process to reset the admin password..." + +echo "Re-mounting root filesystem read/write..." +mount -o remount,rw / + +if [ ! -f /etc/passwd ] +then dead "Missing password file" +fi + +if [ ! -d /opt/vyatta/etc/config ] +then dead "Missing VyOS config directory /opt/vyatta/etc/config" +fi + +# Leftover from V3.0 +if grep -q /opt/vyatta/etc/config /etc/fstab +then + echo "Mounting the config filesystem..." + mount /opt/vyatta/etc/config/ +fi + +if [ ! -f $CF ] +then dead "$CF file not found" +fi + +if ! grep -q 'system {' $CF +then dead "$CF file does not contain system settings" +fi + +if ! grep -q ' login {' $CF +then + # Recreate login section of system + sed -i -e '/system {/a\ + login {\ + }' $CF +fi + +if ! grep -q " user $ADMIN " $CF +then + echo "Recreating administrator $ADMIN in $CF..." + sed -i -e "/ login {/a\\ + user $ADMIN {\\ + authentication {\\ + encrypted-password \$6$IhbXHdwgYkLnt/$VRIsIN5c2f2v4L2l4F9WPDrRDEtWXzH75yBswmWGERAdX7oBxmq6m.sWON6pO6mi6mrVgYBxdVrFcCP5bI.nt.\\ + plaintext-password \"\"\\ + }\\ + level admin\\ + }" $CF +fi + +echo "Saving backup copy of config.boot..." +cp $CF ${CF}.before_pwrecovery +sync + +echo "Setting the administrator ($ADMIN) password..." +change_password $ADMIN + +echo $(date "+%b%e %T") $(hostname) "Admin password changed" \ + | tee -a /var/log/auth.log >>/var/log/messages + +sync + +echo "System will reboot in 10 seconds..." +sleep 10 +/sbin/reboot -f diff --git a/src/system/uacctd_stop.py b/src/system/uacctd_stop.py new file mode 100755 index 000000000..a1b57335b --- /dev/null +++ b/src/system/uacctd_stop.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# Control pmacct daemons in a tricky way. +# Pmacct has signal processing in a main loop, together with packet +# processing. Because of this, while it is waiting for packets, it cannot +# handle the control signal. We need to start the systemctl command and then +# send some packets to pmacct to wake it up + +from argparse import ArgumentParser +from socket import socket, AF_INET, SOCK_DGRAM +from sys import exit +from time import sleep + +from psutil import Process + + +def stop_process(pid: int, timeout: int) -> None: + """Send a signal to uacctd + and then send packets to special address predefined in a firewall + to unlock main loop in uacctd and finish the process properly + + Args: + pid (int): uacctd PID + timeout (int): seconds to wait for a process end + """ + # find a process + uacctd = Process(pid) + uacctd.terminate() + + # create a socket + trigger = socket(AF_INET, SOCK_DGRAM) + + first_cycle: bool = True + while uacctd.is_running() and timeout: + print('sending a packet to uacctd...') + trigger.sendto(b'WAKEUP', ('127.0.254.0', 1)) + # do not sleep during first attempt + if not first_cycle: + sleep(1) + timeout -= 1 + first_cycle = False + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('process_id', + type=int, + help='PID file of uacctd core process') + parser.add_argument('timeout', + type=int, + help='time to wait for process end') + args = parser.parse_args() + stop_process(args.process_id, args.timeout) + exit() diff --git a/src/systemd/isc-dhcp-server6.service b/src/systemd/isc-dhcp-server6.service deleted file mode 100644 index 1345c5fc5..000000000 --- a/src/systemd/isc-dhcp-server6.service +++ /dev/null @@ -1,24 +0,0 @@ -[Unit] -Description=ISC DHCP IPv6 server -Documentation=man:dhcpd(8) -RequiresMountsFor=/run -ConditionPathExists=/run/dhcp-server/dhcpdv6.conf -After=vyos-router.service - -[Service] -Type=forking -WorkingDirectory=/run/dhcp-server -RuntimeDirectory=dhcp-server -RuntimeDirectoryPreserve=yes -Environment=PID_FILE=/run/dhcp-server/dhcpdv6.pid CONFIG_FILE=/run/dhcp-server/dhcpdv6.conf LEASE_FILE=/config/dhcpdv6.leases -PIDFile=/run/dhcp-server/dhcpdv6.pid -ExecStartPre=/bin/sh -ec '\ -touch ${LEASE_FILE}; \ -chown nobody:nogroup ${LEASE_FILE}* ; \ -chmod 664 ${LEASE_FILE}* ; \ -/usr/sbin/dhcpd -6 -t -T -q -user dhcpd -group nogroup -pf ${PID_FILE} -cf ${CONFIG_FILE} -lf ${LEASE_FILE} ' -ExecStart=/usr/sbin/dhcpd -6 -q -user dhcpd -group nogroup -pf ${PID_FILE} -cf ${CONFIG_FILE} -lf ${LEASE_FILE} -Restart=always - -[Install] -WantedBy=multi-user.target diff --git a/src/systemd/vyos-grub-update.service b/src/systemd/vyos-grub-update.service new file mode 100644 index 000000000..522b13a33 --- /dev/null +++ b/src/systemd/vyos-grub-update.service @@ -0,0 +1,14 @@ +[Unit] +Description=Update GRUB loader configuration structure +After=local-fs.target +Before=vyos-router.service + +[Service] +Type=oneshot +ExecStart=/usr/libexec/vyos/system/grub_update.py +TimeoutSec=5 +KillMode=process +StandardOutput=journal+console + +[Install] +WantedBy=vyos-router.service
\ No newline at end of file diff --git a/src/tests/test_jinja_filters.py b/src/tests/test_jinja_filters.py deleted file mode 100644 index 8a7241fe3..000000000 --- a/src/tests/test_jinja_filters.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020 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 unittest import TestCase - -from ipaddress import ip_network -from vyos.template import address_from_cidr -from vyos.template import netmask_from_cidr -from vyos.template import is_ipv4 -from vyos.template import is_ipv6 -from vyos.template import first_host_address -from vyos.template import last_host_address -from vyos.template import inc_ip - -class TestTeamplteHelpers(TestCase): - def setUp(self): - pass - - def test_helpers_from_cidr(self): - network_v4 = '192.0.2.0/26' - self.assertEqual(address_from_cidr(network_v4), str(ip_network(network_v4).network_address)) - self.assertEqual(netmask_from_cidr(network_v4), str(ip_network(network_v4).netmask)) - - def test_helpers_ipv4(self): - self.assertTrue(is_ipv4('192.0.2.1')) - self.assertTrue(is_ipv4('192.0.2.0/24')) - self.assertTrue(is_ipv4('192.0.2.1/32')) - self.assertTrue(is_ipv4('10.255.1.2')) - self.assertTrue(is_ipv4('10.255.1.0/24')) - self.assertTrue(is_ipv4('10.255.1.2/32')) - self.assertFalse(is_ipv4('2001:db8::')) - self.assertFalse(is_ipv4('2001:db8::1')) - self.assertFalse(is_ipv4('2001:db8::/64')) - - def test_helpers_ipv6(self): - self.assertFalse(is_ipv6('192.0.2.1')) - self.assertFalse(is_ipv6('192.0.2.0/24')) - self.assertFalse(is_ipv6('192.0.2.1/32')) - self.assertFalse(is_ipv6('10.255.1.2')) - self.assertFalse(is_ipv6('10.255.1.0/24')) - self.assertFalse(is_ipv6('10.255.1.2/32')) - self.assertTrue(is_ipv6('2001:db8::')) - self.assertTrue(is_ipv6('2001:db8::1')) - self.assertTrue(is_ipv6('2001:db8::1/64')) - self.assertTrue(is_ipv6('2001:db8::/32')) - self.assertTrue(is_ipv6('2001:db8::/64')) - - def test_helpers_first_host_address(self): - self.assertEqual(first_host_address('10.0.0.0/24'), '10.0.0.1') - self.assertEqual(first_host_address('10.0.0.128/25'), '10.0.0.129') - self.assertEqual(first_host_address('10.0.0.200/29'), '10.0.0.201') - - self.assertEqual(first_host_address('2001:db8::/64'), '2001:db8::') - self.assertEqual(first_host_address('2001:db8::/112'), '2001:db8::') - self.assertEqual(first_host_address('2001:db8::10/112'), '2001:db8::10') - self.assertEqual(first_host_address('2001:db8::100/112'), '2001:db8::100') diff --git a/src/tests/test_task_scheduler.py b/src/tests/test_task_scheduler.py index f15fcde88..130f825e6 100644 --- a/src/tests/test_task_scheduler.py +++ b/src/tests/test_task_scheduler.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,16 +17,16 @@ import os import tempfile import unittest +import importlib from vyos import ConfigError try: - from src.conf_mode import task_scheduler + task_scheduler = importlib.import_module("src.conf_mode.system_task-scheduler") except ModuleNotFoundError: # for unittest.main() import sys sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) - from src.conf_mode import task_scheduler - + task_scheduler = importlib.import_module("src.conf_mode.system_task-scheduler") class TestUpdateCrontab(unittest.TestCase): diff --git a/src/tests/test_template.py b/src/tests/test_template.py index 2d065f545..aba97015e 100644 --- a/src/tests/test_template.py +++ b/src/tests/test_template.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,6 +17,7 @@ import os import vyos.template +from ipaddress import ip_network from unittest import TestCase class TestVyOSTemplate(TestCase): @@ -67,6 +68,9 @@ class TestVyOSTemplate(TestCase): # ValueError: 2001:db8::1/48 has host bits set self.assertEqual(vyos.template.address_from_cidr('2001:db8::1/48'), '2001:db8::1') + network_v4 = '192.0.2.0/26' + self.assertEqual(vyos.template.address_from_cidr(network_v4), str(ip_network(network_v4).network_address)) + def test_netmask_from_cidr(self): self.assertEqual(vyos.template.netmask_from_cidr('192.0.2.0/24'), '255.255.255.0') self.assertEqual(vyos.template.netmask_from_cidr('192.0.2.128/25'), '255.255.255.128') @@ -80,28 +84,35 @@ class TestVyOSTemplate(TestCase): # ValueError: 2001:db8:1:/64 has host bits set self.assertEqual(vyos.template.netmask_from_cidr('2001:db8:1:/64'), 'ffff:ffff:ffff:ffff::') + network_v4 = '192.0.2.0/26' + self.assertEqual(vyos.template.netmask_from_cidr(network_v4), str(ip_network(network_v4).netmask)) + def test_first_host_address(self): - self.assertEqual(vyos.template.first_host_address('10.0.0.0/24'), '10.0.0.1') - self.assertEqual(vyos.template.first_host_address('10.0.0.128/25'), '10.0.0.129') - self.assertEqual(vyos.template.first_host_address('2001:db8::/64'), '2001:db8::') + self.assertEqual(vyos.template.first_host_address('10.0.0.0/24'), '10.0.0.1') + self.assertEqual(vyos.template.first_host_address('10.0.0.10/24'), '10.0.0.1') + self.assertEqual(vyos.template.first_host_address('10.0.0.255/24'), '10.0.0.1') + self.assertEqual(vyos.template.first_host_address('10.0.0.128/25'), '10.0.0.129') + self.assertEqual(vyos.template.first_host_address('2001:db8::/64'), '2001:db8::1') + self.assertEqual(vyos.template.first_host_address('2001:db8::1000/64'), '2001:db8::1') + self.assertEqual(vyos.template.first_host_address('2001:db8::ffff:ffff:ffff:ffff/64'), '2001:db8::1') def test_last_host_address(self): - self.assertEqual(vyos.template.last_host_address('10.0.0.0/24'), '10.0.0.254') - self.assertEqual(vyos.template.last_host_address('10.0.0.128/25'), '10.0.0.254') - self.assertEqual(vyos.template.last_host_address('2001:db8::/64'), '2001:db8::ffff:ffff:ffff:ffff') + self.assertEqual(vyos.template.last_host_address('10.0.0.0/24'), '10.0.0.254') + self.assertEqual(vyos.template.last_host_address('10.0.0.128/25'), '10.0.0.254') + self.assertEqual(vyos.template.last_host_address('2001:db8::/64'), '2001:db8::ffff:ffff:ffff:ffff') def test_increment_ip(self): - self.assertEqual(vyos.template.inc_ip('10.0.0.0/24', '2'), '10.0.0.2') - self.assertEqual(vyos.template.inc_ip('10.0.0.0', '2'), '10.0.0.2') - self.assertEqual(vyos.template.inc_ip('10.0.0.0', '10'), '10.0.0.10') - self.assertEqual(vyos.template.inc_ip('2001:db8::/64', '2'), '2001:db8::2') - self.assertEqual(vyos.template.inc_ip('2001:db8::', '10'), '2001:db8::a') + self.assertEqual(vyos.template.inc_ip('10.0.0.0/24', '2'), '10.0.0.2') + self.assertEqual(vyos.template.inc_ip('10.0.0.0', '2'), '10.0.0.2') + self.assertEqual(vyos.template.inc_ip('10.0.0.0', '10'), '10.0.0.10') + self.assertEqual(vyos.template.inc_ip('2001:db8::/64', '2'), '2001:db8::2') + self.assertEqual(vyos.template.inc_ip('2001:db8::', '10'), '2001:db8::a') def test_decrement_ip(self): - self.assertEqual(vyos.template.dec_ip('10.0.0.100/24', '1'), '10.0.0.99') - self.assertEqual(vyos.template.dec_ip('10.0.0.90', '10'), '10.0.0.80') - self.assertEqual(vyos.template.dec_ip('2001:db8::b/64', '10'), '2001:db8::1') - self.assertEqual(vyos.template.dec_ip('2001:db8::f', '5'), '2001:db8::a') + self.assertEqual(vyos.template.dec_ip('10.0.0.100/24', '1'), '10.0.0.99') + self.assertEqual(vyos.template.dec_ip('10.0.0.90', '10'), '10.0.0.80') + self.assertEqual(vyos.template.dec_ip('2001:db8::b/64', '10'), '2001:db8::1') + self.assertEqual(vyos.template.dec_ip('2001:db8::f', '5'), '2001:db8::a') def test_is_network(self): self.assertFalse(vyos.template.is_ip_network('192.0.2.0')) @@ -181,4 +192,3 @@ class TestVyOSTemplate(TestCase): for group_name, group_config in data['ike_group'].items(): ciphers = vyos.template.get_esp_ike_cipher(group_config) self.assertIn(IKEv2_DEFAULT, ','.join(ciphers)) - diff --git a/src/validators/accel-radius-dictionary b/src/validators/accel-radius-dictionary deleted file mode 100755 index 05287e770..000000000 --- a/src/validators/accel-radius-dictionary +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -DICT_PATH=/usr/share/accel-ppp/radius -NAME=$1 - -if [ -n "$NAME" -a -e $DICT_PATH/dictionary.$NAME ]; then - exit 0 -else - echo "$NAME is not a valid RADIUS dictionary name" - echo "Please make sure that $DICT_PATH/dictionary.$NAME file exists" - exit 1 -fi - diff --git a/src/validators/bgp-large-community-list b/src/validators/bgp-large-community-list index 80112dfdc..9ba5b27eb 100755 --- a/src/validators/bgp-large-community-list +++ b/src/validators/bgp-large-community-list @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright (C) 2021-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,9 +17,8 @@ import re import sys -from vyos.template import is_ipv4 - pattern = '(.*):(.*):(.*)' +allowedChars = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '+', '*', '?', '^', '$', '(', ')', '[', ']', '{', '}', '|', '\\', ':', '-' } if __name__ == '__main__': if len(sys.argv) != 2: @@ -29,8 +28,7 @@ if __name__ == '__main__': if not len(value) == 3: sys.exit(1) - if not (re.match(pattern, sys.argv[1]) and - (is_ipv4(value[0]) or value[0].isdigit()) and (value[1].isdigit() or value[1] == '*')): + if not (re.match(pattern, sys.argv[1]) and set(sys.argv[1]).issubset(allowedChars)): sys.exit(1) sys.exit(0) diff --git a/src/validators/ddclient-protocol b/src/validators/ddclient-protocol index bc6826120..ce5efbd52 100755 --- a/src/validators/ddclient-protocol +++ b/src/validators/ddclient-protocol @@ -14,7 +14,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/>. -ddclient -list-protocols | grep -vE 'nsupdate|cloudns' | grep -qw $1 +ddclient -list-protocols | grep -vE 'cloudns|porkbun' | grep -qw $1 if [ $? -gt 0 ]; then echo "Error: $1 is not a valid protocol, please choose from the supported list of protocols" diff --git a/src/validators/ipv4-range-mask b/src/validators/ipv4-range-mask new file mode 100755 index 000000000..9373328ff --- /dev/null +++ b/src/validators/ipv4-range-mask @@ -0,0 +1,27 @@ +#!/bin/bash + +error_exit() { + echo "Error: $1 is not a valid IPv4 address range or these IPs are not under /$2" + exit 1 +} + +# Check if address range is under the same netmask +# -m - mask +# -r - IP range in format x.x.x.x-y.y.y.y +while getopts m:r: flag +do + case "${flag}" in + m) mask=${OPTARG};; + r) range=${OPTARG} + esac +done + +if [[ "${range}" =~ "-" ]]&&[[ ! -z ${mask} ]]; then + ipaddrcheck --range-prefix-length ${mask} --is-ipv4-range ${range} + if [ $? -gt 0 ]; then + error_exit ${range} ${mask} + fi + exit 0 +fi + +error_exit ${range} ${mask} diff --git a/src/validators/ipv6-duid b/src/validators/ipv6-duid deleted file mode 100755 index fd4728e50..000000000 --- a/src/validators/ipv6-duid +++ /dev/null @@ -1,27 +0,0 @@ -#!/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/>. - -import re -import sys - -pattern = "^([0-9A-Fa-f]{2}:){,127}([0-9A-Fa-f]{2})$" - -if __name__ == '__main__': - if len(sys.argv) != 2: - sys.exit(1) - if not re.match(pattern, sys.argv[1]): - sys.exit(1) - sys.exit(0) |