This article illustrates how to use Route 53 and Lambda to setup single-tenant DDNS service, with code examples.
It’s technically possible to use a Python script as a DDNS client to update the DNS record on Route 53 directly. However, an AWS access key and secret access key need to be loaded to that node. If that node is compromised, other DNS records in that hosted zone may get tempered because IAM policy does not allow fine-grained permission on a single DNS record. The risk can be mitigated using AWS Lambda.
First, create a Lambda function (Python 3.8 runtime) in AWS for updating Route 53 record. In this example, I didn’t give too much info when there is an HTTP 501 because I don’t want to reveal too much detail if someone tried to poke it. For troubleshooting, use CloudWatch.
import json import hashlib import boto3 EXPECTED_API_KEY_SHA256 = "PUT_SHA256_HASHED_RANDOM_STRING_HERE" def lambda_handler(event, context): fail_return = { "statusCode": 501, "body": "" } if not event.get("queryStringParameters"): return fail_return api_key = event["queryStringParameters"].get("api_key") if not api_key: return fail_return if hashlib.sha256(api_key.encode()).hexdigest() != EXPECTED_API_KEY_SHA256: return fail_return dns_ip = event["headers"]["x-forwarded-for"] if not dns_ip: return fail_return route53 = boto3.client("route53") route53.change_resource_record_sets( HostedZoneId="YOUR_HOSTED_ZONE_ID", ChangeBatch={ "Changes": [{ "Action": "UPSERT", "ResourceRecordSet": { "Name": "your-ddns-host-name.kaosy.org", "Type": "A", "ResourceRecords": [{ "Value": dns_ip, }], "TTL": 300, } }] } ) return { "statusCode": 200, "body": "" }
And create IAM policy (to modify Route 53 records) and attach it to the role of the Lambda function
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": "route53:ChangeResourceRecordSets", "Resource": "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" } ] }
Also, attach API Gateway (HTTP) to the Lambda function so it becomes accessible via HTTP(S).
For DDNS client, use the following code
#!/usr/bin/env -S PYTHONUNBUFFERED=1 python3 -u import sys import requests import json from datetime import datetime from dateutil import tz import time import socket def get_time_at_location(utcnow, location, fmt): utc_moment = utcnow.replace(tzinfo=tz.gettz("UTC")) local_date_time = utc_moment.astimezone(tz.gettz(location)) return local_date_time.strftime(fmt) def dns_lookup(): try: dns_info = socket.getaddrinfo("your-ddns-host-name.kaosy.org", 0) return dns_info[0][4][0] except: return None def dns_update(): try: r = requests.get("https://XXXXXXXXXX.execute-api.ap-southeast-2.amazonaws.com/default/ddns?api_key=PUT_A_RANDOM_STRING_HERE", timeout=10) if r.status_code == 200: return True return False except: return False def get_ip(): try: r = requests.get("https://api.ipify.org?format=json", timeout=10) if r.status_code == 200: return json.loads(r.text)["ip"] return None except: return None def oprint(msg): sys.stdout.write(msg) sys.stdout.flush sleep_time = 60 while True: now = datetime.utcnow() date_str = get_time_at_location(now, "Australia/Sydney", "%F %T %Z") oprint(date_str + "\n") inet_ip = get_ip() dns_ip = dns_lookup() oprint(f"Internet IP: {inet_ip}\n") oprint(f"DNS record IP: {dns_ip}\n") sleep_time = 60 if inet_ip and dns_ip: if inet_ip == dns_ip: sleep_time = 300 else: update_result = dns_update() oprint(f"DNS update result: {update_result}\n") if update_result: sleep_time = 300 oprint(f"Sleep for {sleep_time} seconds\n") time.sleep(sleep_time)