diff --git a/smoketest/scripts/cli/test_system_login.py b/smoketest/scripts/cli/test_system_login.py
index 8327235fb..af3a5851c 100755
--- a/smoketest/scripts/cli/test_system_login.py
+++ b/smoketest/scripts/cli/test_system_login.py
@@ -1,204 +1,239 @@
#!/usr/bin/env python3
#
# Copyright (C) 2019-2020 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import re
import platform
import unittest
from base_vyostest_shim import VyOSUnitTestSHIM
from distutils.version import LooseVersion
from platform import release as kernel_version
from subprocess import Popen, PIPE
from vyos.configsession import ConfigSession
from vyos.configsession import ConfigSessionError
from vyos.util import cmd
from vyos.util import read_file
from vyos.template import inc_ip
base_path = ['system', 'login']
-users = ['vyos1', 'vyos2']
+users = ['vyos1', 'vyos-roxx123', 'VyOS-123_super.Nice']
+
+ssh_pubkey = """
+AAAAB3NzaC1yc2EAAAADAQABAAABgQD0NuhUOEtMIKnUVFIHoFatqX/c4mjerXyF
+TlXYfVt6Ls2NZZsUSwHbnhK4BKDrPvVZMW/LycjQPzWW6TGtk6UbZP1WqdviQ9hP
+jsEeKJSTKciMSvQpjBWyEQQPXSKYQC7ryQQilZDqnJgzqwzejKEe+nhhOdBvjuZc
+uukxjT69E0UmWAwLxzvfiurwiQaC7tG+PwqvtfHOPL3i6yRO2C5ORpFarx8PeGDS
+IfIXJCr3LoUbLHeuE7T2KaOKQcX0UsWJ4CoCapRLpTVYPDB32BYfgq7cW1Sal1re
+EGH2PzuXBklinTBgCHA87lHjpwDIAqdmvMj7SXIW9LxazLtP+e37sexE7xEs0cpN
+l68txdDbY2P2Kbz5mqGFfCvBYKv9V2clM5vyWNy/Xp5TsCis89nn83KJmgFS7sMx
+pHJz8umqkxy3hfw0K7BRFtjWd63sbOP8Q/SDV7LPaIfIxenA9zv2rY7y+AIqTmSr
+TTSb0X1zPGxPIRFy5GoGtO9Mm5h4OZk=
+"""
class TestSystemLogin(VyOSUnitTestSHIM.TestCase):
def tearDown(self):
# Delete individual users from configuration
for user in users:
self.cli_delete(base_path + ['user', user])
self.cli_commit()
def test_add_linux_system_user(self):
+ # We are not allowed to re-use a username already taken by the Linux
+ # base system
system_user = 'backup'
self.cli_set(base_path + ['user', system_user, 'authentication', 'plaintext-password', system_user])
# check validate() - can not add username which exists on the Debian
# base system (UID < 1000)
with self.assertRaises(ConfigSessionError):
self.cli_commit()
self.cli_delete(base_path + ['user', system_user])
def test_system_login_user(self):
# Check if user can be created and we can SSH to localhost
self.cli_set(['service', 'ssh', 'port', '22'])
for user in users:
name = "VyOS Roxx " + user
home_dir = "/tmp/" + user
self.cli_set(base_path + ['user', user, 'authentication', 'plaintext-password', user])
self.cli_set(base_path + ['user', user, 'full-name', 'VyOS Roxx'])
self.cli_set(base_path + ['user', user, 'home-directory', home_dir])
self.cli_commit()
for user in users:
cmd = ['su','-', user]
proc = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
tmp = "{}\nuname -a".format(user)
proc.stdin.write(tmp.encode())
proc.stdin.flush()
(stdout, stderr) = proc.communicate()
# stdout is something like this:
- # b'Linux vyos 4.19.101-amd64-vyos #1 SMP Sun Feb 2 10:18:07 UTC 2020 x86_64 GNU/Linux\n'
+ # b'Linux LR1.wue3 5.10.61-amd64-vyos #1 SMP Fri Aug 27 08:55:46 UTC 2021 x86_64 GNU/Linux\n'
self.assertTrue(len(stdout) > 40)
+ def test_system_user_ssh_key(self):
+ ssh_user = 'ssh-test_user'
+ public_keys = 'vyos'
+ type = 'ssh-rsa'
+
+ self.cli_set(base_path + ['user', ssh_user, 'authentication', 'public-keys', public_keys, 'key', ssh_pubkey.replace('\n','')])
+
+ # check validate() - missing type for public-key
+ with self.assertRaises(ConfigSessionError):
+ self.cli_commit()
+ self.cli_set(base_path + ['user', ssh_user, 'authentication', 'public-keys', public_keys, 'type', type])
+
+ self.cli_commit()
+
+ # Check that SSH key was written properly
+ tmp = cmd(f'sudo cat /home/{ssh_user}/.ssh/authorized_keys')
+ key = f'{type} ' + ssh_pubkey.replace('\n','')
+ self.assertIn(key, tmp)
+
+ self.cli_delete(base_path + ['user', ssh_user])
+
def test_radius_kernel_features(self):
# T2886: RADIUS requires some Kernel options to be present
kernel = platform.release()
kernel_config = read_file(f'/boot/config-{kernel}')
# 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)
def test_system_login_radius_ipv4(self):
# Verify generated RADIUS configuration files
radius_key = 'VyOSsecretVyOS'
radius_server = '172.16.100.10'
radius_source = '127.0.0.1'
radius_port = '2000'
radius_timeout = '1'
self.cli_set(base_path + ['radius', 'server', radius_server, 'key', radius_key])
self.cli_set(base_path + ['radius', 'server', radius_server, 'port', radius_port])
self.cli_set(base_path + ['radius', 'server', radius_server, 'timeout', radius_timeout])
self.cli_set(base_path + ['radius', 'source-address', radius_source])
self.cli_set(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)])
# check validate() - Only one IPv4 source-address supported
with self.assertRaises(ConfigSessionError):
self.cli_commit()
self.cli_delete(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)])
self.cli_commit()
# this file must be read with higher permissions
pam_radius_auth_conf = cmd('sudo cat /etc/pam_radius_auth.conf')
tmp = re.findall(r'\n?{}:{}\s+{}\s+{}\s+{}'.format(radius_server,
radius_port, radius_key, radius_timeout,
radius_source), pam_radius_auth_conf)
self.assertTrue(tmp)
# required, static options
self.assertIn('priv-lvl 15', pam_radius_auth_conf)
self.assertIn('mapped_priv_user radius_priv_user', pam_radius_auth_conf)
# PAM
pam_common_account = read_file('/etc/pam.d/common-account')
self.assertIn('pam_radius_auth.so', pam_common_account)
pam_common_auth = read_file('/etc/pam.d/common-auth')
self.assertIn('pam_radius_auth.so', pam_common_auth)
pam_common_session = read_file('/etc/pam.d/common-session')
self.assertIn('pam_radius_auth.so', pam_common_session)
pam_common_session_noninteractive = read_file('/etc/pam.d/common-session-noninteractive')
self.assertIn('pam_radius_auth.so', pam_common_session_noninteractive)
# NSS
nsswitch_conf = read_file('/etc/nsswitch.conf')
tmp = re.findall(r'passwd:\s+mapuid\s+files\s+mapname', nsswitch_conf)
self.assertTrue(tmp)
tmp = re.findall(r'group:\s+mapname\s+files', nsswitch_conf)
self.assertTrue(tmp)
def test_system_login_radius_ipv6(self):
# Verify generated RADIUS configuration files
radius_key = 'VyOS-VyOS'
radius_server = '2001:db8::1'
radius_source = '::1'
radius_port = '4000'
radius_timeout = '4'
self.cli_set(base_path + ['radius', 'server', radius_server, 'key', radius_key])
self.cli_set(base_path + ['radius', 'server', radius_server, 'port', radius_port])
self.cli_set(base_path + ['radius', 'server', radius_server, 'timeout', radius_timeout])
self.cli_set(base_path + ['radius', 'source-address', radius_source])
self.cli_set(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)])
# check validate() - Only one IPv4 source-address supported
with self.assertRaises(ConfigSessionError):
self.cli_commit()
self.cli_delete(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)])
self.cli_commit()
# this file must be read with higher permissions
pam_radius_auth_conf = cmd('sudo cat /etc/pam_radius_auth.conf')
tmp = re.findall(r'\n?\[{}\]:{}\s+{}\s+{}\s+\[{}\]'.format(radius_server,
radius_port, radius_key, radius_timeout,
radius_source), pam_radius_auth_conf)
self.assertTrue(tmp)
# required, static options
self.assertIn('priv-lvl 15', pam_radius_auth_conf)
self.assertIn('mapped_priv_user radius_priv_user', pam_radius_auth_conf)
# PAM
pam_common_account = read_file('/etc/pam.d/common-account')
self.assertIn('pam_radius_auth.so', pam_common_account)
pam_common_auth = read_file('/etc/pam.d/common-auth')
self.assertIn('pam_radius_auth.so', pam_common_auth)
pam_common_session = read_file('/etc/pam.d/common-session')
self.assertIn('pam_radius_auth.so', pam_common_session)
pam_common_session_noninteractive = read_file('/etc/pam.d/common-session-noninteractive')
self.assertIn('pam_radius_auth.so', pam_common_session_noninteractive)
# NSS
nsswitch_conf = read_file('/etc/nsswitch.conf')
tmp = re.findall(r'passwd:\s+mapuid\s+files\s+mapname', nsswitch_conf)
self.assertTrue(tmp)
tmp = re.findall(r'group:\s+mapname\s+files', nsswitch_conf)
self.assertTrue(tmp)
if __name__ == '__main__':
- unittest.main(verbosity=2)
+ unittest.main(verbosity=2, failfast=True)
diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py
index 59ea1d34b..78830931d 100755
--- a/src/conf_mode/system-login.py
+++ b/src/conf_mode/system-login.py
@@ -1,312 +1,312 @@
#!/usr/bin/env python3
#
# Copyright (C) 2020-2021 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import os
from crypt import crypt
from crypt import METHOD_SHA512
from psutil import users
from pwd import getpwall
from pwd import getpwnam
from spwd import getspnam
from sys import exit
from vyos.config import Config
from vyos.configdict import dict_merge
from vyos.configverify import verify_vrf
from vyos.template import render
from vyos.template import is_ipv4
from vyos.util import cmd
from vyos.util import call
from vyos.util import DEVNULL
from vyos.util import dict_search
from vyos.xml import defaults
from vyos import ConfigError
from vyos import airbag
airbag.enable()
radius_config_file = "/etc/pam_radius_auth.conf"
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)
return local_users
def get_config(config=None):
if config:
conf = config
else:
conf = Config()
base = ['system', 'login']
login = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True)
+ no_tag_node_value_mangle=True, get_first_key=True)
# users no longer existing in the running configuration need to be deleted
local_users = get_local_users()
cli_users = []
if 'user' in login:
cli_users = list(login['user'])
# 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 + ['user'])
for user in login['user']:
login['user'][user] = dict_merge(default_values, login['user'][user])
# 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])
# XXX: for a yet unknown reason when we only have one source-address
# get_config_dict() will show a string over a string
if 'radius' in login and 'source_address' in login['radius']:
if isinstance(login['radius']['source_address'], str):
login['radius']['source_address'] = [login['radius']['source_address']]
# create a list of all users, cli and users
all_users = list(set(local_users + cli_users))
# We will remove any normal users that dos not exist in the current
# configuration. This can happen if user is added but configuration was not
# saved and system is rebooted.
rm_users = [tmp for tmp in all_users if tmp not in cli_users]
if rm_users: login.update({'rm_users' : rm_users})
return login
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}')
if 'user' in login:
system_users = getpwall()
for user, user_config in login['user'].items():
# 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:
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():
if 'type' not in pubkey_options:
raise ConfigError(f'Missing type for public-key "{pubkey}"!')
if 'key' not in pubkey_options:
raise ConfigError(f'Missing key for public-key "{pubkey}"!')
# At lease one RADIUS server must not be disabled
if 'radius' in login:
if 'server' not in login['radius']:
raise ConfigError('No RADIUS server defined!')
fail = True
for server, server_config in dict_search('radius.server', login).items():
if 'key' not in server_config:
raise ConfigError(f'RADIUS server "{server}" requires key!')
if 'disabled' not in server_config:
fail = False
continue
if fail:
raise ConfigError('All RADIUS servers are disabled')
verify_vrf(login['radius'])
if 'source_address' in login['radius']:
ipv4_count = 0
ipv6_count = 0
for address in login['radius']['source_address']:
if is_ipv4(address): ipv4_count += 1
else: ipv6_count += 1
if ipv4_count > 1:
raise ConfigError('Only one IPv4 source-address can be set!')
if ipv6_count > 1:
raise ConfigError('Only one IPv6 source-address can be set!')
return None
def generate(login):
# calculate users encrypted password
if 'user' in login:
for user, user_config in login['user'].items():
tmp = dict_search('authentication.plaintext_password', user_config)
if tmp:
encrypted_password = crypt(tmp, METHOD_SHA512)
login['user'][user]['authentication']['encrypted_password'] = encrypted_password
del login['user'][user]['authentication']['plaintext_password']
# remove old plaintext password and set new encrypted password
env = os.environ.copy()
env['vyos_libexec_dir'] = '/usr/libexec/vyos'
# 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}'"
lvl = env['VYATTA_EDIT_LEVEL']
# We're in config edit level, for example "edit system login"
# Change default commands for re-adding user with encrypted password
if lvl != '/':
# Replace '/system/login' to 'system login'
lvl = lvl.strip('/').split('/')
# Convert command str to list
del_user_plain = del_user_plain.split()
# New command exclude level, for example "edit system login"
del_user_plain = del_user_plain[len(lvl):]
# Convert string to list
del_user_plain = " ".join(del_user_plain)
add_user_encrypt = add_user_encrypt.split()
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)
call(f"/opt/vyatta/sbin/my_set {add_user_encrypt}", env=env)
else:
try:
if getspnam(user).sp_pwdp == dict_search('authentication.encrypted_password', user_config):
# If the current encrypted bassword matches the encrypted password
# from the config - do not update it. This will remove the encrypted
# value from the system logs.
#
# The encrypted password will be set only once during the first boot
# after an image upgrade.
del login['user'][user]['authentication']['encrypted_password']
except:
pass
if 'radius' in login:
render(radius_config_file, 'login/pam_radius_auth.conf.tmpl', login,
permission=0o600, user='root', group='root')
else:
if os.path.isfile(radius_config_file):
os.unlink(radius_config_file)
return None
def apply(login):
if 'user' in 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 -m -N'
# check if user already exists:
if user in get_local_users():
# update existing account
command = 'usermod'
# all accounts use /bin/vbash
command += ' -s /bin/vbash'
# we need to use '' quotes when passing formatted data to the shell
# else it will not work as some data parts are lost in translation
tmp = dict_search('authentication.encrypted_password', user_config)
if tmp: command += f" -p '{tmp}'"
tmp = dict_search('full_name', user_config)
if tmp: command += f" -c '{tmp}'"
tmp = dict_search('home_directory', user_config)
if tmp: command += f" -d '{tmp}'"
else: command += f" -d '/home/{user}'"
command += f' -G frrvty,vyattacfg,sudo,adm,dip,disk {user}'
try:
cmd(command)
# we should not rely on the value stored in
# user_config['home_directory'], as a crazy user will choose
# username root or any other system user which will fail.
#
# XXX: Should we deny using root at all?
home_dir = getpwnam(user).pw_dir
render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.tmpl',
user_config, permission=0o600, user=user, group='users')
except Exception as e:
raise ConfigError(f'Adding user "{user}" raised exception: "{e}"')
if 'rm_users' in login:
for user in login['rm_users']:
try:
# Logout user if he is still logged in
if user in list(set([tmp[0] for tmp in users()])):
print(f'{user} is logged in, forcing logout!')
call(f'pkill -HUP -u {user}')
# Remove user account but leave home directory to be safe
call(f'userdel -r {user}', stderr=DEVNULL)
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}')
return None
if __name__ == '__main__':
try:
c = get_config()
verify(c)
generate(c)
apply(c)
except ConfigError as e:
print(e)
exit(1)