I’ve been sort of relearning how to use Ansible and trying to improve my automations. To that end, I’m building out a project from the ground up to try to fill in missing holes in my knowledge and maybe learn some new tricks.
In this article, I’ll create an Ansible playbook that:
- creates an EC2 instance
- creates SSH keys for that instance
- configures my laptop to be able to SSH in immediately
- destroys the instance and keys, and removes the corresponding config from my laptop
This will be a pretty simple playbook, and security is not a priority yet, so there will be a few security issues and drawbacks that I’ll talk about at the end. I plan to keep revising the playbook to make incremental improvements.
Step By Step Creation
To do the EC2 creation, the playbook will carry out these steps:
1. Create SSH keys on AWS and copy the private key to the local machine.
- name: Create an EC2 key
block:
- name: Create the EC2 key
amazon.aws.ec2_key:
name: "{{ ssh_key_name }}"
region: "{{ aws_region }}"
force: true
register: ec2key
- name: Save the private key locally
ansible.builtin.copy:
content: "{{ ec2key.key.private_key }}"
dest: "{{ ssh_key_path_local }}"
mode: 0600
when: ec2key.changed
tags: ['create']
2. Create a security group that will allow SSH access from any IP address.
AWS is “Allow Only”, so we need to whitelist our access.
- name: Create EC2 ssh security group
block:
- name: Create security group
amazon.aws.ec2_security_group:
name: "{{ host_name }} SSH"
description: SSH access for instance
region: "{{ aws_region }}"
rules:
- proto: tcp
ports: 22
cidr_ip: 0.0.0.0/0
rule_desc: allow all ssh on port 22
register: security_group
tags: ['never', 'create']
3. Create an instance using the created SSH keys and security group.
We also configure User Data to add passwordless sudo for the “ubuntu” user. User Data runs once on the initial launch as root. It can be configured to run on every reboot.
- name: Create EC2 Block
block:
- name: Create EC2 instances
amazon.aws.ec2_instance:
name: "{{ ssh_key_name }}"
region: "{{ aws_region }}"
availability_zone: "{{ availability_zone }}"
key_name: "{{ ssh_key_name }}"
instance_type: "{{ instance_type }}"
image_id: "{{ image_id }}"
user_data: |
#!/bin/bash
sudo apt update && sudo apt upgrade
sudo awk '/root\s+ALL=\(ALL:ALL\) ALL/ {print; print "ubuntu ALL=(ALL) NOPASSWD:ALL"; next}1' /etc/sudoers | sudo tee /etc/sudoers
security_groups: ['default', "{{ security_group.group_id }}"]
network:
assign_public_ip: true
register: ec2
4. Wait for SSH access to come up.
Sometimes I’m getting some failures where AWS seems to be hanging or taking a little longer to do things. Occasionally, the playbook fails here because the previous step has “completed”, but the instance isn’t available yet.
I haven’t investigated this yet, but I figure it partly has to do with my frequent create/destroy cycle.
- name: Wait for ssh to come up
local_action:
module: wait_for
host: "{{ item.public_ip_address }}"
port: 22
delay: 10
timeout: 120
loop: "{{ ec2.instances }}"
5. Update the local ~/.ssh/config.
This allows immediate access into the instance via SSH.
- name: Update local .ssh/config
ansible.builtin.blockinfile:
path: ~/.ssh/config
backup: "{{ backup_ssh_config }}"
block: |
Host {{ host_name }}
IdentityFile {{ ssh_key_path_local }}
IdentitiesOnly yes
User ubuntu
HostName {{ item.public_ip_address }}
StrictHostKeyChecking no
UserKnownHostsFile ~/.ssh/known_hosts_amz
loop: "{{ ec2.instances }}"
6. Make sure there is a group line in the Ansible hosts file.
This usually looks something like:
[webservers]
Do it in Ansible like this:
- name: Ensure there is a group line for our instances in hosts file
ansible.builtin.lineinfile:
path: ./hosts
line: "[{{ inventory_host_groups }}]"
state: present
7. Add our new instance to the Ansible hosts file after the group line.
- name: Update local ansible hosts file
ansible.builtin.lineinfile:
path: ./hosts
line: "{{ host_name }}"
insertafter: "[{{ inventory_host_groups }}]"
state: present
I’m using Ansible tags to specify tasks that should be executed on create and destroy.
Step By Step Destruction
Destruction of the server is pretty simple. We start by finding the instance and then reverse most of the changes we made on creation.
Note that I’m not removing the group line from the Ansible hosts file.
- name: Terminate EC2 Block
block:
- name: Get EC2 instance info
amazon.aws.ec2_instance_info:
filters:
"tag:Name": "{{ host_name }}"
register: instance
- name: Terminate EC2 instances
amazon.aws.ec2_instance:
state: terminated
name: "{{ ssh_key_name }}"
register: terminated_instances
- name: Remove the EC2 key
amazon.aws.ec2_key:
name: "{{ ssh_key_name }}"
state: absent
- name: Remove security group
amazon.aws.ec2_security_group:
name: "{{ host_name }} SSH"
state: absent
- name: Delete local ssh key
ansible.builtin.file:
path: "{{ ssh_key_path_local }}"
state: absent
# TODO Remove known hosts entry
- name: Update local ansible hosts file
ansible.builtin.lineinfile:
path: ./hosts
search_string: "{{ host_name }}"
state: absent
- name: Remove block from local .ssh/config
ansible.builtin.blockinfile:
path: ~/.ssh/config
state: absent
block: |
Host {{ host_name }}
IdentityFile {{ ssh_key_path_local }}
IdentitiesOnly yes
User ubuntu
HostName {{ item.public_ip_address }}
StrictHostKeyChecking no
UserKnownHostsFile ~/.ssh/known_hosts_amz
loop: "{{ instance.instances }}"
when: item.public_ip_address is defined
tags: ['never', 'destroy']
Use of Ansible Tags and Blocks
Blocks are used in playbooks to group related tasks together.
I’m also using tags to specify which tasks should be run based on creation or destruction.
Tags are used like this:
# Create the server
ansible-playbook ec2-manage.yml --tags=create
# Destroy the server
ansible-playbook ec2-manage.yml --tags=destroy
Ansible has the special tags “always” and “never”, as well. “never” will keep a task from running unless you call it specifically by tag, as I’m doing here.
I’ll probably reorganize, retag, or even split the playbook in the future, depending on how I decide to use it.
The Full Playbook
---
- name: Manage EC2 instances
hosts: x270
# gather_facts: false
vars:
project: test
unit: server
aws_region: us-east-1
availability_zone: us-east-1a
instance_type: t2.micro
# Ubuntu 22.04
image_id: ami-007855ac798b5175e
inventory_host_groups: "{{ unit }}"
host_name: "{{ project }}_{{ unit }}"
ssh_key_name: "{{ project }}_{{ unit }}"
ssh_key_path_local: "~/.ssh/{{ ssh_key_name }}.pem"
backup_ssh_config: false
print_instance_info: false
tasks:
- name: Get info block
block:
- name: Get running instance info
ec2_instance_info:
register: ec2info
- name: Print instance info
debug: var="ec2info.instances"
when: print_instance_info
tags: ['always']
- name: Create an EC2 key
block:
- name: Create the EC2 key
amazon.aws.ec2_key:
name: "{{ ssh_key_name }}"
region: "{{ aws_region }}"
force: true
register: ec2key
- name: Save the private key locally
ansible.builtin.copy:
content: "{{ ec2key.key.private_key }}"
dest: "{{ ssh_key_path_local }}"
mode: 0600
when: ec2key.changed
tags: ['create']
- name: Terminate EC2 Block
block:
- name: Get EC2 instance info
amazon.aws.ec2_instance_info:
filters:
"tag:Name": "{{ host_name }}"
register: instance
- name: Terminate EC2 instances
amazon.aws.ec2_instance:
state: terminated
name: "{{ ssh_key_name }}"
register: terminated_instances
- name: Remove the EC2 key
amazon.aws.ec2_key:
name: "{{ ssh_key_name }}"
state: absent
- name: Remove security group
amazon.aws.ec2_security_group:
name: "{{ host_name }} SSH"
state: absent
- name: Delete local ssh key
ansible.builtin.file:
path: "{{ ssh_key_path_local }}"
state: absent
# TODO Remove known hosts entry
- name: Update local ansible hosts file
ansible.builtin.lineinfile:
path: ./hosts
search_string: "{{ host_name }}"
state: absent
- name: Remove block from local .ssh/config
ansible.builtin.blockinfile:
path: ~/.ssh/config
state: absent
block: |
Host {{ host_name }}
IdentityFile {{ ssh_key_path_local }}
IdentitiesOnly yes
User ubuntu
HostName {{ item.public_ip_address }}
StrictHostKeyChecking no
UserKnownHostsFile ~/.ssh/known_hosts_amz
loop: "{{ instance.instances }}"
when: item.public_ip_address is defined
tags: ['never', 'destroy']
- name: Create EC2 ssh security group
block:
- name: Create security group
amazon.aws.ec2_security_group:
name: "{{ host_name }} SSH"
description: SSH access for instance
region: "{{ aws_region }}"
rules:
- proto: tcp
ports: 22
cidr_ip: 0.0.0.0/0
rule_desc: allow all ssh on port 22
register: security_group
tags: ['never', 'create']
- name: Create EC2 Block
block:
- name: Create EC2 instances
amazon.aws.ec2_instance:
name: "{{ ssh_key_name }}"
region: "{{ aws_region }}"
availability_zone: "{{ availability_zone }}"
key_name: "{{ ssh_key_name }}"
instance_type: "{{ instance_type }}"
image_id: "{{ image_id }}"
user_data: |
#!/bin/bash
sudo apt update && sudo apt upgrade
sudo awk '/root\s+ALL=\(ALL:ALL\) ALL/ {print; print "ubuntu ALL=(ALL) NOPASSWD:ALL"; next}1' /etc/sudoers | sudo tee /etc/sudoers
security_groups: ['default', "{{ security_group.group_id }}"]
network:
assign_public_ip: true
register: ec2
- name: Wait for ssh to come up
local_action:
module: wait_for
host: "{{ item.public_ip_address }}"
port: 22
delay: 10
timeout: 120
loop: "{{ ec2.instances }}"
- name: Update local .ssh/config
ansible.builtin.blockinfile:
path: ~/.ssh/config
backup: "{{ backup_ssh_config }}"
block: |
Host {{ host_name }}
IdentityFile {{ ssh_key_path_local }}
IdentitiesOnly yes
User ubuntu
HostName {{ item.public_ip_address }}
StrictHostKeyChecking no
UserKnownHostsFile ~/.ssh/known_hosts_amz
loop: "{{ ec2.instances }}"
# TODO
# when: ec2key.changed
- name: Ensure there is a group line for our instances in hosts file
ansible.builtin.lineinfile:
path: ./hosts
line: "[{{ inventory_host_groups }}]"
state: present
- name: Update local ansible hosts file
ansible.builtin.lineinfile:
path: ./hosts
line: "{{ host_name }}"
insertafter: "[{{ inventory_host_groups }}]"
state: present
- name: print
debug: var="item.public_ip_address"
loop: "{{ ec2.instances }}"
tags: ['never', 'create']
Drawbacks
There are a few downsides to this playbook:
- Certain tasks aren’t set up to handle multiple servers.
- Most of the variables should be configured elsewhere so that the playbook can be used for multiple types of servers in different regions.
- The known_hosts file never gets cleaned up.
Security Issues
There is some basic hardening that this setup is missing:
- SSH isn’t hardened.
- The security group allows SSH access from any IP address.
StrictHostKeyChecking
is disabled in the ~/.ssh/config file.- Passwordless sudo is enabled for the “ubuntu” user.
- The instance storage is not encrypted.