From df510bdd0b73e765da2fda546715d8d81d56c815 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Tue, 4 Aug 2020 14:33:48 -0400 Subject: [PATCH 01/13] Update docs --- lib/ansible/modules/user.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index 2818a11646d..df79fd9eb1b 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -38,6 +38,7 @@ options: non_unique: description: - Optionally when used with the C(-u) option, this option allows to change the user ID to a non-unique value. + - Not supported on distributions using BusyBox. type: bool default: no version_added: "1.1" @@ -148,6 +149,7 @@ options: description: - Whether to generate a SSH key for the user in question. - This will B(not) overwrite an existing SSH key unless used with O(force=yes). + - Requires C(ssh-keygen) from OpenSSH. type: bool default: no version_added: "0.9" @@ -217,6 +219,7 @@ options: - This will check C(/etc/passwd) for an existing account before invoking commands. If the local account database exists somewhere other than C(/etc/passwd), this setting will not work properly. - This requires that the above commands as well as C(/etc/passwd) must exist on the target host, otherwise it will be a fatal error. + - Not supported on distributions using BusyBox. type: bool default: no version_added: "2.4" @@ -311,6 +314,8 @@ notes: C(/Library/Preferences/com.apple.loginwindow.plist). - On FreeBSD, this module uses C(pw useradd) and C(chpass) to create, C(pw usermod) and C(chpass) to modify, C(pw userdel) remove, C(pw lock) to lock, and C(pw unlock) to unlock accounts. + - On distributions using BusyBox, this module uses C(adduser), C(chpasswd), C(deluser), and C(delgroup). + The C(/etc/passwd) file is modified directly by this module. - On all other platforms, this module uses C(useradd) to create, C(usermod) to modify, and C(userdel) to remove accounts. seealso: From 0a4d7f7096a3ea0bf9d8f275e5ce279b6fcde966 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 6 Nov 2025 08:57:54 -0500 Subject: [PATCH 02/13] Add changelog fragment --- changelogs/fragments/66679-busybox-modify-user.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/66679-busybox-modify-user.yml diff --git a/changelogs/fragments/66679-busybox-modify-user.yml b/changelogs/fragments/66679-busybox-modify-user.yml new file mode 100644 index 00000000000..7aa9e01dbc4 --- /dev/null +++ b/changelogs/fragments/66679-busybox-modify-user.yml @@ -0,0 +1,2 @@ +bugfixes: + - user - fix modifying users when using busybox, such as on Alpine (https://github.com/ansible/ansible/issues/66679) From 039f54717f171af04c864dd54f630ad4c309144a Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 6 Nov 2025 14:47:04 -0500 Subject: [PATCH 03/13] Modify user information on Alpine Modify the passwd file directly when changing user shell. This is the recommended approach by Alpine. --- lib/ansible/modules/user.py | 63 +++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index df79fd9eb1b..140a5e58419 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -506,6 +506,7 @@ import select import shutil import socket import subprocess +import tempfile import time import math import typing as t @@ -3143,6 +3144,7 @@ class BusyBox(User): if self.group is not None: if not self.group_exists(self.group): self.module.fail_json(msg='Group {0} does not exist'.format(self.group)) + cmd.append('-G') cmd.append(self.group) @@ -3197,8 +3199,8 @@ class BusyBox(User): self.module.fail_json(name=self.name, msg=err, rc=rc) # Add to additional groups - if self.groups is not None and len(self.groups): - groups = self.get_groups_set() + if self.groups: + groups = self.get_groups_set() or set() add_cmd_bin = self.module.get_bin_path('adduser', True) for group in groups: cmd = [add_cmd_bin, self.name, group] @@ -3226,13 +3228,26 @@ class BusyBox(User): rc = None out = '' err = '' - info = self.user_info() + user_info = self.user_info() + + if not user_info: + return rc, out, err + + gid = user_info[3] + if self.group is not None: + if not self.group_exists(self.group): + self.module.fail_json(msg="Group %s does not exist" % self.group) + + group_info = self.group_info(self.group) + if group_info: + gid = group_info[2] + add_cmd_bin = self.module.get_bin_path('adduser', True) remove_cmd_bin = self.module.get_bin_path('delgroup', True) # Manage group membership - if self.groups is not None and len(self.groups): - groups = self.get_groups_set() + if self.groups: + groups = self.get_groups_set() or set() group_diff = set(current_groups).symmetric_difference(groups) if group_diff: @@ -3251,7 +3266,7 @@ class BusyBox(User): self.module.fail_json(name=self.name, msg=err, rc=rc) # Manage password - if self.update_password == 'always' and self.password is not None and info[1] != self.password: + if self.update_password == 'always' and self.password is not None and user_info[1] != self.password: cmd = [self.module.get_bin_path('chpasswd', True)] cmd.append('--encrypted') data = '{name}:{password}'.format(name=self.name, password=self.password) @@ -3260,6 +3275,42 @@ class BusyBox(User): if rc is not None and rc != 0: self.module.fail_json(name=self.name, msg=err, rc=rc) + # Manage user settings + uid = user_info[2] + if self.uid is not None: + uid = self.uid + + passwd_entry = [ + self.name, + 'x', + to_native(uid), + to_native(gid), + self.comment or user_info[4], + self.home or user_info[5], + self.shell or user_info[6], + ] + + contents = [] + change = False + with open(self.PASSWORDFILE, 'r') as password_file: + for line in password_file: + if line.startswith(self.name): + fields = line.strip().split(':') + if fields != passwd_entry: + change = True + line = ':'.join(passwd_entry) + '\n' + + contents.append(line) + + if change: + rc = 0 + if not self.module.check_mode: + tmpfd, tmpfile = tempfile.mkstemp(dir=self.module.tmpdir) + with open(tmpfile, 'w') as f: + f.writelines(contents) + + self.module.atomic_move(tmpfile, self.PASSWORDFILE) + return rc, out, err From d30f0880768a369f535b6efe8c7f9a8920155a6b Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 6 Nov 2025 17:49:39 -0500 Subject: [PATCH 04/13] Make account locking work correctly --- lib/ansible/modules/user.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index 140a5e58419..dd219ff94e9 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3131,6 +3131,18 @@ class BusyBox(User): - remove_user() - modify_user() """ + def _build_password_string(self, current_password=None): + lock = '!' if self.password_lock else '' + password = '' + if self.password is not None: + password = self.password + elif current_password: + password = current_password.lstrip('!') + + # Ensure the account is locked at a minimum + result = '{lock}{password}'.format(lock=lock, password=password) or '!' + + return result def create_user(self): cmd = [self.module.get_bin_path('adduser', True)] @@ -3192,7 +3204,7 @@ class BusyBox(User): if self.password is not None: cmd = [self.module.get_bin_path('chpasswd', True)] cmd.append('--encrypted') - data = '{name}:{password}'.format(name=self.name, password=self.password) + data = '{name}:{password}'.format(name=self.name, password=self._build_password_string()) rc, out, err = self.execute_command(cmd, data=data) if rc is not None and rc != 0: @@ -3266,14 +3278,20 @@ class BusyBox(User): self.module.fail_json(name=self.name, msg=err, rc=rc) # Manage password - if self.update_password == 'always' and self.password is not None and user_info[1] != self.password: - cmd = [self.module.get_bin_path('chpasswd', True)] - cmd.append('--encrypted') - data = '{name}:{password}'.format(name=self.name, password=self.password) - rc, out, err = self.execute_command(cmd, data=data) + current_password = user_info[1] + new_password = self._build_password_string(current_password) + if self.update_password == 'always': + if ( + (self.password_lock and not current_password.startswith('!')) + or (new_password != current_password) + ): + cmd = [self.module.get_bin_path('chpasswd', True)] + cmd.append('--encrypted') + data = '{name}:{password}'.format(name=self.name, password=new_password) + rc, out, err = self.execute_command(cmd, data=data) - if rc is not None and rc != 0: - self.module.fail_json(name=self.name, msg=err, rc=rc) + if rc is not None and rc != 0: + self.module.fail_json(name=self.name, msg=err, rc=rc) # Manage user settings uid = user_info[2] From 86d06eea1e2c9626071222d500e92d6c2ce7441e Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 6 Nov 2025 17:49:51 -0500 Subject: [PATCH 05/13] Run more tests on Alpine --- test/integration/targets/user/tasks/main.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/integration/targets/user/tasks/main.yml b/test/integration/targets/user/tasks/main.yml index 075e740a36f..85462e4e92d 100644 --- a/test/integration/targets/user/tasks/main.yml +++ b/test/integration/targets/user/tasks/main.yml @@ -19,9 +19,7 @@ - import_tasks: test_expires_warn.yml - import_tasks: test_ssh_key_passphrase.yml - include_tasks: test_password_lock.yml - when: ansible_distribution != 'Alpine' - include_tasks: test_password_lock_new_user.yml - when: ansible_distribution != 'Alpine' - include_tasks: test_local.yml when: ansible_distribution != 'Alpine' - include_tasks: test_umask.yml From f84cfea772438001ee717c679890452159d0ce8d Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 6 Nov 2025 18:00:01 -0500 Subject: [PATCH 06/13] Create a backup /etc/passwd --- lib/ansible/modules/user.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index dd219ff94e9..830a72c73b2 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -315,7 +315,7 @@ notes: - On FreeBSD, this module uses C(pw useradd) and C(chpass) to create, C(pw usermod) and C(chpass) to modify, C(pw userdel) remove, C(pw lock) to lock, and C(pw unlock) to unlock accounts. - On distributions using BusyBox, this module uses C(adduser), C(chpasswd), C(deluser), and C(delgroup). - The C(/etc/passwd) file is modified directly by this module. + The C(/etc/passwd) file is modified directly by this module and is backed up before modification. - On all other platforms, this module uses C(useradd) to create, C(usermod) to modify, and C(userdel) to remove accounts. seealso: @@ -3327,6 +3327,7 @@ class BusyBox(User): with open(tmpfile, 'w') as f: f.writelines(contents) + self.module.backup_local(self.PASSWORDFILE) self.module.atomic_move(tmpfile, self.PASSWORDFILE) return rc, out, err From b5f9a2c0ce4e1f6a84bea711700bbc6f172d1964 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Fri, 7 Nov 2025 09:33:35 -0500 Subject: [PATCH 07/13] Use fstrings. --- lib/ansible/modules/user.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index 830a72c73b2..e8a2860da87 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3140,7 +3140,7 @@ class BusyBox(User): password = current_password.lstrip('!') # Ensure the account is locked at a minimum - result = '{lock}{password}'.format(lock=lock, password=password) or '!' + result = f'{lock}{password or "!"}' return result @@ -3155,7 +3155,7 @@ class BusyBox(User): if self.group is not None: if not self.group_exists(self.group): - self.module.fail_json(msg='Group {0} does not exist'.format(self.group)) + self.module.fail_json(msg=f'Group {self.group} does not exist') cmd.append('-G') cmd.append(self.group) @@ -3204,7 +3204,7 @@ class BusyBox(User): if self.password is not None: cmd = [self.module.get_bin_path('chpasswd', True)] cmd.append('--encrypted') - data = '{name}:{password}'.format(name=self.name, password=self._build_password_string()) + data = f'{self.name}:{self._build_password_string()}' rc, out, err = self.execute_command(cmd, data=data) if rc is not None and rc != 0: @@ -3248,7 +3248,7 @@ class BusyBox(User): gid = user_info[3] if self.group is not None: if not self.group_exists(self.group): - self.module.fail_json(msg="Group %s does not exist" % self.group) + self.module.fail_json(msg=f'Group {self.group} does not exist') group_info = self.group_info(self.group) if group_info: @@ -3287,7 +3287,7 @@ class BusyBox(User): ): cmd = [self.module.get_bin_path('chpasswd', True)] cmd.append('--encrypted') - data = '{name}:{password}'.format(name=self.name, password=new_password) + data = f'{self.name}:{new_password}' rc, out, err = self.execute_command(cmd, data=data) if rc is not None and rc != 0: From 8dc893df07694b7843b453cac6c5945598abe145 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Fri, 7 Nov 2025 09:43:11 -0500 Subject: [PATCH 08/13] Ensure password is a string. --- lib/ansible/modules/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index e8a2860da87..d23c22b21ce 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3278,7 +3278,7 @@ class BusyBox(User): self.module.fail_json(name=self.name, msg=err, rc=rc) # Manage password - current_password = user_info[1] + current_password = to_native(user_info[1]) new_password = self._build_password_string(current_password) if self.update_password == 'always': if ( From 0d389d28d37fbba3df886054301fa85e6ddb5e05 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Wed, 17 Dec 2025 17:25:41 -0500 Subject: [PATCH 09/13] Change the default password to a value that enables the account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default behavior on Alpine is the create a locked account. In order to enable the account, set the password to ‘*’ if no password was provided and the account was not specified to be locked. --- lib/ansible/modules/user.py | 45 ++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index d23c22b21ce..6a51e3de29b 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3132,17 +3132,34 @@ class BusyBox(User): - modify_user() """ def _build_password_string(self, current_password=None): + """ + Build the appropriate password string based on the current password and + module parameters. + + This method will return '*' at a minimum to avoid creating an enabled + account with no password. + """ lock = '!' if self.password_lock else '' - password = '' + + # Order of precedence when choosing the password: + # 1. password from module parameters + # 2. current password + # 3. string to enable the account but without a password + password = '*' if self.password is not None: password = self.password elif current_password: - password = current_password.lstrip('!') + if current_password == '!': + # Special handling when the password is only a '!' to avoid + # unnecessary changes to the password to values like '!!' or '!*'. + lock = '' + password = current_password + elif current_password.startswith('!'): + # Preserve the existing password but unlock the account even if + # no password hash was provided in the module parameters. + password = current_password.lstrip('!') - # Ensure the account is locked at a minimum - result = f'{lock}{password or "!"}' - - return result + return f'{lock}{password}' def create_user(self): cmd = [self.module.get_bin_path('adduser', True)] @@ -3201,14 +3218,13 @@ class BusyBox(User): if rc is not None and rc != 0: self.module.fail_json(name=self.name, msg=err, rc=rc) - if self.password is not None: - cmd = [self.module.get_bin_path('chpasswd', True)] - cmd.append('--encrypted') - data = f'{self.name}:{self._build_password_string()}' - rc, out, err = self.execute_command(cmd, data=data) + cmd = [self.module.get_bin_path('chpasswd', True)] + cmd.append('--encrypted') + data = f'{self.name}:{self._build_password_string()}' + rc, out, err = self.execute_command(cmd, data=data) - if rc is not None and rc != 0: - self.module.fail_json(name=self.name, msg=err, rc=rc) + if rc is not None and rc != 0: + self.module.fail_json(name=self.name, msg=err, rc=rc) # Add to additional groups if self.groups: @@ -3285,8 +3301,7 @@ class BusyBox(User): (self.password_lock and not current_password.startswith('!')) or (new_password != current_password) ): - cmd = [self.module.get_bin_path('chpasswd', True)] - cmd.append('--encrypted') + cmd = [self.module.get_bin_path('chpasswd', True), '--encrypted'] data = f'{self.name}:{new_password}' rc, out, err = self.execute_command(cmd, data=data) From 7aeb7be9147cb3000a6a488d1e5888823a58a261 Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Fri, 7 Nov 2025 12:43:04 -0500 Subject: [PATCH 10/13] Use a variable for storing the lock indicator --- lib/ansible/modules/user.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index 6a51e3de29b..eb1c60453aa 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3139,7 +3139,8 @@ class BusyBox(User): This method will return '*' at a minimum to avoid creating an enabled account with no password. """ - lock = '!' if self.password_lock else '' + lock_indicator = '!' + lock = lock_indicator if self.password_lock else '' # Order of precedence when choosing the password: # 1. password from module parameters @@ -3149,15 +3150,15 @@ class BusyBox(User): if self.password is not None: password = self.password elif current_password: - if current_password == '!': + if current_password == lock_indicator: # Special handling when the password is only a '!' to avoid # unnecessary changes to the password to values like '!!' or '!*'. lock = '' password = current_password - elif current_password.startswith('!'): + elif current_password.startswith(lock_indicator): # Preserve the existing password but unlock the account even if # no password hash was provided in the module parameters. - password = current_password.lstrip('!') + password = current_password.lstrip(lock_indicator) return f'{lock}{password}' From 5f7352f3e0efc5a1cac2bc4e42cf12cb836bd13f Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Fri, 7 Nov 2025 13:24:05 -0500 Subject: [PATCH 11/13] Make conditional easier to read --- lib/ansible/modules/user.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index eb1c60453aa..4fb78abde19 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3298,10 +3298,9 @@ class BusyBox(User): current_password = to_native(user_info[1]) new_password = self._build_password_string(current_password) if self.update_password == 'always': - if ( - (self.password_lock and not current_password.startswith('!')) - or (new_password != current_password) - ): + lock_status_mismatch = self.password_lock and not current_password.startswith('!') + password_changed = new_password != current_password + if lock_status_mismatch or password_changed: cmd = [self.module.get_bin_path('chpasswd', True), '--encrypted'] data = f'{self.name}:{new_password}' rc, out, err = self.execute_command(cmd, data=data) From 20e121d0e97349423e38f313990c43c51039551b Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Fri, 7 Nov 2025 13:27:07 -0500 Subject: [PATCH 12/13] Update changelogs --- changelogs/fragments/66679-busybox-modify-user.yml | 2 +- .../fragments/68676-busybox-unlocked-account-by-default.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/68676-busybox-unlocked-account-by-default.yml diff --git a/changelogs/fragments/66679-busybox-modify-user.yml b/changelogs/fragments/66679-busybox-modify-user.yml index 7aa9e01dbc4..c043ffb0217 100644 --- a/changelogs/fragments/66679-busybox-modify-user.yml +++ b/changelogs/fragments/66679-busybox-modify-user.yml @@ -1,2 +1,2 @@ bugfixes: - - user - fix modifying users when using busybox, such as on Alpine (https://github.com/ansible/ansible/issues/66679) + - user - fix modifying users on BusyBox (https://github.com/ansible/ansible/issues/66679) diff --git a/changelogs/fragments/68676-busybox-unlocked-account-by-default.yml b/changelogs/fragments/68676-busybox-unlocked-account-by-default.yml new file mode 100644 index 00000000000..b10ab77c71a --- /dev/null +++ b/changelogs/fragments/68676-busybox-unlocked-account-by-default.yml @@ -0,0 +1,2 @@ +bugfixes: + - user - create accounts in an unlocked state by default on BusyBox (https://github.com/ansible/ansible/issues/68676) From b4c68bf03631cca614e85d6f28aedc3a0a7b265d Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Thu, 13 Nov 2025 09:27:41 -0500 Subject: [PATCH 13/13] Use global for lock indicator Only used in the BusyBox class currently to keep the scope of these changes focused. --- lib/ansible/modules/user.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index 4fb78abde19..b6bd8b0351e 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -556,6 +556,7 @@ except AttributeError: _HASH_RE = re.compile(r'[^a-zA-Z0-9./=]') +LOCK_INDICATOR = '!' def getspnam(b_name): @@ -3139,8 +3140,7 @@ class BusyBox(User): This method will return '*' at a minimum to avoid creating an enabled account with no password. """ - lock_indicator = '!' - lock = lock_indicator if self.password_lock else '' + lock = LOCK_INDICATOR if self.password_lock else '' # Order of precedence when choosing the password: # 1. password from module parameters @@ -3150,15 +3150,15 @@ class BusyBox(User): if self.password is not None: password = self.password elif current_password: - if current_password == lock_indicator: + if current_password == LOCK_INDICATOR: # Special handling when the password is only a '!' to avoid # unnecessary changes to the password to values like '!!' or '!*'. lock = '' password = current_password - elif current_password.startswith(lock_indicator): + elif current_password.startswith(LOCK_INDICATOR): # Preserve the existing password but unlock the account even if # no password hash was provided in the module parameters. - password = current_password.lstrip(lock_indicator) + password = current_password.lstrip(LOCK_INDICATOR) return f'{lock}{password}'