diff options
35 files changed, 737 insertions, 324 deletions
| diff --git a/data/templates/high-availability/keepalived.conf.j2 b/data/templates/high-availability/keepalived.conf.j2 index 85b89c70c..bcd92358f 100644 --- a/data/templates/high-availability/keepalived.conf.j2 +++ b/data/templates/high-availability/keepalived.conf.j2 @@ -25,6 +25,9 @@ global_defs {  {%     if vrrp.global_parameters.garp.master_repeat is vyos_defined %}      vrrp_garp_master_repeat {{ vrrp.global_parameters.garp.master_repeat }}  {%     endif %} +{%     if vrrp.global_parameters.version is vyos_defined %} +    vrrp_version {{ vrrp.global_parameters.version }} +{%     endif %}  {% endif %}      notify_fifo /run/keepalived/keepalived_notify_fifo      notify_fifo_script /usr/libexec/vyos/system/keepalived-fifo.py diff --git a/data/templates/login/nsswitch.conf.j2 b/data/templates/login/nsswitch.conf.j2 new file mode 100644 index 000000000..65dc88291 --- /dev/null +++ b/data/templates/login/nsswitch.conf.j2 @@ -0,0 +1,21 @@ +# Automatically generated by system-login.py +# /etc/nsswitch.conf +# +# Example configuration of GNU Name Service Switch functionality. + +passwd:         {{ 'mapuid ' if radius is vyos_defined }}{{ 'tacplus ' if tacacs is vyos_defined }}files{{ ' mapname' if radius is vyos_defined }} +group:          {{ 'mapname ' if radius is vyos_defined }}{{ 'tacplus ' if tacacs is vyos_defined }}files +shadow:         files +gshadow:        files + +# Per T2678, commenting out myhostname +hosts:          files dns #myhostname +networks:       files + +protocols:      db files +services:       db files +ethers:         db files +rpc:            db files + +netgroup:       nis + diff --git a/data/templates/login/tacplus_nss.conf.j2 b/data/templates/login/tacplus_nss.conf.j2 new file mode 100644 index 000000000..2a30b1710 --- /dev/null +++ b/data/templates/login/tacplus_nss.conf.j2 @@ -0,0 +1,74 @@ +#%NSS_TACPLUS-1.0 +# Install this file as /etc/tacplus_nss.conf +# Edit /etc/nsswitch.conf to add tacplus to the passwd lookup, similar to this +# where tacplus precede compat (or files), and depending on local policy can +# follow or precede ldap, nis, etc. +#    passwd: tacplus compat +# +#  Servers are tried in the order listed, and once a server +#  replies, no other servers are attempted in a given process instantiation +# +#  This configuration is similar to the libpam_tacplus configuration, but +#  is maintained as a configuration file, since nsswitch.conf doesn't +#  support passing parameters.  Parameters must start in the first +#  column, and parsing stops at the first whitespace + +# if set, errors and other issues are logged with syslog +#debug=1 + +# min_uid is the minimum uid to lookup via tacacs.  Setting this to 0 +# means uid 0 (root) is never looked up, good for robustness and performance +# Cumulus Linux ships with it set to 1001, so we never lookup our standard +# local users, including the cumulus uid of 1000.  Should not be greater +# than the local tacacs{0..15} uids +min_uid=900 + +# This is a comma separated list of usernames that are never sent to +# a tacacs server, they cause an early not found return. +# +# "*" is not a wild card.  While it's not a legal username, it turns out +# that during pathname completion, bash can do an NSS lookup on "*" +# To avoid server round trip delays, or worse, unreachable server delays +# on filename completion, we include "*" in the exclusion list. +exclude_users=root,telegraf,radvd,strongswan,tftp,conservr,frr,ocserv,pdns,_chrony,_lldpd,sshd,openvpn,radius_user,radius_priv_user,*{{ ',' + user | join(',') if user is vyos_defined }} + +# The include keyword allows centralizing the tacacs+ server information +# including the IP address and shared secret +# include=/etc/tacplus_servers + +#  The server IP address can be optionally followed by a ':' and a port +#  number (server=1.1.1.1:49).  It is strongly recommended that you NOT +#  add secret keys to this file, because it is world readable. +{% if tacacs.server is vyos_defined %} +{%     for server, server_config in tacacs.server.items() %} +secret={{ server_config.key }} +server={{ server }}:{{ server_config.port }} + +{%     endfor %} +{% endif %} + +{% if tacacs.vrf is vyos_defined %} +# If the management network is in a vrf, set this variable to the vrf name. +# This would usually be "mgmt". When this variable is set, the connection to the +# TACACS+ accounting servers will be made through the named vrf. +vrf={{ tacacs.vrf }} +{% endif %} + +{% if tacacs.source_address is vyos_defined %} +# Sets the IPv4 address used as the source IP address when communicating with +# the TACACS+ server. IPv6 addresses are not supported, nor are hostnames. +# The address must work when passsed to the bind() system call, that is, it must +# be valid for the interface being used. +source_ip={{ tacacs.source_address }} +{% endif %} + +# The connection timeout for an NSS library should be short, since it is +# invoked for many programs and daemons, and a failure is usually not +# catastrophic.  Not set or set to a negative value disables use of poll(). +# This follows the include of tacplus_servers, so it can override any +# timeout value set in that file. +# It's important to have this set in this file, even if the same value +# as in tacplus_servers, since tacplus_servers should not be readable +# by users other than root. +timeout={{ tacacs.timeout }} + diff --git a/data/templates/login/tacplus_servers.j2 b/data/templates/login/tacplus_servers.j2 new file mode 100644 index 000000000..5a65d6e68 --- /dev/null +++ b/data/templates/login/tacplus_servers.j2 @@ -0,0 +1,59 @@ +# Automatically generated by system-login.py +# TACACS+ configuration file + +# This is a common file used by audisp-tacplus, libpam_tacplus, and +# libtacplus_map config files as shipped. +# +# Any tac_plus client config can go here that is common to all users of this +# file, but typically it's just the TACACS+ server IP address(es) and shared +# secret(s) +# +# This file should normally be mode 600, if you care about the security of your +# secret key. When set to mode 600 NSS lookups for TACACS users will only work +# for tacacs users that are logged in, via the local mapping. For root, lookups +# will work for any tacacs users, logged in or not. + +# Set a per-connection timeout of 10 seconds, and enable the use of poll() when +# trying to read from tacacs servers. Otherwise standard TCP timeouts apply. +# Not set or set to a negative value disables use of poll(). There are usually +# multiple connection attempts per login. +timeout={{ tacacs.timeout }} + +{% if tacacs.server is vyos_defined %} +{%     for server, server_config in tacacs.server.items() %} +secret={{ server_config.key }} +server={{ server }}:{{ server_config.port }} +{%     endfor %} +{% endif %} + +# If set, login/logout accounting records are sent to all servers in +# the list, otherwise only to the first responding server +# Also used by audisp-tacplus per-command accounting, if it sources this file. +acct_all=1 + +{% if tacacs.vrf is vyos_defined %} +# If the management network is in a vrf, set this variable to the vrf name. +# This would usually be "mgmt". When this variable is set, the connection to the +# TACACS+ accounting servers will be made through the named vrf. +vrf={{ tacacs.vrf }} +{% endif %} + +{% if tacacs.source_address is vyos_defined %} +# Sets the IPv4 address used as the source IP address when communicating with +# the TACACS+ server. IPv6 addresses are not supported, nor are hostnames. +# The address must work when passsed to the bind() system call, that is, it must +# be valid for the interface being used. +source_ip={{ tacacs.source_address }} +{% endif %} + +# If user_homedir=1, then tacacs users will be set to have a home directory +# based on their login name, rather than the mapped tacacsN home directory. +# mkhomedir_helper is used to create the directory if it does not exist (similar +# to use of pam_mkhomedir.so). This flag is ignored for users with restricted +# shells, e.g., users mapped to a tacacs privilege level that has enforced +# per-command authorization (see the tacplus-restrict man page). +user_homedir=1 + +service=shell +protocol=ssh + diff --git a/debian/control b/debian/control index 797c01acf..40920cadc 100644 --- a/debian/control +++ b/debian/control @@ -26,6 +26,10 @@ Standards-Version: 3.9.6  Package: vyos-1x  Architecture: amd64 arm64 +Pre-Depends: +  libnss-tacplus [amd64], +  libpam-tacplus [amd64], +  libpam-radius-auth [amd64]  Depends:    ${python3:Depends} (>= 3.10),    aardvark-dns, @@ -83,7 +87,6 @@ Depends:    libndp-tools,    libnetfilter-conntrack3,    libnfnetlink0, -  libpam-radius-auth (>= 1.5.0),    libqmi-utils,    libstrongswan-extra-plugins (>=5.9),    libstrongswan-standard-plugins (>=5.9), diff --git a/debian/vyos-1x.postinst b/debian/vyos-1x.postinst index 6653cd585..9822ce286 100644 --- a/debian/vyos-1x.postinst +++ b/debian/vyos-1x.postinst @@ -29,10 +29,60 @@ do      sed -i "/^# Standard Un\*x authentication\./i${PAM_CONFIG}" $file  done +# Remove TACACS user added by base package - we use our own UID range and group +# assignments - see below +if grep -q '^tacacs' /etc/passwd; then +    if [ $(id -u tacacs0) -ge 1000 ]; then +        level=0 +        vyos_group=vyattaop +        while [ $level -lt 16 ]; do +            userdel tacacs${level} || true +            level=$(( level+1 )) +        done 2>&1 +    fi +fi + +# Add TACACS system users required for TACACS based system authentication +if ! grep -q '^tacacs' /etc/passwd; then +    # Add the tacacs group and all 16 possible tacacs privilege-level users to +    # the password file, home directories, etc. The accounts are not enabled +    # for local login, since they are only used to provide uid/gid/homedir for +    # the mapped TACACS+ logins (and lookups against them). The tacacs15 user +    # is also added to the sudo group, and vyattacfg group rather than vyattaop +    # (used for tacacs0-14). +    level=0 +    vyos_group=vyattaop +    while [ $level -lt 16 ]; do +        adduser --quiet --system --firstuid 900 --disabled-login --ingroup ${vyos_group} \ +            --no-create-home --gecos "TACACS+ mapped user at privilege level ${level}" \ +            --shell /bin/vbash tacacs${level} +        adduser --quiet tacacs${level} frrvty +        adduser --quiet tacacs${level} adm +        adduser --quiet tacacs${level} dip +        adduser --quiet tacacs${level} users +        adduser --quiet tacacs${level} aaa +        if [ $level -lt 15 ]; then +            adduser --quiet tacacs${level} vyattaop +            adduser --quiet tacacs${level} operator +        else +            adduser --quiet tacacs${level} vyattacfg +            adduser --quiet tacacs${level} sudo +            adduser --quiet tacacs${level} disk +            adduser --quiet tacacs${level} frr +        fi +        level=$(( level+1 )) +    done 2>&1 | grep -v 'User tacacs${level} already exists' +fi + + +if ! grep -q '^aaa' /etc/group; then +    addgroup --firstgid 1000 --quiet aaa +fi +  # Add RADIUS operator user for RADIUS authenticated users to map to  if ! grep -q '^radius_user' /etc/passwd; then      adduser --quiet --firstuid 1000 --disabled-login --ingroup vyattaop \ -        --no-create-home --gecos "radius user" \ +        --no-create-home --gecos "RADIUS mapped user at privilege level operator" \          --shell /sbin/radius_shell radius_user      adduser --quiet radius_user frrvty      adduser --quiet radius_user vyattaop @@ -40,12 +90,13 @@ if ! grep -q '^radius_user' /etc/passwd; then      adduser --quiet radius_user adm      adduser --quiet radius_user dip      adduser --quiet radius_user users +    adduser --quiet radius_user aaa  fi  # Add RADIUS admin user for RADIUS authenticated users to map to  if ! grep -q '^radius_priv_user' /etc/passwd; then      adduser --quiet --firstuid 1000 --disabled-login --ingroup vyattacfg \ -        --no-create-home --gecos "radius privileged user" \ +        --no-create-home --gecos "RADIUS mapped user at privilege level admin" \          --shell /sbin/radius_shell radius_priv_user      adduser --quiet radius_priv_user frrvty      adduser --quiet radius_priv_user vyattacfg @@ -55,6 +106,7 @@ if ! grep -q '^radius_priv_user' /etc/passwd; then      adduser --quiet radius_priv_user disk      adduser --quiet radius_priv_user users      adduser --quiet radius_priv_user frr +    adduser --quiet radius_priv_user aaa  fi  # add hostsd group for vyos-hostsd diff --git a/debian/vyos-1x.preinst b/debian/vyos-1x.preinst index 949ffcbc4..bfbeb112c 100644 --- a/debian/vyos-1x.preinst +++ b/debian/vyos-1x.preinst @@ -3,6 +3,7 @@ dpkg-divert --package vyos-1x --add --no-rename /etc/security/capability.conf  dpkg-divert --package vyos-1x --add --no-rename /lib/systemd/system/lcdproc.service  dpkg-divert --package vyos-1x --add --no-rename /etc/logrotate.d/conntrackd  dpkg-divert --package vyos-1x --add --no-rename /usr/share/pam-configs/radius +dpkg-divert --package vyos-1x --add --no-rename /usr/share/pam-configs/tacplus  dpkg-divert --package vyos-1x --add --no-rename /etc/rsyslog.conf  dpkg-divert --package vyos-1x --add --no-rename /etc/skel/.bashrc  dpkg-divert --package vyos-1x --add --no-rename /etc/skel/.profile diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in index 6651fc642..d36b34941 100644 --- a/interface-definitions/container.xml.in +++ b/interface-definitions/container.xml.in @@ -334,6 +334,42 @@                  </properties>                  <defaultValue>rw</defaultValue>                </leafNode> +              <leafNode name="propagation"> +                <properties> +                  <help>Volume bind propagation</help> +                  <completionHelp> +                    <list>shared slave private rshared rslave rprivate</list> +                  </completionHelp> +                  <valueHelp> +                    <format>shared</format> +                    <description>Sub-mounts of the original mount are exposed to replica mounts</description> +                  </valueHelp> +                  <valueHelp> +                    <format>slave</format> +                    <description>Allow replica mount to see sub-mount from the original mount but not vice versa</description> +                  </valueHelp> +                  <valueHelp> +                    <format>private</format> +                    <description>Sub-mounts within a mount are not visible to replica mounts or the original mount</description> +                  </valueHelp> +                  <valueHelp> +                    <format>rshared</format> +                    <description>Allows sharing of mount points and their nested mount points between both the original and replica mounts</description> +                  </valueHelp> +                  <valueHelp> +                    <format>rslave</format> +                    <description>Allows mount point and their nested mount points between original an replica mounts</description> +                  </valueHelp> +                  <valueHelp> +                    <format>rprivate</format> +                    <description>No mount points within original or replica mounts in any direction</description> +                  </valueHelp> +                  <constraint> +                    <regex>(shared|slave|private|rshared|rslave|rprivate)</regex> +                  </constraint> +                </properties> +                <defaultValue>rprivate</defaultValue> +              </leafNode>              </children>            </tagNode>          </children> diff --git a/interface-definitions/high-availability.xml.in b/interface-definitions/high-availability.xml.in index c5e46ed38..d1bbcc365 100644 --- a/interface-definitions/high-availability.xml.in +++ b/interface-definitions/high-availability.xml.in @@ -30,6 +30,22 @@                    </constraint>                  </properties>                </leafNode> +              <leafNode name="version"> +                <properties> +                  <help>Default VRRP version to use, IPv6 always uses VRRP version 3</help> +                  <valueHelp> +                    <format>2</format> +                    <description>VRRP version 2</description> +                  </valueHelp> +                  <valueHelp> +                    <format>3</format> +                    <description>VRRP version 3</description> +                  </valueHelp> +                  <constraint> +                    <validator name="numeric" argument="--range 2-3"/> +                  </constraint> +                </properties> +              </leafNode>              </children>            </node>            <tagNode name="group"> diff --git a/interface-definitions/include/policy/extended-community-value-list.xml.i b/interface-definitions/include/policy/extended-community-value-list.xml.i index c79f78c67..33a279be1 100644 --- a/interface-definitions/include/policy/extended-community-value-list.xml.i +++ b/interface-definitions/include/policy/extended-community-value-list.xml.i @@ -12,4 +12,4 @@  </constraint>  <constraintErrorMessage>Should be in form: ASN:NN or IPADDR:NN where ASN is autonomous system number</constraintErrorMessage>  <multi/> -        <!-- include end --> +<!-- include end --> diff --git a/interface-definitions/include/qos/bandwidth.xml.i b/interface-definitions/include/qos/bandwidth.xml.i index cc923f642..0e29b6499 100644 --- a/interface-definitions/include/qos/bandwidth.xml.i +++ b/interface-definitions/include/qos/bandwidth.xml.i @@ -27,7 +27,7 @@        <description>Terabits per second</description>      </valueHelp>      <valueHelp> -      <format><number>%</format> +      <format><number>%%</format>        <description>Percentage of interface link speed</description>      </valueHelp>      <constraint> diff --git a/interface-definitions/include/radius-server-auth-port.xml.i b/interface-definitions/include/radius-server-auth-port.xml.i index 660fa540f..d9ea1d445 100644 --- a/interface-definitions/include/radius-server-auth-port.xml.i +++ b/interface-definitions/include/radius-server-auth-port.xml.i @@ -1,15 +1,6 @@  <!-- include start from radius-server-auth-port.xml.i --> +#include <include/port-number.xml.i>  <leafNode name="port"> -  <properties> -    <help>Authentication port</help> -    <valueHelp> -      <format>u32:1-65535</format> -      <description>Numeric IP port</description> -    </valueHelp> -    <constraint> -      <validator name="numeric" argument="--range 1-65535"/> -    </constraint> -  </properties>    <defaultValue>1812</defaultValue>  </leafNode>  <!-- include end --> diff --git a/interface-definitions/system-login.xml.in b/interface-definitions/system-login.xml.in index be4f53c3b..d772c7821 100644 --- a/interface-definitions/system-login.xml.in +++ b/interface-definitions/system-login.xml.in @@ -193,20 +193,7 @@              <children>                <tagNode name="server">                  <children> -                  <leafNode name="timeout"> -                    <properties> -                      <help>Session timeout</help> -                      <valueHelp> -                        <format>u32:1-30</format> -                        <description>Session timeout in seconds</description> -                      </valueHelp> -                      <constraint> -                        <validator name="numeric" argument="--range 1-30"/> -                      </constraint> -                      <constraintErrorMessage>Timeout must be between 1 and 30 seconds</constraintErrorMessage> -                    </properties> -                    <defaultValue>2</defaultValue> -                  </leafNode> +                  #include <include/radius-timeout.xml.i>                    <leafNode name="priority">                      <properties>                        <help>Server priority</help> @@ -225,6 +212,50 @@                #include <include/interface/vrf.xml.i>              </children>            </node> +          <node name="tacacs"> +            <properties> +              <help>TACACS+ based user authentication</help> +            </properties> +            <children> +              <tagNode name="server"> +                <properties> +                  <help>TACACS+ server configuration</help> +                  <valueHelp> +                    <format>ipv4</format> +                    <description>TACACS+ server IPv4 address</description> +                  </valueHelp> +                  <constraint> +                    <validator name="ipv4-address"/> +                  </constraint> +                </properties> +                <children> +                  #include <include/generic-disable-node.xml.i> +                  #include <include/radius-server-key.xml.i> +                  #include <include/port-number.xml.i> +                  <leafNode name="port"> +                    <defaultValue>49</defaultValue> +                  </leafNode> +                </children> +              </tagNode> +              <leafNode name="source-address"> +                <properties> +                  <help>Source IP used to communicate with TACACS+ server</help> +                  <completionHelp> +                    <script>${vyos_completion_dir}/list_local_ips.sh --ipv4</script> +                  </completionHelp> +                  <valueHelp> +                    <format>ipv4</format> +                    <description>IPv4 source address</description> +                  </valueHelp> +                  <constraint> +                    <validator name="ipv4-address"/> +                  </constraint> +                </properties> +              </leafNode> +              #include <include/radius-timeout.xml.i> +              #include <include/interface/vrf.xml.i> +            </children> +          </node>            <leafNode name="max-login-session">              <properties>                <help>Maximum number of all login sessions</help> diff --git a/op-mode-definitions/configure.xml.in b/op-mode-definitions/configure.xml.in index 3dd5a0f45..a711fa4a9 100644 --- a/op-mode-definitions/configure.xml.in +++ b/op-mode-definitions/configure.xml.in @@ -11,7 +11,12 @@          echo "Please do it as an administrator level VyOS user instead."      else          if grep -q -e '^overlay.*/filesystem.squashfs' /proc/mounts; then -            echo "WARNING: You are currently configuring a live-ISO environment, changes will not persist until installed" +        echo "WARNING: You are currently configuring a live-ISO environment, changes will not persist until installed" +        else +            if grep -q -s '1' /tmp/vyos-config-status; then +            echo "WARNING: There was a config error on boot: saving the configuration now could overwrite data." +            echo "You may want to check and reload the boot config" +            fi          fi          history -w          export _OFR_CONFIGURE=ok diff --git a/op-mode-definitions/monitor-log.xml.in b/op-mode-definitions/monitor-log.xml.in index 06b1cf129..c577c5a39 100644 --- a/op-mode-definitions/monitor-log.xml.in +++ b/op-mode-definitions/monitor-log.xml.in @@ -6,13 +6,13 @@          <properties>            <help>Monitor last lines of messages file</help>          </properties> -        <command>journalctl --no-hostname --follow --boot</command> +        <command>SYSTEMD_LOG_COLOR=false journalctl --no-hostname --follow --boot</command>          <children>            <node name="color">              <properties>                <help>Output log in a colored fashion</help>              </properties> -            <command>grc journalctl --no-hostname --follow --boot</command> +            <command>SYSTEMD_LOG_COLOR=false grc journalctl --no-hostname --follow --boot</command>            </node>            <node name="ids">              <properties> diff --git a/python/vyos/config.py b/python/vyos/config.py index 287fd2ed1..c3bb68373 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -1,4 +1,4 @@ -# Copyright 2017, 2019 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2017, 2019-2023 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -67,9 +67,9 @@ import re  import json  from copy import deepcopy -import vyos.xml -import vyos.util  import vyos.configtree +from vyos.xml_ref import multi_to_list, merge_defaults, relative_defaults +from vyos.utils.dict import get_sub_dict, mangle_dict_keys  from vyos.configsource import ConfigSource, ConfigSourceSession  class Config(object): @@ -225,7 +225,8 @@ class Config(object):      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): +                        no_tag_node_value_mangle=False, +                        with_defaults=False, with_recursive_defaults=False):          """          Args:              path (str list): Configuration tree path, can be empty @@ -238,19 +239,23 @@ class Config(object):          """          lpath = self._make_path(path)          root_dict = self.get_cached_root_dict(effective) -        conf_dict = vyos.util.get_sub_dict(root_dict, lpath, get_first_key) +        conf_dict = get_sub_dict(root_dict, lpath, get_first_key) -        if not key_mangling and no_multi_convert: +        if key_mangling is None and no_multi_convert and not with_defaults:              return deepcopy(conf_dict) -        xmlpath = lpath if get_first_key else lpath[:-1] +        rpath = lpath if get_first_key else lpath[:-1] -        if not key_mangling: -            conf_dict = vyos.xml.multi_to_list(xmlpath, conf_dict) -            return conf_dict +        if not no_multi_convert: +            conf_dict = multi_to_list(rpath, conf_dict) + +        if with_defaults or with_recursive_defaults: +            conf_dict = merge_defaults(lpath, conf_dict, +                                       get_first_key=get_first_key, +                                       recursive=with_recursive_defaults) -        if no_multi_convert is False: -            conf_dict = vyos.xml.multi_to_list(xmlpath, conf_dict) +        if key_mangling is None: +            return conf_dict          if not (isinstance(key_mangling, tuple) and \                  (len(key_mangling) == 2) and \ @@ -258,10 +263,35 @@ class Config(object):                  isinstance(key_mangling[1], str)):              raise ValueError("key_mangling must be a tuple of two strings") -        conf_dict = vyos.util.mangle_dict_keys(conf_dict, key_mangling[0], key_mangling[1], abs_path=xmlpath, no_tag_node_value_mangle=no_tag_node_value_mangle) +        conf_dict = mangle_dict_keys(conf_dict, key_mangling[0], key_mangling[1], abs_path=rpath, no_tag_node_value_mangle=no_tag_node_value_mangle)          return conf_dict +    def get_config_defaults(self, path=[], effective=False, key_mangling=None, +                            no_tag_node_value_mangle=False, get_first_key=False, +                            recursive=False) -> dict: +        lpath = self._make_path(path) +        root_dict = self.get_cached_root_dict(effective) +        conf_dict = get_sub_dict(root_dict, lpath, get_first_key) + +        defaults = relative_defaults(lpath, conf_dict, +                                     get_first_key=get_first_key, +                                     recursive=recursive) +        if key_mangling is None: +            return defaults + +        rpath = lpath if get_first_key else lpath[:-1] + +        if not (isinstance(key_mangling, tuple) and \ +                (len(key_mangling) == 2) and \ +                isinstance(key_mangling[0], str) and \ +                isinstance(key_mangling[1], str)): +            raise ValueError("key_mangling must be a tuple of two strings") + +        defaults = mangle_dict_keys(defaults, key_mangling[0], key_mangling[1], abs_path=rpath, no_tag_node_value_mangle=no_tag_node_value_mangle) + +        return defaults +      def is_multi(self, path):          """          Args: diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 6ab5c252c..9618ec93e 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -389,7 +389,7 @@ def get_pppoe_interfaces(conf, vrf=None):      return pppoe_interfaces -def get_interface_dict(config, base, ifname=''): +def get_interface_dict(config, base, ifname='', recursive_defaults=True):      """      Common utility function to retrieve and mangle the interfaces configuration      from the CLI input nodes. All interfaces have a common base where value @@ -405,46 +405,23 @@ def get_interface_dict(config, base, ifname=''):              raise ConfigError('Interface (VYOS_TAGNODE_VALUE) not specified')          ifname = os.environ['VYOS_TAGNODE_VALUE'] -    # retrieve interface default values -    default_values = defaults(base) - -    # We take care about VLAN (vif, vif-s, vif-c) default values later on when -    # parsing vlans in default dict and merge the "proper" values in correctly, -    # see T2665. -    for vif in ['vif', 'vif_s']: -        if vif in default_values: del default_values[vif] - -    dict = config.get_config_dict(base + [ifname], key_mangling=('-', '_'), -                                  get_first_key=True, -                                  no_tag_node_value_mangle=True) -      # Check if interface has been removed. We must use exists() as      # get_config_dict() will always return {} - even when an empty interface      # node like the following exists.      # +macsec macsec1 {      # +}      if not config.exists(base + [ifname]): +        dict = config.get_config_dict(base + [ifname], key_mangling=('-', '_'), +                                      get_first_key=True, +                                      no_tag_node_value_mangle=True)          dict.update({'deleted' : {}}) - -    # Add interface instance name into dictionary -    dict.update({'ifname': ifname}) - -    # Check if QoS policy applied on this interface - See ifconfig.interface.set_mirror_redirect() -    if config.exists(['qos', 'interface', ifname]): -        dict.update({'traffic_policy': {}}) - -    # XXX: T2665: When there is no DHCPv6-PD configuration given, we can safely -    # remove the default values from the dict. -    if 'dhcpv6_options' not in dict: -        if 'dhcpv6_options' in default_values: -            del default_values['dhcpv6_options'] - -    # We have gathered the dict representation of the CLI, but there are -    # default options which we need to update into the dictionary retrived. -    # But we should only add them when interface is not deleted - as this might -    # confuse parsers -    if 'deleted' not in dict: -        dict = dict_merge(default_values, dict) +    else: +        # Get config_dict with default values +        dict = config.get_config_dict(base + [ifname], key_mangling=('-', '_'), +                                      get_first_key=True, +                                      no_tag_node_value_mangle=True, +                                      with_defaults=True, +                                      with_recursive_defaults=recursive_defaults)          # If interface does not request an IPv4 DHCP address there is no need          # to keep the dhcp-options key @@ -452,8 +429,12 @@ def get_interface_dict(config, base, ifname=''):              if 'dhcp_options' in dict:                  del dict['dhcp_options'] -    # XXX: T2665: blend in proper DHCPv6-PD default values -    dict = T2665_set_dhcpv6pd_defaults(dict) +    # Add interface instance name into dictionary +    dict.update({'ifname': ifname}) + +    # Check if QoS policy applied on this interface - See ifconfig.interface.set_mirror_redirect() +    if config.exists(['qos', 'interface', ifname]): +        dict.update({'traffic_policy': {}})      address = leaf_node_changed(config, base + [ifname, 'address'])      if address: dict.update({'address_old' : address}) @@ -497,9 +478,6 @@ def get_interface_dict(config, base, ifname=''):          else:              dict['ipv6']['address'].update({'eui64_old': eui64}) -    # Implant default dictionary in vif/vif-s VLAN interfaces. Values are -    # identical for all types of VLAN interfaces as they all include the same -    # XML definitions which hold the defaults.      for vif, vif_config in dict.get('vif', {}).items():          # Add subinterface name to dictionary          dict['vif'][vif].update({'ifname' : f'{ifname}.{vif}'}) @@ -507,22 +485,10 @@ def get_interface_dict(config, base, ifname=''):          if config.exists(['qos', 'interface', f'{ifname}.{vif}']):              dict['vif'][vif].update({'traffic_policy': {}}) -        default_vif_values = defaults(base + ['vif']) -        # XXX: T2665: When there is no DHCPv6-PD configuration given, we can safely -        # remove the default values from the dict. -        if not 'dhcpv6_options' in vif_config: -            del default_vif_values['dhcpv6_options'] - -        # 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, base + [ifname, '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]) -              # If interface does not request an IPv4 DHCP address there is no need              # to keep the dhcp-options key              if 'address' not in dict['vif'][vif] or 'dhcp' not in dict['vif'][vif]['address']: @@ -544,26 +510,10 @@ def get_interface_dict(config, base, ifname=''):          if config.exists(['qos', 'interface', f'{ifname}.{vif_s}']):              dict['vif_s'][vif_s].update({'traffic_policy': {}}) -        default_vif_s_values = defaults(base + ['vif-s']) -        # XXX: T2665: we only wan't the vif-s defaults - do not care about vif-c -        if 'vif_c' in default_vif_s_values: del default_vif_s_values['vif_c'] - -        # XXX: T2665: When there is no DHCPv6-PD configuration given, we can safely -        # remove the default values from the dict. -        if not 'dhcpv6_options' in vif_s_config: -            del default_vif_s_values['dhcpv6_options'] - -        # 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, base + [ifname, '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]) -              # If interface does not request an IPv4 DHCP address there is no need              # to keep the dhcp-options key              if 'address' not in dict['vif_s'][vif_s] or 'dhcp' not in \ @@ -586,26 +536,11 @@ def get_interface_dict(config, base, ifname=''):              if config.exists(['qos', 'interface', f'{ifname}.{vif_s}.{vif_c}']):                  dict['vif_s'][vif_s]['vif_c'][vif_c].update({'traffic_policy': {}}) -            default_vif_c_values = defaults(base + ['vif-s', 'vif-c']) - -            # XXX: T2665: When there is no DHCPv6-PD configuration given, we can safely -            # remove the default values from the dict. -            if not 'dhcpv6_options' in vif_c_config: -                del default_vif_c_values['dhcpv6_options'] - -            # 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, base + [ifname, '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, 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]) -                  # If interface does not request an IPv4 DHCP address there is no need                  # to keep the dhcp-options key                  if 'address' not in dict['vif_s'][vif_s]['vif_c'][vif_c] or 'dhcp' \ diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index 8fddd91d0..94dcdf4d9 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -322,7 +322,7 @@ def verify_dhcpv6(config):          # It is not allowed to have duplicate SLA-IDs as those identify an          # assigned IPv6 subnet from a delegated prefix -        for pd in dict_search('dhcpv6_options.pd', config): +        for pd in (dict_search('dhcpv6_options.pd', config) or []):              sla_ids = []              interfaces = dict_search(f'dhcpv6_options.pd.{pd}.interface', config) diff --git a/python/vyos/util.py b/python/vyos/util.py index e62f9d5cf..33da5da40 100644 --- a/python/vyos/util.py +++ b/python/vyos/util.py @@ -1139,6 +1139,19 @@ def boot_configuration_complete() -> bool:          return True      return False +def boot_configuration_success() -> bool: +    from vyos.defaults import config_status + +    try: +        with open(config_status) as f: +            res = f.read().strip() +    except FileNotFoundError: +        return False + +    if int(res) == 0: +        return True +    return False +  def sysctl_read(name):      """ Read and return current value of sysctl() option """      tmp = cmd(f'sysctl {name}') diff --git a/python/vyos/utils/dict.py b/python/vyos/utils/dict.py index 3faf5c596..28d32bb8d 100644 --- a/python/vyos/utils/dict.py +++ b/python/vyos/utils/dict.py @@ -65,7 +65,7 @@ def colon_separated_to_dict(data_string, uniquekeys=False):      return data -def _mangle_dict_keys(data, regex, replacement, abs_path=[], no_tag_node_value_mangle=False, mod=0): +def mangle_dict_keys(data, regex, replacement, abs_path=None, no_tag_node_value_mangle=False):      """ Mangles dict keys according to a regex and replacement character.      Some libraries like Jinja2 do not like certain characters in dict keys.      This function can be used for replacing all offending characters @@ -73,44 +73,39 @@ def _mangle_dict_keys(data, regex, replacement, abs_path=[], no_tag_node_value_m      Args:          data (dict): Original dict to mangle +        regex, replacement (str): arguments to re.sub(regex, replacement, ...) +        abs_path (list): if data is a config dict and no_tag_node_value_mangle is True +                         then abs_path should be the absolute config path to the first +                         keys of data, non-inclusive +        no_tag_node_value_mangle (bool): do not mangle keys of tag node values      Returns: dict      """ -    from vyos.xml import is_tag - -    new_dict = {} +    import re +    from vyos.xml_ref import is_tag_value -    for key in data.keys(): -        save_mod = mod -        save_path = abs_path[:] +    if abs_path is None: +        abs_path = [] -        abs_path.append(key) +    new_dict = {} -        if not is_tag(abs_path): -            new_key = re.sub(regex, replacement, key) +    for k in data.keys(): +        if no_tag_node_value_mangle and is_tag_value(abs_path + [k]): +            new_key = k          else: -            if mod%2: -                new_key = key -            else: -                new_key = re.sub(regex, replacement, key) -            if no_tag_node_value_mangle: -                mod += 1 +            new_key = re.sub(regex, replacement, k) -        value = data[key] +        value = data[k]          if isinstance(value, dict): -            new_dict[new_key] = _mangle_dict_keys(value, regex, replacement, abs_path=abs_path, mod=mod, no_tag_node_value_mangle=no_tag_node_value_mangle) +            new_dict[new_key] = mangle_dict_keys(value, regex, replacement, +                                                 abs_path=abs_path + [k], +                                                 no_tag_node_value_mangle=no_tag_node_value_mangle)          else:              new_dict[new_key] = value -        mod = save_mod -        abs_path = save_path[:] -      return new_dict -def mangle_dict_keys(data, regex, replacement, abs_path=[], no_tag_node_value_mangle=False): -    return _mangle_dict_keys(data, regex, replacement, abs_path=abs_path, no_tag_node_value_mangle=no_tag_node_value_mangle, mod=0) -  def _get_sub_dict(d, lpath):      k = lpath[0]      if k not in d.keys(): diff --git a/python/vyos/validate.py b/python/vyos/validate.py index d18785aaf..e5d8c6043 100644 --- a/python/vyos/validate.py +++ b/python/vyos/validate.py @@ -98,7 +98,7 @@ def is_intf_addr_assigned(intf, address) -> bool:      return False  def is_addr_assigned(ip_address, vrf=None) -> bool: -    """ Verify if the given IPv4/IPv6 address is assigned to any interfac """ +    """ Verify if the given IPv4/IPv6 address is assigned to any interface """      from netifaces import interfaces      from vyos.util import get_interface_config      from vyos.util import dict_search @@ -115,6 +115,24 @@ def is_addr_assigned(ip_address, vrf=None) -> bool:      return False +def is_afi_configured(interface, afi): +    """ Check if given address family is configured, or in other words - an IP +    address is assigned to the interface. """ +    from netifaces import ifaddresses +    from netifaces import AF_INET +    from netifaces import AF_INET6 + +    if afi not in [AF_INET, AF_INET6]: +        raise ValueError('Address family must be in [AF_INET, AF_INET6]') + +    try: +        addresses = ifaddresses(interface) +    except ValueError as e: +        print(e) +        return False + +    return afi in addresses +  def is_loopback_addr(addr):      """ Check if supplied IPv4/IPv6 address is a loopback address """      from ipaddress import ip_address diff --git a/python/vyos/xml_ref/__init__.py b/python/vyos/xml_ref/__init__.py index 2e144ef10..62d3680a1 100644 --- a/python/vyos/xml_ref/__init__.py +++ b/python/vyos/xml_ref/__init__.py @@ -58,12 +58,15 @@ def get_defaults(path: list, get_first_key=False, recursive=False) -> dict:      return load_reference().get_defaults(path, get_first_key=get_first_key,                                           recursive=recursive) -def get_config_defaults(rpath: list, conf: dict, get_first_key=False, -                        recursive=False) -> dict: +def relative_defaults(rpath: list, conf: dict, get_first_key=False, +                      recursive=False) -> dict: -    return load_reference().relative_defaults(rpath, conf=conf, +    return load_reference().relative_defaults(rpath, conf,                                                get_first_key=get_first_key,                                                recursive=recursive) -def merge_defaults(path: list, conf: dict) -> dict: -    return load_reference().merge_defaults(path, conf) +def merge_defaults(path: list, conf: dict, get_first_key=False, +                   recursive=False) -> dict: +    return load_reference().merge_defaults(path, conf, +                                           get_first_key=get_first_key, +                                           recursive=recursive) diff --git a/python/vyos/xml_ref/definition.py b/python/vyos/xml_ref/definition.py index 95ecc01a6..7fd7a7b77 100644 --- a/python/vyos/xml_ref/definition.py +++ b/python/vyos/xml_ref/definition.py @@ -13,8 +13,7 @@  # 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 typing import Union, Any -from vyos.configdict import dict_merge +from typing import Optional, Union, Any  class Xml:      def __init__(self): @@ -141,9 +140,17 @@ class Xml:          return res -    def _get_default_value(self, node: dict): +    def _get_default_value(self, node: dict) -> Optional[str]:          return self._get_ref_node_data(node, "default_value") +    def _get_default(self, node: dict) -> Optional[Union[str, list]]: +        default = self._get_default_value(node) +        if default is None: +            return None +        if self._is_multi_node(node) and not isinstance(default, list): +            return [default] +        return default +      def get_defaults(self, path: list, get_first_key=False, recursive=False) -> dict:          """Return dict containing default values below path @@ -153,18 +160,23 @@ class Xml:          'relative_defaults'          """          res: dict = {} +        if self.is_tag(path): +            return res +          d = self._get_ref_path(path) + +        if self._is_leaf_node(d): +            default_value = self._get_default(d) +            if default_value is not None: +                return {path[-1]: default_value} if path else {} +          for k in list(d):              if k in ('node_data', 'component_version') :                  continue -            d_k = d[k] -            if self._is_leaf_node(d_k): -                default_value = self._get_default_value(d_k) +            if self._is_leaf_node(d[k]): +                default_value = self._get_default(d[k])                  if default_value is not None: -                    pos = default_value -                    if self._is_multi_node(d_k) and not isinstance(pos, list): -                        pos = [pos] -                    res |= {k: pos} +                    res |= {k: default_value}              elif self.is_tag(path + [k]):                  # tag node defaults are used as suggestion, not default value;                  # should this change, append to path and continue if recursive @@ -175,8 +187,6 @@ class Xml:                      res |= pos          if res:              if get_first_key or not path: -                if not isinstance(res, dict): -                    raise TypeError("Cannot get_first_key as data under node is not of type dict")                  return res              return {path[-1]: res} @@ -188,7 +198,7 @@ class Xml:              return [next(iter(c.keys()))] if c else []          try:              tmp = step(conf) -            if self.is_tag_value(path + tmp): +            if tmp and self.is_tag_value(path + tmp):                  c = conf[tmp[0]]                  if not isinstance(c, dict):                      raise ValueError @@ -200,57 +210,67 @@ class Xml:              return False          return True -    def relative_defaults(self, rpath: list, conf: dict, get_first_key=False, -                          recursive=False) -> dict: -        """Return dict containing defaults along paths of a config dict -        """ -        if not conf: -            return self.get_defaults(rpath, get_first_key=get_first_key, -                                     recursive=recursive) -        if rpath and rpath[-1] in list(conf): -            conf = conf[rpath[-1]] -            if not isinstance(conf, dict): -                raise TypeError('conf at path is not of type dict') +    # use local copy of function in module configdict, to avoid circular +    # import +    def _dict_merge(self, source, destination): +        from copy import deepcopy +        tmp = deepcopy(destination) -        if not self._well_defined(rpath, conf): -            print('path to config dict does not define full config paths') -            return {} +        for key, value in source.items(): +            if key not in tmp: +                tmp[key] = value +            elif isinstance(source[key], dict): +                tmp[key] = self._dict_merge(source[key], tmp[key]) +        return tmp + +    def _relative_defaults(self, rpath: list, conf: dict, recursive=False) -> dict:          res: dict = {} +        res = self.get_defaults(rpath, recursive=recursive, +                                get_first_key=True)          for k in list(conf): -            pos = self.get_defaults(rpath + [k], recursive=recursive) -            res |= pos -              if isinstance(conf[k], dict): -                step = self.relative_defaults(rpath + [k], conf=conf[k], -                                              recursive=recursive) +                step = self._relative_defaults(rpath + [k], conf=conf[k], +                                               recursive=recursive)                  res |= step          if res: -            if get_first_key: -                return res              return {rpath[-1]: res} if rpath else res          return {} -    def merge_defaults(self, path: list, conf: dict) -> dict: +    def relative_defaults(self, path: list, conf: dict, get_first_key=False, +                          recursive=False) -> dict: +        """Return dict containing defaults along paths of a config dict +        """ +        if not conf: +            return self.get_defaults(path, get_first_key=get_first_key, +                                     recursive=recursive) +        if path and path[-1] in list(conf): +            conf = conf[path[-1]] +            conf = {} if not isinstance(conf, dict) else conf + +        if not self._well_defined(path, conf): +            print('path to config dict does not define full config paths') +            return {} + +        res = self._relative_defaults(path, conf, recursive=recursive) + +        if get_first_key and path: +            if res.values(): +                res = next(iter(res.values())) +            else: +                res = {} + +        return res + +    def merge_defaults(self, path: list, conf: dict, get_first_key=False, +                       recursive=False) -> dict:          """Return config dict with defaults non-destructively merged          This merges non-recursive defaults relative to the config dict.          """ -        if path[-1] in list(conf): -            config = conf[path[-1]] -            if not isinstance(config, dict): -                raise TypeError('conf at path is not of type dict') -            shift = False -        else: -            config = conf -            shift = True - -        if not self._well_defined(path, config): -            print('path to config dict does not define config paths; conf returned unchanged') -            return conf - -        d = self.relative_defaults(path, conf=config, get_first_key=shift) -        d = dict_merge(d, conf) +        d = self.relative_defaults(path, conf, get_first_key=get_first_key, +                                   recursive=recursive) +        d = self._dict_merge(d, conf)          return d diff --git a/smoketest/scripts/cli/base_vyostest_shim.py b/smoketest/scripts/cli/base_vyostest_shim.py index 7cfb53045..9415d6f44 100644 --- a/smoketest/scripts/cli/base_vyostest_shim.py +++ b/smoketest/scripts/cli/base_vyostest_shim.py @@ -14,6 +14,7 @@  import os  import unittest +import paramiko  from time import sleep  from typing import Type @@ -87,6 +88,19 @@ class VyOSUnitTestSHIM:                  pprint.pprint(out)              return out +        @staticmethod +        def ssh_send_cmd(command, username, password, hostname='localhost'): +            """ SSH command execution helper """ +            # Try to login via SSH +            ssh_client = paramiko.SSHClient() +            ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +            ssh_client.connect(hostname=hostname, username=username, password=password) +            _, stdout, stderr = ssh_client.exec_command(command) +            output = stdout.read().decode().strip() +            error = stderr.read().decode().strip() +            ssh_client.close() +            return output, error +  # standard construction; typing suggestion: https://stackoverflow.com/a/70292317  def ignore_warning(warning: Type[Warning]):      import warnings diff --git a/smoketest/scripts/cli/test_ha_vrrp.py b/smoketest/scripts/cli/test_ha_vrrp.py index 3a4de2d8d..03bdbdd61 100755 --- a/smoketest/scripts/cli/test_ha_vrrp.py +++ b/smoketest/scripts/cli/test_ha_vrrp.py @@ -96,6 +96,7 @@ class TestVRRP(VyOSUnitTestSHIM.TestCase):          group_garp_master_delay = '12'          group_garp_master_repeat = '13'          group_garp_master_refresh = '14' +        vrrp_version = '3'          for group in groups:              vlan_id = group.lstrip('VLAN') @@ -133,6 +134,7 @@ class TestVRRP(VyOSUnitTestSHIM.TestCase):          self.cli_set(global_param_base + ['garp', 'master-repeat', f'{garp_master_repeat}'])          self.cli_set(global_param_base + ['garp', 'master-refresh', f'{garp_master_refresh}'])          self.cli_set(global_param_base + ['garp', 'master-refresh-repeat', f'{garp_master_refresh_repeat}']) +        self.cli_set(global_param_base + ['version', vrrp_version])          # commit changes          self.cli_commit() @@ -145,6 +147,7 @@ class TestVRRP(VyOSUnitTestSHIM.TestCase):          self.assertIn(f'vrrp_garp_master_repeat {garp_master_repeat}', config)          self.assertIn(f'vrrp_garp_master_refresh {garp_master_refresh}', config)          self.assertIn(f'vrrp_garp_master_refresh_repeat {garp_master_refresh_repeat}', config) +        self.assertIn(f'vrrp_version {vrrp_version}', config)          for group in groups:              vlan_id = group.lstrip('VLAN') diff --git a/smoketest/scripts/cli/test_service_ssh.py b/smoketest/scripts/cli/test_service_ssh.py index 8de98f34f..e03907dd8 100755 --- a/smoketest/scripts/cli/test_service_ssh.py +++ b/smoketest/scripts/cli/test_service_ssh.py @@ -174,18 +174,6 @@ class TestServiceSSH(VyOSUnitTestSHIM.TestCase):          #          # We also try to login as an invalid user - this is not allowed to work. -        def ssh_send_cmd(command, username, password, host='localhost'): -            """ SSH command execution helper """ -            # Try to login via SSH -            ssh_client = paramiko.SSHClient() -            ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -            ssh_client.connect(hostname='localhost', username=username, password=password) -            _, stdout, stderr = ssh_client.exec_command(command) -            output = stdout.read().decode().strip() -            error = stderr.read().decode().strip() -            ssh_client.close() -            return output, error -          test_user = 'ssh_test'          test_pass = 'v2i57DZs8idUwMN3VC92'          test_command = 'uname -a' @@ -197,14 +185,14 @@ class TestServiceSSH(VyOSUnitTestSHIM.TestCase):          self.cli_commit()          # Login with proper credentials -        output, error = ssh_send_cmd(test_command, test_user, test_pass) +        output, error = self.ssh_send_cmd(test_command, test_user, test_pass)          # verify login          self.assertFalse(error)          self.assertEqual(output, cmd(test_command))          # Login with invalid credentials          with self.assertRaises(paramiko.ssh_exception.AuthenticationException): -            output, error = ssh_send_cmd(test_command, 'invalid_user', 'invalid_password') +            output, error = self.ssh_send_cmd(test_command, 'invalid_user', 'invalid_password')          self.cli_delete(['system', 'login', 'user', test_user])          self.cli_commit() diff --git a/smoketest/scripts/cli/test_system_login.py b/smoketest/scripts/cli/test_system_login.py index a1d2ba2ad..8a4f5fdd1 100755 --- a/smoketest/scripts/cli/test_system_login.py +++ b/smoketest/scripts/cli/test_system_login.py @@ -17,13 +17,13 @@  import re  import platform  import unittest +import paramiko  from base_vyostest_shim import VyOSUnitTestSHIM -from distutils.version import LooseVersion -from platform import release as kernel_version  from subprocess import Popen, PIPE  from pwd import getpwall +from time import sleep  from vyos.configsession import ConfigSessionError  from vyos.util import cmd @@ -53,12 +53,16 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase):          # ensure we can also run this test on a live system - so lets clean          # out the current configuration which will break this test          cls.cli_delete(cls, base_path + ['radius']) +        cls.cli_delete(cls, base_path + ['tacacs'])      def tearDown(self):          # Delete individual users from configuration          for user in users:              self.cli_delete(base_path + ['user', user]) +        self.cli_delete(base_path + ['radius']) +        self.cli_delete(base_path + ['tacacs']) +          self.cli_commit()          # After deletion, a user is not allowed to remain in /etc/passwd @@ -149,9 +153,6 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase):          # T2886 - RADIUS authentication - check for statically compiled options          options = ['CONFIG_AUDIT', 'CONFIG_AUDITSYSCALL', 'CONFIG_AUDIT_ARCH'] -        if LooseVersion(kernel_version()) < LooseVersion('5.0'): -            options.append('CONFIG_AUDIT_WATCH') -            options.append('CONFIG_AUDIT_TREE')          for option in options:              self.assertIn(f'{option}=y', kernel_config) @@ -284,6 +285,41 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase):          self.cli_delete(base_path + ['timeout'])          self.cli_delete(base_path + ['max-login-session']) +    def test_system_login_tacacs(self): +        tacacs_secret = 'tac_plus_key' +        tacacs_servers = ['100.64.0.11', '100.64.0.12'] + +        # Enable TACACS +        for server in tacacs_servers: +            self.cli_set(base_path + ['tacacs', 'server', server, 'key', tacacs_secret]) + +        self.cli_commit() + +        # NSS +        nsswitch_conf = read_file('/etc/nsswitch.conf') +        tmp = re.findall(r'passwd:\s+tacplus\s+files', nsswitch_conf) +        self.assertTrue(tmp) + +        tmp = re.findall(r'group:\s+tacplus\s+files', nsswitch_conf) +        self.assertTrue(tmp) + +        # PAM TACACS configuration +        pam_tacacs_conf = read_file('/etc/tacplus_servers') +        # NSS TACACS configuration +        nss_tacacs_conf = read_file('/etc/tacplus_nss.conf') +        # Users have individual home directories +        self.assertIn('user_homedir=1', pam_tacacs_conf) + +        # specify services +        self.assertIn('service=shell', pam_tacacs_conf) +        self.assertIn('protocol=ssh', pam_tacacs_conf) + +        for server in tacacs_servers: +            self.assertIn(f'secret={tacacs_secret}', pam_tacacs_conf) +            self.assertIn(f'server={server}', pam_tacacs_conf) + +            self.assertIn(f'secret={tacacs_secret}', nss_tacacs_conf) +            self.assertIn(f'server={server}', nss_tacacs_conf)  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/src/conf_mode/bcast_relay.py b/src/conf_mode/bcast_relay.py index 39a2971ce..459e4cdd4 100755 --- a/src/conf_mode/bcast_relay.py +++ b/src/conf_mode/bcast_relay.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2017-2022 VyOS maintainers and contributors +# Copyright (C) 2017-2023 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -17,12 +17,14 @@  import os  from glob import glob -from netifaces import interfaces +from netifaces import AF_INET  from sys import exit  from vyos.config import Config -from vyos.util import call +from vyos.configverify import verify_interface_exists  from vyos.template import render +from vyos.util import call +from vyos.validate import is_afi_configured  from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -52,16 +54,14 @@ def verify(relay):          if 'port' not in config:              raise ConfigError(f'Port number mandatory for udp broadcast relay "{instance}"') -        # if only oone interface is given it's a string -> move to list -        if isinstance(config.get('interface', []), str): -            config['interface'] = [ config['interface'] ]          # Relaying data without two interface is kinda senseless ...          if len(config.get('interface', [])) < 2:              raise ConfigError('At least two interfaces are required for udp broadcast relay "{instance}"')          for interface in config.get('interface', []): -            if interface not in interfaces(): -                raise ConfigError('Interface "{interface}" does not exist!') +            verify_interface_exists(interface) +            if not is_afi_configured(interface, AF_INET): +                raise ConfigError(f'Interface "{interface}" has no IPv4 address configured!')      return None diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index aceb27fb0..6198bb65f 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -321,7 +321,8 @@ def generate_run_arguments(name, container_config):              svol = vol_config['source']              dvol = vol_config['destination']              mode = vol_config['mode'] -            volume += f' --volume {svol}:{dvol}:{mode}' +            prop = vol_config['propagation'] +            volume += f' --volume {svol}:{dvol}:{mode},{prop}'      container_base_cmd = f'--detach --interactive --tty --replace {cap_add} ' \                           f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index fbb013cf3..24766a5b5 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -30,7 +30,8 @@ from vyos.defaults import directories  from vyos.template import render  from vyos.template import is_ipv4  from vyos.util import cmd -from vyos.util import call, rc_cmd +from vyos.util import call +from vyos.util import rc_cmd  from vyos.util import run  from vyos.util import DEVNULL  from vyos.util import dict_search @@ -42,20 +43,38 @@ airbag.enable()  autologout_file = "/etc/profile.d/autologout.sh"  limits_file = "/etc/security/limits.d/10-vyos.conf"  radius_config_file = "/etc/pam_radius_auth.conf" - +tacacs_pam_config_file = "/etc/tacplus_servers" +tacacs_nss_config_file = "/etc/tacplus_nss.conf" +nss_config_file = "/etc/nsswitch.conf" + +# Minimum UID used when adding system users +MIN_USER_UID: int = 1000 +# Maximim UID used when adding system users +MAX_USER_UID: int = 59999  # LOGIN_TIMEOUT from /etc/loign.defs minus 10 sec  MAX_RADIUS_TIMEOUT: int = 50  # MAX_RADIUS_TIMEOUT divided by 2 sec (minimum recomended timeout)  MAX_RADIUS_COUNT: int = 25 +# Maximum number of supported TACACS servers +MAX_TACACS_COUNT: int = 8 + +# List of local user accounts that must be preserved +SYSTEM_USER_SKIP_LIST: list = ['radius_user', 'radius_priv_user', 'tacacs0', 'tacacs1', +                              'tacacs2', 'tacacs3', 'tacacs4', 'tacacs5', 'tacacs6', +                              'tacacs7', 'tacacs8', 'tacacs9', 'tacacs10',' tacacs11', +                              'tacacs12', 'tacacs13', 'tacacs14', 'tacacs15']  def get_local_users():      """Return list of dynamically allocated users (see Debian Policy Manual)"""      local_users = []      for s_user in getpwall(): -        uid = getpwnam(s_user.pw_name).pw_uid -        if uid in range(1000, 29999): -            if s_user.pw_name not in ['radius_user', 'radius_priv_user']: -                local_users.append(s_user.pw_name) +        if getpwnam(s_user.pw_name).pw_uid < MIN_USER_UID: +            continue +        if getpwnam(s_user.pw_name).pw_uid > MAX_USER_UID: +            continue +        if s_user.pw_name in SYSTEM_USER_SKIP_LIST: +            continue +        local_users.append(s_user.pw_name)      return local_users @@ -88,12 +107,21 @@ def get_config(config=None):          for user in login['user']:              login['user'][user] = dict_merge(default_values, login['user'][user]) +    # Add TACACS global defaults +    if 'tacacs' in login: +        default_values = defaults(base + ['tacacs']) +        if 'server' in default_values: +            del default_values['server'] +        login['tacacs'] = dict_merge(default_values, login['tacacs']) +      # XXX: T2665: we can not safely rely on the defaults() when there are      # tagNodes in place, it is better to blend in the defaults manually. -    default_values = defaults(base + ['radius', 'server']) -    for server in dict_search('radius.server', login) or []: -        login['radius']['server'][server] = dict_merge(default_values, -            login['radius']['server'][server]) +    for backend in ['radius', 'tacacs']: +        default_values = defaults(base + [backend, 'server']) +        for server in dict_search(f'{backend}.server', login) or []: +            login[backend]['server'][server] = dict_merge(default_values, +                login[backend]['server'][server]) +      # create a list of all users, cli and users      all_users = list(set(local_users + cli_users)) @@ -107,9 +135,13 @@ def get_config(config=None):  def verify(login):      if 'rm_users' in login: -        cur_user = os.environ['SUDO_USER'] -        if cur_user in login['rm_users']: -            raise ConfigError(f'Attempting to delete current user: {cur_user}') +        # This check is required as the script is also executed from vyos-router +        # init script and there is no SUDO_USER environment variable available +        # during system boot. +        if 'SUDO_USER' in os.environ: +            cur_user = os.environ['SUDO_USER'] +            if cur_user in login['rm_users']: +                raise ConfigError(f'Attempting to delete current user: {cur_user}')      if 'user' in login:          system_users = getpwall() @@ -117,7 +149,7 @@ def verify(login):              # Linux system users range up until UID 1000, we can not create a              # VyOS CLI user which already exists as system user              for s_user in system_users: -                if s_user.pw_name == user and s_user.pw_uid < 1000: +                if s_user.pw_name == user and s_user.pw_uid < MIN_USER_UID:                      raise ConfigError(f'User "{user}" can not be created, conflict with local system account!')              for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): @@ -126,6 +158,9 @@ def verify(login):                  if 'key' not in pubkey_options:                      raise ConfigError(f'Missing key for public-key "{pubkey}"!') +    if {'radius', 'tacacs'} <= set(login): +        raise ConfigError('Using both RADIUS and TACACS at the same time is not supported!') +      # At lease one RADIUS server must not be disabled      if 'radius' in login:          if 'server' not in login['radius']: @@ -145,7 +180,7 @@ def verify(login):              raise ConfigError('All RADIUS servers are disabled')          if radius_servers_count > MAX_RADIUS_COUNT: -            raise ConfigError('Number of RADIUS servers more than 25 ') +            raise ConfigError(f'Number of RADIUS servers exceeded maximum of {MAX_RADIUS_COUNT}!')          if sum_timeout > MAX_RADIUS_TIMEOUT:              raise ConfigError('Sum of RADIUS servers timeouts ' @@ -165,6 +200,24 @@ def verify(login):              if ipv6_count > 1:                  raise ConfigError('Only one IPv6 source-address can be set!') +    if 'tacacs' in login: +        tacacs_servers_count: int = 0 +        fail = True +        for server, server_config in dict_search('tacacs.server', login).items(): +            if 'key' not in server_config: +                raise ConfigError(f'TACACS server "{server}" requires key!') +            if 'disable' not in server_config: +                tacacs_servers_count += 1 +                fail = False + +        if fail: +            raise ConfigError('All RADIUS servers are disabled') + +        if tacacs_servers_count > MAX_TACACS_COUNT: +            raise ConfigError(f'Number of TACACS servers exceeded maximum of {MAX_TACACS_COUNT}!') + +        verify_vrf(login['tacacs']) +      if 'max_login_session' in login and 'timeout' not in login:          raise ConfigError('"login timeout" must be configured!') @@ -186,8 +239,8 @@ def generate(login):                  env['vyos_libexec_dir'] = directories['base']                  # Set default commands for re-adding user with encrypted password -                del_user_plain = f"system login user '{user}' authentication plaintext-password" -                add_user_encrypt = f"system login user '{user}' authentication encrypted-password '{encrypted_password}'" +                del_user_plain = f"system login user {user} authentication plaintext-password" +                add_user_encrypt = f"system login user {user} authentication encrypted-password '{encrypted_password}'"                  lvl = env['VYATTA_EDIT_LEVEL']                  # We're in config edit level, for example "edit system login" @@ -206,10 +259,10 @@ def generate(login):                      add_user_encrypt = add_user_encrypt[len(lvl):]                      add_user_encrypt = " ".join(add_user_encrypt) -                call(f"/opt/vyatta/sbin/my_delete {del_user_plain}", env=env) +                ret, out = rc_cmd(f"/opt/vyatta/sbin/my_delete {del_user_plain}", env=env) +                if ret: raise ConfigError(out)                  ret, out = rc_cmd(f"/opt/vyatta/sbin/my_set {add_user_encrypt}", env=env) -                if ret: -                    raise ConfigError(out) +                if ret: raise ConfigError(out)              else:                  try:                      if get_shadow_password(user) == dict_search('authentication.encrypted_password', user_config): @@ -223,6 +276,7 @@ def generate(login):                  except:                      pass +    ### RADIUS based user authentication      if 'radius' in login:          render(radius_config_file, 'login/pam_radius_auth.conf.j2', login,                     permission=0o600, user='root', group='root') @@ -230,6 +284,24 @@ def generate(login):          if os.path.isfile(radius_config_file):              os.unlink(radius_config_file) +    ### TACACS+ based user authentication +    if 'tacacs' in login: +        render(tacacs_pam_config_file, 'login/tacplus_servers.j2', login, +                   permission=0o644, user='root', group='root') +        render(tacacs_nss_config_file, 'login/tacplus_nss.conf.j2', login, +                   permission=0o644, user='root', group='root') +    else: +        if os.path.isfile(tacacs_pam_config_file): +            os.unlink(tacacs_pam_config_file) +        if os.path.isfile(tacacs_nss_config_file): +            os.unlink(tacacs_nss_config_file) + + + +    # NSS must always be present on the system +    render(nss_config_file, 'login/nsswitch.conf.j2', login, +               permission=0o644, user='root', group='root') +      # /etc/security/limits.d/10-vyos.conf      if 'max_login_session' in login:          render(limits_file, 'login/limits.j2', login, @@ -253,7 +325,7 @@ def apply(login):          for user, user_config in login['user'].items():              # make new user using vyatta shell and make home directory (-m),              # default group of 100 (users) -            command = 'useradd --create-home --no-user-group' +            command = 'useradd --create-home --no-user-group '              # check if user already exists:              if user in get_local_users():                  # update existing account @@ -323,38 +395,17 @@ def apply(login):              except Exception as e:                  raise ConfigError(f'Deleting user "{user}" raised exception: {e}') -    # -    # RADIUS configuration -    # -    env = os.environ.copy() -    env['DEBIAN_FRONTEND'] = 'noninteractive' -    try: -        if 'radius' in login: -            # Enable RADIUS in PAM -            cmd('pam-auth-update --package --enable radius', env=env) -            # Make NSS system aware of RADIUS -            # This fancy snipped was copied from old Vyatta code -            command = "sed -i -e \'/\smapname/b\' \ -                          -e \'/^passwd:/s/\s\s*/&mapuid /\' \ -                          -e \'/^passwd:.*#/s/#.*/mapname &/\' \ -                          -e \'/^passwd:[^#]*$/s/$/ mapname &/\' \ -                          -e \'/^group:.*#/s/#.*/ mapname &/\' \ -                          -e \'/^group:[^#]*$/s/: */&mapname /\' \ -                          /etc/nsswitch.conf" -        else: -            # Disable RADIUS in PAM -            cmd('pam-auth-update --package --remove radius', env=env) -            # Drop RADIUS from NSS NSS system -            # This fancy snipped was copied from old Vyatta code -            command = "sed -i -e \'/^passwd:.*mapuid[ \t]/s/mapuid[ \t]//\' \ -                   -e \'/^passwd:.*[ \t]mapname/s/[ \t]mapname//\' \ -                   -e \'/^group:.*[ \t]mapname/s/[ \t]mapname//\' \ -                   -e \'s/[ \t]*$//\' \ -                   /etc/nsswitch.conf" - -        cmd(command) -    except Exception as e: -        raise ConfigError(f'RADIUS configuration failed: {e}') +    # Enable RADIUS in PAM configuration +    pam_cmd = '--remove' +    if 'radius' in login: +        pam_cmd = '--enable' +    cmd(f'pam-auth-update --package {pam_cmd} radius') + +    # Enable/Disable TACACS in PAM configuration +    pam_cmd = '--remove' +    if 'tacacs' in login: +        pam_cmd = '--enable' +    cmd(f'pam-auth-update --package {pam_cmd} tacplus')      return None diff --git a/src/op_mode/container.py b/src/op_mode/container.py index d48766a0c..7f726f076 100755 --- a/src/op_mode/container.py +++ b/src/op_mode/container.py @@ -83,7 +83,7 @@ def restart(name: str):      if rc != 0:          print(output)          return None -    print(f'Container name "{name}" restarted!') +    print(f'Container "{name}" restarted!')      return output diff --git a/src/op_mode/powerctrl.py b/src/op_mode/powerctrl.py index 239f766fd..dfacd45c2 100755 --- a/src/op_mode/powerctrl.py +++ b/src/op_mode/powerctrl.py @@ -104,8 +104,9 @@ def cancel_shutdown():  def check_unsaved_config():      from vyos.config_mgmt import unsaved_commits +    from vyos.util import boot_configuration_success -    if unsaved_commits(): +    if unsaved_commits() and boot_configuration_success():          print("Warning: there are unsaved configuration changes!")          print("Run 'save' command if you do not want to lose those changes after reboot/shutdown.")      else: diff --git a/src/pam-configs/radius b/src/pam-configs/radius index aaae6aeb0..08247f77c 100644 --- a/src/pam-configs/radius +++ b/src/pam-configs/radius @@ -1,20 +1,17 @@  Name: RADIUS authentication -Default: yes +Default: no  Priority: 257  Auth-Type: Primary  Auth: -    [default=ignore success=1] pam_succeed_if.so uid eq 1000 quiet -    [default=ignore success=ignore] pam_succeed_if.so uid eq 1001 quiet +    [default=ignore success=ignore] pam_succeed_if.so user ingroup aaa quiet      [authinfo_unavail=ignore success=end default=ignore] pam_radius_auth.so  Account-Type: Primary  Account: -    [default=ignore success=1] pam_succeed_if.so uid eq 1000 quiet -    [default=ignore success=ignore] pam_succeed_if.so uid eq 1001 quiet +    [default=ignore success=ignore] pam_succeed_if.so user ingroup aaa quiet      [authinfo_unavail=ignore success=end perm_denied=bad default=ignore] pam_radius_auth.so  Session-Type: Additional  Session: -    [default=ignore success=1] pam_succeed_if.so uid eq 1000 quiet -    [default=ignore success=ignore] pam_succeed_if.so uid eq 1001 quiet +    [default=ignore success=ignore] pam_succeed_if.so user ingroup aaa quiet      [authinfo_unavail=ignore success=ok default=ignore] pam_radius_auth.so diff --git a/src/pam-configs/tacplus b/src/pam-configs/tacplus new file mode 100644 index 000000000..66a1eaa4c --- /dev/null +++ b/src/pam-configs/tacplus @@ -0,0 +1,17 @@ +Name: TACACS+ authentication +Default: no +Priority: 257 +Auth-Type: Primary +Auth: +    [default=ignore success=ignore] pam_succeed_if.so user ingroup aaa quiet +    [authinfo_unavail=ignore success=end auth_err=bad default=ignore] pam_tacplus.so include=/etc/tacplus_servers login=login + +Account-Type: Primary +Account: +    [default=ignore success=ignore] pam_succeed_if.so user ingroup aaa quiet +    [authinfo_unavail=ignore success=end perm_denied=bad default=ignore] pam_tacplus.so include=/etc/tacplus_servers login=login + +Session-Type: Additional +Session: +    [default=ignore success=ignore] pam_succeed_if.so user ingroup aaa quiet +    [authinfo_unavail=ignore success=ok default=ignore] pam_tacplus.so include=/etc/tacplus_servers login=login diff --git a/src/validators/bgp-extended-community b/src/validators/bgp-extended-community index b69ae3449..d66665519 100755 --- a/src/validators/bgp-extended-community +++ b/src/validators/bgp-extended-community @@ -1,6 +1,6 @@  #!/usr/bin/env python3 -# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2019-2023 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -28,28 +28,27 @@ if __name__ == '__main__':      parser: ArgumentParser = ArgumentParser()      parser.add_argument('community', type=str)      args = parser.parse_args() -    community: str = args.community -    if community.count(':') != 1: -        print("Invalid community format") -        exit(1) -    try: -        # try to extract community parts from an argument -        comm_left: str = community.split(':')[0] -        comm_right: int = int(community.split(':')[1]) - -        # check if left part is an IPv4 address -        if is_ipv4(comm_left) and 0 <= comm_right <= COMM_MAX_2_OCTET: -            exit() -        # check if a left part is a number -        if 0 <= int(comm_left) <= COMM_MAX_2_OCTET \ -                and 0 <= comm_right <= COMM_MAX_4_OCTET: -            exit() - -    except Exception: -        # fail if something was wrong -        print("Invalid community format") -        exit(1) - -    # fail if none of validators catched the value -    print("Invalid community format") -    exit(1)
\ No newline at end of file + +    for community in args.community.split(): +        if community.count(':') != 1: +            print("Invalid community format") +            exit(1) +        try: +            # try to extract community parts from an argument +            comm_left: str = community.split(':')[0] +            comm_right: int = int(community.split(':')[1]) + +            # check if left part is an IPv4 address +            if is_ipv4(comm_left) and 0 <= comm_right <= COMM_MAX_2_OCTET: +                continue +            # check if a left part is a number +            if 0 <= int(comm_left) <= COMM_MAX_2_OCTET \ +                    and 0 <= comm_right <= COMM_MAX_4_OCTET: +                continue + +            raise Exception() + +        except Exception: +            # fail if something was wrong +            print("Invalid community format") +            exit(1)
\ No newline at end of file | 
