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()