Enabling IPv6 in AWS using CloudFormation

This post shows how to set up various VPC-related resources using CloudFormation to enable IPv6 for them.

Components

Diagram below shows all the components described in this post.

VPC

IPv6 requires one additional resource - AWS::EC2::VPCCidrBlock to request an /56 block of addresses from AWS.

rVPC:
    Type: "AWS::EC2::VPC"
    Properties:
      CidrBlock: "172.31.0.0/16"
      InstanceTenancy: "default"
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: "Name"
          Value: "Main VPC"
        - Key: "Account"
          Value:
            Ref: AWS::AccountId

  rVPCCidr:
    Type: "AWS::EC2::VPCCidrBlock"
    Properties : 
      AmazonProvidedIpv6CidrBlock: true
      VpcId:
        Ref: rVPC

We'll also need a standard AWS::EC2::InternetGateway and an IPv6-only AWS::EC2::EgressOnlyInternetGateway that acts in a similar way to a NAT Gateway and allows outbound traffic only.

 rIGW:
    Type: "AWS::EC2::InternetGateway"
    Properties:
        Tags:
          - Key: "Name"
            Value: "igw"
          - Key: "Account"
            Value:
              Ref: AWS::AccountId

rVPCGatewayAttachment:
    Type: "AWS::EC2::VPCGatewayAttachment"
    Properties:
      VpcId:
        Ref: rVPC
      InternetGatewayId:
        Ref: rIGW

 rEGW:
    Type: "AWS::EC2::EgressOnlyInternetGateway"
    Properties: 
        VpcId:
          Ref: rVPC

Public and Private subnets

Each of the subnets must be allocated a /64 prefix. In a typical setup with 3 AZs I generally use mappings to allocate them (please note that the example below uses the IPv6 documentation range of 2001:db8::/32 and not an actual AWS-allocated one, only one subnet in each AZ is shown).

Mappings:
    mSubnets:
        Subnetv4:
            PublicA: "172.31.1.0/24"
            PrivateA: "172.31.33.0/24"
        Subnetv6:                    
            PublicA: "2001:db8:dd5:a01::/64"
            PrivateA: "2001:db8:dd5:a11::/64"

IPv6 addressing is allocated using a AWS::EC2::SubnetCidrBlock resource.

   rSubnetPublicA:
        Type: "AWS::EC2::Subnet"
        Properties:
          VpcId:
            Ref: rVPC
          CidrBlock:
            Fn::FindInMap: [ mSubnets, "Subnetv4", "PublicA" ]
          Tags:
            - Key: "Name"
              Value:
                Fn::Sub: "PublicA"
            - Key: "Account"
              Value:
                Ref: AWS::AccountId
          AvailabilityZone:
            Fn::Sub: "${AWS::Region}a"

    rSubnetPublicAIPv6Cidr:
      Type: "AWS::EC2::SubnetCidrBlock"
      Properties:
        Ipv6CidrBlock:
          Fn::FindInMap: [ mSubnets, "Subnetv6", "PublicA" ]
        SubnetId:
          Ref: rSubnetPublicA

Route tables

Route tables for the public subnets must point their default route at the Internet Gateway, whilst in private subnets they must point at the Egress Only Internet Gateway.

   rRouteTablePublicA:
        Type: "AWS::EC2::RouteTable"
        Properties:
            VpcId:
                Ref: rVPC
            Tags:
                - Key: "Name"
                  Value: "PublicA"
                - Key: "Account"
                  Value:
                    Ref: AWS::AccountId

    rRoutePublicAv6:
        Type: "AWS::EC2::Route"
        Properties:
            DestinationIpv6CidrBlock: "::/0"
            RouteTableId:
                Ref: rRouteTablePublicA
            GatewayId:
                Ref: rIGW
    rRouteTablePrivateA:
        Type: "AWS::EC2::RouteTable"
        Properties:
            VpcId:
                Ref: rVPC
            Tags:
                - Key: "Name"
                  Value: "PrivateA"
                - Key: "Account"
                  Value:
                    Ref: AWS::AccountId

    rRouteTablePrivateA:
        Type: "AWS::EC2::RouteTable"
        Properties:
            VpcId:
                Ref: rVPC
            Tags:
                - Key: "Name"
                  Value: "PrivateA"
                - Key: "Account"
                  Value:
                    Ref: AWS::AccountId

    rRoutePrivateAv6:
        Type: "AWS::EC2::Route"
        Properties:
            DestinationIpv6CidrBlock: "::/0"
            RouteTableId:
                Ref: rRouteTablePrivateA
            EgressOnlyInternetGatewayId:
                Ref: rEGW

Security groups

Security groups can be used to handle both IPv4 and IPv6. There are two key things to remember here: source is identified as CidrIpv6 and that ICMP for IPv6 uses its own protocol value of icmpv6.

    rDefaultSshSG:
        Type: "AWS::EC2::SecurityGroup"
        Properties:
            GroupName: "DefaultSSH"
            GroupDescription: "SSH and ICMP from any"
            Tags:
                - Key: "Name"
                  Value: "DefaultSSH"
                - Key: "Account"
                  Value:
                    Ref: AWS::AccountId
            VpcId:
                Ref: rVPC
            SecurityGroupIngress:
                - IpProtocol: "tcp"
                  Description: "SSH In (v4)"
                  FromPort: 22
                  ToPort: 22
                  CidrIp: "0.0.0.0/0"
                - IpProtocol: "tcp"
                  Description: "SSH In (v6)"
                  FromPort: 22
                  ToPort: 22
                  CidrIpv6: "::/0"
                - IpProtocol: "icmp"
                  Description: "ICMP In (v4)"
                  FromPort: -1
                  ToPort: -1
                  CidrIpv6: "0.0.0.0/0"
                - IpProtocol: "icmpv6"
                  Description: "ICMP In (v6)"
                  CidrIpv6: "::/0"

EC2 instance

Changes required to an EC2 instance are fairly minimal. All that's necessary is either a Ipv6AddressCount to allow AWS to allocate the IPv6 address, or a Ipv6Addresses to specify a list of IPs to use manually. In the second case a new set of IPv6 addresses has to be used during any updates that require replacement of the EC2 instance.

rTestInstance:
        Type: "AWS::EC2::Instance"
        Properties:
		 Ipv6AddressCount: 1
		 [...]
    

Depending on the Linux distribution used some additional steps might be required, for more details please have a look here: AWS docs.

Integration with Route53

Since IPv6 addresses are not particularly easy to memorise, using DNS for resolution is important. Unfortunately that's where CloudFormation falls short. Unlike for IPv4 there's no attribute that can be extracted by Fn::GetAtt to give us the allocated IPv6 address. The only way is to create a λ function and a custom resource that will supply the information.

The function allows for invocations with two different parameters:

  • InstanceId
  • NetworkInterfaceId

NetworkInterfaceId should be used when the instance has more than one network interface, as the function always returns the first IPv6 address of the first matching interface.

const aws = require("aws-sdk");

exports.handler = function(event, context) {
    console.log("REQUEST RECEIVED:\n" + JSON.stringify(event));

    if (event.RequestType === "Delete") {
        sendResponse(event, context, "SUCCESS");
        return;
    }

    let responseStatus = "FAILED";
    let responseData = {};

    let ec2 = new aws.EC2({"region": event.ResourceProperties.Region});

    let describeNetworkInterfacesParams;

    if (event.ResourceProperties.NetworkInterfaceId) {
        describeNetworkInterfacesParams = {
            "Filters": [{
                "Name": "network-interface-id",
                "Values": [ event.ResourceProperties.NetworkInterfaceId ]
            }]
        };
    }
    if (event.ResourceProperties.InstanceId) {
        describeNetworkInterfacesParams = {
            "Filters": [{
                "Name": "attachment.instance-id",
                "Values": [ event.ResourceProperties.InstanceId ]
            }]
        };
    }

    ec2.describeNetworkInterfaces(describeNetworkInterfacesParams, function(err, result) {
        if (err) {
            responseData = {"Error": "describeNetworkInterfaces call failed"};
            console.log(responseData.Error + ":\n", err);
        } else {

            if (result.NetworkInterfaces.length && result.NetworkInterfaces[0].Ipv6Addresses.length) {
                responseData.Ipv6Address = result.NetworkInterfaces[0].Ipv6Addresses[0].Ipv6Address;
                responseStatus = "SUCCESS";
            } else {
                //no IPs found
                // console.log("network interface not found");
                responseData.Ipv6Address = "";
                responseStatus = "SUCCESS";
            }
        }
        sendResponse(event, context, responseStatus, responseData);
    });
};

function sendResponse(event, context, responseStatus, responseData) {

    let responseBody = JSON.stringify({
        "Status": responseStatus,
        "Reason": "See the details in CloudWatch Log Stream: " + context.logStreamName,
        "PhysicalResourceId": context.logStreamName,
        "StackId": event.StackId,
        "RequestId": event.RequestId,
        "LogicalResourceId": event.LogicalResourceId,
        "Data": responseData
    });

    console.log("RESPONSE BODY:\n", responseBody);

    let https = require("https");
    let url = require("url");

    let parsedUrl = url.parse(event.ResponseURL);
    let options = {
        "hostname": parsedUrl.hostname,
        "port": 443,
        "path": parsedUrl.path,
        "method": "PUT",
        "headers": {
            "content-type": "",
            "content-length": responseBody.length
        }
    };

    // console.log("SENDING RESPONSE...\n");

    let request = https.request(options, function() {
        // console.log("STATUS: " + response.statusCode);
        // console.log("HEADERS: " + JSON.stringify(response.headers));
        // Tell AWS Lambda that the function execution is done
        context.done();
    });

    request.on("error", function() {
        // console.log("sendResponse Error:" + error);
        // Tell AWS Lambda that the function execution is done
        context.done();
    });

    // write data to request body
    request.write(responseBody);
    request.end();
}

Function definition in CloudFormation (bucket name changed), including the minimal role definition required to run the function:

Resources:
    rLambdaGetNetIntIpv6Role:
      Type: AWS::IAM::Role
      Properties:
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Principal:
              Service:
              - lambda.amazonaws.com
            Action:
            - sts:AssumeRole
        Path: "/"
        Policies:
        - PolicyName: root
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
            - Effect: Allow
              Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
              Resource: arn:aws:logs:*:*:*
            - Effect: Allow
              Action:
              - ec2:DescribeNetworkInterfaces
              Resource: "*"

    rLambdaGetNetIntIpv6Function:
      Type: "AWS::Lambda::Function"
      Properties:
        Handler: "index.handler"
        Role:
          Fn::GetAtt:
            - rLambdaGetNetIntIpv6Role
            - Arn
        Code:
            S3Bucket: "bucket-name-goes-here"
            S3Key: "getNetIntIpv6.zip"
        Runtime: nodejs8.10
        Timeout: '10'

Outputs:
    rLambdaGetNetIntIpv6Arn:
        Value:
            Fn::GetAtt:
            - rLambdaGetNetIntIpv6Function
            - Arn
        Export:
            Name: "rLambdaGetNetIntIpv6Arn"

And finally - the custom resource and Route53 record set:

    rTestInstanceIpv6:
        Type: Custom::getNetIntIpv6
        Properties:
          ServiceToken:
            Fn::ImportValue: "rLambdaGetNetIntIpv6Arn"
          Region:
            Ref: AWS::Region
          InstanceId: 
            Ref: rTestInstance

    rRoute53ZoneEntryv6:
        Type: "AWS::Route53::RecordSet"
        Properties:
            HostedZoneName: "sample.domain.com."
            Name: "testinstance.sample.domain.com."
            Type: "AAAA"
            TTL: 3600
            ResourceRecords:
                - Fn::GetAtt: rTestInstanceIpv6.Ipv6Address

With all these components in place an EC2 instance is reachable over IPv6 (again IPv6 replaced  with one from the documentation range):

> ssh -l ec2-user 2001:db8:dd5:a01:b5ad:4e38:cdb1:9b3
The authenticity of host '2001:db8:dd5:a01:b5ad:4e38:cdb1:9b3 (2001:db8:dd5:a01:b5ad:4e38:cdb1:9b3)' can't be established.
ECDSA key fingerprint is SHA256:PH0jZGHOrlHrjBpAUKrUvJljBio8bp842SF5EP0WlVU.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '2001:db8:dd5:a01:b5ad:4e38:cdb1:9b3' (ECDSA) to the list of known hosts.

       __|  __|_  )
       _|  (     /   Amazon Linux 2 AMI
      ___|\___|___|

https://aws.amazon.com/amazon-linux-2/
10 package(s) needed for security, out of 16 available
Run "sudo yum update" to apply all updates.
[ec2-user@ip-172-31-1-33 ~]$