├── .python-version ├── .vscode └── settings.json ├── LICENSE ├── PYTHON_WORKFLOW.md ├── README.md └── r53.py /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "trunk.addToolsToPath": true, 3 | "python.analysis.autoImportCompletions": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2025 Eric Fitzgerald 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PYTHON_WORKFLOW.md: -------------------------------------------------------------------------------- 1 | # Python Development Workflow 2 | 3 | This document outlines the canonical workflow for setting up new Python projects to ensure consistency across all development work. 4 | 5 | ## Standard Project Setup Workflow 6 | 7 | Follow these steps in order when starting a new Python project: 8 | 9 | ### 1. Set Python Version 10 | ```bash 11 | pyenv local 12 | ``` 13 | - Sets the Python version for the specific project 14 | - Creates a `.python-version` file in the project root 15 | - Ensures consistent Python version across environments 16 | 17 | ### 2. Initialize Poetry Project 18 | Choose one of the following based on your needs: 19 | 20 | **For existing projects:** 21 | ```bash 22 | poetry init 23 | ``` 24 | - Interactive initialization of `pyproject.toml` 25 | - Allows you to specify project metadata and dependencies 26 | 27 | **For new projects:** 28 | ```bash 29 | poetry new 30 | ``` 31 | - Creates a new project directory with standard structure 32 | - Generates `pyproject.toml` and basic project layout 33 | 34 | ### 3. Add Dependencies 35 | ```bash 36 | poetry add 37 | ``` 38 | - Installs dependencies and adds them to `pyproject.toml` 39 | - Poetry automatically creates and activates a `.venv` virtual environment 40 | - Updates `poetry.lock` file with exact dependency versions 41 | 42 | **Examples:** 43 | ```bash 44 | poetry add requests 45 | poetry add pytest --group dev 46 | poetry add black --group dev 47 | ``` 48 | 49 | ### 4. Run Commands 50 | Use one of these approaches to execute commands in the project environment: 51 | 52 | **Option A: Direct execution with poetry run** 53 | ```bash 54 | poetry run python script.py 55 | poetry run pytest 56 | poetry run black . 57 | ``` 58 | 59 | **Option B: Activate shell environment** 60 | ```bash 61 | poetry shell 62 | # Now you're in the activated environment 63 | python script.py 64 | pytest 65 | black . 66 | ``` 67 | 68 | ## Benefits of This Workflow 69 | 70 | - **Consistency**: Same approach across all projects 71 | - **Isolation**: Each project has its own virtual environment 72 | - **Reproducibility**: `poetry.lock` ensures exact dependency versions 73 | - **Simplicity**: Poetry handles virtual environment creation and activation 74 | - **Modern tooling**: Leverages current Python best practices 75 | 76 | ## Quick Reference 77 | 78 | ```bash 79 | # Complete setup for new project 80 | pyenv local 3.11.0 81 | poetry init 82 | poetry add requests pytest black --group dev 83 | poetry shell 84 | 85 | # Or for existing project 86 | pyenv local 3.11.0 87 | poetry install 88 | poetry shell 89 | ``` 90 | 91 | --- 92 | 93 | *Follow this workflow consistently to maintain clean, reproducible Python development environments.* 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # R53.py - Command Line Route 53 interface with Dynamic DNS support 2 | 3 | This Python 3.10+ script does simple management of Route 53 zones and records using the AWS API. You must have the AWS CLI properly configured with a credentials file containing valid AWS keys. The script supports use of profiles if you have multiple key sets configured properly. 4 | 5 | The script trivially does dynamic DNS using the "--myip" parameter to look up its own public IP and use it to update an A record. 6 | 7 | The script can also set an A record to an EC2 instance's public IP address, even if the instance is in a different region. 8 | 9 | ## COMMAND LINE HELP 10 | 11 | ``` 12 | usage: r53 [-h] [--profile PROFILE] [--region REGION] [--delete] 13 | [--list-hosted-zones] [--zone ZONE] [--name NAME] 14 | [--type {A,AAAA,CAA,CNAME,MX,NAPTR,SPF,SRV,TXT}] [--ttl TTL] 15 | [--value VALUE] [--eip EIP] [--myip] [--instanceid INSTANCEID] 16 | 17 | Manage resource records in AWS Route 53 18 | 19 | optional arguments: 20 | -h, --help show this help message and exit 21 | --profile PROFILE Use a specific named profile in AWS configuration 22 | --region REGION target AWS API calls against a specific region where 23 | applicable 24 | --delete delete a resource record from a zone 25 | --list-hosted-zones list all hosted zones 26 | --zone ZONE DNS name of target zone 27 | --name NAME name of resource record 28 | --type {A,AAAA,CAA,CNAME,MX,NAPTR,SPF,SRV,TXT} 29 | resource record type 30 | --ttl TTL TTL (in seconds) 31 | --value VALUE value to set in resource record 32 | --eip EIP EIP allocation ID; sets value to the EIP address. Type 33 | and value parameters are ignored if EIP is specified. 34 | --myip sets value to the calling computers public IP address. 35 | Type and value parameters are ignored if myip is 36 | specified. Local IP is looked up at https://checkip.amazonaws.com 37 | --instanceid INSTANCEID 38 | EC2 instance ID; sets value to the public IP address 39 | of the instance. Type and value parameters are ignored 40 | if instanceid is specified. 41 | ``` 42 | 43 | ## SETUP 44 | 45 | 1. Install the AWS command line interface (CLI) 46 | 47 | - https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html 48 | 49 | 2. Configure the AWS CLI 50 | 51 | You could install the AWS CLI and use "aws configure": 52 | 53 | - https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html 54 | 55 | or do it manually: 56 | 57 | - Credentials & configuration: https://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html 58 | - Profiles: https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html 59 | 60 | 3. Permissions 61 | 62 | The following AWS permissions are required; set them using IAM policy on the IAM user or role you're using. 63 | 64 | - ec2:DescribeInstances 65 | - route53:ListHostedZones 66 | - route53:ListResourceRecordSets 67 | - route53:ChangeResourceRecordSets 68 | 69 | 4. Install uv (recommended) or configure your Python environment 70 | 71 | **Option A: Using uv (recommended)** 72 | 73 | Install uv if you haven't already: 74 | ``` 75 | curl -LsSf https://astral.sh/uv/install.sh | sh 76 | ``` 77 | 78 | Or on macOS with Homebrew: 79 | ``` 80 | brew install uv 81 | ``` 82 | 83 | No additional setup needed! uv will automatically manage dependencies when you run the script. 84 | 85 | **Option B: Manual Python environment** 86 | 87 | Install boto3 in your Python 3.10+ environment: 88 | ``` 89 | pip install boto3 90 | ``` 91 | 92 | ## USING THE SCRIPT 93 | 94 | The script tries to infer as much information as possible: 95 | 96 | - If no zone is specified, the script attempts to list hosted zones. 97 | - If only a zone and a record name are specified, the script attempts to look up and display matching records. 98 | - If a new value is provided for the record itself or for the TTL, the script attempts to upsert (add or 99 | update) the record. 100 | - Deletes explicitly require the --delete option. 101 | - Type is optional if the type can be cleanly inferred from the value and the value is correctly formatted (e.g. A, AAAA or CNAME). 102 | 103 | ## EXAMPLES 104 | 105 | **Using uv (recommended):** 106 | ```bash 107 | uv run r53.py --help # the above text 108 | uv run r53.py # list hosted zones 109 | uv run r53.py --zone example.com --name test # display all records with name test in zone example.com 110 | uv run r53.py --zone example.com --name test --type A --delete # type is required for delete 111 | uv run r53.py --zone example.com --name test --myip # create/update an A record named test using your ip 112 | (i.e. dynamic DNS) 113 | uv run r53.py --zone example.com --name test --eip # create/update an A record for an EIP 114 | uv run r53.py --zone example.com --name test --instanceid i-123 # create/update an A record for the public IP addr of 115 | an instance 116 | uv run r53.py --zone example.com --name test --value 1.2.3.4 # create/update an A record (--type A is optional as 117 | IPv4 implies A) 118 | uv run r53.py --zone example.com --name test --value ::1 # create/update an AAAA record (--type AAAA is optional 119 | as IPv6 implies AAAA) 120 | uv run r53.py --zone example.com --name test --value foo.bar.com # create/update a CNAME record (--type CNAME is optional 121 | as hostname implies CNAME) 122 | uv run r53.py --profile profilename ... # use the keys and configuration from the profilename 123 | profile in ~/.aws/credentials 124 | uv run r53.py --region us-east-1 ... # override the region specified in .aws configuration 125 | (where is your instance?) 126 | ``` 127 | 128 | **Or using python directly (if you installed dependencies manually):** 129 | ```bash 130 | python r53.py --help 131 | python r53.py --zone example.com --name test --myip 132 | # ... etc 133 | ``` 134 | 135 | ## NOTES 136 | 137 | The script doesn't support aliases, weighted routing, or routing policies. Alias records 138 | can be listed but cannot be created, modified, or deleted. Multi-value records can be 139 | viewed but cannot be deleted. It doesn't support management of zones. It doesn't support 140 | all record types that Route53 supports. 141 | 142 | ## TROUBLESHOOTING 143 | 144 | botocore InvalidClientTokenId - this means that your credentials are wrong or missing. Set up new a new key pair with IAM. 145 | 146 | ## DYNAMIC DNS 147 | 148 | To implement dynamic DNS without subscribing to one of the public DDNS providers: 149 | 150 | 1. Register a domain (e.g. "mydomain.com") and host it with Route 53 151 | 152 | 2. Configure this tool as described above in "SETUP" 153 | 154 | 3. Choose a record name to use for DDNS, e.g. "home" ("home.mydomain.com"). 155 | 156 | 4. Configure the tool to run regularly to create and update that A record with your IP address. Run this regularly from a computer behind that IP address, e.g. on a home server using a CRON job. 157 | 158 | ```bash 159 | uv run r53.py --zone example.com --name home --myip 160 | ``` 161 | 162 | Or add this to your crontab to update every 5 minutes: 163 | ```bash 164 | */5 * * * * cd /path/to/r53 && uv run r53.py --zone example.com --name home --myip >> /var/log/ddns.log 2>&1 165 | ``` 166 | -------------------------------------------------------------------------------- /r53.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # /// script 3 | # requires-python = ">=3.10" 4 | # dependencies = [ 5 | # "boto3>=1.35.81", 6 | # ] 7 | # /// 8 | 9 | """ 10 | r53.py 11 | 12 | This script is a command-line tool for managing AWS Route 53 DNS records. 13 | 14 | It allows users to perform various operations on Route 53 hosted zones 15 | and resource records using the AWS API. 16 | 17 | Features: 18 | - List all hosted zones in your AWS account. 19 | - List all resource records in a specified hosted zone. 20 | - Retrieve details of a specific resource record in a hosted zone. 21 | - Add or update resource records in a hosted zone. 22 | - Delete a specific resource record from a hosted zone. 23 | - Update a DNS record with the public IP address of the host running the script 24 | or an EC2 instance's public IP address. 25 | 26 | Limitations: 27 | - CREATE/UPSERT/DELETE operations only support standard resource records 28 | (A, AAAA, CNAME, MX, TXT, etc.) and do not support alias records. 29 | - Alias records (pointing to ELBs, CloudFront distributions, S3 websites, etc.) 30 | can be listed and viewed but cannot be created, modified, or deleted. 31 | - Weighted, latency-based, geolocation, and failover routing policies are not supported. 32 | - Multi-value answer records can be listed but cannot be deleted via this tool. 33 | 34 | Dependencies: 35 | - boto3: AWS SDK for Python (automatically installed by uv) 36 | - botocore: Low-level AWS SDK dependency 37 | - re: Regular expressions for pattern matching 38 | - socket: For network-related operations 39 | - urllib: For making HTTP requests 40 | - sys: For system-specific parameters and exit codes 41 | - logging: For logging messages 42 | 43 | Usage: 44 | uv run r53.py [options] 45 | 46 | Or with manual Python environment: 47 | python r53.py [options] 48 | 49 | Author: 50 | Eric Fitzgerald (https://github.com/ericfitz) 51 | """ 52 | 53 | import argparse 54 | import re 55 | import socket 56 | from urllib import request, error 57 | import sys 58 | import logging 59 | from botocore.exceptions import ClientError 60 | import boto3 61 | 62 | # set up logging 63 | logger = logging.getLogger(__name__) 64 | logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO) 65 | 66 | 67 | def get_instance_ip(instance_id): 68 | """ 69 | Given an instance id, returns the public IP address of that instance. 70 | 71 | :param instance_id: EC2 instance ID 72 | :return: str, the public IPv4 address of the instance 73 | :raises ValueError: if instance has no public IP or instance not found 74 | """ 75 | try: 76 | response = ec2.describe_instances(InstanceIds=[instance_id]) 77 | except ClientError as e: 78 | logger.error("AWS ec2-describe-instances error: %s", e) 79 | sys.exit() 80 | 81 | # Check if any reservations exist 82 | if not response.get("Reservations"): 83 | raise ValueError(f"No reservations found for instance ID: {instance_id}") 84 | 85 | # Search for the first public IP address 86 | for r in response["Reservations"]: 87 | for i in r.get("Instances", []): 88 | # Check if PublicIpAddress field exists 89 | if "PublicIpAddress" in i: 90 | ip_address = i["PublicIpAddress"] 91 | # Validate the IP address format 92 | if not is_valid_ipv4_address(ip_address): 93 | raise ValueError( 94 | f"Instance {instance_id} returned invalid IP address: {ip_address}" 95 | ) 96 | return ip_address 97 | 98 | # No public IP found 99 | raise ValueError( 100 | f"Instance {instance_id} does not have a public IP address. " 101 | "Ensure the instance is running and has a public IP assigned." 102 | ) 103 | 104 | 105 | def get_ip_from_eip(eip_allocation_id): 106 | """ 107 | Given an Elastic IP Allocation Id, returns the public IP address of that Elastic IP address. 108 | 109 | :param eip_allocation_id: EIP allocation ID (e.g., 'eipalloc-xxxxx') 110 | :return: str, the public IPv4 address associated with the EIP 111 | :raises ValueError: if EIP allocation ID is invalid or not found 112 | """ 113 | try: 114 | response = ec2.describe_addresses(AllocationIds=[eip_allocation_id]) 115 | except ClientError as e: 116 | logger.error("AWS ec2-describe-addresses error: %s", e) 117 | sys.exit() 118 | 119 | # Check if any addresses were returned 120 | addresses = response.get("Addresses", []) 121 | if not addresses: 122 | raise ValueError( 123 | f"No Elastic IP found for allocation ID: {eip_allocation_id}" 124 | ) 125 | 126 | # Get the first address 127 | ip_address = addresses[0].get("PublicIp") 128 | if not ip_address: 129 | raise ValueError( 130 | f"Elastic IP allocation {eip_allocation_id} does not have a public IP address" 131 | ) 132 | 133 | # Validate the IP address format 134 | if not is_valid_ipv4_address(ip_address): 135 | raise ValueError( 136 | f"EIP {eip_allocation_id} returned invalid IP address: {ip_address}" 137 | ) 138 | 139 | return ip_address 140 | 141 | 142 | def get_my_ip(): 143 | """ 144 | Get the public IP address of the host running this script. 145 | 146 | The value is obtained from AWS's public IP address service. 147 | 148 | Returns: 149 | str: the public IP address of this host. 150 | """ 151 | try: 152 | with request.urlopen("https://checkip.amazonaws.com") as f: 153 | return f.read().decode("utf-8").strip() 154 | except (error.URLError, error.HTTPError, socket.error) as e: 155 | logger.error("Error retrieving public IP address: %s", e) 156 | sys.exit() 157 | 158 | 159 | # https://stackoverflow.com/questions/319279/how-to-validate-ip-address-in-python 160 | def is_valid_ipv4_address(address): 161 | """ 162 | Validate an IP address. 163 | 164 | Given a string, this function checks if that string is a valid IPv4 address 165 | in dotted quad format. 166 | 167 | :param address: a string to be validated as an IPv4 address 168 | :return: True if the address is valid, False otherwise 169 | """ 170 | try: 171 | socket.inet_pton(socket.AF_INET, address) 172 | except AttributeError: # no inet_pton here, sorry 173 | try: 174 | socket.inet_aton(address) 175 | except socket.error: 176 | return False 177 | # https://www.oreilly.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html 178 | re_ipv4 = re.compile( 179 | r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" 180 | ) 181 | return bool(re_ipv4.match(address)) 182 | except socket.error: # not a valid address 183 | return False 184 | except TypeError: 185 | return False 186 | return True 187 | 188 | 189 | def is_valid_ipv6_address(address): 190 | """ 191 | Validate an IPv6 address. 192 | 193 | Given a string, this function checks if that string is a valid IPv6 address. 194 | 195 | :param address: A string to be validated as an IPv6 address 196 | :return: True if the address is valid, False otherwise 197 | """ 198 | try: 199 | socket.inet_pton(socket.AF_INET6, address) 200 | except socket.error: # not a valid address 201 | return False 202 | except TypeError: 203 | return False 204 | return True 205 | 206 | 207 | # https://stackoverflow.com/questions/2532053/validate-a-hostname-string 208 | def is_valid_dns_name(dns_name): 209 | """ 210 | Validate a DNS name. 211 | 212 | Given a string, this function checks if that string is a valid DNS name. 213 | A valid DNS name must be 255 characters or fewer, and each label within 214 | the name must be 63 characters or fewer. The function also allows for 215 | the presence of a trailing dot, which is stripped before validation. 216 | 217 | :param dns_name: A string to be validated as a DNS name 218 | :return: True if the DNS name is valid, False otherwise 219 | """ 220 | try: 221 | if len(dns_name) > 255: 222 | return False 223 | if dns_name[-1] == ".": 224 | dns_name = dns_name[ 225 | :-1 226 | ] # strip exactly one dot from the right, if present 227 | allowed = re.compile( 228 | r"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\ 229 | (\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$", 230 | re.IGNORECASE, 231 | ) 232 | except TypeError: 233 | return False 234 | validated_dns_name = allowed.match(dns_name) 235 | return validated_dns_name is not None 236 | 237 | 238 | def is_valid_hostname(hostname): 239 | """ 240 | Validate a hostname. 241 | 242 | Given a string, this function checks if that string is a valid hostname. 243 | A valid hostname must consist of one or more labels separated by periods, 244 | where each label may contain alphanumeric characters and hyphens, but must 245 | not start or end with a hyphen. Each label must be between 1 and 63 characters, 246 | and the entire hostname must not exceed 255 characters. 247 | 248 | :param hostname: A string to be validated as a hostname 249 | :return: True if the hostname is valid, False otherwise 250 | """ 251 | try: 252 | allowed = re.compile( 253 | r"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\ 254 | (\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$", 255 | re.IGNORECASE, 256 | ) 257 | return allowed.match(hostname) is not None 258 | except TypeError: 259 | return False 260 | 261 | 262 | def get_hosted_zone_id_from_name(domain_name): 263 | """ 264 | Retrieve the hosted zone ID for a given domain name. 265 | 266 | This function iterates over all hosted zones in Route 53 and compares their 267 | names to the provided domain name. If a match is found, it returns the 268 | corresponding hosted zone ID. If no match is found, it returns None. 269 | 270 | :param domain_name: The domain name to search for in Route 53 hosted zones. 271 | :return: The hosted zone ID if found, otherwise None. 272 | """ 273 | try: 274 | paginator = route53.get_paginator("list_hosted_zones") 275 | response_iterator = paginator.paginate() 276 | for response in response_iterator: 277 | zones = response["HostedZones"] 278 | for zone in zones: 279 | zone_id = (zone["Id"].split("/")[-1:])[0] 280 | # 1. split by / into a list of strings 281 | # 2. get list of last string only 282 | # 3. convert list to string 283 | current_zone_name = zone["Name"].rstrip(".") # remove trailing . 284 | if current_zone_name == domain_name: 285 | return zone_id 286 | return None 287 | except ClientError as e: 288 | logger.error("AWS route53-list-hosted-zones error: %s", e) 289 | sys.exit() 290 | 291 | 292 | def list_hosted_zones(): 293 | """ 294 | List all hosted zones in Route 53. 295 | 296 | This function iterates over all hosted zones in Route 53 and prints out each 297 | zone's ID and name. 298 | 299 | :return: None 300 | """ 301 | try: 302 | paginator = route53.get_paginator("list_hosted_zones") 303 | response_iterator = paginator.paginate() 304 | for response in response_iterator: 305 | zones = response["HostedZones"] 306 | for zone in zones: 307 | zone_id = (zone["Id"].split("/")[-1:])[0] 308 | zone_name = zone["Name"].rstrip(".") 309 | print(zone_id, zone_name) 310 | except ClientError as e: 311 | logger.error("AWS route53-list-hosted-zones error: %s", e) 312 | sys.exit() 313 | 314 | 315 | def get_current_record(zone_id, record_name): 316 | """ 317 | Retrieve the current record details for a given record name in a specified hosted zone. 318 | 319 | This function uses the AWS Route 53 API to paginate through resource record sets 320 | within a specified hosted zone. It searches for a record set that matches the given 321 | record name and returns its details, including name, type, TTL, and values. 322 | 323 | :param zone_id: The ID of the hosted zone to search in. 324 | :param record_name: The name of the record to retrieve details for. 325 | :return: A dictionary containing the record details if found, otherwise an empty dictionary. 326 | For standard records, "Values" will be a list of all values. 327 | For alias records, "AliasTarget" will contain alias details. 328 | """ 329 | result = {} 330 | 331 | try: 332 | paginator = route53.get_paginator("list_resource_record_sets") 333 | response_iterator = paginator.paginate( 334 | HostedZoneId=zone_id, StartRecordName=record_name 335 | ) 336 | for response in response_iterator: 337 | rrsets = response["ResourceRecordSets"] 338 | for rrset in rrsets: 339 | rrset_name = rrset["Name"].rstrip(".") 340 | if record_name == rrset_name: 341 | result["Name"] = rrset_name 342 | result["Type"] = rrset["Type"] 343 | 344 | # Handle standard records with ResourceRecords 345 | if "ResourceRecords" in rrset: 346 | result["TTL"] = rrset["TTL"] 347 | # Collect all values into a list 348 | result["Values"] = [rr["Value"] for rr in rrset["ResourceRecords"]] 349 | 350 | # Handle alias records 351 | elif "AliasTarget" in rrset: 352 | result["AliasTarget"] = rrset["AliasTarget"] 353 | 354 | return result 355 | except ClientError as e: 356 | logger.error("AWS route53-list-resource-record-sets error: %s", e) 357 | sys.exit() 358 | return result 359 | 360 | 361 | def list_rr(zone_id, record_name): 362 | """ 363 | List resource record sets for a specific record name within a given hosted zone. 364 | 365 | This function uses the AWS Route 53 API to paginate through resource record sets 366 | in a specified hosted zone, starting from a given record name. It prints the 367 | details of each record set, including the name, type, and value(s). For standard 368 | records, it displays TTL and resource record values. For alias records (e.g., ELB, 369 | CloudFront), it displays the alias target information. 370 | 371 | :param zone_id: The ID of the hosted zone to search in. 372 | :param record_name: The name of the record to start listing from. 373 | :return: None 374 | """ 375 | try: 376 | paginator = route53.get_paginator("list_resource_record_sets") 377 | response_iterator = paginator.paginate( 378 | HostedZoneId=zone_id, StartRecordName=record_name 379 | ) 380 | for response in response_iterator: 381 | rrsets = response["ResourceRecordSets"] 382 | for rrset in rrsets: 383 | rrset_name = rrset["Name"].rstrip(".") 384 | 385 | # Handle standard records with ResourceRecords 386 | if "ResourceRecords" in rrset: 387 | rrs = rrset["ResourceRecords"] 388 | for rr in rrs: 389 | print( 390 | "Name: {}, Type: {}, TTL: {}, Value: {}".format( 391 | rrset_name, rrset["Type"], rrset["TTL"], rr["Value"] 392 | ) 393 | ) 394 | 395 | # Handle alias records (ELB, CloudFront, etc.) 396 | elif "AliasTarget" in rrset: 397 | alias = rrset["AliasTarget"] 398 | print( 399 | "Name: {}, Type: {}, Alias: {} (HostedZoneId: {}, EvaluateTargetHealth: {})".format( 400 | rrset_name, 401 | rrset["Type"], 402 | alias["DNSName"].rstrip("."), 403 | alias["HostedZoneId"], 404 | alias.get("EvaluateTargetHealth", "N/A") 405 | ) 406 | ) 407 | 408 | # Handle unexpected record format 409 | else: 410 | logger.warning( 411 | "Unknown record format for %s (Type: %s). Record details: %s", 412 | rrset_name, 413 | rrset.get("Type", "UNKNOWN"), 414 | rrset 415 | ) 416 | except ClientError as e: 417 | logger.error("AWS route53-list-resource-record-sets error: %s", e) 418 | sys.exit() 419 | 420 | 421 | def change_rr(action, zone_id, record_type, record_name, value, ttl): 422 | """ 423 | Update a resource record set in a specified hosted zone. 424 | 425 | This function uses the AWS Route 53 API to update a resource record set in a 426 | specified hosted zone. The function takes in parameters for the action to 427 | perform (CREATE, UPSERT, DELETE), the ID of the hosted zone, the type of the 428 | record, the name of the record, the new value of the record, and the TTL of 429 | the record. It returns the response from the AWS API. 430 | 431 | IMPORTANT: This function only supports standard resource records with a single 432 | value. It does NOT support: 433 | - Alias records (e.g., pointing to ELB, CloudFront, S3) 434 | - Weighted routing policy records 435 | - Latency-based routing policy records 436 | - Geolocation routing policy records 437 | - Failover routing policy records 438 | - Multi-value answer routing (only single values supported) 439 | 440 | To manage alias records or advanced routing policies, use the AWS Console or 441 | AWS CLI directly. 442 | 443 | :param action: The action to perform (CREATE, UPSERT, DELETE) 444 | :param zone_id: The ID of the hosted zone to update 445 | :param record_type: The type of the record to update (A, AAAA, MX, etc.) 446 | :param record_name: The name of the record to update 447 | :param value: The new value of the record 448 | :param ttl: The TTL of the record 449 | :return: The response from the AWS API 450 | """ 451 | try: 452 | response = route53.change_resource_record_sets( 453 | HostedZoneId=zone_id, 454 | ChangeBatch={ 455 | "Comment": "r53.py", 456 | "Changes": [ 457 | { 458 | "Action": action, 459 | "ResourceRecordSet": { 460 | "Name": record_name, 461 | "Type": record_type, 462 | "TTL": ttl, 463 | "ResourceRecords": [ 464 | {"Value": value}, 465 | ], 466 | }, 467 | }, 468 | ], 469 | }, 470 | ) 471 | except ClientError as e: 472 | logger.error("AWS route53-change-resource-record-sets error: %s", e) 473 | sys.exit() 474 | return response 475 | 476 | 477 | #################################################################################################### 478 | # Begin main script 479 | #################################################################################################### 480 | 481 | # parse command line arguments 482 | parser = argparse.ArgumentParser( 483 | prog="r53", description="Manage resource records in AWS Route 53" 484 | ) 485 | 486 | parser.add_argument( 487 | "--profile", 488 | action="store", 489 | help="Use the specified AWS configuration profile instead of the default profile.", 490 | ) 491 | parser.add_argument( 492 | "--region", 493 | action="store", 494 | help="Targets AWS API calls against the specified region instead of the default region in AWS configuration.", 495 | ) 496 | parser.add_argument( 497 | "--delete", 498 | action="store_true", 499 | help="Delete a resource record from a zone.", 500 | ) 501 | # default operation is list. If a value is specified, operation is upsert. Delete must be explicit. 502 | parser.add_argument("--zone", action="store", help="DNS name of target zone") 503 | parser.add_argument("--name", action="store", help="name of resource record") 504 | parser.add_argument( 505 | "--type", 506 | action="store", 507 | help="Specifies the DNS resource record type", 508 | choices=["A", "AAAA", "CAA", "CNAME", "MX", "NAPTR", "NS", "PTR", "SOA", "SPF", "SRV", "TXT"], 509 | ) 510 | parser.add_argument( 511 | "--ttl", action="store", type=int, default=300, help="TTL (in seconds, 0-2147483647)" 512 | ) 513 | parser.add_argument( 514 | "--value", action="store", help="Specifies the value to set in the resource record" 515 | ) 516 | parser.add_argument( 517 | "--eip", 518 | action="store", 519 | help="Sets value to the IP address associated with the specified EIP. Type and value parameters are ignored if EIP is specified.", 520 | ) 521 | parser.add_argument( 522 | "--myip", 523 | action="store_true", 524 | help="Uses the calling computer's public IP address. Type and value parameters are ignored if --myip is specified.", 525 | ) 526 | # noinspection SpellCheckingInspection,SpellCheckingInspection 527 | parser.add_argument( 528 | "--instanceid", 529 | action="store", 530 | help="Sets value to the public IP address of the specified EC2 instance. Type and value parameters are ignored if instance ID is specified.", 531 | ) 532 | 533 | args = parser.parse_args() 534 | logger.debug("Arguments: %s", str(args)) 535 | 536 | # set up the boto3 clients 537 | if args.profile is not None: 538 | logger.info("Using AWS profile: %s", args.profile) 539 | try: 540 | boto3.setup_default_session(profile_name=args.profile) 541 | except ClientError as e: 542 | logger.error( 543 | "Boto error %s creating session using specified profile %s", e, args.profile 544 | ) 545 | sys.exit() 546 | 547 | # AWS Route 53 is global, not regional, so we can ignore region for Route 53 connection. 548 | try: 549 | route53 = boto3.client("route53") 550 | except ClientError as e: 551 | logger.error("Boto error %s creating Route 53 client", e) 552 | sys.exit() 553 | 554 | # EC2 is regional, so we need to use region if specified 555 | # r53 uses EC2 to look up instance IP addresses & EIPs 556 | if args.region is not None: 557 | logger.info("Using AWS region: %s", args.region) 558 | try: 559 | ec2 = boto3.client("ec2", region_name=args.region) 560 | except ClientError as e: 561 | logger.error( 562 | "Boto error %s creating EC2 client in specified region %s", e, args.region 563 | ) 564 | sys.exit() 565 | else: 566 | try: 567 | ec2 = boto3.client("ec2") 568 | except ClientError as e: 569 | logger.error("Boto error %s creating EC2 client in default region", e) 570 | sys.exit() 571 | 572 | # Validate and process the provided parameters 573 | ZONE_NAME = args.zone 574 | RECORD_NAME = args.name 575 | RECORD_TYPE = args.type 576 | value = args.value 577 | eip = args.eip 578 | myip = args.myip 579 | instanceid = args.instanceid 580 | ttl = args.ttl 581 | 582 | # Validate TTL range per Route 53 constraints 583 | if ttl < 0 or ttl > 2147483647: 584 | raise ValueError( 585 | f"TTL must be between 0 and 2147483647 seconds, got: {ttl}" 586 | ) 587 | 588 | # only allow one of (value, eip, myip, instanceid) to be specified 589 | NUMVALUES = 0 590 | 591 | if value is not None: 592 | NUMVALUES += 1 593 | 594 | if eip is not None: 595 | NUMVALUES += 1 596 | try: 597 | value = get_ip_from_eip(args.eip) 598 | logger.debug("Calculated value from eip: %s", value) 599 | except ValueError as e: 600 | logger.error("Failed to retrieve IP from EIP: %s", e) 601 | sys.exit(1) 602 | 603 | if myip: 604 | NUMVALUES += 1 605 | value = get_my_ip() 606 | logger.debug("Calculated value from IP lookup: %s", value) 607 | 608 | if instanceid is not None: 609 | NUMVALUES += 1 610 | try: 611 | value = get_instance_ip(args.instanceid) 612 | logger.debug("Calculated value from EC2 instance ID: %s", value) 613 | except ValueError as e: 614 | logger.error("Failed to retrieve IP from instance: %s", e) 615 | sys.exit(1) 616 | 617 | if NUMVALUES > 1: 618 | raise ValueError( 619 | "Specify only one of value, eip, myip, or instanceid" 620 | ) # only one of value, eip, myip, or instanceid can be specified 621 | 622 | # figure out the record type if not explicitly specified 623 | if RECORD_TYPE is None: 624 | # Only attempt type inference if a value exists 625 | if value is not None: 626 | if is_valid_ipv4_address(value): 627 | RECORD_TYPE = "A" 628 | elif is_valid_ipv6_address(value): 629 | RECORD_TYPE = "AAAA" 630 | elif is_valid_dns_name(value): 631 | RECORD_TYPE = "CNAME" 632 | else: 633 | raise ValueError( 634 | f"Cannot infer record type from value '{value}'. " 635 | "Please specify --type explicitly." 636 | ) 637 | logger.info("Inferred record type: %s", RECORD_TYPE) 638 | else: 639 | # value is None - this is OK for DESCRIBE and LIST operations 640 | # but will be caught later if trying to UPSERT 641 | logger.debug("No value provided, type inference skipped") 642 | 643 | # look up zone id if zone name provided 644 | ZONE_ID = None 645 | if ZONE_NAME is not None: 646 | if not is_valid_dns_name(args.zone): 647 | raise ValueError(f"Invalid zone name: {args.zone}") 648 | ZONE_ID = get_hosted_zone_id_from_name(args.zone) 649 | if ZONE_ID is None: 650 | raise ValueError( 651 | f"Zone '{args.zone}' not found in Route 53. " 652 | "Use 'r53' without arguments to list available zones." 653 | ) 654 | logger.debug("Matched zone name %s to zone ID %s", ZONE_NAME, ZONE_ID) 655 | 656 | # append the zone name to the record name if required 657 | if RECORD_NAME is not None and ZONE_NAME is not None: 658 | RECORD_NAME = str(args.name) + "." + str(args.zone) 659 | logger.debug("Concatenated record name: %s", RECORD_NAME) 660 | 661 | # figure out the action to take 662 | # logic is described in Google sheet: https://bit.ly/r53params 663 | if ZONE_ID is None: 664 | ACTION = "LISTZONES" 665 | else: 666 | if RECORD_NAME is None: 667 | ACTION = "LIST" 668 | else: 669 | if RECORD_TYPE is None: 670 | ACTION = "DESCRIBE" 671 | else: 672 | if value is None: 673 | if args.delete: 674 | ACTION = "DELETE" 675 | else: 676 | raise ValueError("Must specify value for upserts") 677 | else: 678 | ACTION = "UPSERT" 679 | 680 | if ACTION is None: 681 | raise ValueError( 682 | "Invalid parameter combination and/or values" 683 | ) # if we got here, there was a bug in the parameter validation code above 684 | else: 685 | logger.info("Inferred action: %s", ACTION) 686 | 687 | # execute the action 688 | match ACTION: 689 | case "LISTZONES": 690 | logger.debug("Executing action: action:%s", ACTION) 691 | list_hosted_zones() 692 | case "LIST": 693 | logger.debug("Executing action: action:%s zoneid:%s", ACTION, ZONE_ID) 694 | list_rr(ZONE_ID, ".") 695 | case "DESCRIBE": 696 | logger.debug( 697 | "Executing action: action:%s zoneid:%s recordname:%s", 698 | ACTION, 699 | ZONE_ID, 700 | RECORD_NAME, 701 | ) 702 | list_rr(ZONE_ID, RECORD_NAME) 703 | case "UPSERT": 704 | logger.debug( 705 | "Executing action: action:%s zoneid:%s recordname:%s value:%s ttl:%s", 706 | ACTION, 707 | ZONE_ID, 708 | RECORD_NAME, 709 | value, 710 | args.ttl, 711 | ) 712 | change_rr(ACTION, ZONE_ID, RECORD_TYPE, RECORD_NAME, value, args.ttl) 713 | case "DELETE": 714 | current_record = get_current_record(ZONE_ID, RECORD_NAME) 715 | 716 | # Check if record exists 717 | if not current_record: 718 | logger.error("Cannot delete nonexistent record: %s", RECORD_NAME) 719 | sys.exit() 720 | 721 | # Check if this is an alias record 722 | if "AliasTarget" in current_record: 723 | logger.error( 724 | "Cannot delete alias record: %s. " 725 | "Alias records must be deleted via AWS Console or AWS CLI.", 726 | RECORD_NAME 727 | ) 728 | sys.exit() 729 | 730 | # Check for multi-value records 731 | values = current_record.get("Values", []) 732 | if len(values) > 1: 733 | logger.error( 734 | "Record %s has %d values: %s", 735 | RECORD_NAME, 736 | len(values), 737 | ", ".join(values) 738 | ) 739 | logger.error( 740 | "Multi-value record deletion is not supported. " 741 | "Use AWS Console or AWS CLI to delete this record." 742 | ) 743 | sys.exit() 744 | 745 | value = values[0] 746 | 747 | logger.debug( 748 | "Executing action: action:%s zoneid:%s recordname:%s value:%s ttl:%s", 749 | ACTION, 750 | ZONE_ID, 751 | RECORD_NAME, 752 | value, 753 | current_record["TTL"], 754 | ) 755 | 756 | try: 757 | change_rr( 758 | ACTION, ZONE_ID, RECORD_TYPE, RECORD_NAME, value, current_record["TTL"] 759 | ) 760 | except ClientError as e: 761 | logger.error("Failed to delete record: %s", e) 762 | sys.exit() 763 | case _: 764 | raise ValueError( 765 | "Invalid parameter combination and/or values." 766 | ) # if we got here, there was a bug in the parameter validation code above 767 | 768 | logger.info("Success") 769 | --------------------------------------------------------------------------------