diff options
Diffstat (limited to 'src')
32 files changed, 2176 insertions, 548 deletions
| diff --git a/src/completion/list_ddclient_protocols.sh b/src/completion/list_ddclient_protocols.sh index c8855b5d1..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|porkbun') +echo -n $(ddclient -list-protocols | grep  -vE 'cloudns|porkbun') diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py index 2bccaee0f..3ddc8e7fd 100755 --- a/src/conf_mode/dns_dynamic.py +++ b/src/conf_mode/dns_dynamic.py @@ -30,16 +30,18 @@ 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'] +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', -                        'regfishde'] +                        'nsupdate', 'regfishde']  # Protocols that support TTL -ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn'] +ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn', +                 'nsupdate']  # Protocols that support both IPv4 and IPv6  dualstack_supported = ['cloudflare', 'digitalocean', 'dnsexit2', 'duckdns', @@ -70,63 +72,65 @@ def get_config(config=None):  def verify(dyndns):      # bail out early - looks like removal from running config -    if not dyndns or 'address' not in dyndns: +    if not dyndns or 'name' 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) +    # Dynamic DNS service provider - configuration validation +    for service, config in dyndns['name'].items(): -        # 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}"') +        error_msg_req = f'is required for Dynamic DNS service "{service}"' +        error_msg_uns = f'is not supported for Dynamic DNS service "{service}"' -        # Dynamic DNS service provider - configuration validation -        if 'web_options' in dyndns['address'][address] and address != 'web': -            raise ConfigError(f'"web-options" is applicable only when using HTTP(S) web request to obtain the IP address') +        for field in ['protocol', 'address', 'host_name']: +            if field not in config: +                raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}') -        # Dynamic DNS service provider - configuration validation -        if 'service' in dyndns['address'][address]: -            for service, config in dyndns['address'][address]['service'].items(): -                error_msg_req = f'is required for Dynamic DNS service "{service}" on "{address}"' -                error_msg_uns = f'is not supported for Dynamic DNS service "{service}" on "{address}" with protocol "{config["protocol"]}"' +        # If dyndns address is an interface, ensure that it exists +        # and that web-options are not set +        if config['address'] != 'web': +            verify_interface_exists(config['address']) +            if 'web_options' in config: +                raise ConfigError(f'"web-options" is applicable only when using HTTP(S) web request to obtain the IP address') -                for field in ['host_name', 'password', 'protocol']: -                    if field not in config: -                        raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}') +        # RFC2136 uses 'key' instead of 'password' +        if config['protocol'] != 'nsupdate' and 'password' not in config: +            raise ConfigError(f'"password" {error_msg_req}') -                if config['protocol'] in zone_necessary and 'zone' not in config: -                    raise ConfigError(f'"zone" {error_msg_req} with protocol "{config["protocol"]}"') +        # 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'] not in zone_supported and 'zone' in config: -                    raise ConfigError(f'"zone" {error_msg_uns}') +        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 username_unnecessary and 'username' not in config: -                    raise ConfigError(f'"username" {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 ttl_supported and 'ttl' in config: -                    raise ConfigError(f'"ttl" {error_msg_uns}') +        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['ip_version'] == 'both': -                    if config['protocol'] not in dualstack_supported: -                        raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns}') -                    # 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} for "{config["server"]}"') +        if config['protocol'] not in ttl_supported and 'ttl' in config: +            raise ConfigError(f'"ttl" {error_msg_uns} 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"') +        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} 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} 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 Dynamic DNS service "{service}"')      return None  def generate(dyndns):      # bail out early - looks like removal from running config -    if not dyndns or 'address' not in dyndns: +    if not dyndns or 'name' not in dyndns:          return None      render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns, permission=0o600) @@ -139,7 +143,7 @@ def apply(dyndns):      call('systemctl daemon-reload')      # bail out early - looks like removal from running config -    if not dyndns or 'address' not in dyndns: +    if not dyndns or 'name' not in dyndns:          call(f'systemctl stop {systemd_service}')          if os.path.exists(config_file):              os.unlink(config_file) diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py deleted file mode 100755 index d8fe3b736..000000000 --- a/src/conf_mode/http-api.py +++ /dev/null @@ -1,154 +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.utils.process import is_systemd_service_running -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: -        if is_systemd_service_running(f'{service_name}'): -            call(f'systemctl reload {service_name}') -        else: -            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 index 010490c7e..40b7de557 100755 --- a/src/conf_mode/https.py +++ b/src/conf_mode/https.py @@ -16,19 +16,24 @@  import os  import sys +import json  from copy import deepcopy +from time import sleep  import vyos.defaults  import vyos.certbot_util  from vyos.config import Config +from vyos.configdiff import get_config_diff  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.process import is_systemd_service_running +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 @@ -42,6 +47,9 @@ cert_dir = '/etc/ssl/certs'  key_dir = '/etc/ssl/private'  certbot_dir = vyos.defaults.directories['certbot'] +api_config_state = '/run/http-api-state' +systemd_service = '/run/systemd/system/vyos-http-api.service' +  # 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, @@ -52,7 +60,7 @@ default_server_block = {      'address'   : '*',      'port'      : '443',      'name'      : ['_'], -    'api'       : {}, +    'api'       : False,      'vyos_cert' : {},      'certbot'   : False  } @@ -67,15 +75,41 @@ def get_config(config=None):      if not conf.exists(base):          return None +    diff = get_config_diff(conf) +      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) +                                            no_tag_node_value_mangle=True, +                                            get_first_key=True) + +    https['children_changed'] = diff.node_changed_children(base) +    https['api_add_or_delete'] = diff.node_changed_presence(base + ['api']) + +    if 'api' not in https: +        return https + +    http_api = conf.get_config_dict(base + ['api'], key_mangling=('-', '_'), +                                    no_tag_node_value_mangle=True, +                                    get_first_key=True, +                                    with_recursive_defaults=True) + +    if http_api.from_defaults(['graphql']): +        del http_api['graphql'] + +    # 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) + +    https['api'] = http_api      return https  def verify(https): +    from vyos.utils.dict import dict_search +      if https is None:          return None @@ -101,7 +135,7 @@ def verify(https):          if 'certbot' in https['certificates']:              vhost_names = [] -            for vh, vh_conf in https.get('virtual-host', {}).items(): +            for _, 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] @@ -122,7 +156,7 @@ def verify(https):              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['port'] = data.get('port', '443')              server_block_list.append(server_block)      for entry in server_block_list: @@ -135,12 +169,44 @@ def verify(https):              raise ConfigError(f'"{proto}" 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') +      return None  def generate(https):      if https is None:          return None +    if 'api' not in https: +        if os.path.exists(systemd_service): +            os.unlink(systemd_service) +    else: +        render(systemd_service, 'https/vyos-http-api.service.j2', https['api']) +        with open(api_config_state, 'w') as f: +            json.dump(https['api'], f, indent=2) +      server_block_list = []      # organize by vhosts @@ -156,7 +222,7 @@ def generate(https):              server_block['id'] = vhost              data = vhost_dict.get(vhost, {})              server_block['address'] = data.get('listen-address', '*') -            server_block['port'] = data.get('listen-port', '443') +            server_block['port'] = data.get('port', '443')              name = data.get('server-name', ['_'])              server_block['name'] = name              allow_client = data.get('allow-client', {}) @@ -206,40 +272,18 @@ def generate(https):                      # 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', []) +        vhost_list = https.get('api-restrict', {}).get('virtual-host', [])          if not vhost_list:              for block in server_block_list: -                block['api'] = api_data +                block['api'] = True          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] +                    block['api'] = True      data = {          'server_block_list': server_block_list, -        'api_set': api_set,          'certbot': certbot      } @@ -250,10 +294,31 @@ def generate(https):  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') +    http_api_service_name = 'vyos-http-api.service' +    https_service_name = 'nginx.service' + +    if https is None: +        if is_systemd_service_active(f'{http_api_service_name}'): +            call(f'systemctl stop {http_api_service_name}') +        call(f'systemctl stop {https_service_name}') +        return + +    if 'api' in https['children_changed']: +        if 'api' in https: +            if is_systemd_service_running(f'{http_api_service_name}'): +                call(f'systemctl reload {http_api_service_name}') +            else: +                call(f'systemctl restart {http_api_service_name}') +            # Let uvicorn settle before (possibly) restarting nginx +            sleep(1) +        else: +            if is_systemd_service_active(f'{http_api_service_name}'): +                call(f'systemctl stop {http_api_service_name}') + +    if (not is_systemd_service_running(f'{https_service_name}') or +        https['api_add_or_delete'] or +        set(https['children_changed']) - set(['api'])): +        call(f'systemctl restart {https_service_name}')  if __name__ == '__main__':      try: diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index 0a03a172c..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): diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py index 6bf3227d5..4251e611b 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -60,8 +60,14 @@ def get_config(config=None):              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 @@ -98,14 +104,31 @@ def verify(vxlan):      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): +    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: -            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}') +            # 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" '\ @@ -165,7 +188,7 @@ 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): +    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!') 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_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/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/sysctl.d/30-vyos-router.conf b/src/etc/sysctl.d/30-vyos-router.conf index 1c9b8999f..67d96969e 100644 --- a/src/etc/sysctl.d/30-vyos-router.conf +++ b/src/etc/sysctl.d/30-vyos-router.conf @@ -105,3 +105,11 @@ net.core.rps_sock_flow_entries = 32768  net.core.default_qdisc=fq_codel  net.ipv4.tcp_congestion_control=bbr +# VRF - Virtual routing and forwarding +# When net.vrf.strict_mode=0 (default) it is possible to associate multiple +# VRF devices to the same table. Conversely, when net.vrf.strict_mode=1 a +# table can be associated to a single VRF device. +# +# A VRF table can be used by the VyOS CLI only once (ensured by verify()), +# this simply adds an additional Kernel safety net +net.vrf.strict_mode=1 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/migration-scripts/dns-dynamic/2-to-3 b/src/migration-scripts/dns-dynamic/2-to-3 new file mode 100755 index 000000000..187c2a895 --- /dev/null +++ b/src/migration-scripts/dns-dynamic/2-to-3 @@ -0,0 +1,88 @@ +#!/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> ..." + +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'] +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]): +            # Move RFC2136 as service configuration, rename to avoid name conflict and set protocol to 'nsupdate' +            if svc_type == 'rfc2136': +                for rfc_cfg_old in config.list_nodes(address_path_tag + ['rfc2136']): +                    rfc_cfg_new = f'{rfc_cfg_old}-rfc2136' +                    config.rename(address_path_tag + ['rfc2136', rfc_cfg_old], rfc_cfg_new) +                    config.set(address_path_tag + ['rfc2136', rfc_cfg_new, '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>' +            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 + [svc_cfg]) + +# Finally cleanup the old address path +config.delete(address_path) + +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/12-to-13 b/src/migration-scripts/firewall/12-to-13 index c2b34b2d8..4eaae779b 100755 --- a/src/migration-scripts/firewall/12-to-13 +++ b/src/migration-scripts/firewall/12-to-13 @@ -70,7 +70,7 @@ for family in ['ipv4', 'ipv6', 'bridge']:                                          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', state]) +                                            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']) 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/interfaces/31-to-32 b/src/migration-scripts/interfaces/31-to-32 index ca3d19320..0fc27b70a 100755 --- a/src/migration-scripts/interfaces/31-to-32 +++ b/src/migration-scripts/interfaces/31-to-32 @@ -15,6 +15,7 @@  # 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 @@ -43,6 +44,9 @@ for vxlan in config.list_nodes(base):      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()) 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/6-to-7 b/src/migration-scripts/policy/6-to-7 index 1f955aa02..727b8487a 100755 --- a/src/migration-scripts/policy/6-to-7 +++ b/src/migration-scripts/policy/6-to-7 @@ -66,7 +66,7 @@ for family in ['route', 'route6']:                                  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', state]) +                                    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']) 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/firewall.py b/src/op_mode/firewall.py index 20f54b9ba..36bb013fe 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -113,19 +113,14 @@ def output_firewall_name(family, hook, priority, firewall_conf, single_rule_id=N      if hook in ['input', 'forward', 'output']:          def_action = firewall_conf['default_action'] if 'default_action' in firewall_conf else 'accept' -        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) +    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)) -    elif '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) +    rows.append(row)      if rows:          header = ['Rule', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions'] @@ -314,7 +309,7 @@ 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 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/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..cdb84a152 --- /dev/null +++ b/src/op_mode/image_installer.py @@ -0,0 +1,787 @@ +#!/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 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.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 + +# 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_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 32 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' + +# default boot variables +DEFAULT_BOOT_VARS: dict[str, str] = { +    'timeout': '5', +    'console_type': 'tty', +    'console_num': '0', +    '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) +    disk.disk_cleanup(target_disk) +    disk_details: disk.DiskDetails = disk.parttable_create(target_disk, +                                                           rootfs_size) + +    return disk_details + + +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() + +    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() + +    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': +        for pubkey in [ +                '/usr/share/vyos/keys/vyos-release.minisign.pub', +                '/usr/share/vyos/keys/vyos-backup.minisign.pub' +        ]: +            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 image_fetch(image_path: 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 +    """ +    try: +        # check a type of path +        if urlparse(image_path).scheme: +            # download an image +            download(ISO_DOWNLOAD_PATH, image_path, True, True, +                     raise_error=True) +            # download a signature +            sign_file = (False, '') +            for sign_type in ['minisig', 'asc']: +                try: +                    download(f'{ISO_DOWNLOAD_PATH}.{sign_type}', +                             f'{image_path}.{sign_type}', raise_error=True) +                    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: +        print(f'The image cannot be fetched from: {image_path}') +        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) +    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) + +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') + +    # 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') + +        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, 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, 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: +            image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, version_name) +            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, +                     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'Whooops: {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('--no-prompt', action='store_true', +                        help='perform action non-interactively') +    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.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..e75485f9f --- /dev/null +++ b/src/op_mode/image_manager.py @@ -0,0 +1,210 @@ +#!/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' + + +@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] = 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] = 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/pki.py b/src/op_mode/pki.py index 35c7ce0e2..6c854afb5 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -896,11 +896,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' 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/services/vyos-http-api-server b/src/services/vyos-http-api-server index 3a9efb73e..bfd50cc80 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -50,7 +50,7 @@ from vyos.configsession import ConfigSession, ConfigSessionError  import api.graphql.state -DEFAULT_CONFIG_FILE = '/etc/vyos/http-api.conf' +api_config_state = '/run/http-api-state'  CFG_GROUP = 'vyattacfg'  debug = True @@ -68,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 @@ -223,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] @@ -236,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 @@ -713,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 @@ -733,6 +779,26 @@ 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 @@ -794,19 +860,28 @@ def shutdown_handler(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() +        keys = flatten_keys(server_config)      except Exception as e:          logger.critical(f'Failed to load the HTTP API server config: {e}')          sys.exit(1)      app.state.vyos_session = session -    app.state.vyos_keys = server_config['api_keys'] +    app.state.vyos_keys = keys -    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 @@ -815,7 +890,7 @@ def initialization(session: ConfigSession, app: FastAPI = app):                  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'] @@ -825,15 +900,7 @@ def initialization(session: ConfigSession, app: FastAPI = app):      if app.state.vyos_graphql:          graphql_init(app) -    if not server_config['socket']: -        config = ApiServerConfig(app, -                                 host=server_config["listen_address"], -                                 port=int(server_config["port"]), -                                 proxy_headers=True) -    else: -        config = ApiServerConfig(app, -                                 uds="/run/api.sock", -                                 proxy_headers=True) +    config = ApiServerConfig(app, uds="/run/api.sock", proxy_headers=True)      server = ApiServer(config)  def run_server(): diff --git a/src/system/grub_update.py b/src/system/grub_update.py new file mode 100644 index 000000000..3c851f0e0 --- /dev/null +++ b/src/system/grub_update.py @@ -0,0 +1,107 @@ +#!/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'] +    } +    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/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/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/validators/ddclient-protocol b/src/validators/ddclient-protocol index 8f455e12e..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|porkbun' | 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" | 
