From 8c31384fd19cc3e0f9c26e2125b029f35dd3077a 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 b81258153fd..8588ced7b7e 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 c9aff65e0e3b92b8c043e911e2aa1f4f794c0a13 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 4bc0b642ade3af06772990ba6c048123ef4cfb3d 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 8588ced7b7e..4751fa49dad 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 @@ -3132,6 +3133,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) @@ -3186,8 +3188,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] @@ -3215,13 +3217,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: @@ -3240,7 +3255,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) @@ -3249,6 +3264,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 c68940ea582ef642f87c1e28567341310efe348c 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 4751fa49dad..3099ab79f07 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3120,6 +3120,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)] @@ -3181,7 +3193,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: @@ -3255,14 +3267,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 1f84a0579b69a79c387fb2b7e1e7994f7094a052 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 6a3c84eecd7..e6ef135c626 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 a0e638ea37c8e08833be3a5dbff24f487c5c27ca 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 3099ab79f07..bd5786db5db 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: @@ -3316,6 +3316,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 3d8129da02fa79e565a6df73966f28e9b1222ed3 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 bd5786db5db..8975f90f526 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3129,7 +3129,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 @@ -3144,7 +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)) + self.module.fail_json(msg=f'Group {self.group} does not exist') cmd.append('-G') cmd.append(self.group) @@ -3193,7 +3193,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: @@ -3237,7 +3237,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: @@ -3276,7 +3276,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 598ba78417c01acd2a8cd486a2cc2cfb061e1d1d 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 8975f90f526..99b685172c3 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3267,7 +3267,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 419dbf9533a924679a68e926ac98bd5af1cfce1a Mon Sep 17 00:00:00 2001 From: Sam Doran Date: Fri, 7 Nov 2025 11:24:04 -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, 31 insertions(+), 14 deletions(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index 99b685172c3..3080a0d8462 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3121,17 +3121,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('!') - - # Ensure the account is locked at a minimum - result = f'{lock}{password or "!"}' - - return result + 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('!') + + return f'{lock}{password}' def create_user(self): cmd = [self.module.get_bin_path('adduser', True)] @@ -3190,14 +3207,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: @@ -3270,6 +3286,7 @@ class BusyBox(User): current_password = to_native(user_info[1]) new_password = self._build_password_string(current_password) if self.update_password == 'always': + # password_is_different = new_password != current_password if ( (self.password_lock and not current_password.startswith('!')) or (new_password != current_password) From 29b4b7a0c55211090ef8cd4b00a7f2c2347252cf 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 3080a0d8462..02143fc6ea6 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3128,7 +3128,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 @@ -3138,15 +3139,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 3af37a68019f8a6eb4e1cc98ff975794c45dae13 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 | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/ansible/modules/user.py b/lib/ansible/modules/user.py index 02143fc6ea6..349555c23e8 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -3287,11 +3287,9 @@ class BusyBox(User): current_password = to_native(user_info[1]) new_password = self._build_password_string(current_password) if self.update_password == 'always': - # password_is_different = new_password != current_password - 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)] cmd.append('--encrypted') data = f'{self.name}:{new_password}' From 6c64979ab9c29f1e3c98222f79ae3e82c0283b4d 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 3f8bf20d6a399bbfe0e3ea53917eff45d5ea9a1a 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 349555c23e8..a55400aab08 100644 --- a/lib/ansible/modules/user.py +++ b/lib/ansible/modules/user.py @@ -547,6 +547,7 @@ except AttributeError: _HASH_RE = re.compile(r'[^a-zA-Z0-9./=]') +LOCK_INDICATOR = '!' def getspnam(b_name): @@ -3128,8 +3129,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 @@ -3139,15 +3139,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}'