diff options
Diffstat (limited to 'src')
| -rwxr-xr-x | src/conf_mode/pki.py | 11 | ||||
| -rwxr-xr-x | src/conf_mode/protocols_isis.py | 15 | ||||
| -rwxr-xr-x | src/conf_mode/protocols_ospf.py | 13 | ||||
| -rwxr-xr-x | src/conf_mode/service_dhcp-server.py | 15 | ||||
| -rwxr-xr-x | src/conf_mode/service_https.py | 237 | ||||
| -rw-r--r-- | src/etc/systemd/system/nginx.service.d/10-override.conf | 3 | ||||
| -rwxr-xr-x | src/helpers/vyos-boot-config-loader.py | 3 | ||||
| -rwxr-xr-x | src/migration-scripts/dhcp-server/8-to-9 | 69 | ||||
| -rwxr-xr-x | src/migration-scripts/https/5-to-6 | 76 | ||||
| -rwxr-xr-x | src/op_mode/image_manager.py | 25 | ||||
| -rw-r--r-- | src/op_mode/zone.py | 215 | ||||
| -rwxr-xr-x | src/services/vyos-http-api-server | 10 | ||||
| -rwxr-xr-x | src/system/on-dhcp-event.sh | 65 | 
13 files changed, 568 insertions, 189 deletions
| diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 239e44c3b..4be40e99e 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -130,28 +130,27 @@ def get_config(config=None):      if len(argv) > 1 and argv[1] == 'certbot_renew':          pki['certbot_renew'] = {} -    tmp = node_changed(conf, base + ['ca'], key_mangling=('-', '_'), recursive=True) +    tmp = node_changed(conf, base + ['ca'], recursive=True)      if tmp:          if 'changed' not in pki: pki.update({'changed':{}})          pki['changed'].update({'ca' : tmp}) -    tmp = node_changed(conf, base + ['certificate'], key_mangling=('-', '_'), recursive=True) +    tmp = node_changed(conf, base + ['certificate'], recursive=True)      if tmp:          if 'changed' not in pki: pki.update({'changed':{}})          pki['changed'].update({'certificate' : tmp}) -    tmp = node_changed(conf, base + ['dh'], key_mangling=('-', '_'), recursive=True) +    tmp = node_changed(conf, base + ['dh'], recursive=True)      if tmp:          if 'changed' not in pki: pki.update({'changed':{}})          pki['changed'].update({'dh' : tmp}) -    tmp = node_changed(conf, base + ['key-pair'], key_mangling=('-', '_'), recursive=True) +    tmp = node_changed(conf, base + ['key-pair'], recursive=True)      if tmp:          if 'changed' not in pki: pki.update({'changed':{}})          pki['changed'].update({'key_pair' : tmp}) -    tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], key_mangling=('-', '_'), -                       recursive=True) +    tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], recursive=True)      if tmp:          if 'changed' not in pki: pki.update({'changed':{}})          pki['changed'].update({'openvpn' : tmp}) diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index ce67ccff7..8d594bb68 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -220,7 +220,20 @@ def verify(isis):                  if ("explicit_null" in prefix_config['index']) and ("no_php_flag" in prefix_config['index']):                      raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\                                        f'and no-php-flag configured at the same time.') -                 +  +    # Check for index ranges being larger than the segment routing global block     +    if dict_search('segment_routing.global_block', isis): +        g_high_label_value = dict_search('segment_routing.global_block.high_label_value', isis) +        g_low_label_value = dict_search('segment_routing.global_block.low_label_value', isis) +        g_label_difference = int(g_high_label_value) - int(g_low_label_value) +        if dict_search('segment_routing.prefix', isis): +            for prefix, prefix_config in isis['segment_routing']['prefix'].items(): +                if 'index' in prefix_config: +                    index_size = isis['segment_routing']['prefix'][prefix]['index']['value'] +                    if int(index_size) > int(g_label_difference): +                        raise ConfigError(f'Segment routing prefix {prefix} cannot have an '\ +                                          f'index base size larger than the SRGB label base.') +                  # Check for LFA tiebreaker index duplication      if dict_search('fast_reroute.lfa.local.tiebreaker', isis):          comparison_dictionary = {} diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index 2f07142a3..34cf49286 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -213,6 +213,19 @@ def verify(ospf):                      raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\                                        f'and no-php-flag configured at the same time.') +    # Check for index ranges being larger than the segment routing global block     +    if dict_search('segment_routing.global_block', ospf): +        g_high_label_value = dict_search('segment_routing.global_block.high_label_value', ospf) +        g_low_label_value = dict_search('segment_routing.global_block.low_label_value', ospf) +        g_label_difference = int(g_high_label_value) - int(g_low_label_value) +        if dict_search('segment_routing.prefix', ospf): +            for prefix, prefix_config in ospf['segment_routing']['prefix'].items(): +                if 'index' in prefix_config: +                    index_size = ospf['segment_routing']['prefix'][prefix]['index']['value'] +                    if int(index_size) > int(g_label_difference): +                        raise ConfigError(f'Segment routing prefix {prefix} cannot have an '\ +                                          f'index base size larger than the SRGB label base.') +      # Check route summarisation      if 'summary_address' in ospf:          for prefix, prefix_options in ospf['summary_address'].items(): diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index 7ebc560ba..ceaba019e 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -31,6 +31,7 @@ from vyos.utils.file import chmod_775  from vyos.utils.file import makedir  from vyos.utils.file import write_file  from vyos.utils.process import call +from vyos.utils.network import interface_exists  from vyos.utils.network import is_subnet_connected  from vyos.utils.network import is_addr_assigned  from vyos import ConfigError @@ -222,6 +223,7 @@ def verify(dhcp):              if 'static_mapping' in subnet_config:                  # Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set) +                used_ips = []                  for mapping, mapping_config in subnet_config['static_mapping'].items():                      if 'ip_address' in mapping_config:                          if ip_address(mapping_config['ip_address']) not in ip_network(subnet): @@ -233,6 +235,11 @@ def verify(dhcp):                              raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for '                                                f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') +                        if mapping_config['ip_address'] in used_ips: +                            raise ConfigError(f'Configured IP address for static mapping "{mapping}" exists on another static mapping') + +                        used_ips.append(mapping_config['ip_address']) +              # There must be one subnet connected to a listen interface.              # This only counts if the network itself is not disabled!              if 'disable' not in network_config: @@ -294,12 +301,18 @@ def verify(dhcp):          else:              raise ConfigError(f'listen-address "{address}" not configured on any interface') -      if not listen_ok:          raise ConfigError('None of the configured subnets have an appropriate primary IP address on any\n'                            'broadcast interface configured, nor was there an explicit listen-address\n'                            'configured for serving DHCP relay packets!') +    if 'listen_address' in dhcp and 'listen_interface' in dhcp: +        raise ConfigError(f'Cannot define listen-address and listen-interface at the same time') + +    for interface in (dict_search('listen_interface', dhcp) or []): +        if not interface_exists(interface): +            raise ConfigError(f'listen-interface "{interface}" does not exist') +      return None  def generate(dhcp): diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index 2e7ebda5a..46efc3c93 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -15,51 +15,41 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  import os +import socket  import sys  import json -from copy import deepcopy  from time import sleep -import vyos.defaults -  from vyos.base import Warning  from vyos.config import Config +from vyos.config import config_dict_merge  from vyos.configdiff import get_config_diff  from vyos.configverify import verify_vrf -from vyos import ConfigError +from vyos.defaults import api_config_state  from vyos.pki import wrap_certificate  from vyos.pki import wrap_private_key +from vyos.pki import wrap_dh_parameters +from vyos.pki import load_dh_parameters  from vyos.template import render +from vyos.utils.dict import dict_search  from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active  from vyos.utils.network import check_port_availability  from vyos.utils.network import is_listen_port_bind_service  from vyos.utils.file import write_file - +from vyos import ConfigError  from vyos import airbag  airbag.enable() -config_file = '/etc/nginx/sites-available/default' +config_file = '/etc/nginx/sites-enabled/default'  systemd_override = r'/run/systemd/system/nginx.service.d/override.conf' -cert_dir = '/etc/ssl/certs' -key_dir = '/etc/ssl/private' - -api_config_state = '/run/http-api-state' -systemd_service = '/run/systemd/system/vyos-http-api.service' - -# https config needs to coordinate several subsystems: api, -# self-signed certificate, as well as the virtual hosts defined within the -# https config definition itself. Consequently, one needs a general dict, -# encompassing the https and other configs, and a list of such virtual hosts -# (server blocks in nginx terminology) to pass to the jinja2 template. -default_server_block = { -    'id'        : '', -    'address'   : '*', -    'port'      : '443', -    'name'      : ['_'], -    'api'       : False, -    'vyos_cert' : {}, -} +cert_dir = '/run/nginx/certs' + +user = 'www-data' +group = 'www-data' + +systemd_service_api = '/run/systemd/system/vyos-http-api.service'  def get_config(config=None):      if config: @@ -71,83 +61,70 @@ 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, with_pki=True) +    https = conf.get_config_dict(base, get_first_key=True, +                                 key_mangling=('-', '_'), +                                 with_pki=True) -    https['api_add_or_delete'] = diff.node_changed_presence(base + ['api']) +    # store path to API config file for later use in templates +    https['api_config_state'] = api_config_state +    # get fully qualified system hsotname +    https['hostname'] = socket.getfqdn() -    if 'api' not in https: -        return https +    # We have gathered the dict representation of the CLI, but there are default +    # options which we need to update into the dictionary retrived. +    default_values = conf.get_config_defaults(**https.kwargs, recursive=True) +    if 'api' not in https or 'graphql' not in https['api']: +        del default_values['api'] -    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 +    # merge CLI and default dictionary +    https = config_dict_merge(default_values, https)      return https  def verify(https): -    from vyos.utils.dict import dict_search -      if https is None:          return None -    if 'certificates' in https: -        certificates = https['certificates'] +    if 'certificates' in https and 'certificate' in https['certificates']: +        cert_name = https['certificates']['certificate'] +        if 'pki' not in https: +            raise ConfigError('PKI is not configured!') -        if 'certificate' in certificates: -            if not https['pki']: -                raise ConfigError('PKI is not configured') +        if cert_name not in https['pki']['certificate']: +            raise ConfigError('Invalid certificate in configuration!') -            cert_name = certificates['certificate'] +        pki_cert = https['pki']['certificate'][cert_name] -            if cert_name not in https['pki']['certificate']: -                raise ConfigError('Invalid certificate on https configuration') +        if 'certificate' not in pki_cert: +            raise ConfigError('Missing certificate in configuration!') -            pki_cert = https['pki']['certificate'][cert_name] +        if 'private' not in pki_cert or 'key' not in pki_cert['private']: +            raise ConfigError('Missing certificate private key in configuration!') -            if 'certificate' not in pki_cert: -                raise ConfigError('Missing certificate on https configuration') +        if 'dh_params' in https['certificates']: +            dh_name = https['certificates']['dh_params'] +            if dh_name not in https['pki']['dh']: +                raise ConfigError('Invalid DH parameter in configuration!') -            if 'private' not in pki_cert or 'key' not in pki_cert['private']: -                raise ConfigError("Missing certificate private key on https configuration") -    else: -        Warning('No certificate specified, using buildin self-signed certificates!') +            pki_dh = https['pki']['dh'][dh_name] +            dh_params = load_dh_parameters(pki_dh['parameters']) +            dh_numbers = dh_params.parameter_numbers() +            dh_bits = dh_numbers.p.bit_length() +            if dh_bits < 2048: +                raise ConfigError(f'Minimum DH key-size is 2048 bits') -    server_block_list = [] +    else: +        Warning('No certificate specified, using build-in self-signed certificates. '\ +                'Do not use them in a production environment!') -    # organize by vhosts -    vhost_dict = https.get('virtual-host', {}) +    # Check if server port is already in use by a different appliaction +    listen_address = ['0.0.0.0'] +    port = int(https['port']) +    if 'listen_address' in https: +        listen_address = https['listen_address'] -    if not vhost_dict: -        # no specified virtual hosts (server blocks); use default -        server_block_list.append(default_server_block) -    else: -        for vhost in list(vhost_dict): -            server_block = deepcopy(default_server_block) -            data = vhost_dict.get(vhost, {}) -            server_block['address'] = data.get('listen-address', '*') -            server_block['port'] = data.get('port', '443') -            server_block_list.append(server_block) - -    for entry in server_block_list: -        _address = entry.get('address') -        _address = '0.0.0.0' if _address == '*' else _address -        _port = entry.get('port') -        proto = 'tcp' -        if check_port_availability(_address, int(_port), proto) is not True and \ -                not is_listen_port_bind_service(int(_port), 'nginx'): -            raise ConfigError(f'"{proto}" port "{_port}" is used by another service') +    for address in listen_address: +        if not check_port_availability(address, port, 'tcp') and not is_listen_port_bind_service(port, 'nginx'): +            raise ConfigError(f'TCP port "{port}" is used by another service!')      verify_vrf(https) @@ -172,89 +149,61 @@ def verify(https):          # 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') +            raise ConfigError('At least one HTTPS API key is required unless GraphQL token authentication is enabled!')          if (not valid_keys_exist) and jwt_auth: -            Warning(f'API keys are not configured: the classic (non-GraphQL) API will be unavailable.') +            Warning(f'API keys are not configured: classic (non-GraphQL) API will be unavailable!')      return None  def generate(https):      if https is None: +        for file in [systemd_service_api, config_file, systemd_override]: +            if os.path.exists(file): +                os.unlink(file)          return None -    if 'api' 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']) +    if 'api' in https: +        render(systemd_service_api, 'https/vyos-http-api.service.j2', https)          with open(api_config_state, 'w') as f:              json.dump(https['api'], f, indent=2) - -    server_block_list = [] - -    # organize by vhosts - -    vhost_dict = https.get('virtual-host', {}) - -    if not vhost_dict: -        # no specified virtual hosts (server blocks); use default -        server_block_list.append(default_server_block)      else: -        for vhost in list(vhost_dict): -            server_block = deepcopy(default_server_block) -            server_block['id'] = vhost -            data = vhost_dict.get(vhost, {}) -            server_block['address'] = data.get('listen-address', '*') -            server_block['port'] = data.get('port', '443') -            name = data.get('server-name', ['_']) -            server_block['name'] = name -            allow_client = data.get('allow-client', {}) -            server_block['allow_client'] = allow_client.get('address', []) -            server_block_list.append(server_block) +        if os.path.exists(systemd_service_api): +            os.unlink(systemd_service_api)      # get certificate data - -    cert_dict = https.get('certificates', {}) - -    if 'certificate' in cert_dict: -        cert_name = cert_dict['certificate'] +    if 'certificates' in https and 'certificate' in https['certificates']: +        cert_name = https['certificates']['certificate']          pki_cert = https['pki']['certificate'][cert_name] -        cert_path = os.path.join(cert_dir, f'{cert_name}.pem') -        key_path = os.path.join(key_dir, f'{cert_name}.pem') +        cert_path = os.path.join(cert_dir, f'{cert_name}_cert.pem') +        key_path = os.path.join(cert_dir, f'{cert_name}_key.pem')          server_cert = str(wrap_certificate(pki_cert['certificate'])) -        if 'ca-certificate' in cert_dict: -            ca_cert = cert_dict['ca-certificate'] -            server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate'])) -        write_file(cert_path, server_cert) -        write_file(key_path, wrap_private_key(pki_cert['private']['key'])) +        # Append CA certificate if specified to form a full chain +        if 'ca_certificate' in https['certificates']: +            ca_cert = https['certificates']['ca_certificate'] +            server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate'])) -        vyos_cert_data = { -            'crt': cert_path, -            'key': key_path -        } +        write_file(cert_path, server_cert, user=user, group=group, mode=0o644) +        write_file(key_path, wrap_private_key(pki_cert['private']['key']), +                    user=user, group=group, mode=0o600) -        for block in server_block_list: -            block['vyos_cert'] = vyos_cert_data +        tmp_path = {'cert_path': cert_path, 'key_path': key_path} -    if 'api' in list(https): -        vhost_list = https.get('api-restrict', {}).get('virtual-host', []) -        if not vhost_list: -            for block in server_block_list: -                block['api'] = True -        else: -            for block in server_block_list: -                if block['id'] in vhost_list: -                    block['api'] = True +        if 'dh_params' in https['certificates']: +            dh_name = https['certificates']['dh_params'] +            pki_dh = https['pki']['dh'][dh_name] +            if 'parameters' in pki_dh: +                dh_path = os.path.join(cert_dir, f'{dh_name}_dh.pem') +                write_file(dh_path, wrap_dh_parameters(pki_dh['parameters']), +                           user=user, group=group, mode=0o600) +                tmp_path.update({'dh_file' : dh_path}) -    data = { -        'server_block_list': server_block_list, -    } +        https['certificates'].update(tmp_path) -    render(config_file, 'https/nginx.default.j2', data) +    render(config_file, 'https/nginx.default.j2', https)      render(systemd_override, 'https/override.conf.j2', https)      return None @@ -273,7 +222,7 @@ def apply(https):          call(f'systemctl reload-or-restart {http_api_service_name}')          # Let uvicorn settle before (possibly) restarting nginx          sleep(1) -    else: +    elif is_systemd_service_active(http_api_service_name):          call(f'systemctl stop {http_api_service_name}')      call(f'systemctl reload-or-restart {https_service_name}') diff --git a/src/etc/systemd/system/nginx.service.d/10-override.conf b/src/etc/systemd/system/nginx.service.d/10-override.conf new file mode 100644 index 000000000..1be5cec81 --- /dev/null +++ b/src/etc/systemd/system/nginx.service.d/10-override.conf @@ -0,0 +1,3 @@ +[Unit] +After= +After=vyos-router.service diff --git a/src/helpers/vyos-boot-config-loader.py b/src/helpers/vyos-boot-config-loader.py index 01b06526d..42de696ce 100755 --- a/src/helpers/vyos-boot-config-loader.py +++ b/src/helpers/vyos-boot-config-loader.py @@ -102,7 +102,8 @@ def failsafe(config_file_name):                                        'authentication',                                        'encrypted-password']) -    cmd(f"useradd -s /bin/bash -G 'users,sudo' -m -N -p '{passwd}' vyos") +    cmd(f"useradd --create-home --no-user-group --shell /bin/vbash --password '{passwd}' "\ +        "--groups frr,frrvty,vyattacfg,sudo,adm,dip,disk vyos")  if __name__ == '__main__':      if len(sys.argv) < 2: diff --git a/src/migration-scripts/dhcp-server/8-to-9 b/src/migration-scripts/dhcp-server/8-to-9 new file mode 100755 index 000000000..908420c18 --- /dev/null +++ b/src/migration-scripts/dhcp-server/8-to-9 @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +# T3316: +# - Migrate dhcp options under new option node + +import sys +import re +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: +    print("Must specify file name!") +    sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +base = ['service', 'dhcp-server', 'shared-network-name'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    sys.exit(0) + +option_nodes = ['bootfile-name', 'bootfile-server', 'bootfile-size', 'captive-portal', +                'client-prefix-length', 'default-router', 'domain-name', 'domain-search', +                'name-server', 'ip-forwarding', 'ipv6-only-preferred', 'ntp-server', +                'pop-server', 'server-identifier', 'smtp-server', 'static-route', +                'tftp-server-name', 'time-offset', 'time-server', 'time-zone', +                'vendor-option', 'wins-server', 'wpad-url'] + +for network in config.list_nodes(base): +    for option in option_nodes: +        if config.exists(base + [network, option]): +            config.set(base + [network, 'option']) +            config.copy(base + [network, option], base + [network, 'option', option]) +            config.delete(base + [network, option]) + +    if config.exists(base + [network, 'subnet']): +        for subnet in config.list_nodes(base + [network, 'subnet']): +            base_subnet = base + [network, 'subnet', subnet] +             +            for option in option_nodes: +                if config.exists(base + [network, 'subnet', subnet, option]): +                    config.set(base + [network, 'subnet', subnet, 'option']) +                    config.copy(base + [network, 'subnet', subnet, option], base + [network, 'subnet', subnet, 'option', option]) +                    config.delete(base + [network, 'subnet', subnet, option]) + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print("Failed to save the modified config: {}".format(e)) +    exit(1) diff --git a/src/migration-scripts/https/5-to-6 b/src/migration-scripts/https/5-to-6 index b4159f02f..6d6efd32c 100755 --- a/src/migration-scripts/https/5-to-6 +++ b/src/migration-scripts/https/5-to-6 @@ -16,12 +16,14 @@  # T5886: Add support for ACME protocol (LetsEncrypt), migrate https certbot  #        to new "pki certificate" CLI tree +# T5902: Remove virtual-host  import os  import sys  from vyos.configtree import ConfigTree  from vyos.defaults import directories +from vyos.utils.process import cmd  vyos_certbot_dir = directories['certbot'] @@ -36,30 +38,68 @@ with open(file_name, 'r') as f:  config = ConfigTree(config_file) -base = ['service', 'https', 'certificates'] +base = ['service', 'https']  if not config.exists(base):      # Nothing to do      sys.exit(0) -# both domain-name and email must be set on CLI - ensured by previous verify() -domain_names = config.return_values(base + ['certbot', 'domain-name']) -email = config.return_value(base + ['certbot', 'email']) -config.delete(base) - -# Set default certname based on domain-name -cert_name = 'https-' + domain_names[0].split('.')[0] -# Overwrite certname from previous certbot calls if available -if os.path.exists(f'{vyos_certbot_dir}/live'): -    for cert in [f.path.split('/')[-1] for f in os.scandir(f'{vyos_certbot_dir}/live') if f.is_dir()]: -        cert_name = cert -        break - -for domain in domain_names: -    config.set(['pki', 'certificate', cert_name, 'acme', 'domain-name'], value=domain, replace=False) +if config.exists(base + ['certificates']): +    # both domain-name and email must be set on CLI - ensured by previous verify() +    domain_names = config.return_values(base + ['certificates', 'certbot', 'domain-name']) +    email = config.return_value(base + ['certificates', 'certbot', 'email']) +    config.delete(base + ['certificates']) + +    # Set default certname based on domain-name +    cert_name = 'https-' + domain_names[0].split('.')[0] +    # Overwrite certname from previous certbot calls if available +    # We can not use python code like os.scandir due to filesystem permissions. +    # This must be run as root +    certbot_live = f'{vyos_certbot_dir}/live/' # we need the trailing / +    if os.path.exists(certbot_live): +        tmp = cmd(f'sudo find {certbot_live} -maxdepth 1 -type d') +        tmp = tmp.split() # tmp = ['/config/auth/letsencrypt/live', '/config/auth/letsencrypt/live/router.vyos.net'] +        tmp.remove(certbot_live) +        cert_name = tmp[0].replace(certbot_live, '') +      config.set(['pki', 'certificate', cert_name, 'acme', 'email'], value=email) +    config.set_tag(['pki', 'certificate']) +    for domain in domain_names: +        config.set(['pki', 'certificate', cert_name, 'acme', 'domain-name'], value=domain, replace=False) + +    # Update Webserver certificate +    config.set(base + ['certificates', 'certificate'], value=cert_name) + +if config.exists(base + ['virtual-host']): +    allow_client = [] +    listen_port = [] +    listen_address = [] +    for virtual_host in config.list_nodes(base + ['virtual-host']): +        allow_path = base + ['virtual-host', virtual_host, 'allow-client', 'address'] +        if config.exists(allow_path): +            tmp = config.return_values(allow_path) +            allow_client.extend(tmp) + +        port_path = base + ['virtual-host', virtual_host, 'listen-port'] +        if config.exists(port_path): +            tmp = config.return_value(port_path) +            listen_port.append(tmp) + +        listen_address_path = base + ['virtual-host', virtual_host, 'listen-address'] +        if config.exists(listen_address_path): +            tmp = config.return_value(listen_address_path) +            listen_address.append(tmp) + +    config.delete(base + ['virtual-host']) +    for client in allow_client: +        config.set(base + ['allow-client', 'address'], value=client, replace=False) + +    #  clear listen-address if "all" were specified +    if '*' in listen_address: +        listen_address = [] +    for address in listen_address: +        config.set(base + ['listen-address'], value=address, replace=False) + -# Update Webserver certificate -config.set(base + ['certificate'], value=cert_name)  try:      with open(file_name, 'w') as f: diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py index e75485f9f..e64a85b95 100755 --- a/src/op_mode/image_manager.py +++ b/src/op_mode/image_manager.py @@ -33,6 +33,27 @@ DELETE_IMAGE_PROMPT_MSG: str = 'Select an image to delete:'  MSG_DELETE_IMAGE_RUNNING: str = 'Currently running image cannot be deleted; reboot into another image first'  MSG_DELETE_IMAGE_DEFAULT: str = 'Default image cannot be deleted; set another image as default first' +def annotated_list(images_list: list[str]) -> list[str]: +    """Annotate list of images with additional info + +    Args: +        images_list (list[str]): a list of image names + +    Returns: +        list[str]: a list of image names with additional info +    """ +    index_running: int = None +    index_default: int = None +    try: +        index_running = images_list.index(image.get_running_image()) +        index_default = images_list.index(image.get_default_image()) +    except ValueError: +        pass +    if index_running is not None: +        images_list[index_running] += ' (running)' +    if index_default is not None: +        images_list[index_default] += ' (default boot)' +    return images_list  @compat.grub_cfg_update  def delete_image(image_name: Optional[str] = None, @@ -42,7 +63,7 @@ def delete_image(image_name: Optional[str] = None,      Args:          image_name (str): a name of image to delete      """ -    available_images: list[str] = grub.version_list() +    available_images: list[str] = annotated_list(grub.version_list())      if image_name is None:          if no_prompt:              exit('An image name is required for delete action') @@ -83,7 +104,7 @@ def set_image(image_name: Optional[str] = None,      Args:          image_name (str): an image name      """ -    available_images: list[str] = grub.version_list() +    available_images: list[str] = annotated_list(grub.version_list())      if image_name is None:          if not prompt:              exit('An image name is required for set action') diff --git a/src/op_mode/zone.py b/src/op_mode/zone.py new file mode 100644 index 000000000..d24b1065b --- /dev/null +++ b/src/op_mode/zone.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +import typing +import sys +import vyos.opmode + +import tabulate +from vyos.configquery import ConfigTreeQuery +from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search + + +def get_config_zone(conf, name=None): +    config_path = ['firewall', 'zone'] +    if name: +        config_path += [name] + +    zone_policy = conf.get_config_dict(config_path, key_mangling=('-', '_'), +                                       get_first_key=True, +                                       no_tag_node_value_mangle=True) +    return zone_policy + + +def _convert_one_zone_data(zone: str, zone_config: dict) -> dict: +    """ +    Convert config dictionary of one zone to API dictionary +    :param zone: Zone name +    :type zone: str +    :param zone_config: config dictionary +    :type zone_config: dict +    :return: AP dictionary +    :rtype: dict +    """ +    list_of_rules = [] +    intrazone_dict = {} +    if dict_search('from', zone_config): +        for from_zone, from_zone_config in zone_config['from'].items(): +            from_zone_dict = {'name': from_zone} +            if dict_search('firewall.name', from_zone_config): +                from_zone_dict['firewall'] = dict_search('firewall.name', +                                                         from_zone_config) +            if dict_search('firewall.ipv6_name', from_zone_config): +                from_zone_dict['firewall_v6'] = dict_search( +                    'firewall.ipv6_name', from_zone_config) +            list_of_rules.append(from_zone_dict) + +    zone_dict = { +        'name': zone, +        'interface': dict_search('interface', zone_config), +        'type': 'LOCAL' if dict_search('local_zone', +                                       zone_config) is not None else None, +    } +    if list_of_rules: +        zone_dict['from'] = list_of_rules +    if dict_search('intra_zone_filtering.firewall.name', zone_config): +        intrazone_dict['firewall'] = dict_search( +            'intra_zone_filtering.firewall.name', zone_config) +    if dict_search('intra_zone_filtering.firewall.ipv6_name', zone_config): +        intrazone_dict['firewall_v6'] = dict_search( +            'intra_zone_filtering.firewall.ipv6_name', zone_config) +    if intrazone_dict: +        zone_dict['intrazone'] = intrazone_dict +    return zone_dict + + +def _convert_zones_data(zone_policies: dict) -> list: +    """ +    Convert all config dictionary to API list of zone dictionaries +    :param zone_policies: config dictionary +    :type zone_policies: dict +    :return: API list +    :rtype: list +    """ +    zone_list = [] +    for zone, zone_config in zone_policies.items(): +        zone_list.append(_convert_one_zone_data(zone, zone_config)) +    return zone_list + + +def _convert_config(zones_config: dict, zone: str = None) -> list: +    """ +    convert config to API list +    :param zones_config: zones config +    :type zones_config: +    :param zone: zone name +    :type zone: str +    :return: API list +    :rtype: list +    """ +    if zone: +        if zones_config: +            output = [_convert_one_zone_data(zone, zones_config)] +        else: +            raise vyos.opmode.DataUnavailable(f'Zone {zone} not found') +    else: +        if zones_config: +            output = _convert_zones_data(zones_config) +        else: +            raise vyos.opmode.UnconfiguredSubsystem( +                'Zone entries are not configured') +    return output + + +def output_zone_list(zone_conf: dict) -> list: +    """ +    Format one zone row +    :param zone_conf: zone config +    :type zone_conf: dict +    :return: formatted list of zones +    :rtype: list +    """ +    zone_info = [zone_conf['name']] +    if zone_conf['type'] == 'LOCAL': +        zone_info.append('LOCAL') +    else: +        zone_info.append("\n".join(zone_conf['interface'])) + +    from_zone = [] +    firewall = [] +    firewall_v6 = [] +    if 'intrazone' in zone_conf: +        from_zone.append(zone_conf['name']) + +        v4_name = dict_search_args(zone_conf['intrazone'], 'firewall') +        v6_name = dict_search_args(zone_conf['intrazone'], 'firewall_v6') +        if v4_name: +            firewall.append(v4_name) +        else: +            firewall.append('') +        if v6_name: +            firewall_v6.append(v6_name) +        else: +            firewall_v6.append('') + +    if 'from' in zone_conf: +        for from_conf in zone_conf['from']: +            from_zone.append(from_conf['name']) + +            v4_name = dict_search_args(from_conf, 'firewall') +            v6_name = dict_search_args(from_conf, 'firewall_v6') +            if v4_name: +                firewall.append(v4_name) +            else: +                firewall.append('') +            if v6_name: +                firewall_v6.append(v6_name) +            else: +                firewall_v6.append('') + +    zone_info.append("\n".join(from_zone)) +    zone_info.append("\n".join(firewall)) +    zone_info.append("\n".join(firewall_v6)) +    return zone_info + + +def get_formatted_output(zone_policy: list) -> str: +    """ +    Formatted output of all zones +    :param zone_policy: list of zones +    :type zone_policy: list +    :return: formatted table with zones +    :rtype: str +    """ +    headers = ["Zone", +               "Interfaces", +               "From Zone", +               "Firewall IPv4", +               "Firewall IPv6" +               ] +    formatted_list = [] +    for zone_conf in zone_policy: +        formatted_list.append(output_zone_list(zone_conf)) +    tabulate.PRESERVE_WHITESPACE = True +    output = tabulate.tabulate(formatted_list, headers, numalign="left") +    return output + + +def show(raw: bool, zone: typing.Optional[str]): +    """ +    Show zone-policy command +    :param raw: if API +    :type raw: bool +    :param zone: zone name +    :type zone: str +    """ +    conf: ConfigTreeQuery = ConfigTreeQuery() +    zones_config: dict = get_config_zone(conf, zone) +    zone_policy_api: list = _convert_config(zones_config, zone) +    if raw: +        return zone_policy_api +    else: +        return get_formatted_output(zone_policy_api) + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1)
\ No newline at end of file diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index b64e58132..40d442e30 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -1,6 +1,6 @@  #!/usr/share/vyos-http-api-tools/bin/python3  # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright (C) 2019-2024 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -13,8 +13,6 @@  #  # You should have received a copy of the GNU General Public License  # along with this program.  If not, see <http://www.gnu.org/licenses/>. -# -#  import os  import sys @@ -25,6 +23,7 @@ import logging  import signal  import traceback  import threading +  from time import sleep  from typing import List, Union, Callable, Dict @@ -46,11 +45,12 @@ from ariadne.asgi import GraphQL  from vyos.config import Config  from vyos.configtree import ConfigTree  from vyos.configdiff import get_config_diff -from vyos.configsession import ConfigSession, ConfigSessionError +from vyos.configsession import ConfigSession +from vyos.configsession import ConfigSessionError +from vyos.defaults import api_config_state  import api.graphql.state -api_config_state = '/run/http-api-state'  CFG_GROUP = 'vyattacfg'  debug = True diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh index 03574bdc3..e1a9f1884 100755 --- a/src/system/on-dhcp-event.sh +++ b/src/system/on-dhcp-event.sh @@ -15,28 +15,71 @@ if [ $# -lt 1 ]; then  fi  action=$1 -client_name=$LEASE4_HOSTNAME -client_ip=$LEASE4_ADDRESS -client_mac=$LEASE4_HWADDR  hostsd_client="/usr/bin/vyos-hostsd-client" -case "$action" in -  lease4_renew|lease4_recover) # add mapping for new/recovered lease address -    if [ -z "$client_name" ]; then -        logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead" -        client_name=$(echo "host-$client_mac" | tr : -) -    fi +get_subnet_domain_name () { +  python3 <<EOF +from vyos.kea import kea_get_active_config +from vyos.utils.dict import dict_search_args + +config = kea_get_active_config('4') +shared_networks = dict_search_args(config, 'arguments', f'Dhcp4', 'shared-networks') + +found = False -    $hostsd_client --add-hosts "$client_name,$client_ip" --tag "dhcp-server-$client_ip" --apply +if shared_networks: +  for network in shared_networks: +    for subnet in network[f'subnet4']: +      if subnet['id'] == $1: +        for option in subnet['option-data']: +          if option['name'] == 'domain-name': +            print(option['data']) +            found = True + +        if not found: +          for option in network['option-data']: +            if option['name'] == 'domain-name': +              print(option['data']) +EOF +} + +case "$action" in +  lease4_renew|lease4_recover)      exit 0      ;;    lease4_release|lease4_expire|lease4_decline) # delete mapping for released/declined address +    client_ip=$LEASE4_ADDRESS      $hostsd_client --delete-hosts --tag "dhcp-server-$client_ip" --apply      exit 0      ;; -  leases4_committed) # nothing to do +  leases4_committed) # process committed leases (added/renewed/recovered) +    for ((i = 0; i < $LEASES4_SIZE; i++)); do +      client_ip_var="LEASES4_AT${i}_ADDRESS" +      client_mac_var="LEASES4_AT${i}_HWADDR" +      client_name_var="LEASES4_AT${i}_HOSTNAME" +      client_subnet_id_var="LEASES4_AT${i}_SUBNET_ID" + +      client_ip=${!client_ip_var} +      client_mac=${!client_mac_var} +      client_name=${!client_name_var} +      client_subnet_id=${!client_subnet_id_var} + +      if [ -z "$client_name" ]; then +          logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead" +          client_name=$(echo "host-$client_mac" | tr : -) +      fi + +      client_domain=$(get_subnet_domain_name $client_subnet_id) + +      if [ -n "$client_domain" ]; then +        client_name="$client_name.$client_domain" +      fi + +      $hostsd_client --add-hosts "$client_name,$client_ip" --tag "dhcp-server-$client_ip" --apply +    done +      exit 0      ;; | 
