Dynamic DNS using Route 53 and Lambda

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)

Leave a Reply

Your email address will not be published. Required fields are marked *