diff options
Diffstat (limited to 'python')
| -rw-r--r-- | python/vyos/base.py | 9 | ||||
| -rw-r--r-- | python/vyos/configdict.py | 17 | ||||
| -rw-r--r-- | python/vyos/configdiff.py | 30 | ||||
| -rw-r--r-- | python/vyos/configquery.py | 46 | ||||
| -rw-r--r-- | python/vyos/configsource.py | 5 | ||||
| -rw-r--r-- | python/vyos/defaults.py | 7 | ||||
| -rw-r--r-- | python/vyos/firewall.py | 235 | ||||
| -rw-r--r-- | python/vyos/frr.py | 66 | ||||
| -rw-r--r-- | python/vyos/hostsd_client.py | 12 | ||||
| -rw-r--r-- | python/vyos/ifconfig/__init__.py | 1 | ||||
| -rw-r--r-- | python/vyos/ifconfig/ethernet.py | 17 | ||||
| -rwxr-xr-x | python/vyos/ifconfig/interface.py | 92 | ||||
| -rw-r--r-- | python/vyos/ifconfig/section.py | 10 | ||||
| -rw-r--r-- | python/vyos/ifconfig/vxlan.py | 11 | ||||
| -rw-r--r-- | python/vyos/ifconfig/wwan.py | 17 | ||||
| -rw-r--r-- | python/vyos/range_regex.py | 142 | ||||
| -rw-r--r-- | python/vyos/remote.py | 563 | ||||
| -rw-r--r-- | python/vyos/template.py | 78 | ||||
| -rw-r--r-- | python/vyos/util.py | 70 | 
19 files changed, 1023 insertions, 405 deletions
diff --git a/python/vyos/base.py b/python/vyos/base.py index 4e23714e5..c78045548 100644 --- a/python/vyos/base.py +++ b/python/vyos/base.py @@ -1,4 +1,4 @@ -# Copyright 2018 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2018-2021 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -13,6 +13,11 @@  # You should have received a copy of the GNU Lesser General Public  # License along with this library.  If not, see <http://www.gnu.org/licenses/>. +from textwrap import fill  class ConfigError(Exception): -    pass +    def __init__(self, message): +        # Reformat the message and trim it to 72 characters in length +        message = fill(message, width=72) +        # Call the base class constructor with the parameters it needs +        super().__init__(message) diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 425a2e416..d974a7565 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -459,7 +459,10 @@ def get_interface_dict(config, base, ifname=''):          # Only add defaults if interface is not about to be deleted - this is          # to keep a cleaner config dict.          if 'deleted' not in dict: -            dict['vif'][vif] = dict_merge(default_vif_values, vif_config) +            address = leaf_node_changed(config, ['vif', vif, 'address']) +            if address: dict['vif'][vif].update({'address_old' : address}) + +            dict['vif'][vif] = dict_merge(default_vif_values, dict['vif'][vif])              # XXX: T2665: blend in proper DHCPv6-PD default values              dict['vif'][vif] = T2665_set_dhcpv6pd_defaults(dict['vif'][vif]) @@ -480,7 +483,11 @@ def get_interface_dict(config, base, ifname=''):          # Only add defaults if interface is not about to be deleted - this is          # to keep a cleaner config dict.          if 'deleted' not in dict: -            dict['vif_s'][vif_s] = dict_merge(default_vif_s_values, vif_s_config) +            address = leaf_node_changed(config, ['vif-s', vif_s, 'address']) +            if address: dict['vif_s'][vif_s].update({'address_old' : address}) + +            dict['vif_s'][vif_s] = dict_merge(default_vif_s_values, +                    dict['vif_s'][vif_s])              # XXX: T2665: blend in proper DHCPv6-PD default values              dict['vif_s'][vif_s] = T2665_set_dhcpv6pd_defaults(dict['vif_s'][vif_s]) @@ -499,8 +506,12 @@ def get_interface_dict(config, base, ifname=''):              # Only add defaults if interface is not about to be deleted - this is              # to keep a cleaner config dict.              if 'deleted' not in dict: +                address = leaf_node_changed(config, ['vif-s', vif_s, 'vif-c', vif_c, 'address']) +                if address: dict['vif_s'][vif_s]['vif_c'][vif_c].update( +                        {'address_old' : address}) +                  dict['vif_s'][vif_s]['vif_c'][vif_c] = dict_merge( -                    default_vif_c_values, vif_c_config) +                    default_vif_c_values, dict['vif_s'][vif_s]['vif_c'][vif_c])                  # XXX: T2665: blend in proper DHCPv6-PD default values                  dict['vif_s'][vif_s]['vif_c'][vif_c] = T2665_set_dhcpv6pd_defaults(                      dict['vif_s'][vif_s]['vif_c'][vif_c]) diff --git a/python/vyos/configdiff.py b/python/vyos/configdiff.py index 0e41fbe27..4ad7443d7 100644 --- a/python/vyos/configdiff.py +++ b/python/vyos/configdiff.py @@ -17,7 +17,9 @@ from enum import IntFlag, auto  from vyos.config import Config  from vyos.configdict import dict_merge +from vyos.configdict import list_diff  from vyos.util import get_sub_dict, mangle_dict_keys +from vyos.util import dict_search_args  from vyos.xml import defaults  class ConfigDiffError(Exception): @@ -134,6 +136,34 @@ class ConfigDiff(object):                                                      self._key_mangling[1])          return config_dict +    def get_child_nodes_diff_str(self, path=[]): +        ret = {'add': {}, 'change': {}, 'delete': {}} + +        diff = self.get_child_nodes_diff(path, +                                expand_nodes=Diff.ADD | Diff.DELETE | Diff.MERGE | Diff.STABLE, +                                no_defaults=True) + +        def parse_dict(diff_dict, diff_type, prefix=[]): +            for k, v in diff_dict.items(): +                if isinstance(v, dict): +                    parse_dict(v, diff_type, prefix + [k]) +                else: +                    path_str = ' '.join(prefix + [k]) +                    if diff_type == 'add' or diff_type == 'delete': +                        if isinstance(v, list): +                            v = ', '.join(v) +                        ret[diff_type][path_str] = v +                    elif diff_type == 'merge': +                        old_value = dict_search_args(diff['stable'], *prefix, k) +                        if old_value and old_value != v: +                            ret['change'][path_str] = [old_value, v] + +        parse_dict(diff['merge'], 'merge') +        parse_dict(diff['add'], 'add') +        parse_dict(diff['delete'], 'delete') + +        return ret +      def get_child_nodes_diff(self, path=[], expand_nodes=Diff(0), no_defaults=False):          """          Args: diff --git a/python/vyos/configquery.py b/python/vyos/configquery.py index 1cdcbcf39..5b097b312 100644 --- a/python/vyos/configquery.py +++ b/python/vyos/configquery.py @@ -18,16 +18,15 @@ A small library that allows querying existence or value(s) of config  settings from op mode, and execution of arbitrary op mode commands.  ''' -import re -import json -from copy import deepcopy +import os  from subprocess import STDOUT -import vyos.util -import vyos.xml +from  vyos.util import popen, boot_configuration_complete  from vyos.config import Config -from vyos.configtree import ConfigTree -from vyos.configsource import ConfigSourceSession +from vyos.configsource import ConfigSourceSession, ConfigSourceString +from vyos.defaults import directories + +config_file = os.path.join(directories['config'], 'config.boot')  class ConfigQueryError(Exception):      pass @@ -58,21 +57,21 @@ class CliShellApiConfigQuery(GenericConfigQuery):      def exists(self, path: list):          cmd = ' '.join(path) -        (_, err) = vyos.util.popen(f'cli-shell-api existsActive {cmd}') +        (_, err) = popen(f'cli-shell-api existsActive {cmd}')          if err:              return False          return True      def value(self, path: list):          cmd = ' '.join(path) -        (out, err) = vyos.util.popen(f'cli-shell-api returnActiveValue {cmd}') +        (out, err) = popen(f'cli-shell-api returnActiveValue {cmd}')          if err:              raise ConfigQueryError('No value for given path')          return out      def values(self, path: list):          cmd = ' '.join(path) -        (out, err) = vyos.util.popen(f'cli-shell-api returnActiveValues {cmd}') +        (out, err) = popen(f'cli-shell-api returnActiveValues {cmd}')          if err:              raise ConfigQueryError('No values for given path')          return out @@ -81,25 +80,36 @@ class ConfigTreeQuery(GenericConfigQuery):      def __init__(self):          super().__init__() -        config_source = ConfigSourceSession() -        self.configtree = Config(config_source=config_source) +        if boot_configuration_complete(): +            config_source = ConfigSourceSession() +            self.config = Config(config_source=config_source) +        else: +            try: +                with open(config_file) as f: +                    config_string = f.read() +            except OSError as err: +                raise ConfigQueryError('No config file available') from err + +            config_source = ConfigSourceString(running_config_text=config_string, +                                               session_config_text=config_string) +            self.config = Config(config_source=config_source)      def exists(self, path: list): -        return self.configtree.exists(path) +        return self.config.exists(path)      def value(self, path: list): -        return self.configtree.return_value(path) +        return self.config.return_value(path)      def values(self, path: list): -        return self.configtree.return_values(path) +        return self.config.return_values(path)      def list_nodes(self, path: list): -        return self.configtree.list_nodes(path) +        return self.config.list_nodes(path)      def get_config_dict(self, path=[], effective=False, key_mangling=None,                          get_first_key=False, no_multi_convert=False,                          no_tag_node_value_mangle=False): -        return self.configtree.get_config_dict(path, effective=effective, +        return self.config.get_config_dict(path, effective=effective,                  key_mangling=key_mangling, get_first_key=get_first_key,                  no_multi_convert=no_multi_convert,                  no_tag_node_value_mangle=no_tag_node_value_mangle) @@ -110,7 +120,7 @@ class VbashOpRun(GenericOpRun):      def run(self, path: list, **kwargs):          cmd = ' '.join(path) -        (out, err) = vyos.util.popen(f'.  /opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-op-run; _vyatta_op_run {cmd}', stderr=STDOUT, **kwargs) +        (out, err) = popen(f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}', stderr=STDOUT, **kwargs)          if err:              raise ConfigQueryError(out)          return out diff --git a/python/vyos/configsource.py b/python/vyos/configsource.py index b0981d25e..510b5b65a 100644 --- a/python/vyos/configsource.py +++ b/python/vyos/configsource.py @@ -19,6 +19,7 @@ import re  import subprocess  from vyos.configtree import ConfigTree +from vyos.util import boot_configuration_complete  class VyOSError(Exception):      """ @@ -117,7 +118,7 @@ class ConfigSourceSession(ConfigSource):          # Running config can be obtained either from op or conf mode, it always succeeds          # once the config system is initialized during boot;          # before initialization, set to empty string -        if os.path.isfile('/tmp/vyos-config-status'): +        if boot_configuration_complete():              try:                  running_config_text = self._run([self._cli_shell_api, '--show-active-only', '--show-show-defaults', '--show-ignore-edit', 'showConfig'])              except VyOSError: @@ -161,7 +162,7 @@ class ConfigSourceSession(ConfigSource):          if p.returncode != 0:              raise VyOSError()          else: -            return out.decode('ascii', 'ignore') +            return out.decode()      def set_level(self, path):          """ diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 00b14a985..c77b695bd 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -29,6 +29,8 @@ directories = {    "vyos_udev_dir": "/run/udev/vyos"  } +config_status = '/tmp/vyos-config-status' +  cfg_group = 'vyattacfg'  cfg_vintage = 'vyos' @@ -44,8 +46,9 @@ https_data = {  api_data = {      'listen_address' : '127.0.0.1',      'port' : '8080', -    'strict' : 'false', -    'debug' : 'false', +    'socket' : False, +    'strict' : False, +    'debug' : False,      'api_keys' : [ {"id": "testapp", "key": "qwerty"} ]  } diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py new file mode 100644 index 000000000..4993d855e --- /dev/null +++ b/python/vyos/firewall.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import re + +from vyos.util import cmd +from vyos.util import dict_search_args + +def find_nftables_rule(table, chain, rule_matches=[]): +    # Find rule in table/chain that matches all criteria and return the handle +    results = cmd(f'sudo nft -a list chain {table} {chain}').split("\n") +    for line in results: +        if all(rule_match in line for rule_match in rule_matches): +            handle_search = re.search('handle (\d+)', line) +            if handle_search: +                return handle_search[1] +    return None + +def remove_nftables_rule(table, chain, handle): +    cmd(f'sudo nft delete rule {table} {chain} handle {handle}') + +# Functions below used by template generation + +def nft_action(vyos_action): +    if vyos_action == 'accept': +        return 'return' +    return vyos_action + +def parse_rule(rule_conf, fw_name, rule_id, ip_name): +    output = [] +    def_suffix = '6' if ip_name == 'ip6' else '' + +    if 'state' in rule_conf and rule_conf['state']: +        states = ",".join([s for s, v in rule_conf['state'].items() if v == 'enable']) + +        if states: +            output.append(f'ct state {{{states}}}') + +    if 'protocol' in rule_conf and rule_conf['protocol'] != 'all': +        proto = rule_conf['protocol'] +        operator = '' +        if proto[0] == '!': +            operator = '!=' +            proto = proto[1:] +        if proto == 'tcp_udp': +            proto = '{tcp, udp}' +        output.append(f'meta l4proto {operator} {proto}') + +    for side in ['destination', 'source']: +        if side in rule_conf: +            prefix = side[0] +            side_conf = rule_conf[side] + +            if 'address' in side_conf: +                suffix = side_conf['address'] +                if suffix[0] == '!': +                    suffix = f'!= {suffix[1:]}' +                output.append(f'{ip_name} {prefix}addr {suffix}') + +            if 'mac_address' in side_conf: +                suffix = side_conf["mac_address"] +                if suffix[0] == '!': +                    suffix = f'!= {suffix[1:]}' +                output.append(f'ether {prefix}addr {suffix}') + +            if 'port' in side_conf: +                proto = rule_conf['protocol'] +                port = side_conf['port'].split(',') + +                ports = [] +                negated_ports = [] + +                for p in port: +                    if p[0] == '!': +                        negated_ports.append(p[1:]) +                    else: +                        ports.append(p) + +                if proto == 'tcp_udp': +                    proto = 'th' + +                if ports: +                    ports_str = ','.join(ports) +                    output.append(f'{proto} {prefix}port {{{ports_str}}}') + +                if negated_ports: +                    negated_ports_str = ','.join(negated_ports) +                    output.append(f'{proto} {prefix}port != {{{negated_ports_str}}}') + +            if 'group' in side_conf: +                group = side_conf['group'] +                if 'address_group' in group: +                    group_name = group['address_group'] +                    output.append(f'{ip_name} {prefix}addr $A{def_suffix}_{group_name}') +                elif 'network_group' in group: +                    group_name = group['network_group'] +                    output.append(f'{ip_name} {prefix}addr $N{def_suffix}_{group_name}') +                if 'mac_group' in group: +                    group_name = group['mac_group'] +                    output.append(f'ether {prefix}addr $M_{group_name}') +                if 'port_group' in group: +                    proto = rule_conf['protocol'] +                    group_name = group['port_group'] + +                    if proto == 'tcp_udp': +                        proto = 'th' + +                    output.append(f'{proto} {prefix}port $P_{group_name}') + +    if 'log' in rule_conf and rule_conf['log'] == 'enable': +        action = rule_conf['action'] if 'action' in rule_conf else 'accept' +        output.append(f'log prefix "[{fw_name[:19]}-{rule_id}-{action[:1].upper()}] "') + +    if 'hop_limit' in rule_conf: +        operators = {'eq': '==', 'gt': '>', 'lt': '<'} +        for op, operator in operators.items(): +            if op in rule_conf['hop_limit']: +                value = rule_conf['hop_limit'][op] +                output.append(f'ip6 hoplimit {operator} {value}') + +    for icmp in ['icmp', 'icmpv6']: +        if icmp in rule_conf: +            if 'type_name' in rule_conf[icmp]: +                output.append(icmp + ' type ' + rule_conf[icmp]['type_name']) +            else: +                if 'code' in rule_conf[icmp]: +                    output.append(icmp + ' code ' + rule_conf[icmp]['code']) +                if 'type' in rule_conf[icmp]: +                    output.append(icmp + ' type ' + rule_conf[icmp]['type']) + +    if 'ipsec' in rule_conf: +        if 'match_ipsec' in rule_conf['ipsec']: +            output.append('meta ipsec == 1') +        if 'match_non_ipsec' in rule_conf['ipsec']: +            output.append('meta ipsec == 0') + +    if 'fragment' in rule_conf: +        # Checking for fragmentation after priority -400 is not possible, +        # so we use a priority -450 hook to set a mark +        if 'match_frag' in rule_conf['fragment']: +            output.append('meta mark 0xffff1') +        if 'match_non_frag' in rule_conf['fragment']: +            output.append('meta mark != 0xffff1') + +    if 'limit' in rule_conf: +        if 'rate' in rule_conf['limit']: +            output.append(f'limit rate {rule_conf["limit"]["rate"]}/second') +            if 'burst' in rule_conf['limit']: +                output.append(f'burst {rule_conf["limit"]["burst"]} packets') + +    if 'recent' in rule_conf: +        count = rule_conf['recent']['count'] +        time = rule_conf['recent']['time'] +        # output.append(f'meter {fw_name}_{rule_id} {{ ip saddr and 255.255.255.255 limit rate over {count}/{time} burst {count} packets }}') +        # Waiting on input from nftables developers due to +        # bug with above line and atomic chain flushing. + +    if 'time' in rule_conf: +        output.append(parse_time(rule_conf['time'])) + +    tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') +    if tcp_flags: +        output.append(parse_tcp_flags(tcp_flags)) + +    output.append('counter') + +    if 'set' in rule_conf: +        output.append(parse_policy_set(rule_conf['set'], def_suffix)) + +    if 'action' in rule_conf: +        output.append(nft_action(rule_conf['action'])) +    else: +        output.append('return') + +    output.append(f'comment "{fw_name}-{rule_id}"') +    return " ".join(output) + +def parse_tcp_flags(flags): +    include = [flag for flag in flags if flag != 'not'] +    exclude = flags['not'].keys() if 'not' in flags else [] +    return f'tcp flags & ({"|".join(include + exclude)}) == {"|".join(include)}' + +def parse_time(time): +    out = [] +    if 'startdate' in time: +        start = time['startdate'] +        if 'T' not in start and 'starttime' in time: +            start += f' {time["starttime"]}' +        out.append(f'time >= "{start}"') +    if 'starttime' in time and 'startdate' not in time: +        out.append(f'hour >= "{time["starttime"]}"') +    if 'stopdate' in time: +        stop = time['stopdate'] +        if 'T' not in stop and 'stoptime' in time: +            stop += f' {time["stoptime"]}' +        out.append(f'time < "{stop}"') +    if 'stoptime' in time and 'stopdate' not in time: +        out.append(f'hour < "{time["stoptime"]}"') +    if 'weekdays' in time: +        days = time['weekdays'].split(",") +        out_days = [f'"{day}"' for day in days if day[0] != '!'] +        out.append(f'day {{{",".join(out_days)}}}') +    return " ".join(out) + +def parse_policy_set(set_conf, def_suffix): +    out = [] +    if 'dscp' in set_conf: +        dscp = set_conf['dscp'] +        out.append(f'ip{def_suffix} dscp set {dscp}') +    if 'mark' in set_conf: +        mark = set_conf['mark'] +        out.append(f'meta mark set {mark}') +    if 'table' in set_conf: +        table = set_conf['table'] +        if table == 'main': +            table = '254' +        mark = 0x7FFFFFFF - int(table) +        out.append(f'meta mark set {mark}') +    if 'tcp_mss' in set_conf: +        mss = set_conf['tcp_mss'] +        out.append(f'tcp option maxseg size set {mss}') +    return " ".join(out) diff --git a/python/vyos/frr.py b/python/vyos/frr.py index df6849472..cbba19ab7 100644 --- a/python/vyos/frr.py +++ b/python/vyos/frr.py @@ -73,23 +73,25 @@ from vyos.util import cmd  import logging  from logging.handlers import SysLogHandler  import os +import sys +  LOG = logging.getLogger(__name__) +DEBUG = False -DEBUG = os.path.exists('/tmp/vyos.frr.debug') -if DEBUG: -    LOG.setLevel(logging.DEBUG) -    ch = SysLogHandler(address='/dev/log') -    ch2 = logging.StreamHandler() -    LOG.addHandler(ch) -    LOG.addHandler(ch2) +ch = SysLogHandler(address='/dev/log') +ch2 = logging.StreamHandler(stream=sys.stdout) +LOG.addHandler(ch) +LOG.addHandler(ch2)  _frr_daemons = ['zebra', 'bgpd', 'fabricd', 'isisd', 'ospf6d', 'ospfd', 'pbrd', -                'pimd', 'ripd', 'ripngd', 'sharpd', 'staticd', 'vrrpd', 'ldpd'] +                'pimd', 'ripd', 'ripngd', 'sharpd', 'staticd', 'vrrpd', 'ldpd', +                'bfdd']  path_vtysh = '/usr/bin/vtysh'  path_frr_reload = '/usr/lib/frr/frr-reload.py'  path_config = '/run/frr' +default_add_before = r'(ip prefix-list .*|route-map .*|line vty|end)'  class FrrError(Exception):      pass @@ -119,6 +121,12 @@ class ConfigSectionNotFound(FrrError):      """      pass +def init_debugging(): +    global DEBUG + +    DEBUG = os.path.exists('/tmp/vyos.frr.debug') +    if DEBUG: +        LOG.setLevel(logging.DEBUG)  def get_configuration(daemon=None, marked=False):      """ Get current running FRR configuration @@ -214,13 +222,8 @@ def reload_configuration(config, daemon=None):  def save_configuration(): -    """Save FRR configuration to /run/frr/config/frr.conf -       It save configuration on each commit. T3217 -    """ - -    cmd(f'{path_vtysh} -n -w') - -    return +    """ T3217: Save FRR configuration to /run/frr/config/frr.conf """ +    return cmd(f'{path_vtysh} -n -w')  def execute(command): @@ -427,6 +430,8 @@ class FRRConfig:          Using this overwrites the current loaded config objects and replaces the original loaded config          ''' +        init_debugging() +          self.imported_config = get_configuration(daemon=daemon)          if daemon:              LOG.debug(f'load_configuration: Configuration loaded from FRR daemon {daemon}') @@ -448,16 +453,37 @@ class FRRConfig:          mark_configuration('\n'.join(self.config))      def commit_configuration(self, daemon=None): -        '''Commit the current configuration to FRR -           daemon: str with name of the FRR daemon to commit to or -                   None to use the consolidated config +        ''' +        Commit the current configuration to FRR daemon: str with name of the +        FRR daemon to commit to or None to use the consolidated config. + +        Configuration is automatically saved after apply          '''          LOG.debug('commit_configuration:  Commiting configuration')          for i, e in enumerate(self.config):              LOG.debug(f'commit_configuration: new_config {i:3} {e}') -        reload_configuration('\n'.join(self.config), daemon=daemon) -    def modify_section(self, start_pattern, replacement=[], stop_pattern=r'\S+', remove_stop_mark=False, count=0): +        # https://github.com/FRRouting/frr/issues/10132 +        # https://github.com/FRRouting/frr/issues/10133 +        count = 0 +        count_max = 5 +        while count < count_max: +            count += 1 +            try: +                reload_configuration('\n'.join(self.config), daemon=daemon) +                break +            except: +                # we just need to re-try the commit of the configuration +                # for the listed FRR issues above +                pass +        if count >= count_max: +            raise ConfigurationNotValid(f'Config commit retry counter ({count_max}) exceeded') + +        # Save configuration to /run/frr/config/frr.conf +        save_configuration() + + +    def modify_section(self, start_pattern, replacement='!', stop_pattern=r'\S+', remove_stop_mark=False, count=0):          if isinstance(replacement, str):              replacement = replacement.split('\n')          elif not isinstance(replacement, list): diff --git a/python/vyos/hostsd_client.py b/python/vyos/hostsd_client.py index 303b6ea47..f31ef51cf 100644 --- a/python/vyos/hostsd_client.py +++ b/python/vyos/hostsd_client.py @@ -79,6 +79,18 @@ class Client(object):          msg = {'type': 'forward_zones', 'op': 'get'}          return self._communicate(msg) +    def add_authoritative_zones(self, data): +        msg = {'type': 'authoritative_zones', 'op': 'add', 'data': data} +        self._communicate(msg) + +    def delete_authoritative_zones(self, data): +        msg = {'type': 'authoritative_zones', 'op': 'delete', 'data': data} +        self._communicate(msg) + +    def get_authoritative_zones(self): +        msg = {'type': 'authoritative_zones', 'op': 'get'} +        return self._communicate(msg) +      def add_search_domains(self, data):          msg = {'type': 'search_domains', 'op': 'add', 'data': data}          self._communicate(msg) diff --git a/python/vyos/ifconfig/__init__.py b/python/vyos/ifconfig/__init__.py index 2d3e406ac..a37615c8f 100644 --- a/python/vyos/ifconfig/__init__.py +++ b/python/vyos/ifconfig/__init__.py @@ -26,6 +26,7 @@ from vyos.ifconfig.ethernet import EthernetIf  from vyos.ifconfig.geneve import GeneveIf  from vyos.ifconfig.loopback import LoopbackIf  from vyos.ifconfig.macvlan import MACVLANIf +from vyos.ifconfig.input import InputIf  from vyos.ifconfig.vxlan import VXLANIf  from vyos.ifconfig.wireguard import WireGuardIf  from vyos.ifconfig.vtun import VTunIf diff --git a/python/vyos/ifconfig/ethernet.py b/python/vyos/ifconfig/ethernet.py index 2e59a7afc..9d54dc78e 100644 --- a/python/vyos/ifconfig/ethernet.py +++ b/python/vyos/ifconfig/ethernet.py @@ -80,6 +80,23 @@ class EthernetIf(Interface):          super().__init__(ifname, **kargs)          self.ethtool = Ethtool(ifname) +    def remove(self): +        """ +        Remove interface from config. Removing the interface deconfigures all +        assigned IP addresses. +        Example: +        >>> from vyos.ifconfig import WWANIf +        >>> i = EthernetIf('eth0') +        >>> i.remove() +        """ + +        if self.exists(self.ifname): +            # interface is placed in A/D state when removed from config! It +            # will remain visible for the operating system. +            self.set_admin_state('down') + +        super().remove() +      def set_flow_control(self, enable):          """          Changes the pause parameters of the specified Ethernet device. diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 58d130ef6..91c7f0c33 100755 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -37,6 +37,7 @@ from vyos.util import mac2eui64  from vyos.util import dict_search  from vyos.util import read_file  from vyos.util import get_interface_config +from vyos.util import get_interface_namespace  from vyos.util import is_systemd_service_active  from vyos.template import is_ipv4  from vyos.template import is_ipv6 @@ -135,6 +136,9 @@ class Interface(Control):              'validate': assert_mtu,              'shellcmd': 'ip link set dev {ifname} mtu {value}',          }, +        'netns': { +            'shellcmd': 'ip link set dev {ifname} netns {value}', +        },          'vrf': {              'convert': lambda v: f'master {v}' if v else 'nomaster',              'shellcmd': 'ip link set dev {ifname} {value}', @@ -512,6 +516,35 @@ class Interface(Control):          if prev_state == 'up':              self.set_admin_state('up') +    def del_netns(self, netns): +        """ +        Remove interface from given NETNS. +        """ + +        # If NETNS does not exist then there is nothing to delete +        if not os.path.exists(f'/run/netns/{netns}'): +            return None + +        # As a PoC we only allow 'dummy' interfaces +        if 'dum' not in self.ifname: +            return None + +        # Check if interface realy exists in namespace +        if get_interface_namespace(self.ifname) != None: +            self._cmd(f'ip netns exec {get_interface_namespace(self.ifname)} ip link del dev {self.ifname}') +            return + +    def set_netns(self, netns): +        """ +        Add interface from given NETNS. + +        Example: +        >>> from vyos.ifconfig import Interface +        >>> Interface('dum0').set_netns('foo') +        """ + +        self.set_interface('netns', netns) +      def set_vrf(self, vrf):          """          Add/Remove interface from given VRF instance. @@ -544,6 +577,15 @@ class Interface(Control):              return None          return self.set_interface('arp_cache_tmo', tmo) +    def _cleanup_mss_rules(self, table, ifname): +        commands = [] +        results = self._cmd(f'nft -a list chain {table} VYOS_TCP_MSS').split("\n") +        for line in results: +            if f'oifname "{ifname}"' in line: +                handle_search = re.search('handle (\d+)', line) +                if handle_search: +                    self._cmd(f'nft delete rule {table} VYOS_TCP_MSS handle {handle_search[1]}') +      def set_tcp_ipv4_mss(self, mss):          """          Set IPv4 TCP MSS value advertised when TCP SYN packets leave this @@ -555,22 +597,14 @@ class Interface(Control):          >>> from vyos.ifconfig import Interface          >>> Interface('eth0').set_tcp_ipv4_mss(1340)          """ -        iptables_bin = 'iptables' -        base_options = f'-A FORWARD -o {self.ifname} -p tcp -m tcp --tcp-flags SYN,RST SYN' -        out = self._cmd(f'{iptables_bin}-save -t mangle') -        for line in out.splitlines(): -            if line.startswith(base_options): -                # remove OLD MSS mangling configuration -                line = line.replace('-A FORWARD', '-D FORWARD') -                self._cmd(f'{iptables_bin} -t mangle {line}') - -        cmd_mss = f'{iptables_bin} -t mangle {base_options} --jump TCPMSS' +        self._cleanup_mss_rules('raw', self.ifname) +        nft_prefix = 'nft add rule raw VYOS_TCP_MSS' +        base_cmd = f'oifname "{self.ifname}" tcp flags & (syn|rst) == syn'          if mss == 'clamp-mss-to-pmtu': -            self._cmd(f'{cmd_mss} --clamp-mss-to-pmtu') +            self._cmd(f"{nft_prefix} '{base_cmd} tcp option maxseg size set rt mtu'")          elif int(mss) > 0: -            # probably add option to clamp only if bigger:              low_mss = str(int(mss) + 1) -            self._cmd(f'{cmd_mss} -m tcpmss --mss {low_mss}:65535 --set-mss {mss}') +            self._cmd(f"{nft_prefix} '{base_cmd} tcp option maxseg size {low_mss}-65535 tcp option maxseg size set {mss}'")      def set_tcp_ipv6_mss(self, mss):          """ @@ -583,22 +617,14 @@ class Interface(Control):          >>> from vyos.ifconfig import Interface          >>> Interface('eth0').set_tcp_mss(1320)          """ -        iptables_bin = 'ip6tables' -        base_options = f'-A FORWARD -o {self.ifname} -p tcp -m tcp --tcp-flags SYN,RST SYN' -        out = self._cmd(f'{iptables_bin}-save -t mangle') -        for line in out.splitlines(): -            if line.startswith(base_options): -                # remove OLD MSS mangling configuration -                line = line.replace('-A FORWARD', '-D FORWARD') -                self._cmd(f'{iptables_bin} -t mangle {line}') - -        cmd_mss = f'{iptables_bin} -t mangle {base_options} --jump TCPMSS' +        self._cleanup_mss_rules('ip6 raw', self.ifname) +        nft_prefix = 'nft add rule ip6 raw VYOS_TCP_MSS' +        base_cmd = f'oifname "{self.ifname}" tcp flags & (syn|rst) == syn'          if mss == 'clamp-mss-to-pmtu': -            self._cmd(f'{cmd_mss} --clamp-mss-to-pmtu') +            self._cmd(f"{nft_prefix} '{base_cmd} tcp option maxseg size set rt mtu'")          elif int(mss) > 0: -            # probably add option to clamp only if bigger:              low_mss = str(int(mss) + 1) -            self._cmd(f'{cmd_mss} -m tcpmss --mss {low_mss}:65535 --set-mss {mss}') +            self._cmd(f"{nft_prefix} '{base_cmd} tcp option maxseg size {low_mss}-65535 tcp option maxseg size set {mss}'")      def set_arp_filter(self, arp_filter):          """ @@ -1271,8 +1297,8 @@ class Interface(Control):              source_if = next(iter(self._config['is_mirror_intf']))              config = self._config['is_mirror_intf'][source_if].get('mirror', None) -        # Check configuration stored by old perl code before delete T3782 -        if not 'redirect' in self._config: +        # Check configuration stored by old perl code before delete T3782/T4056 +        if not 'redirect' in self._config and not 'traffic_policy' in self._config:              # Please do not clear the 'set $? = 0 '. It's meant to force a return of 0              # Remove existing mirroring rules              delete_tc_cmd  = f'tc qdisc del dev {source_if} handle ffff: ingress 2> /dev/null;' @@ -1353,6 +1379,16 @@ class Interface(Control):              if mac:                  self.set_mac(mac) +        # If interface is connected to NETNS we don't have to check all other +        # settings like MTU/IPv6/sysctl values, etc. +        # Since the interface is pushed onto a separate logical stack +        # Configure NETNS +        if dict_search('netns', config) != None: +            self.set_netns(config.get('netns', '')) +            return +        else: +            self.del_netns(config.get('netns', '')) +          # Update interface description          self.set_alias(config.get('description', '')) diff --git a/python/vyos/ifconfig/section.py b/python/vyos/ifconfig/section.py index 0e4447b9e..91f667b65 100644 --- a/python/vyos/ifconfig/section.py +++ b/python/vyos/ifconfig/section.py @@ -52,12 +52,12 @@ class Section:          name: name of the interface          vlan: if vlan is True, do not stop at the vlan number          """ -        name = name.rstrip('0123456789') -        name = name.rstrip('.') -        if vlan: -            name = name.rstrip('0123456789.')          if vrrp: -            name = name.rstrip('0123456789v') +            name = re.sub(r'\d(\d|v|\.)*$', '', name) +        elif vlan: +            name = re.sub(r'\d(\d|\.)*$', '', name) +        else: +            name = re.sub(r'\d+$', '', name)          return name      @classmethod diff --git a/python/vyos/ifconfig/vxlan.py b/python/vyos/ifconfig/vxlan.py index d73fb47b8..0c5282db4 100644 --- a/python/vyos/ifconfig/vxlan.py +++ b/python/vyos/ifconfig/vxlan.py @@ -54,18 +54,21 @@ class VXLANIf(Interface):          # arguments used by iproute2. For more information please refer to:          # - https://man7.org/linux/man-pages/man8/ip-link.8.html          mapping = { -            'source_address'             : 'local', -            'source_interface'           : 'dev', -            'remote'                     : 'remote',              'group'                      : 'group', +            'external'                   : 'external', +            'gpe'                        : 'gpe',              'parameters.ip.dont_fragment': 'df set',              'parameters.ip.tos'          : 'tos',              'parameters.ip.ttl'          : 'ttl',              'parameters.ipv6.flowlabel'  : 'flowlabel',              'parameters.nolearning'      : 'nolearning', +            'remote'                     : 'remote', +            'source_address'             : 'local', +            'source_interface'           : 'dev', +            'vni'                        : 'id',          } -        cmd = 'ip link add {ifname} type {type} id {vni} dstport {port}' +        cmd = 'ip link add {ifname} type {type} dstport {port}'          for vyos_key, iproute2_key in mapping.items():              # dict_search will return an empty dict "{}" for valueless nodes like              # "parameters.nolearning" - thus we need to test the nodes existence diff --git a/python/vyos/ifconfig/wwan.py b/python/vyos/ifconfig/wwan.py index f18959a60..845c9bef9 100644 --- a/python/vyos/ifconfig/wwan.py +++ b/python/vyos/ifconfig/wwan.py @@ -26,3 +26,20 @@ class WWANIf(Interface):              'eternal': 'wwan[0-9]+$',          },      } + +    def remove(self): +        """ +        Remove interface from config. Removing the interface deconfigures all +        assigned IP addresses. +        Example: +        >>> from vyos.ifconfig import WWANIf +        >>> i = WWANIf('wwan0') +        >>> i.remove() +        """ + +        if self.exists(self.ifname): +            # interface is placed in A/D state when removed from config! It +            # will remain visible for the operating system. +            self.set_admin_state('down') + +        super().remove() diff --git a/python/vyos/range_regex.py b/python/vyos/range_regex.py new file mode 100644 index 000000000..a8190d140 --- /dev/null +++ b/python/vyos/range_regex.py @@ -0,0 +1,142 @@ +'''Copyright (c) 2013, Dmitry Voronin +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +''' +import math + +# coding=utf8 + +#  Split range to ranges that has its unique pattern. +#  Example for 12-345: +# +#  12- 19: 1[2-9] +#  20- 99: [2-9]\d +# 100-299: [1-2]\d{2} +# 300-339: 3[0-3]\d +# 340-345: 34[0-5] + +def range_to_regex(inpt_range): +    if isinstance(inpt_range, str): +        range_list = inpt_range.split('-') +        # Check input arguments +        if len(range_list) == 2: +            # The first element in range must be higher then the second +            if int(range_list[0]) < int(range_list[1]): +                return regex_for_range(int(range_list[0]), int(range_list[1])) + +    return None + +def bounded_regex_for_range(min_, max_): +    return r'\b({})\b'.format(regex_for_range(min_, max_)) + +def regex_for_range(min_, max_): +    """ +    > regex_for_range(12, 345) +    '1[2-9]|[2-9]\d|[1-2]\d{2}|3[0-3]\d|34[0-5]' +    """ +    positive_subpatterns = [] +    negative_subpatterns = [] + +    if min_ < 0: +        min__ = 1 +        if max_ < 0: +            min__ = abs(max_) +        max__ = abs(min_) + +        negative_subpatterns = split_to_patterns(min__, max__) +        min_ = 0 + +    if max_ >= 0: +        positive_subpatterns = split_to_patterns(min_, max_)     + +    negative_only_subpatterns = ['-' + val for val in negative_subpatterns if val not in positive_subpatterns] +    positive_only_subpatterns = [val for val in positive_subpatterns if val not in negative_subpatterns] +    intersected_subpatterns = ['-?' + val for val in negative_subpatterns if val in positive_subpatterns] + +    subpatterns = negative_only_subpatterns + intersected_subpatterns + positive_only_subpatterns +    return '|'.join(subpatterns) + + +def split_to_patterns(min_, max_): +    subpatterns = [] + +    start = min_ +    for stop in split_to_ranges(min_, max_): +        subpatterns.append(range_to_pattern(start, stop)) +        start = stop + 1 + +    return subpatterns + + +def split_to_ranges(min_, max_): +    stops = {max_} + +    nines_count = 1 +    stop = fill_by_nines(min_, nines_count) +    while min_ <= stop < max_: +        stops.add(stop) + +        nines_count += 1 +        stop = fill_by_nines(min_, nines_count) + +    zeros_count = 1 +    stop = fill_by_zeros(max_ + 1, zeros_count) - 1 +    while min_ < stop <= max_: +        stops.add(stop) + +        zeros_count += 1 +        stop = fill_by_zeros(max_ + 1, zeros_count) - 1 + +    stops = list(stops) +    stops.sort() + +    return stops + + +def fill_by_nines(integer, nines_count): +    return int(str(integer)[:-nines_count] + '9' * nines_count) + + +def fill_by_zeros(integer, zeros_count): +    return integer - integer % 10 ** zeros_count + + +def range_to_pattern(start, stop): +    pattern = '' +    any_digit_count = 0 + +    for start_digit, stop_digit in zip(str(start), str(stop)): +        if start_digit == stop_digit: +            pattern += start_digit +        elif start_digit != '0' or stop_digit != '9': +            pattern += '[{}-{}]'.format(start_digit, stop_digit) +        else: +            any_digit_count += 1 + +    if any_digit_count: +        pattern += r'\d' + +    if any_digit_count > 1: +        pattern += '{{{}}}'.format(any_digit_count) + +    return pattern
\ No newline at end of file diff --git a/python/vyos/remote.py b/python/vyos/remote.py index e972050b7..66044fa52 100644 --- a/python/vyos/remote.py +++ b/python/vyos/remote.py @@ -13,38 +13,40 @@  # You should have received a copy of the GNU Lesser General Public  # License along with this library.  If not, see <http://www.gnu.org/licenses/>. -from ftplib import FTP  import os  import shutil  import socket +import ssl  import stat  import sys  import tempfile  import urllib.parse -import urllib.request as urlreq -from vyos.template import get_ip -from vyos.template import ip_from_cidr -from vyos.template import is_interface -from vyos.template import is_ipv6 -from vyos.util import cmd +from ftplib import FTP +from ftplib import FTP_TLS + +from paramiko import SSHClient +from paramiko import MissingHostKeyPolicy + +from requests import Session +from requests.adapters import HTTPAdapter +from requests.packages.urllib3 import PoolManager +  from vyos.util import ask_yes_no -from vyos.util import print_error -from vyos.util import make_progressbar +from vyos.util import begin +from vyos.util import cmd  from vyos.util import make_incremental_progressbar +from vyos.util import make_progressbar +from vyos.util import print_error  from vyos.version import get_version -from paramiko import SSHClient -from paramiko import SSHException -from paramiko import MissingHostKeyPolicy -# This is a hardcoded path and no environment variable can change it. -KNOWN_HOSTS_FILE = os.path.expanduser('~/.ssh/known_hosts') +  CHUNK_SIZE = 8192  class InteractivePolicy(MissingHostKeyPolicy):      """ -    Policy for interactively querying the user on whether to proceed with -     SSH connections to unknown hosts. +    Paramiko policy for interactively querying the user on whether to proceed +     with SSH connections to unknown hosts.      """      def missing_host_key(self, client, hostname, key):          print_error(f"Host '{hostname}' not found in known hosts.") @@ -57,347 +59,270 @@ class InteractivePolicy(MissingHostKeyPolicy):          else:              raise SSHException(f"Cannot connect to unknown host '{hostname}'.") - -## Helper routines -def get_authentication_variables(default_username=None, default_password=None): +class SourceAdapter(HTTPAdapter):      """ -    Return the environment variables `$REMOTE_USERNAME` and `$REMOTE_PASSWORD` and -     return the defaults provided if environment variables are empty or nonexistent. +    urllib3 transport adapter for setting source addresses per session.      """ -    username, password = os.getenv('REMOTE_USERNAME'), os.getenv('REMOTE_PASSWORD') -    # Fall back to defaults if the username variable doesn't exist or is an empty string. -    # Note that this is different from `os.getenv('REMOTE_USERNAME', default=default_username)`, -    #  as we want the username and the password to have the same behaviour. -    if not username: -        return default_username, default_password -    else: -        return username, password +    def __init__(self, source_pair, *args, **kwargs): +        # A source pair is a tuple of a source host string and source port respectively. +        # Supply '' and 0 respectively for default values. +        self._source_pair = source_pair +        super(SourceAdapter, self).__init__(*args, **kwargs) -def get_source_address(source): -    """ -    Take a string vaguely indicating an origin source (interface, hostname or IP address), -     return a tuple in the format `(source_pair, address_family)` where -     `source_pair` is `(source_address, source_port)`. -    """ -    # TODO: Properly distinguish between IPv4 and IPv6. -    port = 0 -    if is_interface(source): -        source = ip_from_cidr(get_ip(source)[0]) -    if is_ipv6(source): -        return (source, port), socket.AF_INET6 -    else: -        return (socket.gethostbyname(source), port), socket.AF_INET +    def init_poolmanager(self, connections, maxsize, block=False): +        self.poolmanager = PoolManager( +            num_pools=connections, maxsize=maxsize, +            block=block, source_address=self._source_pair) -def get_port_from_url(url): + +def check_storage(path, size):      """ -    Return the port number from the given `url` named tuple, fall back to -     the default if there isn't one. +    Check whether `path` has enough storage space for a transfer of `size` bytes.      """ -    defaults = {"http": 80, "https": 443, "ftp": 21, "tftp": 69,\ -                "ssh": 22, "scp": 22, "sftp": 22} -    if url.port: -        return url.port +    path = os.path.abspath(os.path.expanduser(path)) +    directory = path if os.path.isdir(path) else (os.path.dirname(os.path.expanduser(path)) or os.getcwd()) +    # `size` can be None or 0 to indicate unknown size. +    if not size: +        print_error('Warning: Cannot determine size of remote file. Bravely continuing regardless.') +        return + +    if size < 1024 * 1024: +        print_error(f'The file is {size / 1024.0:.3f} KiB.')      else: -        return defaults[url.scheme] - - -## FTP routines -def upload_ftp(local_path, hostname, remote_path,\ -               username='anonymous', password='', port=21,\ -               source_pair=None, progressbar=False): -    size = os.path.getsize(local_path) -    with FTP(source_address=source_pair) as conn: -        conn.connect(hostname, port) -        conn.login(username, password) -        with open(local_path, 'rb') as file: -            if progressbar and size: +        print_error(f'The file is {size / (1024.0 * 1024.0):.3f} MiB.') + +    # Will throw `FileNotFoundError' if `directory' is absent. +    if size > shutil.disk_usage(directory).free: +        raise OSError(f'Not enough disk space available in "{directory}".') + + +class FtpC: +    def __init__(self, url, progressbar=False, check_space=False, source_host='', source_port=0): +        self.secure = url.scheme == 'ftps' +        self.hostname = url.hostname +        self.path = url.path +        self.username = url.username or os.getenv('REMOTE_USERNAME', 'anonymous') +        self.password = url.password or os.getenv('REMOTE_PASSWORD', '') +        self.port = url.port or 21 +        self.source = (source_host, source_port) +        self.progressbar = progressbar +        self.check_space = check_space + +    def _establish(self): +        if self.secure: +            return FTP_TLS(source_address=self.source, context=ssl.create_default_context()) +        else: +            return FTP(source_address=self.source) + +    def download(self, location: str): +        # Open the file upfront before establishing connection. +        with open(location, 'wb') as f, self._establish() as conn: +            conn.connect(self.hostname, self.port) +            conn.login(self.username, self.password) +            # Set secure connection over TLS. +            if self.secure: +                conn.prot_p() +            # Almost all FTP servers support the `SIZE' command. +            if self.check_space: +                check_storage(path, conn.size(self.path)) +            # No progressbar if we can't determine the size or if the file is too small. +            if self.progressbar and size and size > CHUNK_SIZE:                  progress = make_incremental_progressbar(CHUNK_SIZE / size)                  next(progress) -                callback = lambda block: next(progress) +                callback = lambda block: begin(f.write(block), next(progress))              else: -                callback = None -            conn.storbinary(f'STOR {remote_path}', file, CHUNK_SIZE, callback) - -def download_ftp(local_path, hostname, remote_path,\ -                 username='anonymous', password='', port=21,\ -                 source_pair=None, progressbar=False): -    with FTP(source_address=source_pair) as conn: -        conn.connect(hostname, port) -        conn.login(username, password) -        size = conn.size(remote_path) -        with open(local_path, 'wb') as file: -            # No progressbar if we can't determine the size. -            if progressbar and size: +                callback = f.write +            conn.retrbinary('RETR ' + self.path, callback, CHUNK_SIZE) + +    def upload(self, location: str): +        size = os.path.getsize(location) +        with open(location, 'rb') as f, self._establish() as conn: +            conn.connect(self.hostname, self.port) +            conn.login(self.username, self.password) +            if self.secure: +                conn.prot_p() +            if self.progressbar and size and size > CHUNK_SIZE:                  progress = make_incremental_progressbar(CHUNK_SIZE / size)                  next(progress) -                callback = lambda block: (file.write(block), next(progress)) +                callback = lambda block: next(progress)              else: -                callback = file.write -            conn.retrbinary(f'RETR {remote_path}', callback, CHUNK_SIZE) - -def get_ftp_file_size(hostname, remote_path,\ -                      username='anonymous', password='', port=21,\ -                      source_pair=None): -    with FTP(source_address=source) as conn: -        conn.connect(hostname, port) -        conn.login(username, password) -        size = conn.size(remote_path) -        if size: -            return size -        else: -            # SIZE is an extension to the FTP specification, although it's extremely common. -            raise ValueError('Failed to receive file size from FTP server. \ -            Perhaps the server does not implement the SIZE command?') - - -## SFTP/SCP routines -def transfer_sftp(mode, local_path, hostname, remote_path,\ -                  username=None, password=None, port=22,\ -                  source_tuple=None, progressbar=False): -    sock = None -    if source_tuple: -        (source_address, source_port), address_family = source_tuple -        sock = socket.socket(address_family, socket.SOCK_STREAM) -        sock.bind((source_address, source_port)) -        sock.connect((hostname, port)) -    callback = make_progressbar() if progressbar else None -    with SSHClient() as ssh: +                callback = None +            conn.storbinary('STOR ' + self.path, f, CHUNK_SIZE, callback) + +class SshC: +    known_hosts = os.path.expanduser('~/.ssh/known_hosts') +    def __init__(self, url, progressbar=False, check_space=False, source_host='', source_port=0): +        self.hostname = url.hostname +        self.path = url.path +        self.username = url.username or os.getenv('REMOTE_USERNAME') +        self.password = url.password or os.getenv('REMOTE_PASSWORD') +        self.port = url.port or 22 +        self.source = (source_host, source_port) +        self.progressbar = progressbar +        self.check_space = check_space + +    def _establish(self): +        ssh = SSHClient()          ssh.load_system_host_keys() -        if os.path.exists(KNOWN_HOSTS_FILE): -            ssh.load_host_keys(KNOWN_HOSTS_FILE) +        # Try to load from a user-local known hosts file if one exists. +        if os.path.exists(self.known_hosts): +            ssh.load_host_keys(self.known_hosts)          ssh.set_missing_host_key_policy(InteractivePolicy()) -        ssh.connect(hostname, port, username, password, sock=sock) -        with ssh.open_sftp() as sftp: -            if mode == 'upload': +        # `socket.create_connection()` automatically picks a NIC and an IPv4/IPv6 address family +        #  for us on dual-stack systems. +        sock = socket.create_connection((self.hostname, self.port), socket.getdefaulttimeout(), self.source) +        ssh.connect(self.hostname, self.port, self.username, self.password, sock=sock) +        return ssh + +    def download(self, location: str): +        callback = make_progressbar() if self.progressbar else None +        with self._establish() as ssh, ssh.open_sftp() as sftp: +            if self.check_space: +                check_storage(location, sftp.stat(self.path).st_size) +            sftp.get(self.path, location, callback=callback) + +    def upload(self, location: str): +        callback = make_progressbar() if self.progressbar else None +        with self._establish() as ssh, ssh.open_sftp() as sftp: +            try: +                # If the remote path is a directory, use the original filename. +                if stat.S_ISDIR(sftp.stat(self.path).st_mode): +                    path = os.path.join(self.path, os.path.basename(location)) +                # A file exists at this destination. We're simply going to clobber it. +                else: +                    path = self.path +            # This path doesn't point at any existing file. We can freely use this filename. +            except IOError: +                path = self.path +            finally: +                sftp.put(location, path, callback=callback) + + +class HttpC: +    def __init__(self, url, progressbar=False, check_space=False, source_host='', source_port=0): +        self.urlstring = urllib.parse.urlunsplit(url) +        self.progressbar = progressbar +        self.check_space = check_space +        self.source_pair = (source_host, source_port) +        self.username = url.username or os.getenv('REMOTE_USERNAME') +        self.password = url.password or os.getenv('REMOTE_PASSWORD') + +    def _establish(self): +        session = Session() +        session.mount(self.urlstring, SourceAdapter(self.source_pair)) +        session.headers.update({'User-Agent': 'VyOS/' + get_version()}) +        if self.username: +            session.auth = self.username, self.password +        return session + +    def download(self, location: str): +        with self._establish() as s: +            # We ask for uncompressed downloads so that we don't have to deal with decoding. +            # Not only would it potentially mess up with the progress bar but +            # `shutil.copyfileobj(request.raw, file)` does not handle automatic decoding. +            s.headers.update({'Accept-Encoding': 'identity'}) +            with s.head(self.urlstring, allow_redirects=True) as r: +                # Abort early if the destination is inaccessible. +                r.raise_for_status() +                # If the request got redirected, keep the last URL we ended up with. +                final_urlstring = r.url +                if r.history and self.progressbar: +                    print_error('Redirecting to ' + final_urlstring) +                # Check for the prospective file size.                  try: -                    # If the remote path is a directory, use the original filename. -                    if stat.S_ISDIR(sftp.stat(remote_path).st_mode): -                        path = os.path.join(remote_path, os.path.basename(local_path)) -                    # A file exists at this destination. We're simply going to clobber it. -                    else: -                        path = remote_path -                # This path doesn't point at any existing file. We can freely use this filename. -                except IOError: -                    path = remote_path -                finally: -                    sftp.put(local_path, path, callback=callback) -            elif mode == 'download': -                sftp.get(remote_path, local_path, callback=callback) -            elif mode == 'size': -                return sftp.stat(remote_path).st_size - -def upload_sftp(*args, **kwargs): -    transfer_sftp('upload', *args, **kwargs) - -def download_sftp(*args, **kwargs): -    transfer_sftp('download', *args, **kwargs) - -def get_sftp_file_size(*args, **kwargs): -    return transfer_sftp('size', None, *args, **kwargs) - - -## TFTP routines -def upload_tftp(local_path, hostname, remote_path, port=69, source=None, progressbar=False): -    source_option = f'--interface {source}' if source else '' -    progress_flag = '--progress-bar' if progressbar else '-s' -    with open(local_path, 'rb') as file: -        cmd(f'curl {source_option} {progress_flag} -T - tftp://{hostname}:{port}/{remote_path}',\ -            stderr=None, input=file.read()).encode() - -def download_tftp(local_path, hostname, remote_path, port=69, source=None, progressbar=False): -    source_option = f'--interface {source}' if source else '' -    # Not really applicable but we pass it for the sake of uniformity. -    progress_flag = '--progress-bar' if progressbar else '-s' -    with open(local_path, 'wb') as file: -        file.write(cmd(f'curl {source_option} {progress_flag} tftp://{hostname}:{port}/{remote_path}',\ -                       stderr=None).encode()) - -# get_tftp_file_size() is unimplemented because there is no way to obtain a file's size through TFTP, -#  as TFTP does not specify a SIZE command. - - -## HTTP(S) routines -def install_request_opener(urlstring, username, password): -    """ -    Take `username` and `password` strings and install the appropriate -     password manager to `urllib.request.urlopen()` for the given `urlstring`. -    """ -    manager = urlreq.HTTPPasswordMgrWithDefaultRealm() -    manager.add_password(None, urlstring, username, password) -    urlreq.install_opener(urlreq.build_opener(urlreq.HTTPBasicAuthHandler(manager))) - -# upload_http() is unimplemented. - -def download_http(local_path, urlstring, username=None, password=None, progressbar=False): -    """ -    Download the file from from `urlstring` to `local_path`. -    Optionally takes `username` and `password` for authentication. -    """ -    request = urlreq.Request(urlstring, headers={'User-Agent': 'VyOS/' + get_version()}) -    if username: -        install_request_opener(urlstring, username, password) -    with open(local_path, 'wb') as file, urlreq.urlopen(request) as response: -        size = response.getheader('Content-Length') -        if progressbar and size: -            progress = make_incremental_progressbar(CHUNK_SIZE / int(size)) -            next(progress) -            for chunk in iter(lambda: response.read(CHUNK_SIZE), b''): -                file.write(chunk) -                next(progress) -            next(progress) -        # If we can't determine the size or if a progress bar wasn't requested, -        #  we can let `shutil` take care of the copying. -        else: -            shutil.copyfileobj(response, file) - -def get_http_file_size(urlstring, username=None, password=None): -    """ -    Return the size of the file from `urlstring` in terms of number of bytes. -    Optionally takes `username` and `password` for authentication. +                    size = int(r.headers['Content-Length']) +                # In case the server does not supply the header. +                except KeyError: +                    size = None +            if self.check_space: +                check_storage(location, size) +            with s.get(final_urlstring, stream=True) as r, open(location, 'wb') as f: +                if self.progressbar and size: +                    progress = make_incremental_progressbar(CHUNK_SIZE / size) +                    next(progress) +                    for chunk in iter(lambda: begin(next(progress), r.raw.read(CHUNK_SIZE)), b''): +                        f.write(chunk) +                else: +                    # We'll try to stream the download directly with `copyfileobj()` so that large +                    #  files (like entire VyOS images) don't occupy much memory. +                    shutil.copyfileobj(r.raw, f) + +    def upload(self, location: str): +        # Does not yet support progressbars. +        with self._establish() as s, open(location, 'rb') as f: +            s.post(self.urlstring, data=f, allow_redirects=True) + + +class TftpC: +    # We simply allow `curl` to take over because +    # 1. TFTP is rather simple. +    # 2. Since there's no concept authentication, we don't need to deal with keys/passwords. +    # 3. It would be a waste to import, audit and maintain a third-party library for TFTP. +    # 4. I'd rather not implement the entire protocol here, no matter how simple it is. +    def __init__(self, url, progressbar=False, check_space=False, source_host=None, source_port=0): +        source_option = f'--interface {source_host} --local-port {source_port}' if source_host else '' +        progress_flag = '--progress-bar' if progressbar else '-s' +        self.command = f'curl {source_option} {progress_flag}' +        self.urlstring = urllib.parse.urlunsplit(url) + +    def download(self, location: str): +        with open(location, 'wb') as f: +            f.write(cmd(f'{self.command} "{self.urlstring}"').encode()) + +    def upload(self, location: str): +        with open(location, 'rb') as f: +            cmd(f'{self.command} -T - "{self.urlstring}"', input=f.read()) + + +def urlc(urlstring, *args, **kwargs):      """ -    request = urlreq.Request(urlstring, headers={'User-Agent': 'VyOS/' + get_version()}) -    if username: -        install_request_opener(urlstring, username, password) -    with urlreq.urlopen(request) as response: -        size = response.getheader('Content-Length') -        if size: -            return int(size) -        # The server didn't send 'Content-Length' in the response headers. -        else: -            raise ValueError('Failed to receive file size from HTTP server.') - - -## Dynamic dispatchers -def download(local_path, urlstring, source=None, progressbar=False): +    Dynamically dispatch the appropriate protocol class.      """ -    Dispatch the appropriate download function for the given `urlstring` and save to `local_path`. -    Optionally takes a `source` address or interface (not valid for HTTP(S)). -    Supports HTTP, HTTPS, FTP, SFTP, SCP (through SFTP) and TFTP. -    Reads `$REMOTE_USERNAME` and `$REMOTE_PASSWORD` environment variables. -    """ -    url = urllib.parse.urlparse(urlstring) -    username, password = get_authentication_variables(url.username, url.password) -    port = get_port_from_url(url) - -    if url.scheme == 'http' or url.scheme == 'https': -        if source: -            print_error('Warning: Custom source address not supported for HTTP connections.') -        download_http(local_path, urlstring, username, password, progressbar) -    elif url.scheme == 'ftp': -        source = get_source_address(source)[0] if source else None -        username = username if username else 'anonymous' -        download_ftp(local_path, url.hostname, url.path, username, password, port, source, progressbar) -    elif url.scheme == 'sftp' or url.scheme == 'scp': -        source = get_source_address(source) if source else None -        download_sftp(local_path, url.hostname, url.path, username, password, port, source, progressbar) -    elif url.scheme == 'tftp': -        download_tftp(local_path, url.hostname, url.path, port, source, progressbar) -    else: -        raise ValueError(f'Unsupported URL scheme: {url.scheme}') +    url_classes = {'http': HttpC, 'https': HttpC, 'ftp': FtpC, 'ftps': FtpC, \ +                   'sftp': SshC, 'ssh': SshC, 'scp': SshC, 'tftp': TftpC} +    url = urllib.parse.urlsplit(urlstring) +    try: +        return url_classes[url.scheme](url, *args, **kwargs) +    except KeyError: +        raise ValueError(f'Unsupported URL scheme: "{url.scheme}"') -def upload(local_path, urlstring, source=None, progressbar=False): -    """ -    Dispatch the appropriate upload function for the given URL and upload from local path. -    Optionally takes a `source` address. -    Supports FTP, SFTP, SCP (through SFTP) and TFTP. -    Reads `$REMOTE_USERNAME` and `$REMOTE_PASSWORD` environment variables. -    """ -    url = urllib.parse.urlparse(urlstring) -    username, password = get_authentication_variables(url.username, url.password) -    port = get_port_from_url(url) - -    if url.scheme == 'ftp': -        username = username if username else 'anonymous' -        source = get_source_address(source)[0] if source else None -        upload_ftp(local_path, url.hostname, url.path, username, password, port, source, progressbar) -    elif url.scheme == 'sftp' or url.scheme == 'scp': -        source = get_source_address(source) if source else None -        upload_sftp(local_path, url.hostname, url.path, username, password, port, source, progressbar) -    elif url.scheme == 'tftp': -        upload_tftp(local_path, url.hostname, url.path, port, source, progressbar) -    else: -        raise ValueError(f'Unsupported URL scheme: {url.scheme}') +def download(local_path, urlstring, *args, **kwargs): +    urlc(urlstring, *args, **kwargs).download(local_path) -def get_remote_file_size(urlstring, source=None): -    """ -    Dispatch the appropriate function to return the size of the remote file from `urlstring` -     in terms of number of bytes. -    Optionally takes a `source` address (not valid for HTTP(S)). -    Supports HTTP, HTTPS, FTP and SFTP (through SFTP). -    Reads `$REMOTE_USERNAME` and `$REMOTE_PASSWORD` environment variables. -    """ -    url = urllib.parse.urlparse(urlstring) -    username, password = get_authentication_variables(url.username, url.password) -    port = get_port_from_url(url) - -    if url.scheme == 'http' or url.scheme == 'https': -        if source: -            print_error('Warning: Custom source address not supported for HTTP connections.') -        return get_http_file_size(urlstring, username, password) -    elif url.scheme == 'ftp': -        source = get_source_address(source)[0] if source else None -        username = username if username else 'anonymous' -        return get_ftp_file_size(url.hostname, url.path, username, password, port, source) -    elif url.scheme == 'sftp' or url.scheme == 'scp': -        source = get_source_address(source) if source else None -        return get_sftp_file_size(url.hostname, url.path, username, password, port, source) -    else: -        raise ValueError(f'Unsupported URL scheme: {url.scheme}') +def upload(local_path, urlstring, *args, **kwargs): +    urlc(urlstring, *args, **kwargs).upload(local_path) -def get_remote_config(urlstring, source=None): +def get_remote_config(urlstring, source_host='', source_port=0):      """ -    Download remote (config) file from `urlstring` and return the contents as a string. -        Args: -            remote file URI: -                tftp://<host>[:<port>]/<file> -                http[s]://<host>[:<port>]/<file> -                [scp|sftp|ftp]://[<user>[:<passwd>]@]<host>[:port]/<file> -            source address (optional): -                <interface> -                <IP address> +    Quietly download a file and return it as a string.      """      temp = tempfile.NamedTemporaryFile(delete=False).name      try: -        download(temp, urlstring, source) -        with open(temp, 'r') as file: -            return file.read() +        download(temp, urlstring, False, False, source_host, source_port) +        with open(temp, 'r') as f: +            return f.read()      finally:          os.remove(temp) -def friendly_download(local_path, urlstring, source=None): +def friendly_download(local_path, urlstring, source_host='', source_port=0):      """ -    Download from `urlstring` to `local_path` in an informative way. -    Checks the storage space before attempting download. -    Intended to be called from interactive, user-facing scripts. +    Download with a progress bar, reassuring messages and free space checks.      """ -    destination_directory = os.path.dirname(local_path)      try: -        free_space = shutil.disk_usage(destination_directory).free -        try: -            file_size = get_remote_file_size(urlstring, source) -            if file_size < 1024 * 1024: -                print_error(f'The file is {file_size / 1024.0:.3f} KiB.') -            else: -                print_error(f'The file is {file_size / (1024.0 * 1024.0):.3f} MiB.') -            if file_size > free_space: -                raise OSError(f'Not enough disk space available in "{destination_directory}".') -        except ValueError: -            # Can't do a storage check in this case, so we bravely continue. -            file_size = 0 -            print_error('Could not determine the file size in advance.') -        else: -            print_error('Downloading...') -            download(local_path, urlstring, source, progressbar=file_size > 1024 * 1024) +        print_error('Downloading...') +        download(local_path, urlstring, True, True, source_host, source_port)      except KeyboardInterrupt: -        print_error('Download aborted by user.') +        print_error('\nDownload aborted by user.')          sys.exit(1)      except:          import traceback +        print_error(f'Failed to download {urlstring}.')          # There are a myriad different reasons a download could fail.          # SSH errors, FTP errors, I/O errors, HTTP errors (403, 404...)          # We omit the scary stack trace but print the error nevertheless. -        print_error(f'Failed to download {urlstring}.') -        traceback.print_exception(*sys.exc_info()[:2], None) +        exc_type, exc_value, exc_traceback = sys.exc_info() +        traceback.print_exception(exc_type, exc_value, None, 0, None, False)          sys.exit(1)      else:          print_error('Download complete.') +        sys.exit(0) diff --git a/python/vyos/template.py b/python/vyos/template.py index b32cafe74..633b28ade 100644 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -22,6 +22,7 @@ from jinja2 import FileSystemLoader  from vyos.defaults import directories  from vyos.util import chmod  from vyos.util import chown +from vyos.util import dict_search_args  from vyos.util import makedir  # Holds template filters registered via register_filter() @@ -151,6 +152,16 @@ def bracketize_ipv6(address):          return f'[{address}]'      return address +@register_filter('dot_colon_to_dash') +def dot_colon_to_dash(text): +    """ Replace dot and colon to dash for string +    Example: +    192.0.2.1 => 192-0-2-1, 2001:db8::1 => 2001-db8--1 +    """ +    text = text.replace(":", "-") +    text = text.replace(".", "-") +    return text +  @register_filter('netmask_from_cidr')  def netmask_from_cidr(prefix):      """ Take CIDR prefix and convert the prefix length to a "subnet mask". @@ -479,3 +490,70 @@ def get_openvpn_ncp_ciphers(ciphers):          else:              out.append(cipher)      return ':'.join(out).upper() + +@register_filter('snmp_auth_oid') +def snmp_auth_oid(type): +    if type not in ['md5', 'sha', 'aes', 'des', 'none']: +        raise ValueError() + +    OIDs = { +        'md5' : '.1.3.6.1.6.3.10.1.1.2', +        'sha' : '.1.3.6.1.6.3.10.1.1.3', +        'aes' : '.1.3.6.1.6.3.10.1.2.4', +        'des' : '.1.3.6.1.6.3.10.1.2.2', +        'none': '.1.3.6.1.6.3.10.1.2.1' +    } +    return OIDs[type] + +@register_filter('nft_action') +def nft_action(vyos_action): +    if vyos_action == 'accept': +        return 'return' +    return vyos_action + +@register_filter('nft_rule') +def nft_rule(rule_conf, fw_name, rule_id, ip_name='ip'): +    from vyos.firewall import parse_rule +    return parse_rule(rule_conf, fw_name, rule_id, ip_name) + +@register_filter('nft_default_rule') +def nft_default_rule(fw_conf, fw_name): +    output = ['counter'] +    default_action = fw_conf.get('default_action', 'accept') + +    if 'enable_default_log' in fw_conf: +        action_suffix = default_action[:1].upper() +        output.append(f'log prefix "[{fw_name[:19]}-default-{action_suffix}] "') + +    output.append(nft_action(default_action)) +    output.append(f'comment "{fw_name} default-action {default_action}"') +    return " ".join(output) + +@register_filter('nft_state_policy') +def nft_state_policy(conf, state, ipv6=False): +    out = [f'ct state {state}'] + +    if 'log' in conf and 'enable' in conf['log']: +        out.append('log') + +    out.append('counter') + +    if 'action' in conf: +        out.append(conf['action']) + +    return " ".join(out) + +@register_filter('nft_intra_zone_action') +def nft_intra_zone_action(zone_conf, ipv6=False): +    if 'intra_zone_filtering' in zone_conf: +        intra_zone = zone_conf['intra_zone_filtering'] +        fw_name = 'ipv6_name' if ipv6 else 'name' + +        if 'action' in intra_zone: +            if intra_zone['action'] == 'accept': +                return 'return' +            return intra_zone['action'] +        elif dict_search_args(intra_zone, 'firewall', fw_name): +            name = dict_search_args(intra_zone, 'firewall', fw_name) +            return f'jump {name}' +    return 'return' diff --git a/python/vyos/util.py b/python/vyos/util.py index 9c4c29322..571d43754 100644 --- a/python/vyos/util.py +++ b/python/vyos/util.py @@ -794,6 +794,24 @@ def get_interface_address(interface):      tmp = loads(cmd(f'ip -d -j addr show {interface}'))[0]      return tmp +def get_interface_namespace(iface): +    """ +       Returns wich netns the interface belongs to +    """ +    from json import loads +    # Check if netns exist +    tmp = loads(cmd(f'ip --json netns ls')) +    if len(tmp) == 0: +        return None + +    for ns in tmp: +        namespace = f'{ns["name"]}' +        # Search interface in each netns +        data = loads(cmd(f'ip netns exec {namespace} ip -j link show')) +        for compare in data: +            if iface == compare["ifname"]: +                return namespace +  def get_all_vrfs():      """ Return a dictionary of all system wide known VRF instances """      from json import loads @@ -856,6 +874,20 @@ def make_incremental_progressbar(increment: float):      while True:          yield +def begin(*args): +    """ +    Evaluate arguments in order and return the result of the *last* argument. +    For combining multiple expressions in one statement. Useful for lambdas. +    """ +    return args[-1] + +def begin0(*args): +    """ +    Evaluate arguments in order and return the result of the *first* argument. +    For combining multiple expressions in one statement. Useful for lambdas. +    """ +    return args[0] +  def is_systemd_service_active(service):      """ Test is a specified systemd service is activated.      Returns True if service is active, false otherwise. @@ -920,14 +952,48 @@ def install_into_config(conf, config_paths, override_prompt=True):          return None      count = 0 +    failed = []      for path in config_paths:          if override_prompt and conf.exists(path) and not conf.is_multi(path):              if not ask_yes_no(f'Config node "{node}" already exists. Do you want to overwrite it?'):                  continue -        cmd(f'/opt/vyatta/sbin/my_set {path}') -        count += 1 +        try: +            cmd(f'/opt/vyatta/sbin/my_set {path}') +            count += 1 +        except: +            failed.append(path) + +    if failed: +        print(f'Failed to install {len(failed)} value(s). Commands to manually install:') +        for path in failed: +            print(f'set {path}')      if count > 0:          print(f'{count} value(s) installed. Use "compare" to see the pending changes, and "commit" to apply.') + +def is_wwan_connected(interface): +    """ Determine if a given WWAN interface, e.g. wwan0 is connected to the +    carrier network or not """ +    import json + +    if not interface.startswith('wwan'): +        raise ValueError(f'Specified interface "{interface}" is not a WWAN interface') + +    modem = interface.lstrip('wwan') + +    tmp = cmd(f'mmcli --modem {modem} --output-json') +    tmp = json.loads(tmp) + +    # return True/False if interface is in connected state +    return dict_search('modem.generic.state', tmp) == 'connected' + +def boot_configuration_complete() -> bool: +    """ Check if the boot config loader has completed +    """ +    from vyos.defaults import config_status + +    if os.path.isfile(config_status): +        return True +    return False  | 
