Skip to content

Commit 6b6f75e

Browse files
image: T5455: Add migration of SSH known_hosts files during image upgrade
During upgrade, the script now checks if any `known_hosts` files exist. If so, it prompts the user to save these SSH fingerprints, and upon confirmation, copies the files to the new image persistence directory.
1 parent 06e491c commit 6b6f75e

File tree

6 files changed

+316
-30
lines changed

6 files changed

+316
-30
lines changed

python/vyos/utils/auth.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,35 @@
2020

2121
from enum import StrEnum
2222
from decimal import Decimal
23+
from pwd import getpwall
24+
from pwd import getpwnam
2325
from vyos.utils.process import cmd
2426

25-
27+
# Minimum UID used when adding system users
28+
MIN_USER_UID: int = 1000
29+
# Maximim UID used when adding system users
30+
MAX_USER_UID: int = 59999
31+
# List of local user accounts that must be preserved
32+
SYSTEM_USER_SKIP_LIST: frozenset = {
33+
'radius_user',
34+
'radius_priv_user',
35+
'tacacs0',
36+
'tacacs1',
37+
'tacacs2',
38+
'tacacs3',
39+
'tacacs4',
40+
'tacacs5',
41+
'tacacs6',
42+
'tacacs7',
43+
'tacacs8',
44+
'tacacs9',
45+
'tacacs10',
46+
'tacacs11',
47+
'tacacs12',
48+
'tacacs13',
49+
'tacacs14',
50+
'tacacs15',
51+
}
2652
DEFAULT_PASSWORD: str = 'vyos'
2753
LOW_ENTROPY_MSG: str = 'should be at least 8 characters long;'
2854
WEAK_PASSWORD_MSG: str = 'The password complexity is too low - @MSG@'
@@ -119,3 +145,24 @@ def get_current_user() -> str:
119145
elif 'USER' in os.environ:
120146
current_user = os.environ['USER']
121147
return current_user
148+
149+
150+
def get_local_users(min_uid=MIN_USER_UID, max_uid=MAX_USER_UID) -> list:
151+
"""Return list of dynamically allocated users (see Debian Policy Manual)"""
152+
local_users = []
153+
154+
for s_user in getpwall():
155+
if s_user.pw_uid < min_uid:
156+
continue
157+
if s_user.pw_uid > max_uid:
158+
continue
159+
if s_user.pw_name in SYSTEM_USER_SKIP_LIST:
160+
continue
161+
local_users.append(s_user.pw_name)
162+
163+
return local_users
164+
165+
166+
def get_user_home_dir(user: str) -> str:
167+
"""Return user's home directory"""
168+
return getpwnam(user).pw_dir

python/vyos/utils/file.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import os
1717
import tempfile
18+
import shutil
1819

1920
from vyos.utils.permission import chown
2021

@@ -268,3 +269,46 @@ def cleanup():
268269
except OSError as e:
269270
cleanup()
270271
raise OSError(f'rename {e}')
272+
273+
def copy_recursive(src: str, dst: str, overwrite: bool = False):
274+
"""
275+
Recursively copy files from `src` to `dst`.
276+
277+
:param src: Source directory
278+
:param dst: Destination directory
279+
:param overwrite: If True, overwrite existing files. If False, skip them.
280+
"""
281+
282+
if not os.path.exists(src):
283+
raise FileNotFoundError(f"Source path does not exist: {src}")
284+
285+
os.makedirs(dst, exist_ok=True) # Create destination directory if not exists
286+
287+
for root, _, files in os.walk(src):
288+
# Find relative path to maintain directory structure
289+
rel_path = os.path.relpath(root, src)
290+
target_dir = os.path.join(dst, rel_path) if rel_path != "." else dst
291+
292+
os.makedirs(target_dir, exist_ok=True)
293+
294+
for file in files:
295+
src_file = os.path.join(root, file)
296+
dst_file = os.path.join(target_dir, file)
297+
298+
if not os.path.exists(dst_file) or overwrite:
299+
shutil.copy2(src_file, dst_file)
300+
301+
302+
def move_recursive(src: str, dst: str, overwrite=False):
303+
"""
304+
Recursively move files from `src` to `dst` and removing the source.
305+
306+
:param src: Source directory
307+
:param dst: Destination directory
308+
:param overwrite: If True, overwrite existing files. If False, skip them.
309+
"""
310+
if not os.path.exists(src):
311+
raise FileNotFoundError(f"Source path does not exist: {src}")
312+
313+
copy_recursive(src, dst, overwrite=overwrite)
314+
shutil.rmtree(src)

src/conf_mode/system_login.py

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from passlib.hosts import linux_context
2323
from psutil import users
2424
from pwd import getpwall
25-
from pwd import getpwnam
2625
from pwd import getpwuid
2726
from sys import exit
2827
from time import sleep
@@ -39,9 +38,13 @@
3938
from vyos.utils.auth import EPasswdStrength
4039
from vyos.utils.auth import evaluate_strength
4140
from vyos.utils.auth import get_current_user
41+
from vyos.utils.auth import get_local_users
42+
from vyos.utils.auth import get_user_home_dir
43+
from vyos.utils.auth import MIN_USER_UID
4244
from vyos.utils.configfs import delete_cli_node
4345
from vyos.utils.configfs import add_cli_node
4446
from vyos.utils.dict import dict_search
47+
from vyos.utils.file import move_recursive
4548
from vyos.utils.permission import chown
4649
from vyos.utils.process import cmd
4750
from vyos.utils.process import call
@@ -59,41 +62,19 @@
5962
nss_config_file = "/etc/nsswitch.conf"
6063
login_motd_dsa_warning = r'/run/motd.d/92-vyos-user-dsa-deprecation-warning'
6164

62-
# Minimum UID used when adding system users
63-
MIN_USER_UID: int = 1000
64-
# Maximim UID used when adding system users
65-
MAX_USER_UID: int = 59999
66-
# LOGIN_TIMEOUT from /etc/loign.defs minus 10 sec0
65+
# LOGIN_TIMEOUT from /etc/loign.defs minus 10 sec
6766
MAX_RADIUS_TIMEOUT: int = 50
6867
# MAX_RADIUS_TIMEOUT divided by 2 sec (minimum recomended timeout)
6968
MAX_RADIUS_COUNT: int = 8
7069
# Maximum number of supported TACACS servers
7170
MAX_TACACS_COUNT: int = 8
7271
# Minimum USER id for TACACS users
7372
MIN_TACACS_UID = 900
74-
# List of local user accounts that must be preserved
75-
SYSTEM_USER_SKIP_LIST: list = ['radius_user', 'radius_priv_user', 'tacacs0', 'tacacs1',
76-
'tacacs2', 'tacacs3', 'tacacs4', 'tacacs5', 'tacacs6',
77-
'tacacs7', 'tacacs8', 'tacacs9', 'tacacs10',' tacacs11',
78-
'tacacs12', 'tacacs13', 'tacacs14', 'tacacs15']
7973

8074
# As of OpenSSH 9.8p1 in Debian trixie, DSA keys are no longer supported
8175
SSH_DSA_DEPRECATION_WARNING: str = f'{SSH_DSA_DEPRECATION_WARNING} '\
8276
'The following users are using SSH-DSS keys for authentication.'
8377

84-
def get_local_users(min_uid=MIN_USER_UID, max_uid=MAX_USER_UID):
85-
"""Return list of dynamically allocated users (see Debian Policy Manual)"""
86-
local_users = []
87-
for s_user in getpwall():
88-
if getpwnam(s_user.pw_name).pw_uid < min_uid:
89-
continue
90-
if getpwnam(s_user.pw_name).pw_uid > max_uid:
91-
continue
92-
if s_user.pw_name in SYSTEM_USER_SKIP_LIST:
93-
continue
94-
local_users.append(s_user.pw_name)
95-
96-
return local_users
9778

9879
def get_shadow_password(username):
9980
with open('/etc/shadow') as f:
@@ -389,9 +370,10 @@ def apply(login):
389370
tmp = dict_search('full_name', user_config)
390371
if tmp: command += f" --comment '{tmp}'"
391372

392-
tmp = dict_search('home_directory', user_config)
393-
if tmp: command += f" --home '{tmp}'"
394-
else: command += f" --home '/home/{user}'"
373+
home_directory = dict_search('home_directory', user_config)
374+
if not home_directory:
375+
home_directory = f'/home/{user}'
376+
command += f" --home '{home_directory}'"
395377

396378
if 'operator' not in user_config:
397379
command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea'
@@ -404,7 +386,7 @@ def apply(login):
404386
# crazy user will choose username root or any other system user which will fail.
405387
#
406388
# XXX: Should we deny using root at all?
407-
home_dir = getpwnam(user).pw_dir
389+
home_dir = get_user_home_dir(user)
408390
# always re-render SSH keys with appropriate permissions
409391
render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2',
410392
user_config, permission=0o600,
@@ -424,6 +406,17 @@ def apply(login):
424406
except Exception as e:
425407
raise ConfigError(f'Adding user "{user}" raised exception: "{e}"')
426408

409+
# After invoking 'useradd' for each user, if /var/.users_backups/{user} exists, restore the
410+
# backed up files to the newly created home directory. This reinstates the user's
411+
# SSH environment and avoids loss of access or trust relationships due to the user
412+
# creation process, which does not copy such custom files by default.
413+
#
414+
# More details: https://github.com/vyos/vyos-1x/pull/4678#pullrequestreview-3169648265
415+
backup_directory = f"/var/.users_backups/{user}"
416+
if command.startswith('useradd') and os.path.exists(backup_directory):
417+
move_recursive(backup_directory, home_dir)
418+
chown(home_dir, user=user, group='users', recursive=True)
419+
427420
# T5875: ensure UID is properly set on home directory if user is re-added
428421
# the home directory will always exist, as it's created above by --create-home,
429422
# retrieve current owner of home directory and adjust on demand
@@ -459,7 +452,7 @@ def apply(login):
459452
# Disable user to prevent re-login
460453
call(f'usermod -s /sbin/nologin {user}')
461454

462-
home_dir = getpwnam(user).pw_dir
455+
home_dir = get_user_home_dir(user)
463456
# Remove SSH authorized keys file
464457
authorized_keys_file = f'{home_dir}/.ssh/authorized_keys'
465458
if os.path.exists(authorized_keys_file):

src/op_mode/image_installer.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
from vyos.utils.file import read_file
6161
from vyos.utils.file import write_file
6262
from vyos.utils.process import cmd, run, rc_cmd
63+
from vyos.utils.auth import get_local_users
64+
from vyos.utils.auth import get_user_home_dir
6365
from vyos.version import get_version_data
6466

6567
# define text messages
@@ -719,6 +721,20 @@ def copy_ssh_host_keys() -> bool:
719721
return False
720722

721723

724+
def copy_ssh_known_hosts() -> bool:
725+
"""Ask user to copy SSH `known_hosts` files
726+
727+
Returns:
728+
bool: user's decision
729+
"""
730+
known_hosts_files = get_known_hosts_files()
731+
msg = (
732+
'Would you like to save the SSH known hosts (fingerprints) '
733+
'from your current configuration?'
734+
)
735+
return known_hosts_files and ask_yes_no(msg, default=True)
736+
737+
722738
def console_hint() -> str:
723739
pid = getppid() if 'SUDO_USER' in environ else getpid()
724740
try:
@@ -1014,6 +1030,55 @@ def install_image() -> None:
10141030
exit(1)
10151031

10161032

1033+
def get_known_hosts_files(for_root=True, for_users=True) -> list:
1034+
"""Collect all existing `known_hosts` files for root and/or users under /home"""
1035+
1036+
files = []
1037+
1038+
if for_root:
1039+
base_files = ('/root/.ssh/known_hosts', '/etc/ssh/ssh_known_hosts')
1040+
for file_path in base_files:
1041+
root_known_hosts = Path(file_path)
1042+
if root_known_hosts.exists():
1043+
files.append(root_known_hosts)
1044+
1045+
if for_users: # for each non-system user
1046+
for user in get_local_users():
1047+
home_dir = Path(get_user_home_dir(user))
1048+
if home_dir.exists():
1049+
known_hosts = home_dir / '.ssh' / 'known_hosts'
1050+
if known_hosts.exists():
1051+
files.append(known_hosts)
1052+
1053+
return files
1054+
1055+
1056+
def migrate_known_hosts(target_dir: str):
1057+
"""Copy `known_hosts` for root and all users to the new image directory"""
1058+
1059+
def _mkdir_and_copy_file(known_hosts_file, target_known_hosts):
1060+
target_known_hosts.parent.mkdir(parents=True, exist_ok=True)
1061+
copy(known_hosts_file, target_known_hosts)
1062+
1063+
# Copy root only files using default path
1064+
known_hosts_files = get_known_hosts_files(for_root=True, for_users=False)
1065+
for known_hosts_file in known_hosts_files:
1066+
target_known_hosts = Path(f'{target_dir}{known_hosts_file}')
1067+
_mkdir_and_copy_file(known_hosts_file, target_known_hosts)
1068+
1069+
# During image installation, backup critical user-specific files (e.g., known_hosts)
1070+
# from each user's home directory into /var/.users_backups/{user}. This ensures that their
1071+
# SSH configuration and trust relationships are preserved across system re-installations
1072+
# or provisioning.
1073+
# More details: https://github.com/vyos/vyos-1x/pull/4678#pullrequestreview-3169648265
1074+
known_hosts_files = get_known_hosts_files(for_root=False, for_users=True)
1075+
for known_hosts_file in known_hosts_files:
1076+
username = known_hosts_file.parent.parent.name
1077+
base_dir = Path(f'{target_dir}/var/.users_backups/{username}')
1078+
target_known_hosts = base_dir / '.ssh' / 'known_hosts'
1079+
_mkdir_and_copy_file(known_hosts_file, target_known_hosts)
1080+
1081+
10171082
@compat.grub_cfg_update
10181083
def add_image(image_path: str, vrf: str = None, username: str = '',
10191084
password: str = '', no_prompt: bool = False, force: bool = False) -> None:
@@ -1149,6 +1214,11 @@ def add_image(image_path: str, vrf: str = None, username: str = '',
11491214
for host_key in host_keys:
11501215
copy(host_key, target_ssh_dir)
11511216

1217+
target_ssh_known_hosts_dir: str = f'{root_dir}/boot/{image_name}/rw'
1218+
if no_prompt or copy_ssh_known_hosts():
1219+
print('Copying SSH known_hosts files')
1220+
migrate_known_hosts(target_ssh_known_hosts_dir)
1221+
11521222
# copy system image and kernel files
11531223
print('Copying system image files')
11541224
for file in Path(f'{DIR_ISO_MOUNT}/live').iterdir():

src/tests/test_utils.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
# You should have received a copy of the GNU General Public License
1313
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1414

15+
import pwd
1516
from unittest import TestCase
17+
18+
from vyos.utils import auth
19+
20+
1621
class TestVyOSUtils(TestCase):
1722
def test_key_mangling(self):
1823
from vyos.utils.dict import mangle_dict_keys
@@ -36,3 +41,44 @@ def test_list_strip(self):
3641
self.assertEqual(list_strip(lst, rsb, right=True), ['a', 'b', 'c'])
3742
self.assertEqual(list_strip(lst, non), [])
3843
self.assertEqual(list_strip(sub, lst), [])
44+
45+
class TestVyOSUtilsAuth(TestCase):
46+
47+
def test_get_local_users_returns_existing_usernames(self):
48+
# Returned users exist, skip list is excluded, and UIDs are in range
49+
50+
all_users = set(s_user.pw_name for s_user in pwd.getpwall())
51+
local_users = auth.get_local_users()
52+
53+
# All returned users must really exist
54+
for user in local_users:
55+
self.assertIn(user, all_users)
56+
57+
# Nobody in the skip list
58+
for skipped in auth.SYSTEM_USER_SKIP_LIST:
59+
self.assertNotIn(skipped, local_users)
60+
61+
# All are within UID range
62+
for s_user in pwd.getpwall():
63+
if s_user.pw_name in local_users:
64+
self.assertGreaterEqual(s_user.pw_uid, auth.MIN_USER_UID)
65+
self.assertLessEqual(s_user.pw_uid, auth.MAX_USER_UID)
66+
67+
def test_get_user_home_dir_for_real_user(self):
68+
# User's homedir is a non-empty string for a valid user
69+
70+
local_users = auth.get_local_users()
71+
if local_users:
72+
for user in local_users:
73+
home_dir = auth.get_user_home_dir(user)
74+
self.assertIsInstance(home_dir, str)
75+
self.assertTrue(bool(home_dir)) # Should not be empty
76+
else:
77+
self.skipTest("No suitable non-system users found on this system")
78+
79+
def test_get_user_home_dir_invalid_user(self):
80+
# Raises KeyError for nonexistent username
81+
82+
user = "__this_user_does_not_exist__" # Test using unlikely username
83+
with self.assertRaises(KeyError):
84+
auth.get_user_home_dir(user)

0 commit comments

Comments
 (0)