• 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'