Ansible and AWS Security Groups

We use Amazon CloudFormation for a number of our deployments at $WORK. Although it’s nice to have security group creation inside the same template as the resources it will secure, CloudFormations ‘helpful’ addition of a unique string at the end of the resource names it creates can sometimes be a problem. A couple of tools assume security groups will have an absolute, unchanging name and lack a way to search for an appropriately tagged security group whose name can change on stack rebuild. In order to get around this I tried extracting some of our security group creation from CloudFormation and implemented it in Ansible instead.

For this example I’m going to assume you have a working Ansible install and an AWS account. While you can specify your AWS AWS_ACCESS_KEY and AWS_SECRET_KEY in your Ansible play books I set mine as environmental variables to avoid the risk of checking them in to a VCS. First we create our directory structure, group variables and site.yml -

export AWS_ACCESS_KEY=YOURKEY
export AWS_SECRET_KEY=YOURSECRET

# we'll do all our work under here
mkdir -p aws-sg aws-sg/{group_vars,roles/bastionhosts/tasks}
cd aws-sg

echo '127.0.0.1' > hosts


# add regions to create the security groups in here
cat << 'EOC' > group_vars/all
---
regions:
 - eu-west-1
 - us-east-1
 - us-east-2
EOC


# add the task to run
cat << 'EOC' > site.yml
---
- hosts: all
  roles:
   - role: bastionhosts
EOC

Now we’ve created our scaffolding we can actually write tasks to create our security group

cat << 'EOC' > ./roles/bastionhosts/tasks/main.yml
---
- name: "ssh ingress security group in region {{ item }}"
  local_action:
    module: ec2_group
    name: bastion-ingress
    description: Bastion host servers
    region: "{{ item }}"
    rules:
      - proto: tcp
        from_port: 22
        to_port: 22
        cidr_ip: 0.0.0.0/0
  register: bastion_ingress
  with_items: regions

EOC

The above code creates the ‘bastion-ingress’ security group in each region you specified in group_vars/all. with_items will run the task once for each region in regions. It also sets item to the current array element so it can be used within the task. The other line of interest is register. This stores meta-data about the newly created security group in an array that we can use later.

We now have our security group allowing ssh traffic in to the bastion hosts. We’ll often want to allow ssh traffic from bastion hosts, those with the bastion-ingress group assign to them, to “internal” hosts that are not publicly reachable. To do this we add a second security group, that we assign to the internal hosts, that allows traffic in on port 22 from the existing bastion-ingress group. This reduces the number of places the internal hosts can be reached from and is often useful in default VPCs.

cat << 'EOC' >> ./roles/bastionhosts/tasks/main.yml

- name: "ssh from bastion hosts group in region {{ item }}"
  local_action:
    module: ec2_group
    name: bastion-clients
    description: Allows bastion servers to connect
    region: "{{ item.item }}"
    rules:
      - proto: tcp
        from_port: 22
        to_port: 22
        group_id: "{{ item.group_id }}"
  with_items: bastion_ingress.results

EOC

This new security group task is run for each entry in bastion_ingress.results, the array we stored the creation meta-data of the bastion-ingress group in earlier using register. We then use the group_id to limit where we allow incoming traffic from for this new group.

You can then run it with -

ansible-playbook -i hosts site.yml -v

As an approach, the pattern of storing with register and iterating with with_items: foo.results is a very handy one and I suspect it will be used in many of my playbooks. In terms of the ec2_group module itself it’s inability to handle egress rules limits the number of places we can use it but it’s a nice proof of concept for running under old, ec2 classic based deployments.