diff --git a/debian/vyos-1x-smoketest.postinst b/debian/vyos-1x-smoketest.postinst
index 18612804cd..57149af826 100755
--- a/debian/vyos-1x-smoketest.postinst
+++ b/debian/vyos-1x-smoketest.postinst
@@ -1,10 +1,15 @@
#!/bin/sh -e
BUSYBOX_TAG="docker.io/library/busybox:stable"
-OUTPUT_PATH="/usr/share/vyos/busybox-stable.tar"
-
-if [[ -f $OUTPUT_PATH ]]; then
- rm -f $OUTPUT_PATH
+BUSYBOX_PATH="/usr/share/vyos/busybox-stable.tar"
+if [[ -f $BUSYBOX_PATH ]]; then
+ rm -f $BUSYBOX_PATH
fi
+skopeo copy --additional-tag "$BUSYBOX_TAG" "docker://$BUSYBOX_TAG" "docker-archive:/$BUSYBOX_PATH"
-skopeo copy --additional-tag "$BUSYBOX_TAG" "docker://$BUSYBOX_TAG" "docker-archive:/$OUTPUT_PATH"
+TACPLUS_TAG="docker.io/lfkeitel/tacacs_plus:alpine"
+TACPLUS_PATH="/usr/share/vyos/tacplus-alpine.tar"
+if [[ -f $TACPLUS_PATH ]]; then
+ rm -f $TACPLUS_PATH
+fi
+skopeo copy --additional-tag "$TACPLUS_TAG" "docker://$TACPLUS_TAG" "docker-archive:/$TACPLUS_PATH"
diff --git a/smoketest/scripts/cli/test_container.py b/smoketest/scripts/cli/test_container.py
index 51559a7c67..36622cad1f 100755
--- a/smoketest/scripts/cli/test_container.py
+++ b/smoketest/scripts/cli/test_container.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# Copyright (C) 2021-2025 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
@@ -14,6 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+import os
import unittest
import glob
import json
@@ -26,10 +27,10 @@
from vyos.utils.process import process_named_running
base_path = ['container']
-cont_image = 'busybox:stable' # busybox is included in vyos-build
PROCESS_NAME = 'conmon'
PROCESS_PIDFILE = '/run/vyos-container-{0}.service.pid'
+busybox_image = 'busybox:stable'
busybox_image_path = '/usr/share/vyos/busybox-stable.tar'
def cmd_to_json(command):
@@ -42,11 +43,10 @@ class TestContainer(VyOSUnitTestSHIM.TestCase):
def setUpClass(cls):
super(TestContainer, cls).setUpClass()
- # Load image for smoketest provided in vyos-build
- try:
- cmd(f'cat {busybox_image_path} | sudo podman load')
- except:
- cls.skipTest(cls, reason='busybox image not available')
+ # Load image for smoketest provided in vyos-1x-smoketest
+ if not os.path.exists(busybox_image_path):
+ cls.fail(cls, f'{busybox_image} image not available')
+ cmd(f'sudo podman load -i {busybox_image_path}')
# ensure we can also run this test on a live system - so lets clean
# out the current configuration :)
@@ -55,9 +55,8 @@ def setUpClass(cls):
@classmethod
def tearDownClass(cls):
super(TestContainer, cls).tearDownClass()
-
# Cleanup podman image
- cmd(f'sudo podman image rm -f {cont_image}')
+ cmd(f'sudo podman image rm -f {busybox_image}')
def tearDown(self):
self.cli_delete(base_path)
@@ -78,7 +77,7 @@ def test_basic(self):
self.cli_set(['system', 'name-server', '1.1.1.1'])
self.cli_set(['system', 'name-server', '8.8.8.8'])
- self.cli_set(base_path + ['name', cont_name, 'image', cont_image])
+ self.cli_set(base_path + ['name', cont_name, 'image', busybox_image])
self.cli_set(base_path + ['name', cont_name, 'allow-host-networks'])
self.cli_set(base_path + ['name', cont_name, 'sysctl', 'parameter', 'kernel.msgmax', 'value', '4096'])
@@ -104,7 +103,7 @@ def test_name_server(self):
self.cli_set(base_path + ['network', net_name, 'prefix', prefix])
- self.cli_set(base_path + ['name', cont_name, 'image', cont_image])
+ self.cli_set(base_path + ['name', cont_name, 'image', busybox_image])
self.cli_set(base_path + ['name', cont_name, 'name-server', name_server])
self.cli_set(base_path + ['name', cont_name, 'network', net_name, 'address', str(ip_interface(prefix).ip + 2)])
@@ -125,7 +124,7 @@ def test_cpu_limit(self):
cont_name = 'c2'
self.cli_set(base_path + ['name', cont_name, 'allow-host-networks'])
- self.cli_set(base_path + ['name', cont_name, 'image', cont_image])
+ self.cli_set(base_path + ['name', cont_name, 'image', busybox_image])
self.cli_set(base_path + ['name', cont_name, 'cpu-quota', '1.25'])
self.cli_commit()
@@ -146,7 +145,7 @@ def test_ipv4_network(self):
for ii in range(1, 6):
name = f'{base_name}-{ii}'
- self.cli_set(base_path + ['name', name, 'image', cont_image])
+ self.cli_set(base_path + ['name', name, 'image', busybox_image])
self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix).ip + ii)])
# verify() - first IP address of a prefix can not be used by a container
@@ -176,7 +175,7 @@ def test_ipv6_network(self):
for ii in range(1, 6):
name = f'{base_name}-{ii}'
- self.cli_set(base_path + ['name', name, 'image', cont_image])
+ self.cli_set(base_path + ['name', name, 'image', busybox_image])
self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix).ip + ii)])
# verify() - first IP address of a prefix can not be used by a container
@@ -208,7 +207,7 @@ def test_dual_stack_network(self):
for ii in range(1, 6):
name = f'{base_name}-{ii}'
- self.cli_set(base_path + ['name', name, 'image', cont_image])
+ self.cli_set(base_path + ['name', name, 'image', busybox_image])
self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix4).ip + ii)])
self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix6).ip + ii)])
@@ -242,7 +241,7 @@ def test_no_name_server(self):
self.cli_set(base_path + ['network', net_name, 'no-name-server'])
name = f'{base_name}-2'
- self.cli_set(base_path + ['name', name, 'image', cont_image])
+ self.cli_set(base_path + ['name', name, 'image', busybox_image])
self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix).ip + 2)])
self.cli_commit()
@@ -258,7 +257,7 @@ def test_network_mtu(self):
self.cli_set(base_path + ['network', net_name, 'mtu', '1280'])
name = f'{base_name}-2'
- self.cli_set(base_path + ['name', name, 'image', cont_image])
+ self.cli_set(base_path + ['name', name, 'image', busybox_image])
self.cli_set(base_path + ['name', name, 'network', net_name, 'address', str(ip_interface(prefix).ip + 2)])
self.cli_commit()
@@ -271,7 +270,7 @@ def test_uid_gid(self):
uid = '1001'
self.cli_set(base_path + ['name', cont_name, 'allow-host-networks'])
- self.cli_set(base_path + ['name', cont_name, 'image', cont_image])
+ self.cli_set(base_path + ['name', cont_name, 'image', busybox_image])
self.cli_set(base_path + ['name', cont_name, 'gid', gid])
# verify() - GID can only be set if UID is set
@@ -293,7 +292,7 @@ def test_api_socket(self):
for ii in container_list:
name = f'{base_name}-{ii}'
- self.cli_set(base_path + ['name', name, 'image', cont_image])
+ self.cli_set(base_path + ['name', name, 'image', busybox_image])
self.cli_set(base_path + ['name', name, 'allow-host-networks'])
self.cli_commit()
diff --git a/smoketest/scripts/cli/test_system_login.py b/smoketest/scripts/cli/test_system_login.py
index 28abba0122..f6a2c3cb3e 100755
--- a/smoketest/scripts/cli/test_system_login.py
+++ b/smoketest/scripts/cli/test_system_login.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright (C) 2019-2024 VyOS maintainers and contributors
+# Copyright (C) 2019-2025 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
@@ -14,24 +14,35 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+import os
import re
import unittest
+import jinja2
+import secrets
+import string
+import paramiko
+import shutil
from base_vyostest_shim import VyOSUnitTestSHIM
from gzip import GzipFile
-from subprocess import Popen, PIPE
+from subprocess import Popen
+from subprocess import PIPE
from pwd import getpwall
from vyos.configsession import ConfigSessionError
from vyos.utils.auth import get_current_user
from vyos.utils.process import cmd
+from vyos.utils.process import process_named_running
from vyos.utils.file import read_file
+from vyos.utils.file import write_file
from vyos.template import inc_ip
base_path = ['system', 'login']
users = ['vyos1', 'vyos-roxx123', 'VyOS-123_super.Nice']
+SSH_PROCESS_NAME = 'sshd'
+
ssh_pubkey = """
AAAAB3NzaC1yc2EAAAADAQABAAABgQD0NuhUOEtMIKnUVFIHoFatqX/c4mjerXyF
TlXYfVt6Ls2NZZsUSwHbnhK4BKDrPvVZMW/LycjQPzWW6TGtk6UbZP1WqdviQ9hP
@@ -44,6 +55,53 @@
TTSb0X1zPGxPIRFy5GoGtO9Mm5h4OZk=
"""
+tac_image = 'docker.io/lfkeitel/tacacs_plus:alpine'
+tac_image_path = '/usr/share/vyos/tacplus-alpine.tar'
+
+TAC_PLUS_TMPL_SRC = """
+id = spawnd {
+ debug redirect = /dev/stdout
+ listen = { port = 49 }
+ spawn = {
+ instances min = 1
+ instances max = 10
+ }
+ background = no
+}
+
+id = tac_plus {
+ debug = ALL
+ log = stdout {
+ destination = /dev/stdout
+ }
+ authorization log group = yes
+ authentication log = stdout
+ authorization log = stdout
+ accounting log = stdout
+
+ host = smoketest {
+ address = {{ source_address }}/32
+ enable = clear enable
+ key = {{ tacacs_secret }}
+ }
+
+ group = admin {
+ default service = permit
+ enable = permit
+ service = shell {
+ default command = permit
+ default attribute = permit
+ set priv-lvl = 15
+ }
+ }
+
+ user = {{ username }} {
+ password = clear {{ password }}
+ member = admin
+ }
+}
+"""
+
class TestSystemLogin(VyOSUnitTestSHIM.TestCase):
@classmethod
def setUpClass(cls):
@@ -54,6 +112,17 @@ def setUpClass(cls):
cls.cli_delete(cls, base_path + ['radius'])
cls.cli_delete(cls, base_path + ['tacacs'])
+ # Load image for smoketest provided in vyos-1x-smoketest
+ if not os.path.exists(tac_image_path):
+ cls.fail(cls, f'{tac_image} image not available')
+ cmd(f'sudo podman load -i {tac_image_path}')
+
+ @classmethod
+ def tearDownClass(cls):
+ super(TestSystemLogin, cls).tearDownClass()
+ # Cleanup podman image
+ cmd(f'sudo podman image rm -f {tac_image}')
+
def tearDown(self):
# Delete individual users from configuration
for user in users:
@@ -87,11 +156,11 @@ def test_system_login_user(self):
self.cli_set(['service', 'ssh', 'port', '22'])
for user in users:
- name = "VyOS Roxx " + user
- home_dir = "/tmp/" + user
+ name = f'VyOS Roxx {user}'
+ home_dir = f'/tmp/smoketest/{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, 'full-name', name])
self.cli_set(base_path + ['user', user, 'home-directory', home_dir])
self.cli_commit()
@@ -99,13 +168,13 @@ def test_system_login_user(self):
for user in users:
tmp = ['su','-', user]
proc = Popen(tmp, stdin=PIPE, stdout=PIPE, stderr=PIPE)
- tmp = "{}\nuname -a".format(user)
+ tmp = f'{user}\nuname -a'
proc.stdin.write(tmp.encode())
proc.stdin.flush()
(stdout, stderr) = proc.communicate()
# stdout is something like this:
- # b'Linux LR1.wue3 5.10.61-amd64-vyos #1 SMP Fri Aug 27 08:55:46 UTC 2021 x86_64 GNU/Linux\n'
+ # b'Linux vyos 6.6.66-vyos 6.6.66-vyos #1 SMP Mon Dec 30 19:05:15 UTC 2024 x86_64 GNU/Linux\n'
self.assertTrue(len(stdout) > 40)
locked_user = users[0]
@@ -123,7 +192,6 @@ def test_system_login_user(self):
tmp = cmd(f'sudo passwd -S {locked_user}')
self.assertIn(f'{locked_user} P ', tmp)
-
def test_system_login_otp(self):
otp_user = 'otp-test_user'
otp_password = 'SuperTestPassword'
@@ -300,11 +368,52 @@ def test_system_login_max_login_session(self):
self.cli_delete(base_path + ['max-login-session'])
def test_system_login_tacacs(self):
- tacacs_secret = 'tac_plus_key'
+ tacacs_secret = ''.join(secrets.choice(string.ascii_letters + string.digits) for i in range(10))
tacacs_servers = ['100.64.0.11', '100.64.0.12']
+ source_address = '100.64.0.1'
+ dummy_if = 'dum12759'
+
+ # Load container image for lac_plus daemon
+ tac_plus_config = '/tmp/smoketest-tacacs-server'
+ tac_container_path = ['container', 'name', 'tacacs-1']
+
+ # Generate random string with 10 digits
+ username = 'tactest'
+ password = ''.join(secrets.choice(string.ascii_letters + string.digits) for i in range(10))
+ tac_test_user = {
+ 'username' : username,
+ 'password' : password,
+ 'tacacs_secret' : tacacs_secret,
+ 'source_address' : source_address,
+ }
+
+ tmpl = jinja2.Template(TAC_PLUS_TMPL_SRC)
+ write_file(f'{tac_plus_config}/tac_plus.cfg', tmpl.render(tac_test_user))
+
+ # Check if SSH service is running
+ ssh_running = process_named_running(SSH_PROCESS_NAME)
+ if not ssh_running:
+ # Start SSH service
+ self.cli_set(['service', 'ssh'])
+
+ # Start tac_plus container
+ self.cli_set(tac_container_path + ['allow-host-networks'])
+ self.cli_set(tac_container_path + ['image', tac_image])
+ self.cli_set(tac_container_path + ['volume', 'config', 'destination', '/etc/tac_plus'])
+ self.cli_set(tac_container_path + ['volume', 'config', 'mode', 'ro'])
+ self.cli_set(tac_container_path + ['volume', 'config', 'source', tac_plus_config])
+
+ # Start container
+ self.cli_commit()
+
+ # Define TACACS traffic source address
+ self.cli_set(['interfaces', 'dummy', dummy_if, 'address', f'{source_address}/32'])
+ self.cli_set(base_path + ['tacacs', 'source-address', source_address])
- # Enable TACACS
+ # Define TACACS servers
for server in tacacs_servers:
+ # Use this system as "remote" TACACS server
+ self.cli_set(['interfaces', 'dummy', dummy_if, 'address', f'{server}/32'])
self.cli_set(base_path + ['tacacs', 'server', server, 'key', tacacs_secret])
self.cli_commit()
@@ -328,6 +437,11 @@ def test_system_login_tacacs(self):
self.assertIn('service=shell', pam_tacacs_conf)
self.assertIn('protocol=ssh', pam_tacacs_conf)
+ # Verify configured TACACS source address
+ self.assertIn(f'source_ip={source_address}', pam_tacacs_conf)
+ self.assertIn(f'source_ip={source_address}', nss_tacacs_conf)
+
+ # Verify configured TACACS servers
for server in tacacs_servers:
self.assertIn(f'secret={tacacs_secret}', pam_tacacs_conf)
self.assertIn(f'server={server}', pam_tacacs_conf)
@@ -335,6 +449,32 @@ def test_system_login_tacacs(self):
self.assertIn(f'secret={tacacs_secret}', nss_tacacs_conf)
self.assertIn(f'server={server}', nss_tacacs_conf)
+ # Login with proper credentials
+ test_command = 'uname -a'
+ out, err = self.ssh_send_cmd(test_command, username, password)
+ # verify login
+ self.assertFalse(err)
+ self.assertEqual(out, cmd(test_command))
+
+ # Login with invalid credentials
+ with self.assertRaises(paramiko.ssh_exception.AuthenticationException):
+ _, _ = self.ssh_send_cmd(test_command, username, f'{password}1')
+
+ # Remove TACACS configuration
+ self.cli_delete(base_path + ['tacacs'])
+ # Remove tac_plus container
+ self.cli_delete(tac_container_path)
+ # Remove dummy interface
+ self.cli_delete(['interfaces', 'dummy', dummy_if])
+ self.cli_commit()
+
+ # Remove rendered tac_plus daemon configuration
+ shutil.rmtree(tac_plus_config)
+
+ # Stop SSH service if it was not running before
+ if not ssh_running:
+ self.cli_delete(['service', 'ssh'])
+
def test_delete_current_user(self):
current_user = get_current_user()