• Accessing AWS Services When Remoting

    Having recently moved out of my place, where I had comparitively great Internet access supplied with static IP addresses, I’m currently working mostly tethered to my phone. It turns out that good LTE service actually works pretty well for most things – even long distance SSH – but sitting on a cellular network with a dynamic IP address can get really annoying.

    Yes, I should be establishing a VPN to reach internal services. Yes, opening holes for services on the wider Internet is totally a bad idea. But I have other security measures too, and a VPN would be just one extra. And surprise, tunnelling encrypted TCP over another encrypted TCP connection over a cellular network doesn’t provide a great experience.

    So I’m dialling straight in over SSH, and here’s the little script I knocked together to make that just a little bit nicer than logging into the AWS console and manually creating security exceptions for myself (requires Python 3.6).

    #!/usr/bin/env python3
    
    import argparse, boto3, botocore, os, sys, urllib
    from ipaddress import IPv4Address, IPv4Network
    from pprint import pprint
    
    class AuthError(Exception):
        pass
    
    def describe_rule(GroupId, IpPermissions):
        print(f"  Group ID: {GroupId}")
        print(f"  Port: {IpPermissions[0]['IpProtocol'].upper()}/{IpPermissions[0]['ToPort']}")
        print(f"  CIDR: {IpPermissions[0]['IpRanges'][0]['CidrIp']}")
        print(f"  Description: {IpPermissions[0]['IpRanges'][0]['Description']}")
    
    def main():
        name = os.environ.get('USER').capitalize()
        parser = argparse.ArgumentParser()
        parser.add_argument('-g', '--group', required=True, help='Name of security group to authorize')
        parser.add_argument('-p', '--profile', default='default', help='AWS profile to use')
        parser.add_argument('-r', '--region', default='', help='AWS region to use (defaults to profile setting)')
        parser.add_argument('-t', '--port', type=int, action='append', default=[], help='TCP port to allow (default: 22)')
        parser.add_argument('-d', '--description', default=name, help='Description for rule CIDR')
        parser.add_argument('-D', '--delete', action='store_true', help='Delete other rules with matching description')
        args = parser.parse_args()
    
        description = args.description
    
        try:
            session = boto3.session.Session(profile_name=args.profile)
        except botocore.exceptions.ProfileNotFound as e:
            raise AuthError(e)
    
        client_args = {}
        if args.region:
            client_args['region_name'] = args.region
        client = session.client('ec2', **client_args)
        groups = client.describe_security_groups(Filters=[{'Name': 'group-name', 'Values': [args.group]}])
        if 'SecurityGroups' not in groups or len(groups['SecurityGroups']) == 0:
            raise AuthError('Security group "{}" not found'.format(args.group))
        elif len(groups['SecurityGroups']) > 1:
            raise AuthError("More than one security group found for \"{0}\":\n - {1}".format(args.group, "\n - ".join([g['GroupName'] for g in groups['SecurityGroups']])))
    
        group = groups['SecurityGroups'][0]
        print('Found matching group: {}'.format(group['GroupName']))
    
        try:
            req = urllib.request.Request('https://ifconfig.co/ip', headers={'Accept': 'text/plain', 'User-Agent': 'curl/7.54.0'})
            res = urllib.request.urlopen(req)
            ip = res.read().decode('utf-8').strip()
        except urllib.error.HTTPError as e:
            raise AuthError('Could not determine public IP address, got {0} error when accessing ifconfig.co'.format(e.code))
        cidr = ip + '/32'
        print('Determined current public IP: {}'.format(ip))
    
        if len(args.port):
            ports = args.port
        else:
            ports = (22,)
    
        for port in ports:
            for perm in group['IpPermissions']:
                if perm['IpProtocol'] == 'tcp' and perm['FromPort'] <= port and perm['ToPort'] >= port:
                    for iprange in perm['IpRanges']:
                        if IPv4Address(ip) in IPv4Network(iprange['CidrIp']):
                            print('{0} already authorized by {1}'.format(ip, iprange['CidrIp']))
                            return True
    
            if args.delete:
                for perm in group['IpPermissions']:
                    if perm['IpProtocol'] == 'tcp' and perm['FromPort'] <= port and perm['ToPort'] >= port:
                        for iprange in perm['IpRanges']:
                            if 'Description' in iprange and iprange['Description'] == args.description:
                                old_rule = {
                                    'GroupId': group['GroupId'],
                                    'IpPermissions': [{
                                        'IpProtocol': perm['IpProtocol'],
                                        'FromPort': perm['FromPort'],
                                        'ToPort': perm['ToPort'],
                                        'IpRanges': [{
                                            'CidrIp': iprange['CidrIp'],
                                            'Description': iprange['Description'],
                                        }],
                                    }],
                                }
                                print('Deleting rule:')
                                describe_rule(**old_rule)
                                client.revoke_security_group_ingress(**old_rule)
    
            new_rule = {
                'GroupId': group['GroupId'],
                'IpPermissions': [{
                    'IpProtocol': 'tcp',
                    'FromPort': port,
                    'ToPort': port,
                    'IpRanges': [{
                        'CidrIp': cidr,
                        'Description': description,
                    }],
                }],
            }
            print('Creating rule:')
            describe_rule(**new_rule)
            client.authorize_security_group_ingress(**new_rule)
    
    
    if __name__ == "__main__":
        try:
            main()
        except AuthError as e:
            print(str(e), file=sys.stderr)
    
    
    # vim: set ft=python ts=4 sts=4 sw=4 et:

    Run it like this.

    # Create a new rule in the employees security group
    authorize-aws -g employees
    
    # Create a new rule as above, and delete any existing rule with your name on it
    authorize-aws -g employees -D
    
    # As above, but using a different AWS profile than the default one
    authorize-aws -p acme -g employees -D
    
    # For Windows instances
    authorize-aws -g employees -t 3389
    
    # By default, the description contains your local user name, but can be overridden
    authorize-aws -g employees -d 'Carmen mobile'
  • 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.

    resource "aws_acm_certificate" "main" {
      domain_name = "example.net"
      subject_alternative_names = ["*.example.net"]
      validation_method = "DNS"
      tags {
        Name = "example.net"
        terraform = "true"
      }
    }
    
    data "aws_route53_record" "validation" {
      name = "example.net."
    }
    
    resource "aws_route53_record" "validation" {
      name = "${aws_acm_certificate.main.domain_validation_options[0].resource_record_name}"
      type = "${aws_acm_certificate.main.domain_validation_options[0].resource_record_type}"
      zone_id = "${data.aws_route53_zone.validation.zone_id}"
      records = ["${aws_acm_certificate.main.domain_validation_options[0].resource_record_value}"]
      ttl = 60
    }
    
    resource "aws_acm_certificate_validation" "main" {
      certificate_arn = "${aws_acm_certificate.main.arn}"
      validation_record_fqdns = ["${aws_route53_record.validation.*.fqdn}"]
    }

    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):

    variable "domain_names" { type = "list" }
    variable "zone_id" {}
    
    resource "aws_acm_certificate" "main" {
      domain_name = "${var.domain_names[0]}"
      subject_alternative_names = "${slice(var.domain_names, 1, length(var.domain_names))}"
      validation_method = "DNS"
      tags {
        Name = "${var.domain_names[0]}"
        terraform = "true"
      }
    }
    
    resource "aws_route53_record" "validation" {
      count = "${length(var.domain_names)}"
      name = "${lookup(aws_acm_certificate.main.domain_validation_options[count.index], "resource_record_name")}"
      type = "${lookup(aws_acm_certificate.main.domain_validation_options[count.index], "resource_record_type")}"
      zone_id = "${var.zone_id}"
      records = ["${lookup(aws_acm_certificate.main.domain_validation_options[count.index], "resource_record_value")}"]
      ttl = 60
    }
    
    resource "aws_acm_certificate_validation" "main" {
      certificate_arn = "${aws_acm_certificate.main.arn}"
      validation_record_fqdns = ["${aws_route53_record.validation.*.fqdn}"]
    }
    
    output "arn" {
      value = "${aws_acm_certificate.main.arn}"
    }

    Use this as a module like this:

    module "acm_ops" {
      source = "modules/aws_acm_certificate"
      domain_names = ["ops.acme.net", "*.ops.acme.net"]
      zone_id = "${aws_route53_zone.external.id}"
    }
    
    module "acm_marketing" {
      source = "modules/aws_acm_certificate"
      domain_names = ["acme.com", "*.acme.com"]
      zone_id = "${aws_route53_zone.acme.id}"
    }

    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.

    The full source code for this module is available on GitHub.

  • Bitwarden Review

    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 Keepass family 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.