summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/templates/high-availability/keepalived.conf.j23
-rw-r--r--data/templates/login/nsswitch.conf.j221
-rw-r--r--data/templates/login/tacplus_nss.conf.j274
-rw-r--r--data/templates/login/tacplus_servers.j259
-rw-r--r--debian/control5
-rw-r--r--debian/vyos-1x.postinst56
-rw-r--r--debian/vyos-1x.preinst1
-rw-r--r--interface-definitions/high-availability.xml.in16
-rw-r--r--interface-definitions/include/qos/bandwidth.xml.i2
-rw-r--r--interface-definitions/include/radius-server-auth-port.xml.i11
-rw-r--r--interface-definitions/system-login.xml.in59
-rw-r--r--op-mode-definitions/monitor-log.xml.in4
-rw-r--r--python/vyos/config.py56
-rw-r--r--python/vyos/configdict.py99
-rw-r--r--python/vyos/configverify.py2
-rw-r--r--python/vyos/utils/dict.py43
-rw-r--r--python/vyos/validate.py20
-rw-r--r--python/vyos/xml_ref/__init__.py13
-rw-r--r--python/vyos/xml_ref/definition.py120
-rw-r--r--smoketest/scripts/cli/base_vyostest_shim.py14
-rwxr-xr-xsmoketest/scripts/cli/test_ha_vrrp.py3
-rwxr-xr-xsmoketest/scripts/cli/test_service_ssh.py16
-rwxr-xr-xsmoketest/scripts/cli/test_system_login.py46
-rwxr-xr-xsrc/conf_mode/bcast_relay.py16
-rwxr-xr-xsrc/conf_mode/system-login.py157
-rwxr-xr-xsrc/op_mode/container.py2
-rw-r--r--src/pam-configs/radius11
-rw-r--r--src/pam-configs/tacplus17
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>&lt;number&gt;%</format>
+ <format>&lt;number&gt;%%</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