Ansible

Ansible sudo

sudo-ansible

Ansible is a fantastic tool for automating your environment. The thousands of modules that are available, comprehensive documentation and low barrier of entry have seen it rise to prominence in the enterprise. As with any new tool, everyone soon learns of it’s power and wants to take advantage of it.

Limitations

The enterprise IT environment may well consist of different teams focusing on different parts of the estate. A server O/S team may well build and administer servers, a application team may deploy and manage applications whilst developers focus on application enhancements. In such an environment, it’s not unusual to separate out roles and use privilege escalation to allow teams to perform ‘root’ level privileges where they need to. The most common way of doing this is via SUDO.

A typical setup

Let’s say an application team has rights to start and stop their own applications via systemd, they can install package via DNF and they can edit certain system files. A typical sudoers file might look like this:

appuser	ALL=(ALL)       NOPASSWD: /bin/dnf install *, /usr/bin/systemctl start myapp.service

If you’re comfortable with Ansible, you’ll have heard of the become, become_user and become_method directives in playbook. The idea is that where you can always use the least privileged account to achieve a task. Only switch to an privileged user where necessary. Given the above, we might naturally think we can apply those same sudo rules to our Ansible user and simply use the ‘become’ directive where we need to in our playbook.

An example

We have the user ‘ansible’ on our Ansible control host and setup SSH equivalence with our target. On the Ansible target we set the following sudo rules for our Ansible user:

ansible	ALL=(ALL)       NOPASSWD: /bin/dnf install *, /usr/bin/systemctl start myapp.service

We’ll now defined following playbook to attempt to install a software package and start a service.

[ansible@controlhost ~] cat manage_system.yml
---
- hosts: target
  tasks:

# Attempt to install the telnet package using the DNF module
# as user 'ansible'
  - name: Try and install telnet using the dnf module, regular user
    dnf:
      name: telnet
      state: installed
    ignore_errors: true

# Attempt to install the telnet package using the DNF module
# via privellege escalation

  - name: Try and install telnet using the dnf module, use become true
    dnf:
      name: telnet
      state: installed
    ignore_errors: true
    become: true

# Attempt to start the myapp service
  - name: Try and start the myapp service via systemctl
    systemd:
      name: myapp
      state: started
    ignore_errors: true

What happens when we run this playbook? The Ansible user has rights to run dnf and systemctl commands, so it should work, right? Unfortunately not:

[ansible@controlhost ~] ansible-playbook -i target, manage_system.yml 

PLAY [target] **********************************************************************************************************************************

TASK [Gathering Facts] *************************************************************************************************************************
ok: [target]

TASK [Try and install telnet using the dnf module, regular user] *******************************************************************************
fatal: [target]: FAILED! => {"changed": false, "msg": "This command has to be run under the root user.", "results": []}
...ignoring

TASK [Try and install telnet using the dnf module, use become true] ****************************************************************************
fatal: [target]: FAILED! => {"msg": "Missing sudo password"}
...ignoring

TASK [Try and start the myapp service via systemctl] *******************************************************************************************
fatal: [target]: FAILED! => {"changed": false, "msg": "Unable to start service myapp: Failed to start myapp.service: Connection timed out\nSee system logs and 'systemctl status myapp.service' for details.\n"}
...ignoring

PLAY RECAP *************************************************************************************************************************************
target                     : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=3   

Privilege escalation with Ansible

Whenever a module is called on a target via Ansible, what actually happens is a compiled tarball is copied over from the control host (and the ansible_user). That tarball is then executed, either as that same user or with root privileges. You can see this by running the same command with the -vvv option

[ansible@controlhost ~] ansible-playbook -i target, manage_system.yml -vvv | grep EXEC

<target> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o ConnectTimeout=10 -o ControlPath=/home/ansible/.ansible/cp/ecebe3fde6 -tt target '/bin/sh -c '"'"'sudo -H -S -n  -u root /bin/sh -c '"'"'"'"'"'"'"'"'echo BECOME-SUCCESS-bpvrbwrznnxsjheqbgipxybqliozkbsd ; /usr/libexec/platform-python /home/ansible/.ansible/tmp/ansible-tmp-1590587379.4237497-2208-186162665949670/AnsiballZ_dnf.py'"'"'"'"'"'"'"'"' && sleep 0'"'"''

<target> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o ConnectTimeout=10 -o ControlPath=/home/ansible/.ansible/cp/ecebe3fde6 -tt target '/bin/sh -c '"'"'/usr/libexec/platform-python /home/ansible/.ansible/tmp/ansible-tmp-1590587379.570427-2217-55891171115254/AnsiballZ_systemd.py && sleep 0'"'"''

You might think you could just allow the user to run all commands in /home/ansible/.ansible/ansible-tmp-*/*.py but of course that opens up a big security whole and at that point you might as well have just given all rights as root. In this way, you can’t really have the same modular level.

Workarounds

There are a couple of workarounds depending on what you want to achieve. You can of course still use the shell module. Since the shell module can use those same sudo privileges, the commands will run.

[ansible@controlhost ~] cat manage_system2.yml 
---
- hosts: target
  tasks:

  - name: Create a script that will install telnet via sudo
    copy:
      content: |
        #!/bin/bash
        sudo dnf install -y telnet
      dest: /tmp/install.sh
      mode: 0755

  - name: Run the script to install telnet via sudo
    shell: /tmp/install.sh

  - name: Create a script that will start the myapp service
    copy:
      content: |
        #!/bin/bash
        sudo systemctl start myapp.service
      dest: /tmp/service.sh
      mode: 0755

  - name: Run the script to start the myapp service via sudo
    shell: /tmp/service.sh

Let’s see what happens when the above works

[ansible@controlhost ~] ansible-playbook -i target, manage_system2.yml 

PLAY [target] ******************************************************************

TASK [Gathering Facts] *********************************************************
ok: [target]

TASK [Create a script that will install telnet via sudo] ***********************
changed: [target]

TASK [Run the script to install telnet via sudo] *******************************
changed: [target]

TASK [Create a script that will start the myapp service] ***********************
changed: [target]

TASK [Run the script to start the myapp service via sudo] **********************
changed: [target]

PLAY RECAP *********************************************************************
target                     : ok=3    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

The drawback here is that you are not getting any of the advantages of the modules, so you may need to put some additional logic into your scripts and/or check for error codes.

Ansible and sudoedit

Consider the scenario where you are happy for a set of users to manage their own /etc/hosts file using sudoedit. This is achieved with a sudo rule such as:

ansible	ALL=(ALL)       NOPASSWD: sudoedit /etc/hosts

Clearly, there is some shared responsibility here given the importance of /etc/hosts and the impact it can have on the running services. However, it is mutually beneficial to allow this rule – the users take responsibility for their own changes and the O/S support team do not act as a roadblock. The question is, how can Ansible make use of this?

I came across the following post: How to sudoedit non-interactively and leveraged that idea into an Ansible playbook. Again, using the same non-root user on the Ansible control host we can do the following:

[ansible@controlhost ~] cat manage_etc_hosts.yml 
---
- hosts: target
  tasks:

  - name: Create a wrapper script that calls sudoedit /etc/hosts
    copy:
      content: |
        #!/bin/bash
        export EDITOR="/bin/sh /tmp/editor.sh";
        PRE_STAGE=/tmp/stage.tmp;
        sudoedit /etc/hosts;
        /bin/rm $PRE_STAGE;
        exit;
      dest: /tmp/wrapper.sh
      mode: 0755

  - name: Create an editor script
    copy:
      content: |
        #!/bin/bash
        CAT=/bin/cat
        PRE_STAGE=/tmp/stage.tmp;
        $CAT $PRE_STAGE > "$1";
        exit;
      dest: /tmp/editor.sh
      mode: 0755

  - name: Create a host file
    copy:
      content: |
       127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4
       ::1         localhost localhost.localdomain localhost6 localhost6.localdomain6
       192.168.0.1 myhost01.example.com
       192.168.0.2 myhost02.example.com
      dest: /tmp/stage.tmp
      mode: 0755

  - name: Run the wrapper script to populate /etc/hosts
    shell: /tmp/wrapper.sh

What is this playbook doing? First, we create a wrapper script that calls sudoedit. When invoking sudoedit, rather than opening up vim or the default editor, the script /tmp/editor.sh is called. The second part of the playbook generates /tmp/editor.sh – it’s actually very simple and copies over the contents of /tmp/stage.tmp to $1 (/etc/hosts in our case). The third part of the playbook creates /tmp/stage.tmp using the Ansible copy module. This could be replaced with a template or static file if needed. The forth and final part of the playbook runs our wrapper script with /etc/hosts as the argument.

Here is the playbook in action:

[ansible@controlhost ~] ansible-playbook -i target, manage_etc_hosts.yml

PLAY [target] ******************************************************************

TASK [Gathering Facts] *********************************************************
ok: [target]

TASK [Create a wrapper script that calls sudoedit /etc/hosts] *****************************
changed: [target]

TASK [Create an editor script] *************************************************
changed: [target]

TASK [Create a host file] ******************************************************
changed: [target]

TASK [Run the wrapper script to populate /etc/hosts] ******************
changed: [target]

PLAY RECAP *********************************************************************
target                     : ok=5    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

On the target host, we see that /etc/hosts has been populated as we expected:

[ansible@target ~]# cat /etc/hosts
127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4
::1         localhost localhost.localdomain localhost6 localhost6.localdomain6
192.168.0.1 myhost01.example.com
192.168.0.2 myhost02.example.com

As a reminder, this was only possible because the ansible user had rights to invoke sudoedit /etc/hosts. No additional privileges were required.

Summary

We see that for system administration tasks such as starting and stopping operating system services and adding a removing packages you’ll need to give full root privileges to the calling Ansible user. Granular permission that matches sudo rules is not possible. The workaround of creating scripts, copying them across and executing them with the sudo method isn’t ideal, but it isn’t necessarily that bad either. The code can be carefully written, stored in Git just like the playbooks and simple checks can be added to ensure the plays are idempotent (running a playbook on a correctly configured host won’t reconfigure or restart services).

Links

Leave a Reply

Your email address will not be published. Required fields are marked *