Showing posts with label ansible. Show all posts
Showing posts with label ansible. Show all posts

December 22, 2017

Day 22 - Building a secure bastion host, or, 50 ways to kill your server

By: Anna Kennedy (@anna_ken_)
Edited By: Gillian Gunson (@shebang_the_cat)

Bastion (noun) 1. A projecting part of a fortification 2. A special purpose computer on a network specifically designed and configured to withstand attacks

If you deploy servers to a private network, then you also need a way to connect to them. The two most common ways methods are to use a VPN, or to ssh through a bastion host (also known as a jump box). Shielding services this way massively reduces your attack surface, but you need to make sure that the server exposed to the internet is as secure as you can make it.

At Telenor Digital we have about 20 federated AWS accounts, and we wanted to avoid having to set up a complex system of VPNs. Additionally, we wanted to be able to connect to any account from anywhere, and not just from designated IP ranges. Deploying a bastion host to each account would allow us to connect easily to instances via ssh forwarding. Our preferred forwarding solution is sshuttle, a "transparent proxy server / poor man's VPN".

This is where it got... interesting. At the time of writing, Amazon AWS did not have a designated bastion host instance type available. Nor in fact do any of the other main cloud providers, nor did there appear to be any other trustworthy bastions available from other sources. There didn’t even seem to be any information about how other people were solving this problem.

I knew that we wanted a secure bastion such that:

  1. Only authorised users can ssh into the bastion
  2. The bastion is useless for anything BUT ssh'ing through

How hard could making such a bastion possibly be?

Constraints and processes

Our technology stack uses Ubuntu exclusively, and we wanted the bastion to be compatible with the various services we already deploy, such as Consul, Ansible, and Filebeat. Beyond that, I personally have a lot more experience with Ubuntu than I do with any other OS.

For these reasons we decided to base the bastion on a minimal Ubuntu install, strip out as many packages as possible, add some extra security, and make a golden image bastion AMI. Had it not been for these constraints, there might be better OSs to start with, such as Alpine Linux.

Additionally, we run everything in AWS so one or two points of the following are AWS-specific, but based on a lot of conversations it seems that the bastion problem is one that affects a much wider range of architectures.

We use Packer to build our AMIs, Ansible to set them up and Serverspec to test them, so building AMIs is a pretty fast process, typically taking about five minutes. After that we deploy everything using Terraform, so it's a quick turnaround from code commit to running instance.

Starting point: Ubuntu minimal-server

My first port of call: what packages are pre-installed in an Ubuntu minimal-server? Inspection via $apt list --installed or $ dpkg-query -W showed over 2000 packages, and of those I was surprised how many I'd never heard of. And of the ones I had heard of, I was further surprised how many seemed, well, superfluous.

I spent some time and made a few spreadsheets trying to figure out what all the mystery packages were before I got bored and had the bright idea of leveraging Ubuntu's package rating system: all packages are labelled as one of: required, important, standard, optional, or extra.

$ dpkg-query -Wf '${Package;-40}${Priority}\n'
apt                             important
adduser                         required
at                              standard
a11y-profile-manager-indicator  optional
adium-theme-ubuntu              extra

Remove optional and extra packages

Those optional and extra packages sounded very nonessential. I was pretty sure I could rip those out with a nice one-liner and be done.

dpkg-query -Wf '${Package;-40}${Priority}\n' | awk '$2 ~ /optional|extra/ { print $1 }' | xargs -I % sudo apt-get -y purge %

Turns out this was not my best ever idea. All sorts of surprising packages were marked optional or extra and were thus unceremoniously removed, including:

  • cloud-init
  • grub
  • linux-base
  • openssh-server
  • resolvconf
  • ubuntu-server (meta-package)

It doesn't take a genius to realise that removing grub, open-ssh or resolvconf is colossally ill-advised, but even after I tried not removing these but uninstalling the rest I had no luck. On every build I got an unstable and/or unusable image, often not booting at all. Interestingly it broke in a different way each time, possibly something to do with how fast it was uprooting various dependencies before it got to an unbootable state. After quite a lot of experimenting with package-removal lists and getting apparently nondeterministic results, it was time for a new strategy.

Remove a selected list of packages

I revised my plan somewhat in the realisation that maybe blindly removing lots of packages wasn't the best of ideas. Maybe I could look through the package list and remove the ones that seemed the most 'useful' and remove them. Some obvious candidates for removal were the various scripting languages, plus tools like curl and net-tools. I was pretty sure these were just peripherals to a minimal server.

Package nameOk to remove?Dependency
curlnoConsul
edyes
ftpyes
gawkyes
nanoyes
net-toolsnosshuttle
perlnossh
python 2.7noAnsible
python 3noAWS instance checks
rsyncyes
screenyes
tarnoAnsible
tmuxyes
vimyes
wgetyes

It turns out I was incorrect. Due to the various restrictions placed upon the system because we use Consul, sshuttle, Ansible and AWS, about half of my hitlist was unremovable.

To compensate for the limitations in my "remove all the things" strategy, I decided to explore limiting user powers.

Restrict user capabilities

Really, I didn't want users to be able to do anything - they should only be allowed to ssh tunnel or sshuttle through the bastion. Therefore locking down the specific commands a user could issue ought to limit potential damage. To restrict user capabilities, I found four possible methods:

  • Change all user shells to /bin/nologin
  • Use rbash instead of bash
  • Restrict allowed commands in authorized_keys
  • Remove sudo from all users

All seemed like good ideas - but on testing I discovered that the first three options only work for pure ssh tunnelling, and don’t work in conjunction with sshuttle.

Remove sudo

I disabled sudo by removing all users from the sudo group, which worked perfectly apart from introducing a new dimension to bastion troubleshooting - without sudo it’s not possible to read the logs nor perform any meaningful investigation on the instance.

We offset most of the pain by having the bastion export its logs to our ELG (Elasticsearch, Logstash, Graylog) logging stack, and export metrics to Prometheus. Between these, most issues were easily identified without needing direct sudo access directly. For the couple of bigger build issues that I had in the later stages of development, I built two versions of the bastion at a time, with and without sudo. A little clunky, but only temporarily.

With the bastion locked down as much as possible, I then added in a few more restrictions to finalise the hardening.

Install fail2ban

An oldie but a goodie, fail2ban is fantastic at restricting logon attempts by locking out anyone who fails to login three times in a row for a determined time period.

Use 2FA and port knocking

Some clever folks in my team ended up making a version of sshuttle that invokes AWS two-factor authentication for our users, and implements a port knocking capability which only opens the ssh port in response to a certain request. The details of this are outside the scope of this article, but we hope to make this open-source in the near future.

Finally! A bastion!

After a lot of experiments and some heavy testing, the bastion was declared production-ready and deployed to all accounts. The final image is t2.nano sized, but we’ve not seen any problems with performance so far as the ssh forwarding is so lightweight.

It's now been in use for a least half a year, and it's been surprisingly stable. We have a Jenkins job that builds and tests the bastion AMI on any change to the source AMI or on code merge, and we redeploy our bastions every few weeks.

I still lie awake in bed sometimes and try to work out how to build a bastion from the ground up, but to all intents and purposes I think the one we have is just fine.

References

Related content

December 6, 2013

Day 6 - Ansible: Infrastructure Management Made Easy!

Written By: Derek Carter (@goozbach)
Edited By: Adam Compton (@comptona)

What is Ansible?

Ansible is most easily described by calling it a "configuration management tool", but it is so much more.

Ansible, written in Python, has an extensible, easy to modify architecture, which is built to do more than the common "configuration management" tasks.

Out of the box, Ansible has the following uses: - Normal configuration management tasks: - Create system files via templates - Manage software installation using yum, apt, gem, or the like. - Manage services or daemons (eg. start, stop, enable, disable) - Orchestration tasks: - Remove servers from load balancers - Disable monitoring/alerting - Deploy your code via git - Continuous integration: - Deploy code to QA servers - Run tests - Promote software to production if tests pass

Ansible is agentless. It uses the (most likely already running) ssh server on the target. This access uses either the Python library paramiko (the default) or the stock openssh clients.

An optional connection method is the "Accelerated Mode", which uses an ssh connection to initialize a ZeroMQ connection.

Ansible Architecture

Ansible architecture is simple. An inventory source (by default an INI-style file) defines hosts and the groups they may belong to. You can also use a dynamic inventory script to provide hosts/groups from an external source.

Ansible "Playbooks" are YAML formatted files which define "Plays". "Plays" are lists of "Tasks" which apply to hosts or host groups. "Tasks" define the Ansible "Modules" with the corresponding arguments to accomplish some purpose (eg template a file with the template module). The Ansible "Modules" are the workhorse of the system and do all the heavy lifting. An Ansible "Module" is most often a Python script, but it can be anything which prints JSON.

Getting Started

Prerequisites

The only things that a system needs as an Ansible node is the python-simplejson package, and a running OpenSSH server. Of course, if you properly setup ssh key-based authentication Ansible access becomes easier. Making sure you have proper DNS or host file setup is another way to make Ansible management easier.

Create Your Inventory File

After verifying that SSH access has properly been setup the first step is to create an inventory source.

The default inventory source is the file /etc/ansible/hosts, and is in the INI-file format. Groups use the INI group header syntax [groupname].

A simple host file example:

    # ungrouped hosts
    host1.example.com
    host2.example.com
    webinfra.example.com

    # webservers group
    [webservers]
    web1.example.com
    web2.example.com

    # databaseservers group
    [dbservers]
    db1.example.com
    db2.example.com

Verifying Ansible Connectivity

The easiest way to verify that Ansible can connect to managed nodes is the ping module.

    /usr/bin/ansible all -m ping

Ansible uses "Patterns" to decide which hosts or nodes to apply the current command or playbook. The pattern from our example above is all which is a special pattern which matches all hosts.

If you wanted to run the ping module against the database servers we previously defined we would use this command:

    /usr/bin/ansible dbservers -m ping

You can also use a wild card style syntax to match hosts across groups. For example:

    /usr/bin/ansible web*.example.com

This would match the following from our hosts file:

  • webinfra.example.com
  • web1.example.com
  • web2.example.com

Notice that it matches all hosts regardless of the group.

When using the command /usr/bin/ansible you are operating Ansible in "Ad-Hoc" mode. This is how you run "one-off" modules or commands against Ansible nodes.

One of the more useful things to run in ad-hoc mode is the setup module, which shows you the dynamically created variables which for use in playbooks.

This is what the setup module output looks like:


web1.example.com | success >> {
 "ansible_facts": {
 "ansible_all_ipv4_addresses": [
 "10.0.2.15"
 ],
 "ansible_all_ipv6_addresses": [
 "fe80::a00:27ff:feb9:46f0"
 ],
 "ansible_architecture": "x86_64",
 "ansible_bios_date": "12/01/2006",
 "ansible_bios_version": "VirtualBox",
 "ansible_default_ipv4": {
 "address": "10.0.2.15",
 "alias": "eth0",
 "gateway": "10.0.2.2",
 "interface": "eth0",
 "macaddress": "08:00:27:b9:46:f0",
 "mtu": 1500,
 "netmask": "255.255.255.0",
 "network": "10.0.2.0",
 "type": "ether"
 },
 "ansible_default_ipv6": {},
 "ansible_devices": {
 "sda": {
 "holders": [],
 "host": "SATA controller: Intel Corporation 82801HM/HEM (ICH8M/ICH8M-E) SATA Controller [AHCI mode] (rev 02)",
 "model": "VBOX HARDDISK",
 "partitions": {
 "sda1": {
 "sectors": "1024000",
 "sectorsize": 512,
 "size": "500.00 MB",
 "start": "2048"
 },
 "sda2": {
 "sectors": "82860032",
 "sectorsize": 512,
 "size": "39.51 GB",
 "start": "1026048"
 }
 },
 "removable": "0",
 "rotational": "1",
 "scheduler_mode": "cfq",
 "sectors": "83886080",
 "sectorsize": "512",
 "size": "40.00 GB",
 "support_discard": "0",
 "vendor": "ATA"
 },
 "sr0": {
 "holders": [],
 "host": "IDE interface: Intel Corporation 82371AB/EB/MB PIIX4 IDE (rev 01)",
 "model": "CD-ROM",
 "partitions": {},
 "removable": "1",
 "rotational": "1",
 "scheduler_mode": "cfq",
 "sectors": "2097151",
 "sectorsize": "512",
 "size": "1024.00 MB",
 "support_discard": "0",
 "vendor": "VBOX"
 }
 },
 "ansible_distribution": "CentOS",
 "ansible_distribution_release": "Final",
 "ansible_distribution_version": "6.4",
 "ansible_domain": "vagrantup.com",
 "ansible_eth0": {
 "active": true,
 "device": "eth0",
 "ipv4": {
 "address": "10.0.2.15",
 "netmask": "255.255.255.0",
 "network": "10.0.2.0"
 },
 "ipv4_secondaries": [],
 "ipv6": [
 {
 "address": "fe80::a00:27ff:feb9:46f0",
 "prefix": "64",
 "scope": "link"
 }
 ],
 "macaddress": "08:00:27:b9:46:f0",
 "module": "e1000",
 "mtu": 1500,
 "promisc": false,
 "type": "ether"
 },
 "ansible_form_factor": "Other",
 "ansible_fqdn": "vagrant-centos-6_4.vagrantup.com",
 "ansible_hostname": "vagrant-centos-6_4",
 "ansible_interfaces": [
 "lo",
 "eth0"
 ],
 "ansible_kernel": "2.6.32-358.6.2.el6.x86_64",
 "ansible_lo": {
 "active": true,
 "device": "lo",
 "ipv4": {
 "address": "127.0.0.1",
 "netmask": "255.0.0.0",
 "network": "127.0.0.0"
 },
 "ipv4_secondaries": [],
 "ipv6": [
 {
 "address": "::1",
 "prefix": "128",
 "scope": "host"
 }
 ],
 "mtu": 16436,
 "promisc": false,
 "type": "loopback"
 },
 "ansible_machine": "x86_64",
 "ansible_memfree_mb": 452,
 "ansible_memtotal_mb": 996,
 "ansible_mounts": [
 {
 "device": "/dev/mapper/vg_compute01-lv_root",
 "fstype": "ext4",
 "mount": "/",
 "options": "rw",
 "size_available": 31202488320,
 "size_total": 34373152768
 },
 {
 "device": "/dev/sda1",
 "fstype": "ext4",
 "mount": "/boot",
 "options": "rw",
 "size_available": 426931200,
 "size_total": 507744256
 },
 {
 "device": "/dev/mapper/vg_compute01-lv_home",
 "fstype": "ext4",
 "mount": "/home",
 "options": "rw",
 "size_available": 3109019648,
 "size_total": 3352256512
 },
 {
 "device": "/vagrant",
 "fstype": "vboxsf",
 "mount": "/vagrant",
 "options": "uid=500,gid=500,rw",
 "size_available": 1362345984,
 "size_total": 246850482176
 }
 ],
 "ansible_os_family": "RedHat",
 "ansible_pkg_mgr": "yum",
 "ansible_processor": [
 "Intel(R) Core(TM) i7-3720QM CPU @ 2.60GHz"
 ],
 "ansible_processor_cores": 1,
 "ansible_processor_count": 1,
 "ansible_processor_threads_per_core": 1,
 "ansible_processor_vcpus": 1,
 "ansible_product_name": "VirtualBox",
 "ansible_product_serial": "0",
 "ansible_product_uuid": "D80F25AC-893F-4307-81D6-225A0D274010",
 "ansible_product_version": "1.2",
 "ansible_python_version": "2.6.6",
 "ansible_selinux": {
 "config_mode": "enforcing",
 "mode": "enforcing",
 "policyvers": 24,
 "status": "enabled",
 "type": "targeted"
 },
 "ansible_ssh_host_key_dsa_public": "AAAAB3NzaC1kc3MAAACBAPY3J6VaqhkMGNuMIKwYlqYOqzU/Mnp1k3p8pW4ABHXV6qQBdR9IygvK1VwdcO+OJJeVByfr6WxZszgeGVFjEByjE9ysVybe7pp5LsSKehJYYlnY1JRYBc6T6xvq4PBt2TUBxKDtYc0Wo+B6ieDmDQMlqV+MQyxa0E7nxLcBju/XAAAAFQDHEiCCQVAhA/zakoUAX7seyj4D6wAAAIEAgb08Hr84dSBDc50rIAHnVLoHu321NFuwF7k7tGXw0VileStISTT4sEBgruv0Roh1BheUR3oB1Toi0uBtRqphaVdccYdvvkbR+lWMJdwLmG5G48P8DD6a7940pb1x5anHITKON/0XWJzWUqyYWqvwaUHa5l8HJ8lldN2hGpuH1LsAAACAC9EYHjK3JGZZt9k48cCfFf+kcxDQ0FoJDdz4yJ8ZvYPPrU0yCa9DREKap8L5Vz7TnhHRZ2qipRa1toyZXpicS1v6JeS6rOMxEKcnDhX2HEEdKSoEJIYBwwy8CWdvn8gTvsDEmM5xvLBWozw8Sx5+JwBLR6X9YWC9hY9xZ6QYckk=",
 "ansible_ssh_host_key_rsa_public": "AAAAB3NzaC1yc2EAAAABIwAAAQEAnUqMarmVF5hlX3sH4ktAf36uFSTXKmtDvdo7SHOgkBCY6tItVR0rg35Bqdz2jTHHCSNljWRRTNDnvWUyerr43/vpc7UlacVlCGbnv8DTZ2Ktn5kh29cUCFbaWNZxcQPLKa9Rf2VefiPtn0CQp1R/hadK/ow3LHACNe8WkQPB3RqnrQn273QDYmCIscjPE8RC+v3pqw8adU/EUBmm10q8lBB5lYpZS4C0HBxdBnr40sVLLsH7WzKo+QvQN+p6UU1VOXrdNkQV3Krlrj1qJeLlxG56i+dgmRSHR8cmeiD2+w63zxKEwzqFeOs23pOGq1pOoldl75es4r9Ws4QufcHdqw==",
 "ansible_swapfree_mb": 3902,
 "ansible_swaptotal_mb": 3903,
 "ansible_system": "Linux",
 "ansible_system_vendor": "innotek GmbH",
 "ansible_user_id": "root",
 "ansible_userspace_architecture": "x86_64",
 "ansible_userspace_bits": "64",
 "ansible_virtualization_role": "guest",
 "ansible_virtualization_type": "virtualbox"
 },
 "changed": false
}

Ansible Playbooks

The other method of running Ansible is by creating a playbook. Ansible playbooks are simple YAML syntax files which look something like this:

    ---
    - hosts: all
      remote_user: root
      tasks:
        - name: keep vim-enhanced package up to date
          yum: name=vim-enhanced state=latest

This defines which hosts the playbook will attempt to configure the remote user, used for SSH, and makes sure the package vim-enhanced is the latest version using the yum Ansible module. You would use this playbook by putting it in a file (lets say /etc/ansible/playbooks/vim.yml), and applying or running the playbook by using the command:

    /usr/bin/ansible-playbook /etc/ansible/playbooks/vim.yml

You will see some output like this:

    PLAY [all] ******************************************************************** 

    GATHERING FACTS *************************************************************** 
    ok: [foo.example.com]

    TASK: [keep vim-enhanced package up to date] ********************************** 
    ok: [foo.example.com]

    PLAY RECAP ******************************************************************** 
    foo.example.com                : ok=2    changed=0    unreachable=0    failed=0

Now that we have this good start, let's add a bit of complexity by creating and using some variables. Ansible variables come from a myriad of sources (from highest precedence to lowest):

  • "Facts" derived from the setup module.
  • Passed from the command line using the -e switch. (these variables always win)
  • Set in playbook.
  • Role variables. (More on roles later)
  • Variables passed from inventory.
  • Host variables. (from /etc/ansible/host_vars/<HOSTNAME>)
  • Group variables. (from /etc/ansible/group_vars/<GROUPNAME>)
  • Site default variables. ( from /etc/ansible/group_vars/all)
  • Role "default" variables.

Variable files are defined in YAML files as well. The contents of /etc/ansible/group_vars/all could look something like this:

    ---
    apache_service: httpd

To use a variable in a playbook or a template use this syntax:

        - name: restart apache service
          service: name={{ apache_service }} state=restarted

This task would allow you to define the name of the apache service based on the system or group.

Ansible Work flows

You now know enough about the format of the Ansible playbooks to start using Ansible in earnest. As mentioned before, Ansible is a pretty flexible tool and can be used in many different work flows. And of course, the modules and tasks you'd use for each can overlap.

Common Configuration Management Tasks

Ansible can be used in a configuration management work flow. Some of the common tasks done in this work flow include:

Create a user account:

    - name: create foo user
      user: name=foo state=present password=<SALTED_HASH>

More options for the user module are found in its documentation

install a package

    - name: install foo package
      yum: name=foo state=latest

or

    - name: install foo package via `apt`
      apt: name=foo state=installed

There are additional packaging modules as well.

Enable and start a service

    - name: enable foo service
      service: name=foo enabled=yes

    - name: start foo service
      service: name=foo state=started

Additional service documentation

Creating a file from a template

There are two steps to of a template, the task and the template itself. The task looks like this:

    - name: template the file /etc/motd
      template: src=motd.j2 dest=/etc/motd owner=root group=wheel mode=0644

The template module gets most of its arguments from the file module

The template file syntax is Jinja2 and a simple template for the motd task above would look like this:

    Welcome to host: {{ ansible_hostname }}
    Please be kind

Running tasks on changes

If you want to run a task when a template file changes or another task creates a change, you use a handler:

    tasks:
      - name: template apache config
        template: src=httpd.conf.j2 dest=/etc/httpd/conf/httpd.conf
        notify:
          - restart apache

You then create a handler in the playbook:

    handlers:
      - name: restart apache
        service: name=httpd state=restarted

Notice, you define any module as you would with a normal task.

Orchestration work flow

Ansible can also use some features to do normal orchestration tasks such as deploying code from Git. The real power however, comes from the ability to delegate a single task to a different server.

A good example is removing a server from a load balancer prior to deploying code:

    tasks:
      - name: remove host from LB
        shell: /usr/local/bin/remove_host {{ ansible_hostname }}
        delegate_to: loadbalancer.example.com

      - name: deploy code
        git: repo=http://git.example.com/project/foo.git dest=/var/www/html/
        notify:
          - restart apache

      - name: add host to LB
        shell: /usr/local/bin/add_host {{ ansible_hostname }}
        delegate_to: loadbalancer.example.com

Continuous Integration Work flow

Using Ansible as part of a continuous integration system is a powerful work flow. Ansible can easily use the same playbooks against different environments by simply changing the inventory source.

To run a site-wide playbook against production you would use the default command syntax:

    /usr/bin/ansible-playbook /etc/ansible/playbooks/site.yml

To run the same site-wide playbook against the development environment you would change the hosts file using the -i switch:

    /usr/bin/ansible-playbook -i /etc/ansible/hosts-dev /etc/ansible/playbooks/site.yml

Advanced Playbook Syntax

There are some advanced syntax options for tasks which allow for better control and administration.

Tags

Tags allow for selective execution of tasks.

    tasks:
      - name: template apache config
        template: src=httpd.conf.j2 dest=/etc/httpd/conf/httpd.conf
        tags:
          - templates
          - apache
        notify:
          - restart apache

      - name: template the file /etc/motd
        template: src=motd.j2 dest=/etc/motd owner=root group=wheel mode=0644
        tags:
          - motd

To run just the motd tasks you would run ansible-playbook this way:

    /usr/bin/ansible-playbooks /etc/ansible/playbooks/site.yml --tags=motd

Conditional Execution

You may also want to skip tasks based on some criteria, this is done with the when parameter.

      - name: template the file /foo.txt
        template: src=foo.j2 dest=/foo.txt owner=root group=wheel mode=0644
        when: ansible_hostname == "foo.example.com"

The above task will only run on the host foo.example.com.

Loops

You can also loop over items in a single task. Some modules (such as yum and apt) can also do smart grouping with a list of items.

    - name: install packages loop
      yum: name={{ item }} state=present
      with_items:
        - vim-enhanced
        - screen
        - nano
        - mlocate

Graduate-Level Study

Ansible has more advanced features which are a bit out of the scope of this article.

Ansible Roles

Ansible roles are used for organization and code-reuse. They allow for a more modular and easier to read playbook.

Accelerated Mode and Asynchronous Mode

SSH connections do add some overhead. To properly scale to a large number of hosts, Ansible has a "Accelerated Mode" which starts a transient daemon for a lighter-weight communication channel.

Other Features

  • Python API

You can use the provided Python API to add Ansible features into your own projects.

  • Custom Modules

Ansible modules can be written in any language. They only need to be able to output JSON.

If you use Python however, there are some shortcuts you can use to get some basic features.

  • Callbacks

Callbacks allow for custom logging or notifications for tasks

  • Connection Plugins

Connection plugins allow for custom communication channels.

In Closing

Ansible's power and ease of use makes it a good candidate for your environment without the overhead or complexity of other tools. Also because of the extensible nature of the system it can be placed in the work flow which best fits your requirements.

Spend a few minutes and give Ansible a shot.