Connecting an ASUS/Merlin router to AWS Route 53 for DDNS – Part 2
In a previous post I described the DDNS solution I wanted to make and then focused on the script lives on my router and how it communicates with AWS. In this post I will describe the AWS API Gateway and the AWS Lambda function that does the work.
At a high level, the following diagram shows the API Gateway receives the REST request and forwards it to Lambda. The Lambda function checks Route 53 to make sure there is an actual change (sometimes there is not a change). If there is a change, then the changed IP replaces the old IP in Route 53, a metric is stored in CloudWatch and a text is sent to my phone via SNS.
Before I get into details I have to provide a disclaimer. REST methods and API Gateway are not my strong points. I had to really play with this setup in order to make it work. There are likely better ways to do this.
From the previous post, my router sends the notification of the DDNS as a HTTP GET message to the AWS API Gateway. The only data passed with the GET message is the current (possibly new) IP address and it is sent as part of the query string. So I needed to set up API Gateway to receive this request and forward the IP address to a Lambda function that could process it.
Starting with the Lambda function, I need an IAM role that allows Route 53, CloudWatch, and SNS access. That translates into:
- AWSLambdaBasicExecutionRole
- route53:ListResourceRecordSets for my hosted zone
- route53:ChangeResourceRecordSets for my hosted zone
- sns:Publish to my SNS topic
- cloudwatch:PutMetricData
For the lambda function which is included at the bottom of this post, I used python 3.6 and the microservice-http-endpoint-python3 template. The code confirms that Route 53 has the “A” record, checks the “A” record for the old IP address, and compares the old IP address to the new IP address. If the comparison of the old and new IP addresses is different the “A” record is set to the new IP address. In all cases a message is built to send to the SNS topic, and a value of 1 (changed) or 0 (unchanged) is sent to CloudWatch.
Next I created an API called myhouse and assigned resources to it. From a number of google searches I learned that it is a good idea to have a tree of resources including a version number. So my resource tree is /v100/ddns. From the ddns resource I created a GET method. I also enabled Cross-Origin Resource Sharing (CORS) which added an OPTION method. CORS allows my house domain (in other words, the ISP’s domain) to communicate with the domain of the AGI Gateway. The OPTION method required no further setup. The GET method setup is:
- Request Method
- Authorization: AWS_IAM
- Request Validator: None
- API Key Required: false
- Query String Parameters: ip
- HTTP Request Headers: X-Forwarded-For
- Integration Request
- Integration type: Lambda Function
- Use Lambda Proxy integration: unchecked
- Lambda Region: <your region>
- Lambda Function: <your function name>
- Invoke with caller credentials: unchecked
- Credential cache: Do not add caller credentials to cache key
- Use Default Timeout: checked
- URL Query String Parameters
- Name: ip
- Mapped from: method.request.querystring.ip
- Caching: unchecked
- Body Mapping Templates
- Request body passthrough: When there are no templates defined
- Content-Type: application/json
- {
“ip”: “$input.params(‘ip’)”,
“X-Forwarded-For”: “$input.params(‘X-Forwarded-For’)”
}
In order to use the API you have to deploy the API. In order to deploy the API you need a stage. I created a “prod” stage. Nice thing about this is that I was able to down throttle the limits since this should only be called every once in a while. So the prod stage can only process 10 request per second with a burst of 5. Way more than I need. I don’t think this should run more than once a week. But this is considerably smaller than the default of 10000 per second with a burst of 5000. BTW, if those numbers seem odd to you, you really do need to take into account that a second is a long period of time for a computer. What that means is that the default is 10000 spread out over a second and 5000 at practically at once.
Now that you have a stage, you can deploy the API to that stage. Now you are ready to receive traffic. Fortunately, it was very easy for me to fake a DDNS event and I was able to test using the router.
The last thing I did was create a custom domain name for the API. I wanted my API traffic sent to api.<mydomain>.com/v100/ddns. To do this I need to create a custom domain name. I couldn’t get regional to work in Route 53, so I created a CloudFront API. This is WAY overkill for my needs.
- Domain: api.<mydomain>.com
- Endpoint Configuration: Edge Optimized
- Base Path Mappings
- Path: <empty>
- Destination: myhouse: prod
This will create a Target Domain Name. Now create an “A” record in Route 53 using an alias to the new Target Domain Name.
I learned quite a bit on this exercise, here are some of the lessons.
- Passing values to an API seems to be a relatively undocumented area. I have a feeling that is because it is very easy to do anything you want, especially if you consider the abstraction layer that AWS seems to have, but I could not find a good set of best practices. I would like to know what works well, what is typically used, what doesn’t work well, etc. Sounds like a conversation with an expert over lunch.
- The problem that I had setting up a regional custom domain name to my API Gateway was due to the Route 53 console is not yet ready to create those aliases. I’ll have to use the command line interface (CLI) to add the regional custom domain name to Route 53.
- The Lambda function was about as easy as it gets to write. I’m just fascinated by the potential for Lambda functions.
Last but not least, here is the code to the lambda function:
import boto3 import json print('Loading function') dns = boto3.client('route53') cw = boto3.client('cloudwatch') sns = boto3.client('sns') host='<your host name here>' def respond(err, res=None): return { 'statusCode': '400' if err else '200', 'body': err.message if err else res } def lambda_handler(event, context): print("Received event: " + json.dumps(event, indent=2)) newip = event['ip'] fromip = event['X-Forwarded-For'] error = False rrs = dns.list_resource_record_sets(HostedZoneId='<Your hosted zone ID here>', StartRecordName=host, StartRecordType='A') if len(rrs['ResourceRecordSets']) > 0: oldip = rrs['ResourceRecordSets'][0]['ResourceRecords'][0]['Value'] if oldip != newip: status = 'changed' statusno = 1 delta = {'Changes': [{'Action': 'UPSERT', 'ResourceRecordSet': {'Name': host, 'Type': 'A', 'TTL': 120, 'ResourceRecords': [{'Value': newip}]}}]} dns.change_resource_record_sets(HostedZoneId='<Your hosted zone ID here>', ChangeBatch=delta) else: status = 'unchanged' statusno = 0 msg = 'IP for ' + host + ' ' + status + ' from ' + oldip + ' to ' + newip + ' via request from ' + fromip else: status = 'error' statusno = 0 oldip = '0.0.0.0' msg = 'DDNS IP check failed (host = ' + host + ', fromip = ' + fromip + ', newip = ' + newip + ')' print(msg) sns.publish(TopicArn='arn:aws:sns:us-east-1:800884433085:McDeath', Message=msg) cw.put_metric_data (Namespace='<Topic Name>', MetricData=[{'MetricName': 'Change', 'Value': statusno, 'Unit': 'Count'}]) return respond(None, msg)