Image
![IT Automation ebook](/sysadmin/sites/default/files/styles/image_cta/public/2021-07/automation-expert-e-book-medium.jpg?itok=X_OfbjdH)
Imagine you're in the middle of a cloud migration or a penetration test, and you have to enable an existing account on over 400 hosts as quickly as possible. It sounds like a big problem, but it can be easy with automation.
First things first. You must define the exact requirements you have for the task. This is the step that will help and guide your code for automation. Don't do this in code yet.
Here are your requirements:
Ansible has a module for this.
A quick trip to your favorite editor plus a couple of internet searches, and you have an idea where to start.
To begin your development, write some YAML and test it against localhost. Add this text to a file called addauser.yml
:
---
- name: add a user
hosts: localhost
gather_facts: false
become: yes
tasks:
- name: Add a user with no expiry
user:
name: pentest
expires: -1
Test it out:
$ ansible-playbook addauser.yml
$ sudo chage -l pentest
[sudo] password for stuart:
chage: user 'pentest' does not exist in /etc/passwd
$ ansible-playbook 1-addauser.yml --ask-become
BECOME password:
[WARNING]: No inventory parsed, implicit localhost available
[WARNING]: hosts list is empty, localhost available.
Note that the implicit localhost does not match 'all'
PLAY [add a user] ***************************
TASK [Add a user with no expiry] ************
changed: [localhost]
PLAY RECAP **********************************
localhost: ok=1 changed=1 unreachable=0
failed=0 skipped=0 rescued=0 ignored=0
$ sudo chage -l pentest
Last password change : Apr 26, 2022
Password expires : never
Password inactive : never
Account expires : never
Minimum number of days between password change : 0
Maximum number of days between password change : 99999
Number of days of warning before password expires : 7
Here's what happened:
pentest
did not exist, which you proved with chage -l pentest
.--ask-become
option.That's a great first try, but the requirements were not met. The playbook created an account when one did not exist. So how can you prevent a user from being created when it already exists?
[ Get started with IT automation by downloading Red Hat Ansible Automation Platform: A beginner's guide. ]
You need some way to check whether the user is already in a database of existing users.
A quick search of the internet suggests that you can use getent
:
$ getent passwd not-user
$ echo $?
2
The command returns no output, but it appears to work successfully. Notice that the return code is 2
, and the getent(1) man page says:
Exit Status
2 One or more supplied key could not be found in the database.
Ansible has a built-in module that supports the getent
command, and you can find the documentation at ansible.builtin.getent. Using it, write a task for the playbook that checks for the existence of a specific user:
- name: GETENT pentest info
getent:
database: passwd
key: pentest
ignore_errors: yes
In this code example, I used ignore_errors
because, by default, the getent
module fails when the key (pentest
in this example) does not exist in the database (passwd
in this case) being searched. This is a temporary fix for testing purposes, and you'll modify it later.
Hint: Read the entire manual page and understand the options for making future improvements.
The getent
module returns a Fact
stored in ansible_facts.genent_DBNAME[key]
. In the example above, this is ansible_facts.getent_passwd[pentest]
. Combining this task with the first draft of the code, you can now determine whether a user exists. If it does, you can update the account expiry time with a value of -1
to set it never to expire.
Here's the complete playbook so far:
---
- name: add a user
hosts: localhost
gather_facts: false
become: yes
tasks:
- name: GETENT pentest info
getent:
database: passwd
key: pentest
ignore_errors: yes
- name: Add user pentest with no expiry
user:
name: pentest
expires: -1
when: ansible_facts.getent_passwd[pentest] is defined
Did you meet your goal to enable only an existing account and for a period of six months? Close, but you're not there yet.
There are several ways to get or set the expires
date. Your first thought might be to have a task register a variable or execute set_fact
. That would have to be executed for each host, though, which would lengthen the execution time of the playbook.
Instead, set a variable to be used by all hosts. Better still, set another variable with the user name, making your code reusable for any user account, not just the sample user pentest
. You can always pass in variables from the command line to change which user is configured and set the desired expiry time:
---
- name: update users
hosts: localhost
gather_facts: false
become: yes
vars:
s_user: 'pentest'
s_expires: "{{ lookup('pipe','date +%s -d now+6months') }}"
tasks:
- name: GETENT {{s_user}} info
getent:
database: passwd
key: "{{ s_user }}"
ignore_errors: yes
- name: Change expiry date for user to today + 6 months
user:
name: "{{ s_user }}"
# expires: -1
expires: "{{ s_expires }}"
when: ansible_facts.getent_passwd["{{s_user}}"] is defined
There are many best practices for selecting variable names. Here are a couple of good ones: Don't make them cryptic and do make them easy to identify (don't make them look like module names, for instance).
What happens when you run your playbook now?
$ ansible-playbook adduser.yml --ask-become
BECOME password:
[WARNING]: No inventory parsed, implicit localhost available
[WARNING]: Provided hosts list is empty, localhost is available.
Note that the implicit localhost does not match 'all'
PLAY [update users] *********************************
TASK [GETENT pentest info] **************************
fatal: [localhost]: FAILED! =>
{"changed": false, "msg":
"One or more supplied key could not be found in the database."}
...ignoring
TASK [Change expiry date for user to today + 6 months] ********
[WARNING]: conditional statements should not include jinja2
templating delimiters such as {{ }} or {% %}.
Found: ansible_facts.getent_passwd["{{s_user}}"] is defined
skipping: [localhost]
PLAY RECAP ****************************************************
localhost: ok=1 changed=0 unreachable=0
failed=0 skipped=1 rescued=0 ignored=1
As expected, the getent
task fails because it cannot find the relevant key in the passwd
database. You explicitly use ignore_errors
so that the playbook continues anyway. You don't want to fail on a single error when you have multiple hosts.
It skips the user task because the when
conditional is not met because the variable ansible_facts.getend_passwd[pentest]
is not defined. According to the requirements at the start of this article, this is the expected and desired behavior.
Quick confirmation:
$ sudo chage -l pentest
chage: user 'pentest' does not exist in /etc/passwd
You can add the user either from the terminal or by creating a quick playbook for testing purposes—your choice. Expire the account by setting the expiry for some time in the near future:
$ sudo usermod -e 2022-01-01 pentest
$ sudo chage -l pentest
Last password change : Apr 27, 2022
Password expires : never
Password inactive : never
Account expires : Jan 01, 2022
Minimum number of days between password ch : 0
Maximum number of days between password ch : 99999
Number of days of warning before password : 7
With the expired user present, run the playbook again:
$ ansible-playbook adduser.yml --ask-become
BECOME password:
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
PLAY [update users] **************************************************************************************************
TASK [GETENT pentest info] **************************************************************************************************
ok: [localhost]
TASK [Change expiry date for user to today + 6 months] **************************************************************************************************
[WARNING]: conditional statements should not include jinja2 templating delimiters such as {{ }} or {% %}. Found: ansible_facts.getent_passwd["{{s_user}}"] is defined
changed: [localhost]
PLAY RECAP **************************************************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
$ sudo chage -l pentest
Last password change : Apr 27, 2022
Password expires : never
Password inactive : never
Account expires : Oct 27, 2022
Minimum number of days between password ch: 0
Maximum number of days between password ch: 99999
Days of warning before password expires : 7
At this point, it looks like you've fulfilled the requirements. You enabled an account only if it existed previously and set its expiry for six months from now.
In addition, you created a reusable forward-thinking playbook because you can override the variables s_user
and s_expires
with any user name and any expiry date.
There's one thing left to do. Ignoring errors is a useful trick while you're getting the logic right, but if you ignore all errors, you'll miss all errors, even the important ones. As a colleague reviewing my initial code advised: Be positive, don't override a negative condition.
The fail_key
attribute in the getent
module is set to yes
by default. That's why I had to set ignore_errors
to yes
. In other words, getent
was detected as an error only because fail_key
was set to yes
.
Usually, a return code of 0
means success, and any other return code signifies an error. However, you can use the getent
module parameter fail_key: no
so that a non-zero return code is not seen as an error. This creates a variable ansible_facts.getent_passwd[pentest]
with a null value for the dictionary.
Here's the complete and revised playbook:
---
- name: update users
hosts: all
gather_facts: false
become: yes
vars:
s_user: 'pentest'
s_expires: "{{ lookup('pipe','date +%s -d now+6months') }}"
tasks:
- name: GETENT {{s_user}} info
getent:
database: passwd
key: "{{ s_user }}"
fail_key: no
- name: Change expiry date for user to today + 6 months
user:
name: "{{ s_user }}"
# expires: -1
expires: "{{ s_expires }}"
when: (ansible_facts.getent_passwd[s_user] | type_debug) != 'NoneType'
The user task now updates the user only when the getent_passwd
variable for the user is not a NoneType
or null. (I know, I said to be positive, and this looks like a negative conditional, but technically it's a double negative.)
Of course, before you can use this against multiple hosts, you must update the hosts:
line to operate against all hosts:
hosts: all
Automation means spending time upfront to save a lot more time in the future. You should expect iteration when composing an automation script, and you should thoroughly test your playbooks before running them against your actual targets. Use the principles of open source, and show your scripts to your colleagues to make improvements.
Everybody loves homework, so here's a bonus puzzle: What could you do to expire the account after three months?
Stuart Hayes is an infrastructure and automation architect and a Red Hat customer. He prefers Linux and preventing medium-aged laptops from reaching the recycling bins by giving them to people who wouldn't otherwise afford technology. More about me