diff options
28 files changed, 652 insertions, 294 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 8cbad99d9..dcce8036a 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 (>= 1.0.4), + libpam-tacplus (>= 1.4.3), + libpam-radius-auth (>= 1.5.0) 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/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/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/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/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/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/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 |