6

I have an Ansible playbook I use to manage our sudoers files across our environment. We like to keep a minimal sudoers file at /etc/sudoers, then anything we want to add gets put into separate files under /etc/sudoers.d.

My Ansible playbook contains the following task for pushing these files:

- name: copy sudoers files
  copy:
    src: "{{ item }}"
    dest: "/etc/sudoers.d/{{ item }}"
    backup: yes
    owner: root
    group: root
    mode: 0440
    validate: /usr/sbin/visudo -cf %s
  with_items:
    - admins
    - apache
    - monitor

The task contains a validate clause to make sure the file is valid before committing the file, and this has generally worked well. However, today I ran into a problem where an update broke sudo. The file passed the validation step, but contained a User_Alias with the same name as a User_Alias in the main /etc/sudoers file. Any attempt to run sudo after that resulted in a parse error.

My question is this - how do I test updates to my sudoers files from Ansible that can catch errors like this? Once the file is in place, the error can be caught by running visudo -c, but putting this in as the validation step doesn't work. Ansible requires the %s placeholder, and even if it didn't, the validation is done before copying the file into place so that visudo -c wouldn't catch it.

4
  • Cross-site dup: Visudo sanity check for the whole ecosystem of included files?
    – techraf
    Mar 13, 2018 at 19:25
  • @techraf, thanks for the information, this might prove useful. But it won't work in Ansible because according to the documentation on docs.ansible.com for the validate tag: "The command is passed securely so shell features like expansion and pipes won't work."
    – virtex
    Mar 13, 2018 at 19:40
  • @virtex make a helper shell script that takes {{ item }} and %s as parameters; it can dump /etc/sudoers and all files from /etc/sudoers.d/* except {{ item }} (old file), add content of %s (new file) and feed to visudo. Mar 13, 2018 at 20:01
  • @Konstantin, this is similar to what I will likely end up doing. I'll need to do the verification as a separate task in case I'm updating more than one file, but I can copy /etc/sudoers.d to /etc/sudoers.stage.d and upload my files there, then have the helper script swap the two directories and perform the validation, then swap them back if it fails. Thanks.
    – virtex
    Mar 14, 2018 at 13:17

3 Answers 3

9

Have you tried this:

- copy:
    src: '{{ item }}'
    dest: '/etc/sudoers.d/{{ item }}'
    owner: root
    group: root
    mode: 0440
    validate: 'bash -c "cat /etc/sudoers %s | visudo -cf-"'

It works for me.

0
2

I got it working. Here's what I did. First I added a set of Ansible tasks to create a staging directory at /etc/sudoers.stage.d and copy the contents of /etc/sudoers.d into it. I then upload the files to this staging area and, if any of them are changed, run a custom script to activate them.

This is what the logic in the playbook looks like now

- name: delete staging area
  file:
    path: "/etc/sudoers.stage.d"
    state: absent
  changed_when: false

- name: copy /etc/sudoers.d to staging area
  shell: "cp -rp /etc/sudoers.d /etc/sudoers.stage.d"
  changed_when: false

- name: stage sudoers files
  copy:
    src: "{{item}}"
    dest: "/etc/sudoers.stage.d/{{item}}"
    backup: yes
    owner: root
    group: root
    mode: 0440
    validate: /usr/sbin/visudo -cf %s
  with_items:
    - admins
    - apache
    - monitor
  register: sudoers_d

- block:
  - name: push out activate script
    copy:
      src: activate_sudoers.sh
      dest: /usr/local/bin/activate_sudoers.sh
      owner: root
      group: root
      mode: 0700

  - name: activate change
    shell: /bin/sh /usr/local/bin/activate_sudoers.sh /etc/sudoers.stage.d

  when: sudoers_d.changed

and here's what the activate_sudoers.sh script looks like.

#!/bin/sh

function usage {
    echo "Usage: $0 <stage directory>" >&2
    exit 1
}

function abort {
    echo "*** Error detected" >&2
    [ "$#" -gt 0 ] && echo "***" $@ >&2
    exit 1
}

PATH=/usr/bin:/bin:/usr/sbin:/sbin
export PATH

test $# -eq 1 || usage
test -d "$1" || abort "Stage directory $1: missing or not a directory"
test -d /etc/sudoers.old.d && rm -rf /etc/sudoers.old.d
test -d /etc/sudoers.old.d && abort "Failed to remove /etc/sudoers.old.d"

mv /etc/sudoers.d /etc/sudoers.old.d \
  && mv "$1" /etc/sudoers.d \
  && visudo -c

if [ $? -eq 0 ]; then
    # Success - clean up
    rm -rf /etc/sudoers.old.d
    exit 0
else
    # Failure - roll back
    rm -rf /etc/sudoers.d
    mv /etc/sudoers.old.d /etc/sudoers.d
    abort "sudoers update failed"
fi

It's a bit longer and more complex than I had hoped, but it gets the job done. Hopefully this will be useful to anyone else running into the same problem.

1
  • You don't have to create a staging area, because the module says if "validate" is present, it will be validated in a temporary file before it's moved in-place. Maybe it's a new feature... Apr 3 at 14:13
1

Doing the same thing with Ansible modules only.

- name: Creating assemble directory
  file:
    path: /etc/sudoers.stage.d
    state: directory
    mode: 0600

- name: Applying sudoers.d files on assemble directory
  template:
    src: "{{ item }}.j2"
    dest: /etc/sudoers.stage.d/{{ item }}
    mode: 0400
  loop:
    - 10-wheel
    - 20-sys
  register: check

- name: Copying original sudoers to the assemble directory
  copy:
    remote_src: yes
    src: /etc/sudoers
    dest: /etc/sudoers.stage.d/99-sudoers
  when: check.changed

- name: Removing include line from 99-sudoers
  lineinfile:
    path: /etc/sudoers.stage.d/99-sudoers
    state: absent
    regex: ^#includedir /etc/sudoers.d
  when: check.changed

- name: Assembling unique config for validation
  assemble:
    src: /etc/sudoers.stage.d
    dest: /etc/sudoers.stage
    mode: 0600
    validate: visudo -cf %s
  when: check.changed

- name: Applying sudoers configurations
  template:
    src: "{{ item }}.j2"
    dest: /etc/sudoers.d/{{ item }}
    mode: 0440
  loop:
    - 10-wheel
    - 20-sys
  when: check.changed

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.