Giter Club home page Giter Club logo

aws-lambda-ddns-function's Introduction

Building a Dynamic DNS for Route 53 using CloudWatch Events and Lambda

Introduction

Dynamic registration of resource records is useful when you have instances that are not behind a load balancer and that you would like to address by a host name and domain suffix of your choosing rather than the default <region>.compute.internal or ec2.internal assigned by VPC DNS.

In this project we explore how you can use CloudWatch Events and Lambda to create a Dynamic DNS for Route 53. Besides creating A records, this project allows you to create alias, i.e. CNAME records, for when you want to address a server by a "friendly" or alternate name. Although this is antithetical to treating instances as disposable resources, there are still a lot of shops that find this useful.

Using CloudWatch and Lambda to respond to infrastructure changes in real-time

With the advent of CloudWatch Events in January 2016, you can now get near real-time information when an AWS resource changes its state, including when instances are launched or terminated. When you combine this with the power of Amazon Route 53 and AWS Lambda, you can create a system that closely mimics the behavior of Dynamic DNS.

For example, when a newly-launched instance changes its state from pending to running, an event can be sent to a Lambda function that creates a resource record in the appropriate Route 53 hosted zone. Similarly, when instances are stopped or terminated, Lambda can automatically remove resource records from Route 53.

The example provided in this project works precisely this way. It uses information from a CloudWatch event to gather information about the instance, such as its public and private DNS name, its public and private IP address, the VPC ID of the VPC that the instance was launch in, its tags, and so on. It then uses this information to create A, PTR, and CNAME records in the appropriate Route 53 public or private hosted zone. The solution persists data about the instances in an Amazon DynamoDB table so it can remove resource records when instances are stopped or terminated.

Route 53 Hosted Zones

Route 53 offers the convenience of domain name services without having to build a globally distributed highly reliable DNS infrastructure. It allows instances within your VPC to resolve the names of resources that run within your AWS environment. It also lets clients on the Internet resolve names of your public-facing resources. This is accomplished by querying resource record sets that reside within a Route 53 public or private hosted zone.

A private hosted zone is basically a container that holds information about how you want to route traffic for a domain and its subdomains within one or more VPCs whereas a public hosted zone is a container that holds information about how you want to route traffic from the Internet.

Choosing between VPC DNS or Route 53 Private Hosted Zones

Admittedly, you can use VPC DNS for internal name resolution instead of Route 53 private hosted zones. Although it doesn’t dynamically create resource records, VPC DNS will provide name resolution for all the hosts within a VPC’s CIDR range.

Unless you create a DHCP option set with a custom domain name and disable hostnames at the VPC, you can’t change the domain suffix; all instances are either assigned the ec2.internal or <region>.compute.internal domain suffix. You can’t create aliases or other resource record types with VPC DNS either.

Private hosted zones help you overcome these challenges by allowing you to create different resource record types with a custom domain suffix. Moreover, with Route 53 you can create a subdomain for your current DNS namespace or you can migrate an existing subdomain to Route 53. By using these options, you can create a contiguous DNS namespace between your on-premises environment and AWS.

So, while VPC DNS can provide basic name resolution for your VPC, Route 53 private hosted zones offer richer functionality by comparison. It also has a programmable API that can be used to automate the creation/removal of records sets and hosted zones which we’re going leverage extensively in this project.

Route 53 doesn't offer support for dynamic registration of resource record sets for public or private hosted zones. This can pose challenges when an automatic scaling event occurs and the instances are not behind a load balancer. A common workaround is to use an automation framework like Chef, Puppet, Ansible, or Salt to create resource records, or by adding instance user data to the launch profile of the Auto Scaling group. The drawbacks to these approaches are that:

  1. automation frameworks typically require you to manage additional infrastructure.
  2. instance user data doesn't handle the removal of resource records when the instance is terminated.

This was the motivation for creating a serverless architecture that dynamically creates and removes resource records from Route 53 as EC2 instances are created and destroyed.

DDNS/Lambda example

Make sure that you have the latest version of the AWS CLI installed locally. For more information, see Getting Set Up with the AWS Command Line Interface.

For this example, create a new VPC configured with a private and public subnet, using Scenario 2: VPC with Public and Private Subnets (NAT) from the Amazon VPC User Guide. Ensure that the VPC has the DNS resolution and DNS hostnames options set to yes.

After the VPC is created, you can proceed to the next steps.

Step 1 – Create an IAM role for the Lambda function

In this step, you will use the AWS Command Line Interface (AWS CLI) to create the Identity and Access Management (IAM) role that the Lambda function assumes when the function is invoked. You also need to create an IAM policy with the required permissions and then attach this policy to the role.

  1. Download the ddns-policy.json and ddns-trust.json files from the AWS Labs GitHub repo.

ddns-policy.json

The policy includes ec2:Describe permission, required for the function to obtain the EC2 instance’s attributes, including the private IP address, public IP address, and DNS hostname. The policy also includes DynamoDB and Route 53 full access which the function uses to create the DynamoDB table and update the Route 53 DNS records. The policy also allows the function to create log groups and log events.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "ec2:Describe*",
    "Resource": "*"
  }, {
    "Effect": "Allow",
    "Action": [
      "dynamodb:*"
    ],
    "Resource": "*"
  }, {
    "Effect": "Allow",
    "Action": [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ],
    "Resource": "*"
  }, {
    "Effect": "Allow",
    "Action": [
      "route53:*"
    ],
    "Resource": [
      "*"
    ]
  },
  {
    "Effect": "Allow",
    "Action": [
        "SNS:Publish"
    ],
    "Resource": [
        { "Fn::Join": ["", [ "arn:aws:sns:",{"Ref":"AWS::Region"}, ":",{"Ref":"AWS::AccountId"},":DDNSAlerts"]]}
    ]
  }]
}

ddns-trust.json

The ddns-trust.json file contains the trust policy that grants the Lambda service permission to assume the role.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
  1. Create the policy using the policy document in the ddns-pol.json file. You need to replace <LOCAL PATH> with your local path to the ddns-pol.json file. The output of the aws iam create-policy command includes the Amazon Resource Locator (ARN). Save the ARN since you will need it for future steps.
aws iam create-policy --policy-name ddns-lambda-policy --policy-document file://<LOCAL PATH>/ddns-pol.json
  1. Create the ddns-lambda-role IAM role using the trust policy in the ddns-trust.json file. You need to replace <LOCAL PATH> with your local path to the ddns-trust.json file. The output of the aws iam create-role command includes the ARN associated with the role that you created. Save this ARN since you will need it when you create the Lambda function in the next section.
aws iam create-role --role-name ddns-lambda-role --assume-role-policy-document file://<LOCAL PATH>/ddns-trust.json
  1. Attach the policy to the role. Use the ARN returned in step 2 for the --policy-arn input parameter.
aws iam attach-role-policy --role-name ddns-lambda-role --policy-arn <enter-your-policy-arn-here>
Step 2 – Create the Lambda function

The Lambda function uses modules included in the Python 2.7 Standard Library and the AWS SDK for Python module (boto3), which is preinstalled as part of the Lambda service. As such, you do not need to create a deployment package for this function.

The code performs the following:

  • Checks to see whether the “DDNS” table exists in DynamoDB and creates the table if it does not. This table is used to keep a record of instances that have been created along with their attributes. It’s necessary to persist the instance attributes in a table because once an EC2 instance is terminated, its attributes are no longer available to be queried via the EC2 API. Instead, they must be fetched from the table.

  • Queries the event data to determine the instance's state. If the state is “running”, the function queries the EC2 API for the data it will need to update DNS. If the state is anything else, e.g. "stopped" or "terminated", it will retrieve the necessary information from the “DDNS” DynamoDB table.

  • Verifies that “DNS resolution” and “DNS hostnames” are enabled for the VPC, as these are required in order to use Route 53 for private name resolution. The function then checks whether a reverse lookup zone for the instance already exists. If it does, it checks to see whether the reverse lookup zone is associated with the instance's VPC. If it isn't, it creates the association. This association is necessary in order for the VPC to use Route 53 zone for private name resolution.

  • Checks the EC2 instance’s tags for the CNAME and ZONE tags. If the ZONE tag is found, the function creates A and PTR records in the specified zone. If the CNAME tag is found, the function creates a CNAME record in the specified zone.

  • Verifies whether there's a DHCP option set assigned to the VPC. If there is, it uses the value of the domain name to create resource records in the appropriate Route 53 private hosted zone. The function also checks to see whether there's an association between the instance's VPC and the private hosted zone. If there isn't, it creates it.

  • Deletes the required DNS resource records if the state of the EC2 instance changes to “shutting down” or “stopped”.

Use the AWS CLI to create the Lambda function:

  1. Download the union.py file from the AWS Labs GitHub repo.

  2. Create a ZIP archive union.zip for union.py

zip union.zip union.py
  1. Execute the following command to create the function. Note that you will need to update the command to use the ARN of the role that you created earlier, as well as the local path to the union.zip file containing the Python code for the Lambda function.
aws lambda create-function --function-name ddns_lambda --runtime python2.7 --role <enter-your-role-arn-here> --handler union.lambda_handler --timeout 90 --zip-file fileb://<LOCAL PATH>/union.zip
  1. The output of the command returns the ARN of the newly-created function. Save this ARN, since you will need it in the next section.
Step 3 – Create the CloudWatch Events Rule

In this step, you create the CloudWatch Events rule that triggers the Lambda function whenever CloudWatch detects a change to the state of an EC2 instance. You configure the rule to fire when any EC2 instance state changes to “running”, “shutting down”, or “stopped”. Use the aws events put-rule command to create the rule and set the Lambda function as the execution target:

aws events put-rule --event-pattern "{\"source\":[\"aws.ec2\"],\"detail-type\":[\"EC2 Instance State-change Notification\"],\"detail\":{\"state\":[\"running\",\"shutting-down\",\"stopped\"]}}" --state ENABLED --name ec2_lambda_ddns_rule

The output of the command returns the ARN to the newly created CloudWatch Events rule, named ec2_lambda_ddns_rule. Save the ARN, as you will need it to associate the rule with the Lambda function and to set the appropriate Lambda permissions.

Next, set the target of the rule to the Lambda function. Note that the --targets input parameter requires that you include a unique identifier for the Id target. You also need to update the command to use the ARN of the Lambda function that you created previously.

aws events put-targets --rule ec2_lambda_ddns_rule --targets Id=id123456789012,Arn=<enter-your-lambda-function-arn-here>

Next, you add the permissions required for the CloudWatch Events rule to execute the Lambda function. Note that you need to provide a unique value for the --statement-id input parameter. You also need to provide the ARN of the CloudWatch Events rule you created earlier.

aws lambda add-permission --function-name ddns_lambda --statement-id 45 --action lambda:InvokeFunction --principal events.amazonaws.com --source-arn <enter-your-cloudwatch-events-rule-arn-here>
Step 4 – Create the private hosted zone in Route 53

To create the private hosted zone in Route 53, follow the steps outlined in Creating a Private Hosted Zone.

Step 5 – Create a DHCP options set and associate it with the VPC

In this step, you create a new DHCP options set, and set the domain to be that of your private hosted zone.

  1. Follow the steps outlined in Creating a DHCP Options Set to create a new set of DHCP options.

  2. In the Create DHCP options set dialog box, give the new options set a name, set Domain name to the name of the private hosted zone that you created in Route 53, and set Domain name servers to “AmazonProvidedDNS”. Choose Yes, Create.

DHCP Option Set

  1. Next, follow the steps outlined in Changing the Set of DHCP Options a VPC Uses to update the VPC to use the newly-created DHCP options set.
Step 6 – Launching the EC2 instance and validating results

In this step, you launch an EC2 instance and verify that the function executed successfully.

As mentioned previously, the Lambda function looks for the ZONE or CNAME tags associated with the EC2 instance. If you specify these tags when you launch the instance, you have to include a trailing dot. In this example, the ZONE tag would be set to “ddnslambda.com**.” and the CNAME tag could be set to “test.ddnslambda.com.**”.

Because you updated the DHCP options set in this example, the Lambda function uses the specified zone when it creates the Route 53 DNS resource records. You can use the ZONE tag to override this behavior if you wanted the function to update a different hosted zone.

In this example, you launch an EC2 instance into the private subnet of the VPC. Because you updated the domain value of the DHCP options set to be that of the private hosted zone, the Lambda function creates the DNS resource records in the Route 53 zone file.

Launching the EC2 instance

  1. Follow the steps to launch an EC2 instance outlined in Launching an Instance.

  2. In Step 3: Configure Instance Details, for Network, select the VPC. For Subnet, select the private subnet. Choose Review and Launch.

  3. (Optional) If you would like to update a different private hosted zone than the one you associated with the VPC, specify the ZONE tag in this step. You can also specify the CNAME tag if you would like the function to create a CNAME resource record in the associated zone.

Choose Edit tags in the Step 7: Review Instance Launch.

Enter the key and value for Step 5: Tag Instance then choose Review and Launch.

Tag Instance

  1. Complete the launch of the instance and wait until the instance state changes to “running”. Then, continue to the next step.

Validating results

In this step, you verify that your Lambda function successfully updated the Rout 53 resource records.

  1. Log in to the Route 53 console.

  2. In the left navigation pane, choose Hosted Zones to view the list of private and public zones currently configured in Route 53.

  3. Select the hosted zone that you created in step 4, to view the zone file.

Hosted Zone

  1. Verify that the resource records were created.

Resource Records

  1. Now that you’ve verified that the Lambda function successfully updated the Route 53 resource records in the zone file, stop the EC2 instance and verify that the records are removed by the function.

  2. Log in to the EC2 console.

  3. Choose Instances in the left navigation pane.

Instances

  1. Select the EC2 instance you launched earlier and choose Stop.

Stop Instance

  1. Follow Steps 1 – 3 to view the DNS resource records in the Route 53 zone.

  2. Verify that the records have been removed from the zone file by the Lambda function.

Python3 Lambda

The python3 version of the lambda introduces some new features:

  • Has SNS notifications for errors. NOTE: The cloudformation template will setup an SNS topic named DDNSAlerts. Manually setup the subscriptions you want to this topic.
  • For CNAME tags, an CNAME record is created to to the dns address, and a PTR record it created to point to the CNAME
  • Tests have been added to test the lambda, and any changes. Run 'tox' command in bash shell to run tests and view coverage report in htmlcov directory

Conclusion

Now that you’ve seen how you can combine various AWS services to automate the creation and removal of Route 53 resource records, we hope it inspires you to create your own solutions.  CloudWatch Events is a powerful tool because it allows you to respond to events in real-time, such as when an instance changes state.  When used with Lambda, you can create highly scalable serverless infrastructures that react instantly to infrastructure changes.  

To learn more about CloudWatch Events, see Using CloudWatch Events in the Amazon CloudWatch Developer Guide.  To learn more about Lambda and serverless infrastructures, see the AWS Lambda Developer Guide and the “Microservices without the Servers” blog post.  

We’ve open-sourced the code in this example in the AWS Labs GitHub repo and can’t wait to see your feedback and read your ideas about how to improve the solution.

My colleague Sean Greathouse developed an alternative approach to creating a Dynamic DNS System using API Gateway and Lambda. Since it runs on the endpoint it's perfect for registering machines outside of the AWS ecosystem. For additional information, see Building a Serverless Dynamic DNS System with AWS

aws-lambda-ddns-function's People

Contributors

alxsey avatar captain-sysadmin avatar dwilkie avatar fireballdwf avatar hyandell avatar jicomusic avatar jicowan avatar lngarrett avatar yyolk avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

aws-lambda-ddns-function's Issues

Support Python 3.9

With Python 3.9 I get this error when the lambda must remove a record from the hosted zone (when stopping a EC2 instance) :

[ERROR] JSONDecodeError: Expecting value: line 1 column 2402 (char 2401)
Traceback (most recent call last):
  File "/var/task/union_python3.py", line 167, in lambda_handler
    instance = get_item_from_dynamodb_table(dynamodb_client, 'DDNS', instance_id)
  File "/var/task/union_python3.py", line 1069, in get_item_from_dynamodb_table
    return json.loads(item)
  File "/var/lang/lib/python3.9/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
  File "/var/lang/lib/python3.9/json/decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "/var/lang/lib/python3.9/json/decoder.py", line 355, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None

I don't face any error with Python 3.6 runtime.
Since Python 3.6 will be deprecated in AWS on 18/07/2022, could you please bring the support of Python 3.9 ?

Thanks

60 second wait

What is the purpose of this wait? How dangerous might it be to reduce that wait to says 30 seconds or even less?

if state == 'running':
time.sleep(60)

Thanks !

get_zone_id() doesn't handle split horizon DNS properly

The get_zone_id(zone_name) function does not expect route53.list_hosted_zones() to return multiple zones. It always returns the first zoneid in the list. A split-horizon deployment will have both a public and private zone with the same zone_name. This causes one of the zones to not receive the proper update from the lambda function.

python3 support

Hi,
unfortunately the lambda function isn't compatible with Pyton3 which we should migrate to.

Best

Wrong CNAME/A records created with private domain name

I've created a VPC with an associated private DNS zone, us-east-2.mydomain.com and created all of the necessary roles, and scripts which create associated records when a new machine is created in the VPC. I have also associated a DHCP option set with domain-name = us-east-2.mydomain.com and domain-name-servers = AmazonProvidedDNS

I've noticed that it creates the A records like this:

ip-10-0-0.100.us-east-2.mydomain.com A 10.0.0.100

which seems reasonable, but the CNAME entry beneath it is:

myserver.us-east-2.mydomain.com CNAME ip-10-0-0-100.us-east-2.compute.internal

Which kinda goes against most of my instincts as a DNS admin, to resolve that CNAME, you'll have to make extra queries, instead of being able to request it directly from route53.

Is this by design, is there some other option I can modify to have the CNAME point to the A record created by your script?

Auto IP change in route53 DNS private zone for A records

I am using KOP and installed kubernetes in VPC with private subnet and private DNS. I am using AWS networking CNI for kubernetes , which is providing IP address to POD directly from subnet range. From VPC ec2 instance , I can ping POD with direct IP .

For now I am just creating DNS A record in private zone manually for each POD and if POD deleted or recreated it got new IP address . In that case I need to manually change the A record to new IP address.

Is there way to autoupdate A record incase IP has changed ? ( Here I am not talking about ec2 instance . It just IP address which has a A record in private zone )

DynamoDB records are not removed

I noticed that when an instance is shutdown or terminated that the script doesn't remove the record from DynamoDB after it has removed the records from route 53 and the table slowly keeps growing with instance ids that are no longer used.

Is there a particular reason that the records are not removed? Or is this an oversight?

Python Code error

I have followed your steps found a couple of problems.

1.) while running this command aws lambda create-function --function-name ddns_lambda --runtime python2.7 --role <enter-your-role-arn-here> --handler union.lambda_handler --timeout 30 --zip-file fileb://<LOCAL PATH>/union.py.zip there is no union.py.zip file
2.) python code error, Please reffer screenshort

2019-07-10 12_33_09-Window

reverse record in new zone for each EC2

i have this working but via a terraform build instead of CFN + CLI calls. i didn't change anything.

i have an ASG with CNAME and ZONE tags. they map to a pre-existing zone.

when an ec2 instance is made i get an A record in that zone. looks like this ex:
ip-10-68-1-163.int.systems. A 10.68.1.163

I also get a new reverse lookup hosted zone/domain. looks like this ex:

1.68.10.in-addr.arpa.
163.1.68.10.in-addr.arpa. | PTR | ip-10-68-1-163.us-west-2.compute.internal

when i delete the EC2 the A record and the PTR record are removed. But the rev lookup domain persists.

I'm wondering if the intention was for a rev lookup zone to already exist when using CNAME/ZONE tags? if not, why retain the domain if there are no more records in it? maybe i'm missing the point?

(also, great work on this, super userful!)

File "/var/task/union.py", line 61, in lambda_handler

I've tried doing this both via the instructions, as well as via CloudFormation. Both ways result in the following error:

'Item': KeyError
Traceback (most recent call last):
File "/var/task/union.py", line 61, in lambda_handler
instance = instance['Item']['InstanceAttributes']
KeyError: 'Item'

Not all CNAME records created when instances are launched from auto-scaling group within CloudFormation template

I wanted to thank you so much for publishing the whitepaper on dynamic DNS for Route 53 using Lambda and DynamoDB.

It works great!

I did come across one little issue, it just doesn't create CNAME records for when instances are launched from an auto-scaling group within a CloudFormation template. It does create CNAME records for when I launch the instance(s) manually.

UPDATE: I made sure to clear the DynamoDB table before launching the same CloudFormation template and for some strange reason it created 2 CNAME records but it didn't create all the CNAME records for the other instances that were launched. Maybe Lambda is not able to catch all of the instances launching at the same time. I am using the same tag you mentioned in the documentation.

David Partow
AWS Certified Solutions Architect

Have to increase the Lambda function timeout to avoid KeyError: 'Item at line 61

Hi,

Even 90sec timeout for Lambda function is still causing the issue at below lines -

'Item': KeyError
Traceback (most recent call last):
File "/var/task/DDNS.py", line 64, in lambda_handler
instance = instance['Item']['InstanceAttributes']
KeyError: 'Item'

I made the timeout to max (5min) and then the function worked. So, looks like the timeout in CF templates should be increased to more than 2min.

Getting KeyError when Lambda Functions executes

We are seeing this error when launching new EC2 instances and the private hosted zone is not updated.
'Item': KeyError Traceback (most recent call last): File "/var/task/union.py", line 61, in lambda_handler instance = instance['Item']['InstanceAttributes'] KeyError: 'Item'
Any help is appreciated.

Scalr (opensource version) adds tag too late (I think)

I am testing Scalr for launching instances. I have tested it having Scalr add the tag for CNAME. However, I think the tag is getting added too late for the lambda function to pick it up properly.

This is the log:

START RequestId: 58a34117-40ae-11e6-929c-23df89031780 Version: $LATEST 
DynamoDB table already exists 
The reverse lookup zone for this instance is: 0.10.10.in-addr.arpa. 
DNS hostnames enabled for vpc-eea5d48a 
DNS support enabled for vpc-eea5d48a 
Reverse lookup zone found: 0.10.10.in-addr.arpa. 
Reverse lookup zone ZTRE2RXR9RUPC is associated with VPC vpc-eea5d48a 
Private zone found nxt.panosoft.com. 
Private hosted zone ZYLLXXMYRQLX9 is associated with VPC vpc-eea5d48a 
Updating A record ip-10-10-0-95 in zone nxt.panosoft.com.  
Updating PTR record 95.0.10.10. in zone in-addr.arpa  
No matching zone for AmazonProvidedDNS. 
END RequestId: 58a34117-40ae-11e6-929c-23df89031780

Tried to modify the Split horizon version now getting error.

The error I am receiving is:

local variable 'public_ip' referenced before assignment

I modified the 4 lines in the CNAME section so that it would create A records instead of CNAME records. Below is my current code.

import json
import boto3
import re
import uuid
import time
import random
from datetime import datetime

print('Loading function ' + datetime.now().time().isoformat())
route53 = boto3.client('route53')
ec2 = boto3.resource('ec2')
compute = boto3.client('ec2')
dynamodb_client = boto3.client('dynamodb')
dynamodb_resource = boto3.resource('dynamodb')

def lambda_handler(event, context):
    """ Check to see whether a DynamoDB table already exists.  If not, create it.  This table is used to keep a record of
    instances that have been created along with their attributes.  This is necessary because when you terminate an instance
    its attributes are no longer available, so they have to be fetched from the table."""
    tables = dynamodb_client.list_tables()
    if 'DDNS' in tables['TableNames']:
        print 'DynamoDB table already exists'
    else:
        dynamodb_client.create_table(
            TableName='DDNS',
            AttributeDefinitions=[
                {
                    'AttributeName': 'InstanceId',
                    'AttributeType': 'S'
                },
            ],
            KeySchema=[
                {
                    'AttributeName': 'InstanceId',
                    'KeyType': 'HASH'
                },
            ],
            ProvisionedThroughput={
                'ReadCapacityUnits': 4,
                'WriteCapacityUnits': 4
            }
        )

    # Set variables
    # Get the state from the Event stream
    state = event['detail']['state']

    # Get the instance id, region, and tag collection
    instance_id = event['detail']['instance-id']
    region = event['region']
    table = dynamodb_resource.Table('DDNS')
    
    time.sleep (80)

    if state == 'running':
        instance = compute.describe_instances(InstanceIds=[instance_id])
        # Remove response metadata from the response
        instance.pop('ResponseMetadata')
        # Remove null values from the response.  You cannot save a dict/JSON document in DynamoDB if it contains null
        # values
        instance = remove_empty_from_dict(instance)
        instance_dump = json.dumps(instance,default=json_serial)
        instance_attributes = json.loads(instance_dump)
        table.put_item(
            Item={
                'InstanceId': instance_id,
                'InstanceAttributes': instance_attributes
            }
        )
    else:
        # Fetch item from DynamoDB
        instance = table.get_item(
        Key={
            'InstanceId': instance_id
        },
        AttributesToGet=[
            'InstanceAttributes'
            ]
        )
        instance = instance['Item']['InstanceAttributes']

    try:
        tags = instance['Reservations'][0]['Instances'][0]['Tags']
    except:
        tags = []
    # Get instance attributes
    private_ip = instance['Reservations'][0]['Instances'][0]['PrivateIpAddress']
    private_dns_name = instance['Reservations'][0]['Instances'][0]['PrivateDnsName']
    private_host_name = private_dns_name.split('.')[0]
    try:
        public_ip = instance['Reservations'][0]['Instances'][0]['PublicIpAddress']
        public_dns_name = instance['Reservations'][0]['Instances'][0]['PublicDnsName']
        public_host_name = public_dns_name.split('.')[0]
    except BaseException as e:
        print 'Instance has no public IP or host name', e

    # Get the subnet mask of the instance
    subnet_id = instance['Reservations'][0]['Instances'][0]['SubnetId']
    subnet = ec2.Subnet(subnet_id)
    cidr_block = subnet.cidr_block
    subnet_mask = int(cidr_block.split('/')[-1])

    reversed_ip_address = reverse_list(private_ip)
    reversed_domain_prefix = get_reversed_domain_prefix(subnet_mask, private_ip)
    reversed_domain_prefix = reverse_list(reversed_domain_prefix)

    # Set the reverse lookup zone
    reversed_lookup_zone = reversed_domain_prefix + 'in-addr.arpa.'
    print 'The reverse lookup zone for this instance is:', reversed_lookup_zone

    # Get VPC id
    vpc_id = instance['Reservations'][0]['Instances'][0]['VpcId']
    vpc = ec2.Vpc(vpc_id)

    # Are DNS Hostnames and DNS Support enabled?
    if is_dns_hostnames_enabled(vpc):
        print 'DNS hostnames enabled for %s' % vpc_id
    else:
        print 'DNS hostnames disabled for %s.  You have to enable DNS hostnames to use Route 53 private hosted zones.' % vpc_id
    if is_dns_support_enabled(vpc):
        print 'DNS support enabled for %s' % vpc_id
    else:
        print 'DNS support disabled for %s.  You have to enabled DNS support to use Route 53 private hosted zones.' % vpc_id

    # Create the public and private hosted zone collections.  These are collections of zones in Route 53.
    hosted_zones = route53.list_hosted_zones()
    private_hosted_zones = filter(lambda x: x['Config']['PrivateZone'] is True, hosted_zones['HostedZones'])
    private_hosted_zone_collection = map(lambda x: x['Name'], private_hosted_zones)
    public_hosted_zones = filter(lambda x: x['Config']['PrivateZone'] is False, hosted_zones['HostedZones'])
    public_hosted_zones_collection = map(lambda x: x['Name'], public_hosted_zones)
    # Check to see whether a reverse lookup zone for the instance already exists.  If it does, check to see whether
    # the reverse lookup zone is associated with the instance's VPC.  If it isn't create the association.  You don't
    # need to do this when you create the reverse lookup zone because the association is done automatically.
    if filter(lambda record: record['Name'] == reversed_lookup_zone, hosted_zones['HostedZones']):
        print 'Reverse lookup zone found:', reversed_lookup_zone
        reverse_lookup_zone_id = get_zone_id(reversed_lookup_zone, 'Private')
        reverse_hosted_zone_properties = get_hosted_zone_properties(reverse_lookup_zone_id)
        if vpc_id in map(lambda x: x['VPCId'], reverse_hosted_zone_properties['VPCs']):
            print 'Reverse lookup zone %s is associated with VPC %s' % (reverse_lookup_zone_id, vpc_id)
        else:
            print 'Associating zone %s with VPC %s' % (reverse_lookup_zone_id, vpc_id)
            try:
                associate_zone(reverse_lookup_zone_id, region, vpc_id)
            except BaseException as e:
                print e
    else:
        print 'No matching reverse lookup zone'
        # create private hosted zone for reverse lookups
        if state == 'running':
            create_reverse_lookup_zone(instance, reversed_domain_prefix, region)
            reverse_lookup_zone_id = get_zone_id(reversed_lookup_zone, 'Private')
    # Wait a random amount of time.  This is a poor-mans back-off if a lot of instances are launched all at once.
    time.sleep(random.random())

    # Loop through the instance's tags, looking for the zone and cname tags.  If either of these tags exist, check
    # to make sure that the name is valid.  If it is and if there's a matching zone in DNS, create A and PTR records.
    for tag in tags:
        if 'ZONE' in tag.get('Key',{}).lstrip().upper():
            if is_valid_hostname(tag.get('Value')):
                if tag.get('Value').lstrip().lower() in private_hosted_zone_collection:
                    print 'Private zone found:', tag.get('Value')
                    private_hosted_zone_name = tag.get('Value').lstrip().lower()
                    private_hosted_zone_id = get_zone_id(private_hosted_zone_name, 'Private')
                    private_hosted_zone_properties = get_hosted_zone_properties(private_hosted_zone_id)
                    if state == 'running':
                        if vpc_id in map(lambda x: x['VPCId'], private_hosted_zone_properties['VPCs']):
                            print 'Private hosted zone %s is associated with VPC %s' % (private_hosted_zone_id, vpc_id)
                        else:
                            print 'Associating zone %s with VPC %s' % (private_hosted_zone_id, vpc_id)
                            try:
                                associate_zone(private_hosted_zone_id, region, vpc_id)
                            except BaseException as e:
                                print 'You cannot create an association with a VPC with an overlapping subdomain.\n', e
                                exit()
                        try:
                            create_resource_record(private_hosted_zone_id, private_host_name, private_hosted_zone_name, 'A', private_ip)
                            create_resource_record(reverse_lookup_zone_id, reversed_ip_address, 'in-addr.arpa', 'PTR', private_dns_name)
                        except BaseException as e:
                            print e
                    else:
                        try:
                            delete_resource_record(private_hosted_zone_id, private_host_name, private_hosted_zone_name, 'A', private_ip)
                            delete_resource_record(reverse_lookup_zone_id, reversed_ip_address, 'in-addr.arpa', 'PTR', private_dns_name)
                        except BaseException as e:
                            print e
                    # create PTR record
                # Changed from elif to if
                if tag.get('Value').lstrip().lower() in public_hosted_zones_collection:
                    print 'Public zone found', tag.get('Value')
                    public_hosted_zone_name = tag.get('Value').lstrip().lower()
                    public_hosted_zone_id = get_zone_id(public_hosted_zone_name, 'Public')
                    # create A record in public zone
                    if state =='running':
                        try:
                            create_resource_record(public_hosted_zone_id, public_host_name, public_hosted_zone_name, 'A', public_ip)
                        except BaseException as e:
                            print e
                    else:
                        try:
                            delete_resource_record(public_hosted_zone_id, public_host_name, public_hosted_zone_name, 'A', public_ip)
                        except BaseException as e:
                            print e
                #else:
                #    print 'No matching zone found for %s' % tag.get('Value')
            else:
                print '%s is not a valid host name' % tag.get('Value')
        # Consider making this an elif CNAME
        else:
            print 'The tag \'%s\' is not a zone tag' % tag.get('Key')
        if 'CNAME'in tag.get('Key',{}).lstrip().upper():
            if is_valid_hostname(tag.get('Value')):
                cname = tag.get('Value').lstrip().lower()
                cname_host_name = cname.split('.')[0]
                cname_domain_suffix = cname[cname.find('.')+1:]
                cname_domain_suffix_id = get_zone_id(cname_domain_suffix, 'Private')
                for cname_private_hosted_zone in private_hosted_zone_collection:
                    cname_private_hosted_zone_id = get_zone_id(cname_private_hosted_zone, 'Private')
                    if cname_domain_suffix_id == cname_private_hosted_zone_id:
                        if cname.endswith(cname_private_hosted_zone):
                            #create CNAME record in private zone
                            if state == 'running':
                                try:
                                    create_resource_record(cname_private_hosted_zone_id, cname_host_name, cname_private_hosted_zone, 'A', private_ip)
                                except BaseException as e:
                                    print e
                            else:
                                try:
                                    delete_resource_record(cname_private_hosted_zone_id, cname_host_name, cname_private_hosted_zone, 'A', private_ip)
                                except BaseException as e:
                                    print e
                for cname_public_hosted_zone in public_hosted_zones_collection:
                    if cname.endswith(cname_public_hosted_zone):
                        cname_public_hosted_zone_id = get_zone_id(cname_public_hosted_zone, 'Public')
                        #create CNAME record in public zone
                        if state == 'running':
                            try:
                                create_resource_record(cname_public_hosted_zone_id, cname_host_name, cname_public_hosted_zone, 'A', public_ip)
                            except BaseException as e:
                                print e
                        else:
                            try:
                                delete_resource_record(cname_public_hosted_zone_id, cname_host_name, cname_public_hosted_zone, 'A', public_ip)
                            except BaseException as e:
                                print e
    # Is there a DHCP option set?
    # Get DHCP option set configuration
    try:
        dhcp_options_id = vpc.dhcp_options_id
        dhcp_configurations = get_dhcp_configurations(dhcp_options_id)
    except BaseException as e:
        print 'No DHCP option set assigned to this VPC\n', e
        exit()
    # Look to see whether there's a DHCP option set assigned to the VPC.  If there is, use the value of the domain name
    # to create resource records in the appropriate Route 53 private hosted zone. This will also check to see whether
    # there's an association between the instance's VPC and the private hosted zone.  If there isn't, it will create it.
    for configuration in dhcp_configurations:
        if configuration[0] in private_hosted_zone_collection:
            private_hosted_zone_name = configuration[0]
            print 'Private zone found %s' % private_hosted_zone_name
            # TODO need a way to prevent overlapping subdomains
            private_hosted_zone_id = get_zone_id(private_hosted_zone_name, 'Private')
            private_hosted_zone_properties = get_hosted_zone_properties(private_hosted_zone_id)
            # create A records and PTR records
            if state == 'running':
                if vpc_id in map(lambda x: x['VPCId'], private_hosted_zone_properties['VPCs']):
                    print 'Private hosted zone %s is associated with VPC %s' % (private_hosted_zone_id, vpc_id)
                else:
                    print 'Associating zone %s with VPC %s' % (private_hosted_zone_id, vpc_id)
                    try:
                        associate_zone(private_hosted_zone_id, region,vpc_id)
                    except BaseException as e:
                        print 'You cannot create an association with a VPC with an overlapping subdomain.\n', e
                        exit()
                try:
                    create_resource_record(private_hosted_zone_id, private_host_name, private_hosted_zone_name, 'A', private_ip)
                    create_resource_record(reverse_lookup_zone_id, reversed_ip_address, 'in-addr.arpa', 'PTR', private_dns_name)
                except BaseException as e:
                    print e
            else:
                try:
                    delete_resource_record(private_hosted_zone_id, private_host_name, private_hosted_zone_name, 'A', private_ip)
                    delete_resource_record(reverse_lookup_zone_id, reversed_ip_address, 'in-addr.arpa', 'PTR', private_dns_name)
                except BaseException as e:
                    print e
        else:
            print 'No matching zone for %s' % configuration[0]

def create_resource_record(zone_id, host_name, hosted_zone_name, type, value):
    """This function creates resource records in the hosted zone passed by the calling function."""
    print 'Updating %s record %s in zone %s ' % (type, host_name, hosted_zone_name)
    if host_name[-1] != '.':
        host_name = host_name + '.'
    route53.change_resource_record_sets(
                HostedZoneId=zone_id,
                ChangeBatch={
                    "Comment": "Updated by Lambda DDNS",
                    "Changes": [
                        {
                            "Action": "UPSERT",
                            "ResourceRecordSet": {
                                "Name": host_name + hosted_zone_name,
                                "Type": type,
                                "TTL": 60,
                                "ResourceRecords": [
                                    {
                                        "Value": value
                                    },
                                ]
                            }
                        },
                    ]
                }
            )

def delete_resource_record(zone_id, host_name, hosted_zone_name, type, value):
    """This function deletes resource records from the hosted zone passed by the calling function."""
    print 'Deleting %s record %s in zone %s' % (type, host_name, hosted_zone_name)
    if host_name[-1] != '.':
        host_name = host_name + '.'
    route53.change_resource_record_sets(
                HostedZoneId=zone_id,
                ChangeBatch={
                    "Comment": "Updated by Lambda DDNS",
                    "Changes": [
                        {
                            "Action": "DELETE",
                            "ResourceRecordSet": {
                                "Name": host_name + hosted_zone_name,
                                "Type": type,
                                "TTL": 60,
                                "ResourceRecords": [
                                    {
                                        "Value": value
                                    },
                                ]
                            }
                        },
                    ]
                }
            )
def get_zone_id(zone_name, zone_type):
    """This function returns the zone id for the zone name that's passed into the function."""
    #TODO determine which zone ID to return based on the calling function
    if zone_name[-1] != '.':
        zone_name = zone_name + '.'
    hosted_zones = route53.list_hosted_zones()
    if zone_type == 'Private':
        x = filter(lambda record: record['Name'] == zone_name and record['Config']['PrivateZone'] == True, hosted_zones['HostedZones'])
    if zone_type == 'Public':
        x = filter(lambda record: record['Name'] == zone_name and record['Config']['PrivateZone'] == False, hosted_zones['HostedZones'])
    try:
        zone_id_long = x[0]['Id']
        zone_id = str.split(str(zone_id_long),'/')[2]
        return zone_id
    except:
        return None

def is_valid_hostname(hostname):
    """This function checks to see whether the hostname entered into the zone and cname tags is a valid hostname."""
    if hostname is None or len(hostname) > 255:
        return False
    if hostname[-1] == ".":
        hostname = hostname[:-1]
    allowed = re.compile("(?!-)[A-Z\d-]{1,63}(?<!-)$", re.IGNORECASE)
    return all(allowed.match(x) for x in hostname.split("."))

def get_dhcp_configurations(dhcp_options_id):
    """This function returns the names of the zones/domains that are in the option set."""
    zone_names = []
    dhcp_options = ec2.DhcpOptions(dhcp_options_id)
    dhcp_configurations = dhcp_options.dhcp_configurations
    for configuration in dhcp_configurations:
        zone_names.append(map(lambda x: x['Value'] + '.', configuration['Values']))
    return zone_names

def reverse_list(list):
    """Reverses the order of the instance's IP address and helps construct the reverse lookup zone name."""
    if (re.search('\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}',list)) or (re.search('\d{1,3}.\d{1,3}.\d{1,3}\.',list)) or (re.search('\d{1,3}.\d{1,3}\.',list)) or (re.search('\d{1,3}\.',list)):
        list = str.split(str(list),'.')
        list = filter(None, list)
        list.reverse()
        reversed_list = ''
        for item in list:
            reversed_list = reversed_list + item + '.'
        return reversed_list
    else:
        print 'Not a valid ip'
        exit()

def get_reversed_domain_prefix(subnet_mask, private_ip):
    """Uses the mask to get the zone prefix for the reverse lookup zone"""
    if 32 >= subnet_mask >= 24:
        third_octet = re.search('\d{1,3}.\d{1,3}.\d{1,3}.',private_ip)
        return third_octet.group(0)
    elif 24 > subnet_mask >= 16:
        second_octet = re.search('\d{1,3}.\d{1,3}.', private_ip)
        return second_octet.group(0)
    else:
        first_octet = re.search('\d{1,3}.', private_ip)
        return first_octet.group(0)

def create_reverse_lookup_zone(instance, reversed_domain_prefix, region):
    """Creates the reverse lookup zone."""
    print 'Creating reverse lookup zone %s' % reversed_domain_prefix + 'in.addr.arpa.'
    route53.create_hosted_zone(
        Name = reversed_domain_prefix + 'in-addr.arpa.',
        VPC = {
            'VPCRegion':region,
            'VPCId': instance['Reservations'][0]['Instances'][0]['VpcId']
        },
        CallerReference=str(uuid.uuid1()),
        HostedZoneConfig={
            'Comment': 'Updated by Lambda DDNS',
        },
    )

def json_serial(obj):
    """JSON serializer for objects not serializable by default json code"""
    if isinstance(obj, datetime):
        serial = obj.isoformat()
        return serial
    raise TypeError ("Type not serializable")

def remove_empty_from_dict(d):
    """Removes empty keys from dictionary"""
    if type(d) is dict:
        return dict((k, remove_empty_from_dict(v)) for k, v in d.iteritems() if v and remove_empty_from_dict(v))
    elif type(d) is list:
        return [remove_empty_from_dict(v) for v in d if v and remove_empty_from_dict(v)]
    else:
        return d

def associate_zone(hosted_zone_id, region, vpc_id):
    """Associates private hosted zone with VPC"""
    route53.associate_vpc_with_hosted_zone(
        HostedZoneId=hosted_zone_id,
        VPC={
            'VPCRegion': region,
            'VPCId': vpc_id
        },
        Comment='Updated by Lambda DDNS'
    )

def is_dns_hostnames_enabled(vpc):
    dns_hostnames_enabled = vpc.describe_attribute(
    DryRun=False,
    Attribute='enableDnsHostnames'
)
    return dns_hostnames_enabled['EnableDnsHostnames']['Value']

def is_dns_support_enabled(vpc):
    dns_support_enabled = vpc.describe_attribute(
    DryRun=False,
    Attribute='enableDnsSupport'
)
    return dns_support_enabled['EnableDnsSupport']['Value']

def get_hosted_zone_properties(zone_id):
    hosted_zone_properties = route53.get_hosted_zone(Id=zone_id)
    hosted_zone_properties.pop('ResponseMetadata')
    return hosted_zone_properties

Unable to import module 'union': No module named union

Hello,

I've just tried to set this up on my company account however after following all of steps my Lambda function errors:
Unable to import module 'union': No module named union

Is that my problem or code problem? Not sure is that correct place to raise it - can raise it with support as got Production service plan.

Thanks
Dariusz

Working but showing an error

The Route53 entries are being created and removed successful, but there are errors in the logs. This happens multiple times per invocation.

'Item': KeyError Traceback (most recent call last): File "/var/task/lambda_function.py", line 80, in lambda_handler instance = instance['Item']['InstanceAttributes'] KeyError: 'Item'

'Item': KeyError
Traceback (most recent call last):
File "/var/task/lambda_function.py", line 80, in lambda_handler
instance = instance['Item']['InstanceAttributes']
KeyError: 'Item'

Public and Private Route 53 with same name.

We have 2 Route 53 domains (public and private) that share the same TLD name. The lambda function works, but always updates the public zone only. I only want to update the private zone. I was curious if this was on a feature roadmap before I considered any refactor or code removal for my use case. Thanks!

60s Sleep

Hi, i'm just playing with your example and i found that in handler_union function, if state is running there is a sleep of 60 seconds. Is just a mistake or there is a good reason?
In your guide I read to set the lambda function timeout to 30 seconds, so this code will always timing out.

Thanks

Andrea

union.py.zip does not appear to be an actual zip file

I kept getting

--zip-file must be a zip file with the fileb:// prefix.
Example usage:  --zip-file fileb://path/to/file.zip

So I attempted to unzip the file and it does not unzip properly.

I tried creating the lambda function manually on the AWS console and got this error:

Could not unzip uploaded file. Please check your file, then try to upload again.

A record instead of CName??

Is it possible to have the script create an A record instead of a CName record

For instance, instead of two records one would get created. For example:
servername.domain.com A 100.100.100.100

This is because I am routing traffic between two VPC across regions. And AWS cannot resolve CNames across regions.

IPv6 support

No idea if this project is still under active development, but if so, could we add support for IPv6 addressing as well?

Auto Scaling Group EC2 Instances fail to return tags

When an ASG creates instances it propagates the tags at launch. Unlike a manually created instance, these tags are not written to the instance until after the event fires. This introduces a race condition where when the Lambda function pulls tags for the instance none are available.

I believe a 60 second sleep would resolve this.

Guarantees of using EventBridge and alternatives

Hello! I'm grateful for this example but did want to ask a few clarifying questions:

1/ How reliable is this approach? The documentation on EventBridge says:

Events are produced on a best-effort basis

Bad DNS records could result in clients being routed to dead instances :/ Would a good workaround be to schedule a cron job or something to periodically check the validity of all the values a record points to?

2/ Are lifecycle hooks a more appropriate solution? Maybe lifecycle hooks -> SNS/SQS -> Lambda? This documentation at least doesn't use phrases such as "best effort" 😅 (no shade, I promise -- just trying to understand guarantees). So assuming it is more reliable, my main concern would be whether or not something could happen in between autoscaling:EC2_INSTANCE_LAUNCHING and an actual successfully running instance? I can't glean too much from the state diagram unfortunately so wanted to ask -- especially in regards to the above concern about potentially adding a DNS record for an instance that almost but didn't launch successfully.

🙏

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.