Skip to content

Instantly share code, notes, and snippets.

@TaylorBurnham
Created September 6, 2023 18:54
Show Gist options
  • Save TaylorBurnham/769312c07f76b3948d5652f4b24ecced to your computer and use it in GitHub Desktop.
Save TaylorBurnham/769312c07f76b3948d5652f4b24ecced to your computer and use it in GitHub Desktop.
DIY Dynamic DNS via Python + Boto + AWS Route53
AWS_ACCESS_KEY_ID=<kinda secret>
AWS_SECRET_ACCESS_KEY=<hella secret>
AWS_HOSTED_ZONE_ID=<zone id>
AWS_HOSTED_ZONE_DOMAIN_NAME=server.domain.com
AWS_HOSTED_ZONE_DOMAIN_TYPE=A
AWS_HOSTED_ZONE_DOMAIN_TTL=300

Usage

I wanted a quick and dirty script for dynamic DNS via AWS Route53. I didn't want this dependent on anything other than a crontab entry and a remote endpoint for verifying my IP.

Setup

You can do this one of two ways:

  • Create a hosted zone specifically for this script that has an IAM user with a policy to only allow access to that hosted zone.
  • Live dangerously and let an IAM user control your hosted zone that contains all your other DNS records.

My steps will cover the first approach assuming you want a subdomain called server.domain.com under your primary domain.com DNS configuration. Substitute server.domain.com and domain.com with yours for this.

AWS Configuration

  1. Create a hosted zone for your DNS record, server.domain.com, and note the hosted zone ID.
  2. Copy the NS records in that newly created hosted zone.
  3. In your primary hosted zone, domain.com, add an NS record named server.domain.com with the values copied in step 2.
  4. Go to Services -> IAM -> Policies
  5. Create a new policy and click the JSON tab. Copy the contents of Route53.json into it and update Line 22 to replace ZONEID with the hosted zone ID for your subdomain created in step 1.
  6. Go to Services -> IAM -> Users and create a new user and choose Programmatic access.
  7. On the Permissions page choose Attach existing policies directly and attach the policy created in step 5.
  8. Add whatever tags you want, review that it's correct, then create the user.
  9. Save the keys in a secure place like 1Password or KeePass.

Script Configuration

  1. Create a Python virtual environment and install the dependencies.

    pip install boto3 python-dotenv

  2. Save the r53.py and .env.example file in the same directory, renaming .env.example to .env.

  3. Update .env with the following values:

    AWS_ACCESS_KEY_ID=<Access Key from Step 9>
    AWS_SECRET_ACCESS_KEY=<Secret Access Key from Step 9>
    AWS_HOSTED_ZONE_ID=<Zone ID from Step 1>
    AWS_HOSTED_ZONE_DOMAIN_NAME=server.domain.com
    AWS_HOSTED_ZONE_DOMAIN_TYPE=A
    AWS_HOSTED_ZONE_DOMAIN_TTL=300
    
  4. Run the script and it should create the record if it doesn't exist, or update it if it's incorrect.

You can also add a crontab entry for this. The example below runs every 12 hours.

0 */12 * * * <virtualenvpath> /path/to/script/r53.py

Installed Example:

0 */12 * * * /opt/scripts/r53/env/bin/python /opt/scripts/r53/r53.py
#!/usr/bin/env python3
import os
import sys
import boto3
import logging
from requests import get
from requests.exceptions import RequestException
from dotenv import load_dotenv
def get_public_ip():
try:
response = get('https://ifconfig.me/ip')
ip = response.content.decode()
except RequestException:
ip = None
return ip
class Route53:
def __init__(self):
self.client = boto3.client('route53')
def get_rrsets(self, zone_id):
response = self.client.list_resource_record_sets(
HostedZoneId=zone_id
)
return response.get('ResourceRecordSets')
def get_rrset_for_domain(self, zone_id, domain_name, domain_type):
rrsets = self.get_rrsets(zone_id)
rrset = next(filter(
lambda x: (
x['Name'] == domain_name and
x['Type'] == domain_type
), rrsets), None)
return rrset
def get_rrset_value(self, rrset):
return rrset.get('ResourceRecords').pop().get('Value')
def set_rrset_value(self, **kwargs):
zone_id = kwargs.get('zone_id')
domain_name = kwargs.get('domain_name')
domain_type = kwargs.get('domain_type')
domain_ttl = kwargs.get('domain_ttl')
domain_value = kwargs.get('ip')
rrset_batch = {
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": domain_name,
"Type": domain_type,
"TTL": domain_ttl,
"ResourceRecords": [{
"Value": domain_value
}]
}
}]
}
response = self.client.change_resource_record_sets(
HostedZoneId=zone_id, ChangeBatch=rrset_batch
)
return response.get('ChangeInfo')
if __name__ == "__main__":
logger = logging.getLogger()
streamHandler = logging.StreamHandler(sys.stdout)
streamHandler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s")
)
logger.addHandler(streamHandler)
logger.level = logging.INFO
logger.info("Loading Environment Variables")
# Load the config
load_dotenv()
dns_zone_id = os.getenv('AWS_HOSTED_ZONE_ID')
dns_domain_name = os.getenv('AWS_HOSTED_ZONE_DOMAIN_NAME')
dns_domain_type = os.getenv('AWS_HOSTED_ZONE_DOMAIN_TYPE')
dns_domain_ttl = int(os.getenv('AWS_HOSTED_ZONE_DOMAIN_TTL'))
# Connect to AWS
logger.info("Connecting to AWS and pulling Records")
r53 = Route53()
current_dns = r53.get_rrset_for_domain(
dns_zone_id, dns_domain_name, dns_domain_type
)
if current_dns:
dns_ip = r53.get_rrset_value(current_dns)
else:
logger.info(f"No records found for {dns_domain_name}")
dns_ip = None
current_ip = get_public_ip()
if dns_ip != current_ip:
logger.info(
f"Current IP {current_ip} doesn't match {dns_ip}. Updating...")
response = r53.set_rrset_value(
zone_id=dns_zone_id, domain_name=dns_domain_name,
domain_type=dns_domain_type, domain_ttl=dns_domain_ttl,
ip=current_ip
)
response_joined = ", ".join([
f"{k}: {v}" for k, v in response.items()
])
logger.info(f"Completed Request. Status is: {response_joined}")
else:
logger.info(f"DNS for {dns_domain_name} matches. No action taken.")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment