From b88619931ed6ef30bb1c0b7f1f2a0314f81c8ba2 Mon Sep 17 00:00:00 2001 From: Dermot Bradley Date: Sun, 14 Apr 2024 21:31:46 +0100 Subject: [PATCH] cloudinit/distros/alpine.py: add support for Busybox adduser/addgroup By default Alpine Linux provides Busybox utilities such as adduser and addgroup for managing users and groups. Optionally the Alpine "shadow" package provides the traditional useradd/groupadd utilities. Add fallback support for the Busybox user/group management utilities for Alpine Linux. --- cloudinit/distros/alpine.py | 412 ++++++++++++++++++++++++- tests/unittests/distros/test_alpine.py | 74 +++++ 2 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 tests/unittests/distros/test_alpine.py diff --git a/cloudinit/distros/alpine.py b/cloudinit/distros/alpine.py index fa0bbf23cc39..2c9561377962 100644 --- a/cloudinit/distros/alpine.py +++ b/cloudinit/distros/alpine.py @@ -8,8 +8,10 @@ import logging import os +import re import stat -from typing import Optional +from datetime import datetime +from typing import Any, Dict, Optional from cloudinit import distros, helpers, subp, util from cloudinit.distros.parsers.hostname import HostnameConf @@ -32,6 +34,7 @@ class Distro(distros.Distro): keymap_path = "/usr/share/bkeymaps/" locale_conf_fn = "/etc/profile.d/50-cloud-init-locale.sh" network_conf_fn = "/etc/network/interfaces" + shadow_fn = "/etc/shadow" renderer_configs = { "eni": {"eni_path": network_conf_fn, "eni_header": NETWORK_FILE_HEADER} } @@ -202,6 +205,413 @@ def preferred_ntp_clients(self): return self._preferred_ntp_clients + def add_user(self, name, **kwargs): + """ + Add a user to the system using standard tools + + On Alpine this may use either useradd or adduser depending on whether + the shadow package is installed. + """ + if util.is_user(name): + LOG.info("User %s already exists, skipping.", name) + return + + if "selinux_user" in kwargs: + LOG.warning("Ignoring selinux_user parameter for Alpine Linux") + del kwargs["selinux_user"] + + # If 'useradd' is available then use the 'generic' add_user + # function instead. + if subp.which("useradd"): + return super().add_user(name, **kwargs) + + if "create_groups" in kwargs: + create_groups = kwargs.pop("create_groups") + else: + create_groups = True + + adduser_cmd = ["adduser", "-D"] + + # Since we are creating users, we want to carefully validate the + # inputs. If something goes wrong, we can end up with a system + # that nobody can login to. + adduser_opts = { + "gecos": "-g", + "homedir": "-h", + "primary_group": "-G", + "shell": "-s", + "uid": "-u", + } + + adduser_flags = {"system": "-S"} + + # support kwargs having groups=[list] or groups="g1,g2" + groups = kwargs.get("groups") + if groups: + if isinstance(groups, str): + groups = groups.split(",") + + if isinstance(groups, dict): + util.deprecate( + deprecated=f"The user {name} has a 'groups' config value " + "of type dict", + deprecated_version="22.3", + extra_message="Use a comma-delimited string or " + "array instead: group1,group2.", + ) + + # remove any white spaces in group names, most likely + # that came in as a string like: groups: group1, group2 + groups = [g.strip() for g in groups] + + # kwargs.items loop below wants a comma delimeted string + # that can go right through to the command. + kwargs["groups"] = ",".join(groups) + + primary_group = kwargs.get("primary_group") + if primary_group: + groups.append(primary_group) + + if create_groups and groups: + for group in groups: + if not util.is_group(group): + self.create_group(group) + LOG.debug("created group '%s' for user '%s'", group, name) + if "uid" in kwargs.keys(): + kwargs["uid"] = str(kwargs["uid"]) + + unsupported_busybox_values: Dict[str, Any] = { + "groups": None, + "expiredate": None, + "inactive": None, + "passwd": None, + } + + # Check the values and create the command + for key, val in sorted(kwargs.items()): + if key in adduser_opts and val and isinstance(val, str): + adduser_cmd.extend([adduser_opts[key], val]) + elif ( + key in unsupported_busybox_values + and val + and isinstance(val, str) + ): + # Busybox 'adduser' does not support specifying these + # options so store them for use via alternative means. + if key == "groups": + unsupported_busybox_values[key] = val.split(",") + else: + unsupported_busybox_values[key] = val + elif key in adduser_flags and val: + adduser_cmd.append(adduser_flags[key]) + + # Don't create the home directory if directed so or if the user is a + # system user + if kwargs.get("no_create_home") or kwargs.get("system"): + adduser_cmd.append("-H") + + # Busybox 'adduser' puts username at end of command + adduser_cmd.append(name) + + # Run the command + LOG.debug("Adding user %s", name) + try: + subp.subp(adduser_cmd) + except Exception as e: + util.logexc(LOG, "Failed to create user %s", name) + raise e + + # Process remaining options that Busybox 'adduser' does not support + + addn_groups = unsupported_busybox_values["groups"] + if addn_groups is not None: + # Separately add user to each additional group as Busybox + # 'adduser' does not support specifying additional groups. + for addn_group in addn_groups: + LOG.debug("Adding user to group %s", addn_group) + try: + subp.subp(["addgroup", name, addn_group]) + except Exception as e: + util.logexc( + LOG, + "Failed to add user %s to group %s", + name, + addn_group, + ) + raise e + + passwd = unsupported_busybox_values["passwd"] + if passwd is not None: + # Separately set password as Busybox 'adduser' does not + # support passing password as CLI option. + super().set_passwd(name, passwd, hashed=True) + + # Busybox's 'adduser' is hardcoded to always set the following field + # values (numbered from "0") in /etc/shadow unlike 'useradd': + # + # 3 minimum password age 0 (no min age) + # 4 maximum password age 99999 (days) + # 5 warning period 7 (warn days before max age) + # + # so modify these fields to be empty. + # + # Also set expiredate (field '7') and/or inactive (field '6') values + # directly in /etc/shadow file as Busybox 'adduser' does not support + # passing these as CLI options. + + expiredate = unsupported_busybox_values["expiredate"] + inactive = unsupported_busybox_values["inactive"] + + shadow_contents = None + shadow_file = self.shadow_fn + try: + shadow_contents = util.load_text_file(shadow_file) + except FileNotFoundError as e: + util.logexc( + LOG, "Failed to read %s file, file not found", shadow_file + ) + raise e + + # Find the line in /etc/shadow for the user + original_line = None + for line in shadow_contents.splitlines(): + current_user = line.split(":")[0] + if current_user == name: + original_line = line + + if original_line is not None: + # Modify field(s) in copy of user's shadow file entry + new_list = original_line.split(":") + update_type = "" + + # Minimum password age + new_list[3] = "" + # Maximum password age + new_list[4] = "" + # Password warning period + new_list[5] = "" + update_type = "password aging" + + if expiredate is not None: + # Convert date into number of days since 1st Jan 1970 + days = ( + datetime.fromisoformat(expiredate) + - datetime.fromisoformat("1970-01-01") + ).days + new_list[7] = str(days) + if update_type != "": + update_type = update_type + " & " + update_type = update_type + "acct expiration date" + if inactive is not None: + new_list[6] = inactive + if update_type != "": + update_type = update_type + " & " + update_type = update_type + "inactivity period" + + new_line = ":".join(new_list) + + # Replace existing line for user with modified line + shadow_contents = shadow_contents.replace(original_line, new_line) + LOG.debug("Updating %s for user %s", update_type, name) + try: + util.write_file( + shadow_file, shadow_contents, omode="w", preserve_mode=True + ) + except IOError as e: + util.logexc(LOG, "Failed to update %s file", shadow_file) + raise e + else: + util.logexc( + LOG, "Failed to update %s for user %s", shadow_file, name + ) + + def lock_passwd(self, name): + """ + Lock the password of a user, i.e., disable password logins + """ + # passwd must use short '-l' instead of "--lock" as Busybox + # does not support long form options. + + # Check whether Shadow or Busybox version of 'passwd' + (_out, err) = subp.subp(["passwd", "--help"]) + if err != "": + first_line = err.splitlines().pop(0) + if re.search(r"^BusyBox.*", first_line): + # Busybox 'passwd' + use_shadow = False + else: + # Not Shadow or Busybox 'passwd', assume handles "-l" + use_shadow = True + else: + # Shadow's 'passwd --help' does not output anything to stderr + use_shadow = True + + # If Shadow's 'passwd' is available then use the generic + # lock_passwd function instead. + if use_shadow: + return super().lock_passwd(name) + + cmd = ["passwd", "-l", name] + # Busybox 'passwd', unlike Shadow's 'passwd', errors if + # password is already locked: + # + # "passwd: password for user2 is already locked" + # + # with exit code 1 + try: + (_out, err) = subp.subp(cmd, rcs=[0, 1]) + if re.search(r"is already locked", err): + return True + except Exception as e: + util.logexc(LOG, "Failed to disable password for user %s", name) + raise e + + def unlock_passwd(self, name): + """ + Unlock the password of a user, i.e., enable password logins + """ + # passwd must use short '-u' instead of "--unlock" as Busybox + # does not support long form options. + + # Check whether Shadow or Busybox version of 'passwd' + (_out, err) = subp.subp(["passwd", "--help"]) + if err != "": + first_line = err.splitlines().pop(0) + if re.search(r"^BusyBox.*", first_line): + # Busybox 'passwd' + use_shadow = False + else: + # Not Shadow or Busybox 'passwd', assume handles "-u" + use_shadow = True + else: + # Shadow's 'passwd --help' does not output anything to stderr + use_shadow = True + + # If Shadow's 'passwd' is available then use the generic + # unlock_passwd function instead. + if use_shadow: + return super().unlock_passwd(name) + + cmd = ["passwd", "-u", name] + # Busybox 'passwd', unlike Shadow's 'passwd', errors if + # password is already unlocked: + # + # "passwd: password for user2 is already unlocked" + # + # with exit code 1 + try: + _, err = subp.subp(cmd, rcs=[0, 1]) + if re.search(r"is already unlocked", err): + return True + except Exception as e: + util.logexc(LOG, "Failed to enable password for user %s", name) + raise e + + def expire_passwd(self, user): + # Check whether Shadow or Busybox version of 'passwd' + (_out, err) = subp.subp(["passwd", "--help"]) + if err != "": + first_line = err.splitlines().pop(0) + if re.search(r"^BusyBox.*", first_line): + # Busybox 'passwd' + use_shadow = False + else: + # Not Shadow or Busybox 'passwd', assume handles "--expire" + use_shadow = True + else: + # Shadow's 'passwd --help' does not output anything to stderr + use_shadow = True + + # If Shadow's 'passwd' is available then use the generic + # expire_passwd function instead. + if use_shadow: + return super().expire_passwd(user) + + # Busybox 'passwd' does not provide an expire option so have + # to manipulate the shadow file directly. + shadow_contents = None + shadow_file = self.shadow_fn + try: + shadow_contents = util.load_text_file(shadow_file) + except FileNotFoundError as e: + util.logexc( + LOG, "Failed to read %s file, file not found", shadow_file + ) + raise e + + # Find the line in /etc/shadow for the user + original_line = None + for line in shadow_contents.splitlines(): + current_user = line.split(":")[0] + if current_user == user: + LOG.debug("Found /etc/shadow line matching user %s", user) + original_line = line + + if original_line is not None: + # Replace existing line for user with modified line + new_list = original_line.split(":") + # Field '2' (numbered from '0') in /etc/shadow is the + # 'date of last password change'. + if new_list[2] != "0": + # Busybox 'adduser' always expires password so only + # need to expire it now if this is not a new user. + new_list[2] = "0" + new_line = ":".join(new_list) + shadow_contents = shadow_contents.replace( + original_line, new_line, 1 + ) + + LOG.debug("Expiring password for user %s", user) + try: + util.write_file( + shadow_file, + shadow_contents, + omode="w", + preserve_mode=True, + ) + except IOError as e: + util.logexc(LOG, "Failed to update %s file", shadow_file) + raise e + else: + LOG.debug("Password for user %s is already expired", user) + else: + util.logexc(LOG, "Failed to set 'expire' for %s", user) + + def create_group(self, name, members=None): + # If 'groupadd' is available then use the generic create_group + # function instead. + if subp.which("groupadd"): + return super().create_group(name, members) + + group_add_cmd = ["addgroup", name] + if not members: + members = [] + + # Check if group exists, and then add if it doesn't + if util.is_group(name): + LOG.warning("Skipping creation of existing group '%s'", name) + else: + try: + subp.subp(group_add_cmd) + LOG.info("Created new group %s", name) + except Exception: + util.logexc(LOG, "Failed to create group %s", name) + + # Add members to the group, if so defined + if len(members) > 0: + for member in members: + if not util.is_user(member): + LOG.warning( + "Unable to add group member '%s' to group '%s'" + "; user does not exist.", + member, + name, + ) + continue + + subp.subp(["addgroup", member, name]) + LOG.info("Added user '%s' to group '%s'", member, name) + def shutdown_command(self, mode="poweroff", delay="now", message=None): # called from cc_power_state_change.load_power_state # Alpine has halt/poweroff/reboot, with the following specifics: diff --git a/tests/unittests/distros/test_alpine.py b/tests/unittests/distros/test_alpine.py new file mode 100644 index 000000000000..6823b45a290c --- /dev/null +++ b/tests/unittests/distros/test_alpine.py @@ -0,0 +1,74 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +from unittest import mock + +import pytest + +from cloudinit import distros, util +from tests.unittests.helpers import TestCase + + +class TestAlpineBusyboxUserGroup(TestCase): + distro = distros.fetch("alpine")("alpine", {}, None) + + @mock.patch("cloudinit.distros.alpine.subp.subp") + @mock.patch("cloudinit.distros.subp.which", return_value=False) + def test_busybox_add_group(self, m_which, m_subp): + group = "mygroup" + + self.distro.create_group(group) + + m_subp.assert_called_with(["addgroup", group]) + + @pytest.mark.usefixtures("fake_filesystem") + @mock.patch("cloudinit.distros.alpine.subp.subp") + @mock.patch("cloudinit.distros.subp.which", return_value=False) + def test_busybox_add_user(self, m_which, m_subp, mocker, tmpdir): + shadow_file = tmpdir.join("etc/shadow") + shadow_file.dirpath().mkdir() + # Need to place entry for user 'me2' in /etc/shadow as + # "adduser" is stubbed and so will not create it. + shadow_file.write("root::19848:0:::::\nme2:!:19848:0:99999:7:::\n") + + user = "me2" + + mocker.patch("cloudinit.util.load_text_file", fname=shadow_file) + mocker.patch("cloudinit.util.write_file", filename=shadow_file) + + distro.shadow_fn = shadow_file + + self.distro.add_user(user, lock_passwd=True) + + self.m_subp.assert_called_with(["adduser", "-D", user]) + + contents = util.load_text_file(shadow_file) + expected = ["root::19848:0:::::", "me2:!:19848::::::"] + assert contents == expected + + +class TestAlpineShadowUserGroup(TestCase): + distro = distros.fetch("alpine")("alpine", {}, None) + + @mock.patch("cloudinit.distros.alpine.subp.subp") + @mock.patch( + "cloudinit.distros.subp.which", return_value=("/usr/sbin/groupadd") + ) + def test_shadow_add_group(self, m_which, m_subp): + group = "mygroup" + + self.distro.create_group(group) + + m_subp.assert_called_with(["groupadd", group]) + + @mock.patch("cloudinit.distros.alpine.subp.subp") + @mock.patch( + "cloudinit.distros.subp.which", return_value=("/usr/sbin/useradd") + ) + def test_shadow_add_user(self, m_which, m_subp): + user = "me2" + + self.distro.add_user(user) + + m_subp.assert_called_with( + ["useradd", user, "-m"], logstring=["useradd", user, "-m"] + )