Terraform: AWS ACM Certificates for Multiple Domains
My life got better when AWS introduced Certificate Manager, their service for issuing validated TLS certificates for consumption directly by other AWS services. You don’t get to download certificates issued by ACM to install on your own servers, but you can use them with your EC2 Load Balancers, CloudFront and some other services, alleviating the need to upload certificates and renew them since ACM renews them automatically.
Closing the loop on automated certificates however, was still difficult since domain validation was done through verification emails. In Nov 2017 ACM started supporting DNS validation, which is especially great if your DNS resides on Route53. Looking to drive this combination with a single workflow, I looked at Terraform and happily enough, it supports all requisite services to make this happen. Let’s take a look.
In the basic workflow of a wildcard certificate for a single domain, Terraform first requests a certificate, then creates validation records in DNS using the zone it looked up, then goes back to ACM to request validation. Importantly, Terraform then waits for the validation to complete before continuing, a crucial point that makes it possible to immediately start using this certificate elsewhere with Terraform without racing against the validation process.
This is pretty great, but it’s not yet portable, and what if we want to exploit all 10 (yes, ten) subjectAlternativeNames that ACM offers us?
I toyed with this for some time, getting angry and then sad, but eventually elated, at Terraform’s interpolation functions, until I came up with this (excerpt from a working Terraform module):
The module accepts a list of domain names and a Route53 zone ID, and will generate a unified validated certificate, returning the ARN of the certificate which you can then use with your ELB or CloudFront resources. Peeking inside, this makes use of lookup() and splatting to parse the validation options and create all the necessary DNS records.
I recently worked on a project involving multiple AWS accounts, with different projects and environments spread through those accounts in different combinations. Having opted to use Ansible for driving deployments, I looked at built-in capabilities for account switching. It turns out you can easily inject credentials authenticating with another IAM user, but this can only be done on a per-task (or perhaps, per block?) level. This might seem flexible at first glance, but when you consider you have to duplicate tasks, and therefore roles, and even playbooks, when you have to use different accounts, it quickly becomes unwiedly. That’s not even considering the insane amount of boilerplate you get when forced to specify credentials for each and every task. Perhaps the biggest blocker is that Ansible has no support for assuming IAM roles, which is amplified by the fact that most of the core AWS modules still rely on boto2, which has patchy support for this at best, and won’t be improving any time in the future.
I spent some time digging in the boto2 and boto3 docs to find commonalities in authentication support, and eventually figured that I should be able to inject temporary credentials via environment variables. Thankfully even the Session Token issued with temporary credentials (such as when assuming a role) is barely supported in boto2, albeit with a different environment variable. Now I just needed a way to obtain the credentials, and set them before playbook execution.
My first pass was a wrapper script, making use of AWS CLI calls to STS and parsing out the required bits with jq. This worked, proving the concept, but lacked finesse and intelligence as you’d still need to purposely decide which role to assume before running a playbook.
What I really wanted was a way to automatically figure out which AWS account should be operated on, based on the project and or environment being managed. Since I already have a fairly consistent approach to writing playbooks, where the environment and project are almost always provided as extra vars, this should be easy!
I’ve previously made use of Ansible vars plugins; this is a very underdocumented feature of Ansible that whilst primarily designed for injecting group/host vars from alternative sources, actually provides a really flexible entrypoint into a running Ansible process in which you can do whatever you want. The outputs of a vars plugin are host variables, but with a little cheekiness you can manipulate the environment – which happens to be where Boto and Boto3 look for credentials!
Vars plugins, however cool, are just plugins. There are inputs and outputs, but those do not include a way to inspect existing variables (either global or per-host) from within the plugin itself. Personally I find this a major shortcoming in this particular plugin architecture, however since the required information is always passed as extra vars, I decided to manually parse the CLI arguments to extract them in the plugin and not relying on Ansible to do it.
Here’s how I went about it. So, starting in the vars_plugins directory (relative to playbooks), here is a skeleton plugin that runs but does not yet do anything useful.
from__future__import (absolute_import, division, print_function)__metaclass__=typeDOCUMENTATION=''' vars: aws version_added: "2.5" short_description: Nothing useful yet description: - Is run by Ansible - Runs without error - Does nothing, returns nothing notes: - Nothing to note'''classVarsModule(BaseVarsPlugin):def__init__(self, *args):super(VarsModule, self).__init__(*args)defget_vars(self, loader, path, entities, cache=True):super(VarsModule, self).get_vars(loader, path, entities)return {}# vim: set ft=python ts=4 sts=4 sw=4 et:
We can extend this to parse the CLI arguments with ArgParse, making sure to use parse_known_args() so that we don’t have to duplicate the entire set of Ansible arguments.
from__future__import (absolute_import, division, print_function)__metaclass__=typeDOCUMENTATION=''' vars: aws version_added: "2.5" short_description: Nothing useful yet description: - Is run by Ansible - Runs without error - Does nothing, returns nothing notes: - Nothing to note'''import argparsedefparse_cli_args(): parser = argparse.ArgumentParser() parser.add_argument('-e', '--extra-vars', action='append') opts, unknown = parser.parse_known_args() args =dict()if opts.extra_vars: args['extra_vars'] =dict(e.split('=') for e in opts.extra_vars if'='in e)return argsclassVarsModule(BaseVarsPlugin):def__init__(self, *args):super(VarsModule, self).__init__(*args) cli_args = parse_cli_args()self.extra_vars = cli_args.get('extra_vars', dict())defget_vars(self, loader, path, entities, cache=True):super(VarsModule, self).get_vars(loader, path, entities)return {}# vim: set ft=python ts=4 sts=4 sw=4 et:
Now we have made available any extra vars in dictionary form, making it easy to figure out which environment and project we’re working on. We’ll run playbooks like this:
Next, we’ll build up a configuration to specify which account should be used for different projects/environments. In my situation, the makeup was complex due to some projects having all environments in a single account and some accounts having more than one project, so I needed to model this in a reusable manner. This is the structure I came up with. aws_profiles is a dictionary where the keys are names of AWS CLI/SDK profiles (as configured in ~/.aws), and the values are dictionaries of extra vars to match on.
Parsing this took a bit of thought, and some rubber ducking on zatech, but I eventually figured it out. This could probably be leaner but it balances well in my opinion. We store this configuration in vars_plugins/aws.yml, where the plugin can easily read it.
from__future__import (absolute_import, division, print_function)__metaclass__=typeDOCUMENTATION=''' vars: aws version_added: "2.5" short_description: Nothing useful yet description: - Is run by Ansible - Runs without error - Does nothing, returns nothing notes: - Nothing to note'''import argparseimport os, re, yamltry:import boto3import botocore.exceptionsHAS_BOTO3=TrueexceptImportError:HAS_BOTO3=Falsedefparse_cli_args(): parser = argparse.ArgumentParser() parser.add_argument('-e', '--extra-vars', action='append') opts, unknown = parser.parse_known_args() args =dict()if opts.extra_vars: args['extra_vars'] =dict(e.split('=') for e in opts.extra_vars if'='in e)return argsdefload_config():''' Test for configuration file and return configuration dictionary '''DIR= os.path.dirname(os.path.realpath(__file__))withopen(os.path.join(DIR, 'aws.yml'), 'r') as stream:try: config = yaml.safe_load(stream)return configexcept yaml.YAMLError as e:raise AnsibleParserError('Failed to read aws.yml: {0}'.format(e))classVarsModule(BaseVarsPlugin):def__init__(self, *args):super(VarsModule, self).__init__(*args) cli_args = parse_cli_args()self.extra_vars = cli_args.get('extra_vars', dict())self.config = load_config()self._connect_profiles()self._export_credentials()def_connect_profiles(self):for profile inself._profiles():self._init_session(profile)def_init_session(self, profile):ifnothasattr(self, 'sessions'):self.sessions =dict()self.sessions[profile] = boto3.Session(profile_name=profile)def_credentials(self, profile):returnself.sessions[profile].get_credentials().get_frozen_credentials()def_export_credentials(self):self.aws_profile =None profiles =self.config.get('aws_profiles', None)ifisinstance(profiles, dict): profiles_list = profiles.keys()else: profiles_list = profiles credentials = {profile: self._credentials(profile) for profile in profiles_list} profile_override = os.environ.get('ANSIBLE_AWS_PROFILE') default_profile =Noneif profile_override:if profile_override in profiles: default_profile = profile_overrideelifisinstance(profiles, dict) andself.extra_vars:for profile, rules in profiles.iteritems():ifisinstance(rules, dict): rule_matches = {var: Falsefor var in rules.keys()}for var, vals in rules.iteritems():ifisinstance(vals, basestring): vals = [vals]if var inself.extra_vars andself.extra_vars[var] in vals: rule_matches[var] =Trueifall(m ==Truefor m in rule_matches.values()): default_profile = profilebreakif default_profile:self.aws_profile = default_profile os.environ['AWS_ACCESS_KEY_ID'] = credentials[default_profile].access_key os.environ['AWS_SECRET_ACCESS_KEY'] = credentials[default_profile].secret_key os.environ['AWS_SECURITY_TOKEN'] = credentials[default_profile].token os.environ['AWS_SESSION_TOKEN'] = credentials[default_profile].token cleaner = re.compile('[^a-zA-Z0-9_]')for profile, creds in credentials.iteritems(): profile_clean = cleaner.sub('_', profile).upper() os.environ['{}_AWS_ACCESS_KEY_ID'.format(profile_clean)] = creds.access_key os.environ['{}_AWS_SECRET_ACCESS_KEY'.format(profile_clean)] = creds.secret_key os.environ['{}_AWS_SECURITY_TOKEN'.format(profile_clean)] = creds.token os.environ['{}_AWS_SESSION_TOKEN'.format(profile_clean)] = creds.tokendefget_vars(self, loader, path, entities, cache=True):super(VarsModule, self).get_vars(loader, path, entities)return {}# vim: set ft=python ts=4 sts=4 sw=4 et:
This got busy real quick, let’s break it down a little.
At line 54, we read the configuration file and store a config dictionary.
At line 55, we loop through each configured profile and instantiate a boto3 session for each one, saving the session objects as attributes on our module class.
At line 56, the magic happens. First we retrieve temporary credentials for each of the connected profiles (line 83) – this includes the usual secret access key plus a session key. From line 85 We check for an environment variable ANSIBLE_AWS_PROFILE, a play on the usual AWS_PROFILE, which allows us to override the account selection when invoking Ansible. Should this not be specified, from line 90 we iterate the profile specifications to determine if the specified extra vars match any profile. If they do, default_profile is populated and from line 103 we export the earlier acquired credentials using the usual AWS_* environment variables. From line 110, credentials for all profiles are exported with prefixed environment variable names to allow us to override them on a per-task basis.
This approach takes advantage of the fact that environment variables set here do propagate process-wide, and all Ansible modules running on the control host are able to see them, and will automatically use them to authenticate with AWS.
For specific tasks where you know you’ll always run that task for one specific account, you can reference the corresponding prefixed environment variables to specify credentials for the module. For example:
---- hosts: localhostconnection: localpre_tasks: - name: Validate extra varsassert:that: - env is defined - project is defined - name is definedtasks: - name: Launch EC2 instanceec2:assign_public_ip: yesgroup: externalimage: ami-aabbccdeinstance_tags:Name: "{{ name }}"env: "{{ env }}"project: "{{ project }}"instance_type: t2.mediumkeypair: ansiblevpc_subnet_id: subnet-22334456wait: yesregister: result_ec2 - name: Create DNS recordroute53:aws_access_key: "{{ lookup('env', 'OPS_AWS_ACCESS_KEY_ID') | default(omit) }}"aws_secret_key: "{{ lookup('env', 'OPS_AWS_SECRET_ACCESS_KEY') | default(omit) }}"security_token: "{{ lookup('env', 'OPS_AWS_SECURITY_TOKEN') | default(omit) }}"command: createoverwrite: yesrecord: "{{ name }}.example.net"value: "{{ item.public_ip }}"zone: example.netwith_flattened: - "{{ result_ec2.results | map(attribute='instances') | list }}" - "{{ result_ec2.results | map(attribute='tagged_instances') | list }}"# vim: set ft=ansible ts=2 sts=2 sw=2 et:
In this playbook, the ec2 task launches an instance in the account that was matched based on the env and project variables provided at runtime. The route53 task however, always creates a corresponding DNS record for the instance using the ops AWS profile.
Wrapping up, I added all of this functionality and more to my Ansible AWS Vars Plugin which you can grab from GitHub and use/modify as much as you find it useful.
Having been an avid user of password managers for as long as I can remember, I was an entrenched premium customer of LastPass for several years. It served me well with its multi-platform support, reliable sync and support for quirky authentication schemes, however as time went on I encountered more and more issues, and eventually I had enough and decided to start looking for alternatives. I put myself together a list of must-have features and started searching.
It became quickly obvious that password management is an industry with generally poor options. I toyed with 1Password but it was pricey and had limited platform support [at the time]. Dashlane was a promising contender, but I was already wary of trusting $COMPANY with the keys to my digital kingdom. What I really wanted was an open source option – I figured that would by extension solve my other hard requirements, specifically to have ownership of my data and the ability to back it up. I was seriously disappointed when the best option turned out to be the Keepassfamily of applications. So I gave up, for awhile.
Whilst trying to make peace with LastPass – not easy when your login data gets regularly corrupted and LastPass support refuses to engage on the issue – I stumbled across Bitwarden. With low expectations I started investigating the project and found what appeared to be a serious effort to build something with merit, in the open, and eager for feedback. The project was in early stages but I kept going back to see what progress was being made, and I was very pleasantly surprised. In the space of a few months, they’ve launched apps for just about every platform, addons for every major browser, and – especially promising – refactored the service to run on .NET Core. They started promising the ability to self-host, and sure enough, a few weeks later, announced a one-command install for Linux. I dove right in and have not looked back.
Here are the best things I like about Bitwarden.
Open Source: Although it does appear that a small number of developers are working on all their projects, nevertheless the entirety of the suite is open source. Not just the clients, but the server and the web application. Development happens in the open, on GitHub, and it really does appear that anyone is welcome – they’ve already established what I’d consider a friendly culture for feedback, contributions and feature requests.
Feature Parity with LastPass: Rather than being a blind carbon copy of LastPass, it seems that new features are considered very carefully for inclusion and the implementation is discussed with actual users. They’ve already got support for credit card fills, secure notes, multi-factor authentication, folder-based organization, a password generator and a native MacOS app in addition to browser addons.
TOTP Support: Especially convenient is the ability to add your TOTP secret to a login, after which Bitwarden will push a current code to your clipboard when you auto fill that login. No scrambling for your phone or waiting for the Authy Chrome app to load. This works on both desktop and mobile apps – the latter especially convenient since more and more apps are bundling a web viewer.
Autofill on mobile: Simply imperative, since all my passwords are excruciatingly long and randomized, for sanity reasons tapping them by hand on my phone(s) is just not an option. Bitwarden seems to make full use of autofill related APIs on iOS and Android, and if it doesn’t work, it’s designed well so that switching to the app and copying a password for pasting is non-frustrating.
Self Hosted: My personal killer feature. It’s always difficult to decide whether to self-host a particular cloud/web service, but my password manager is definitely top of list. Bitwarden self installation is smooth and fast, and it exposes all user data for straightforward backups. A helper script stands up all the requisite services as Docker containers, and provides update commands which I’ve found to be totally issue-free so far.
If you’re a LastPass user, you could do worse than give Bitwarden a try. The cloud version is free to use, and there’s an import facility so you can pull in your sites from LastPass and other major services.