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 ~]$