├── .python-version ├── .trunk ├── actions ├── logs ├── notifications ├── out └── tools ├── .vscode ├── easycode.ignore └── settings.json ├── LICENSE ├── README.md ├── pyproject.toml ├── r53.py ├── requirements.txt └── uv.lock /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /.trunk/actions: -------------------------------------------------------------------------------- 1 | /Users/efitz/.cache/trunk/repos/e1859281aaccfbf5dd0c6a8afe306051/actions -------------------------------------------------------------------------------- /.trunk/logs: -------------------------------------------------------------------------------- 1 | /Users/efitz/.cache/trunk/repos/e1859281aaccfbf5dd0c6a8afe306051/logs -------------------------------------------------------------------------------- /.trunk/notifications: -------------------------------------------------------------------------------- 1 | /Users/efitz/.cache/trunk/repos/e1859281aaccfbf5dd0c6a8afe306051/notifications -------------------------------------------------------------------------------- /.trunk/out: -------------------------------------------------------------------------------- 1 | /Users/efitz/.cache/trunk/repos/e1859281aaccfbf5dd0c6a8afe306051/out -------------------------------------------------------------------------------- /.trunk/tools: -------------------------------------------------------------------------------- 1 | /Users/efitz/.cache/trunk/repos/e1859281aaccfbf5dd0c6a8afe306051/tools -------------------------------------------------------------------------------- /.vscode/easycode.ignore: -------------------------------------------------------------------------------- 1 | r53venv/ 2 | node_modules/ 3 | dist/ 4 | vendor/ 5 | cache/ 6 | .*/ 7 | *.min.* 8 | *.test.* 9 | *.spec.* 10 | *.bundle.* 11 | *.bundle-min.* 12 | *.*.js 13 | *.*.ts 14 | *.log -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "trunk.addToolsToPath": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # R53.py - Command Line Route 53 interface with Dynamic DNS support 2 | 3 | This Python 3.7+ 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 | usage: r53 [-h] [--profile PROFILE] [--region REGION] [--delete] 12 | [--list-hosted-zones] [--zone ZONE] [--name NAME] 13 | [--type {A,AAAA,CAA,CNAME,MX,NAPTR,SPF,SRV,TXT}] [--ttl TTL] 14 | [--value VALUE] [--eip EIP] [--myip] [--instanceid INSTANCEID] 15 | 16 | Manage resource records in AWS Route 53 17 | 18 | optional arguments: 19 | -h, --help show this help message and exit 20 | --profile PROFILE Use a specific named profile in AWS configuration 21 | --region REGION target AWS API calls against a specific region where 22 | applicable 23 | --delete delete a resource record from a zone 24 | --list-hosted-zones list all hosted zones 25 | --zone ZONE DNS name of target zone 26 | --name NAME name of resource record 27 | --type {A,AAAA,CAA,CNAME,MX,NAPTR,SPF,SRV,TXT} 28 | resource record type 29 | --ttl TTL TTL (in seconds) 30 | --value VALUE value to set in resource record 31 | --eip EIP EIP allocation ID; sets value to the EIP address. Type 32 | and value parameters are ignored if EIP is specified. 33 | --myip sets value to the calling computers public IP address. 34 | Type and value parameters are ignored if myip is 35 | specified. Local IP is looked up at https://checkip.amazonaws.com 36 | --instanceid INSTANCEID 37 | EC2 instance ID; sets value to the public IP address 38 | of the instance. Type and value parameters are ignored 39 | if instanceid is specified. 40 | ``` 41 | 42 | ## SETUP 43 | 44 | 1. Install the AWS command line interface (CLI) 45 | 46 | - https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html 47 | 48 | 2. Configure the AWS CLI 49 | 50 | You could install the AWS CLI and use "aws configure": 51 | 52 | - https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html 53 | 54 | or do it manually: 55 | 56 | - Credentials & configuration: https://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html 57 | - Profiles: https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html 58 | 59 | 3. Permissions 60 | 61 | The following AWS permissions are required; set them using IAM policy on the IAM user or role you're using. 62 | 63 | - ec2:DescribeInstances 64 | - route53:ListHostedZones 65 | - route53:ListResourceRecordSets 66 | - route53:ChangeResourceRecordSets 67 | 68 | 3. Configure your Python environment 69 | 70 | You must install argparse and boto3 in the python 3.7+ environment where you're going to run the script: 71 | ``` 72 | pip install argparse 73 | pip install boto3 74 | ``` 75 | Alternatively, just 76 | ``` 77 | pip install -r requirements.txt 78 | ``` 79 | 80 | ## USING THE SCRIPT 81 | 82 | The script tries to infer as much information as possible: 83 | - If no zone is specified, the script attempts to list hosted zones. 84 | - If only a zone and a record name are specified, the script attempts to look up and display matching records. 85 | - If a new value is provided for the record itself or for the TTL, the script attempts to upsert (add or 86 | update) the record. 87 | - Deletes explicitly require the --delete option. 88 | - 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). 89 | 90 | ## EXAMPLES 91 | 92 | ``` 93 | r53 --help # the above text 94 | r53 # list hosted zones 95 | r53 --zone example.com --name test # display all records with name test in zone example.com 96 | r53 --zone example.com --name test --type A --delete # type is required for delete 97 | r53 --zone example.com --name test --myip # create/update an A record named test using your ip 98 | (i.e. dynamic DNS) 99 | r53 --zone example.com --name test --eip # create/update an A record for an EIP 100 | r53 --zone example.com --name test --instanceid i-123 # create/update an A record for the public IP addr of 101 | an instance 102 | r53 --zone example.com --name test --value 1.2.3.4 # create/update an A record (--type A is optional as 103 | IPv4 implies A) 104 | r53 --zone example.com --name test --value ::1 # create/update an AAAA record (--type AAAA is optional 105 | as IPv6 implies AAAA) 106 | r53 --zone example.com --name test --value foo.bar.com # create/update a CNAME record (--type CNAME is optional 107 | as hostname implies CNAME) 108 | r53 --profile profilename ... # use the keys and configuration from the profilename 109 | profile in ~/.aws/credentials 110 | r53 --region us-east-1 ... # override the region specified in .aws configuration 111 | (where is your instance?) 112 | ``` 113 | 114 | ## NOTES 115 | 116 | The script doesn't support aliases or weighting. It doesn't support management of zones. It doesn't support 117 | all record types that Route53 supports. It doesn't do a lot of error checking, expecting boto to throw useful 118 | exceptions. 119 | 120 | ## TROUBLESHOOTING 121 | 122 | botocore InvalidClientTokenId - this means that your credentials are wrong or missing. Set up new a new key pair with IAM. 123 | 124 | ## DYNAMIC DNS 125 | 126 | To implement dynamic DNS without subscribing to one of the public DDNS providers: 127 | 128 | 1. Register a domain (e.g. "mydomain.com") and host it with Route 53 129 | 130 | 2. Configure this tool as described above in "SETUP" 131 | 132 | 3. Choose a record name to use for DDNS, e.g. "home" ("home.mydomain.com"). 133 | 134 | 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. 135 | 136 | ``` 137 | r53 --zone example.com --name home --myip 138 | ``` 139 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "r53" 3 | version = "0.1.0" 4 | description = "Command line tool to manage AWS Route53" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = ["argparse>=1.4.0", "boto3>=1.35.81"] 8 | -------------------------------------------------------------------------------- /r53.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | r53.py 5 | 6 | This script is a command-line tool for managing AWS Route 53 DNS records. 7 | 8 | It allows users to perform various operations on Route 53 hosted zones 9 | and resource records using the AWS API. 10 | 11 | Features: 12 | - List all hosted zones in your AWS account. 13 | - List all resource records in a specified hosted zone. 14 | - Retrieve details of a specific resource record in a hosted zone. 15 | - Add or update resource records in a hosted zone. 16 | - Delete a specific resource record from a hosted zone. 17 | - Update a DNS record with the public IP address of the host running the script 18 | or an EC2 instance's public IP address. 19 | 20 | Dependencies: 21 | - boto3: AWS SDK for Python 22 | - argparse: For parsing command-line arguments 23 | - re: Regular expressions for pattern matching 24 | - socket: For network-related operations 25 | - urllib: For making HTTP requests 26 | - sys: For system-specific parameters and functions 27 | - logging: For logging messages 28 | 29 | Usage: 30 | python r53.py [options] 31 | 32 | Author: 33 | Eric Fitzgerald (https://github.com/ericfitz) 34 | 35 | """ 36 | 37 | import argparse 38 | import re 39 | import socket 40 | from urllib import request, error 41 | import sys 42 | import logging 43 | import boto3 44 | 45 | # set up logging 46 | logger = logging.getLogger(__name__) 47 | logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO) 48 | 49 | 50 | def get_instance_ip(instance_id): 51 | """ 52 | Given an instance id, returns the public IP address of that instance. 53 | """ 54 | try: 55 | response = ec2.describe_instances(InstanceIds=[instance_id]) 56 | except boto3.exceptions.Boto3Error as e: 57 | logger.error("AWS ec2-describe-instances error: %s", e) 58 | sys.exit() 59 | # return the first public IP address found 60 | for r in response["Reservations"]: 61 | for i in r["Instances"]: 62 | return i["PublicIpAddress"] 63 | 64 | 65 | def get_ip_from_eip(eip_allocation_id): 66 | """ 67 | Given an Elastic IP Allocation Id, returns the public IP address of that Elastic IP address. 68 | """ 69 | try: 70 | response = ec2.describe_addresses(AllocationIds=[eip_allocation_id]) 71 | except boto3.exceptions.Boto3Error as e: 72 | logger.error("AWS ec2-describe-addresses error: %s", e) 73 | sys.exit() 74 | # return the first public IP address found 75 | for a in response["Addresses"]: 76 | return a["PublicIp"] 77 | 78 | 79 | def get_my_ip(): 80 | """ 81 | Get the public IP address of the host running this script. 82 | 83 | The value is obtained from AWS's public IP address service. 84 | 85 | Returns: 86 | str: the public IP address of this host. 87 | """ 88 | try: 89 | with request.urlopen("https://checkip.amazonaws.com") as f: 90 | return f.read().decode("utf-8").strip() 91 | except (error.URLError, error.HTTPError, socket.error) as e: 92 | logger.error("Error retrieving public IP address: %s", e) 93 | sys.exit() 94 | 95 | 96 | # https://stackoverflow.com/questions/319279/how-to-validate-ip-address-in-python 97 | def is_valid_ipv4_address(address): 98 | """ 99 | Validate an IP address. 100 | 101 | Given a string, this function checks if that string is a valid IPv4 address 102 | in dotted quad format. 103 | 104 | :param address: a string to be validated as an IPv4 address 105 | :return: True if the address is valid, False otherwise 106 | """ 107 | try: 108 | socket.inet_pton(socket.AF_INET, address) 109 | except AttributeError: # no inet_pton here, sorry 110 | try: 111 | socket.inet_aton(address) 112 | except socket.error: 113 | return False 114 | # https://www.oreilly.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html 115 | re_ipv4 = re.compile( 116 | 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]?)$" 117 | ) 118 | is_ipv4 = address == re_ipv4.match(address) 119 | return is_ipv4 120 | except socket.error: # not a valid address 121 | return False 122 | except TypeError: 123 | return False 124 | return True 125 | 126 | 127 | def is_valid_ipv6_address(address): 128 | """ 129 | Validate an IPv6 address. 130 | 131 | Given a string, this function checks if that string is a valid IPv6 address. 132 | 133 | :param address: A string to be validated as an IPv6 address 134 | :return: True if the address is valid, False otherwise 135 | """ 136 | try: 137 | socket.inet_pton(socket.AF_INET6, address) 138 | except socket.error: # not a valid address 139 | return False 140 | except TypeError: 141 | return False 142 | return True 143 | 144 | 145 | # https://stackoverflow.com/questions/2532053/validate-a-hostname-string 146 | def is_valid_dns_name(p_dns_name): 147 | """ 148 | Validate a DNS name. 149 | 150 | Given a string, this function checks if that string is a valid DNS name. 151 | A valid DNS name must be 255 characters or fewer, and each label within 152 | the name must be 63 characters or fewer. The function also allows for 153 | the presence of a trailing dot, which is stripped before validation. 154 | 155 | :param p_dns_name: A string to be validated as a DNS name 156 | :return: True if the DNS name is valid, False otherwise 157 | """ 158 | try: 159 | if len(p_dns_name) > 255: 160 | return False 161 | if p_dns_name[-1] == ".": 162 | p_dns_name = p_dns_name[ 163 | :-1 164 | ] # strip exactly one dot from the right, if present 165 | # noinspection PyPep8 166 | allowed = re.compile( 167 | r"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\ 168 | (\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$", 169 | re.IGNORECASE, 170 | ) 171 | except TypeError: 172 | return False 173 | validated_dns_name = allowed.match(p_dns_name) 174 | return validated_dns_name 175 | 176 | 177 | def is_valid_hostname(hostname): 178 | # noinspection PyPep8 179 | """ 180 | Validate a hostname. 181 | 182 | Given a string, this function checks if that string is a valid hostname. 183 | A valid hostname must consist of one or more labels separated by periods, 184 | where each label may contain alphanumeric characters and hyphens, but must 185 | not start or end with a hyphen. Each label must be between 1 and 63 characters, 186 | and the entire hostname must not exceed 255 characters. 187 | 188 | :param hostname: A string to be validated as a hostname 189 | :return: A match object if the hostname is valid, None otherwise 190 | """ 191 | allowed = re.compile( 192 | r"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\ 193 | (\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$", 194 | re.IGNORECASE, 195 | ) 196 | return allowed.match(hostname) 197 | 198 | 199 | def get_hosted_zone_id_from_name(p_domain_name): 200 | """ 201 | Retrieve the hosted zone ID for a given domain name. 202 | 203 | This function iterates over all hosted zones in Route 53 and compares their 204 | names to the provided domain name. If a match is found, it returns the 205 | corresponding hosted zone ID. If no match is found, it returns None. 206 | 207 | :param p_domain_name: The domain name to search for in Route 53 hosted zones. 208 | :return: The hosted zone ID if found, otherwise None. 209 | """ 210 | try: 211 | paginator = route53.get_paginator("list_hosted_zones") 212 | response_iterator = paginator.paginate() 213 | for response in response_iterator: 214 | zones = response["HostedZones"] 215 | for zone in zones: 216 | l_zone_id = (zone["Id"].split("/")[-1:])[0] 217 | # 1. split by / into a list of strings 218 | # 2. get list of last string only 219 | # 3. convert list to string 220 | current_zone_name = zone["Name"].rstrip(".") # remove trailing . 221 | if current_zone_name == p_domain_name: 222 | return l_zone_id 223 | return None 224 | except boto3.exceptions.Boto3Error as e: 225 | logger.error("AWS route53-list-hosted-zones error: %s", e) 226 | sys.exit() 227 | 228 | 229 | def list_hosted_zones(): 230 | """ 231 | List all hosted zones in Route 53. 232 | 233 | This function iterates over all hosted zones in Route 53 and prints out each 234 | zone's ID and name. 235 | 236 | :return: None 237 | """ 238 | try: 239 | paginator = route53.get_paginator("list_hosted_zones") 240 | response_iterator = paginator.paginate() 241 | for response in response_iterator: 242 | zones = response["HostedZones"] 243 | for zone in zones: 244 | l_zone_id = (zone["Id"].split("/")[-1:])[0] 245 | l_zone_name = zone["Name"].rstrip(".") 246 | print(l_zone_id, l_zone_name) 247 | return None 248 | except boto3.exceptions.Boto3Error as e: 249 | logger.error("AWS route53-list-hosted-zones error: %s", e) 250 | sys.exit() 251 | 252 | 253 | def get_current_record(p_zone_id, p_record_name): 254 | """ 255 | Retrieve the current record details for a given record name in a specified hosted zone. 256 | 257 | This function uses the AWS Route 53 API to paginate through resource record sets 258 | within a specified hosted zone. It searches for a record set that matches the given 259 | record name and returns its details, including name, type, TTL, and value. 260 | 261 | :param p_zone_id: The ID of the hosted zone to search in. 262 | :param p_record_name: The name of the record to retrieve details for. 263 | :return: A dictionary containing the record details if found, otherwise an empty dictionary. 264 | """ 265 | result = {} 266 | 267 | try: 268 | paginator = route53.get_paginator("list_resource_record_sets") 269 | response_iterator = paginator.paginate( 270 | HostedZoneId=p_zone_id, StartRecordName=p_record_name 271 | ) 272 | for response in response_iterator: 273 | rrsets = response["ResourceRecordSets"] 274 | for rrset in rrsets: 275 | rrset_name = rrset["Name"].rstrip(".") 276 | if p_record_name == rrset_name: 277 | rrs = rrset["ResourceRecords"] 278 | for rr in rrs: 279 | result["Name"] = rrset_name 280 | result["Type"] = rrset["Type"] 281 | result["TTL"] = rrset["TTL"] 282 | result["Value"] = rr["Value"] 283 | except boto3.exceptions.Boto3Error as e: 284 | logger.error("AWS route53-list-resource-record-sets error: %s", e) 285 | sys.exit() 286 | return result 287 | 288 | 289 | def list_rr(p_zone_id, p_record_name): 290 | """ 291 | List resource record sets for a specific record name within a given hosted zone. 292 | 293 | This function uses the AWS Route 53 API to paginate through resource record sets 294 | in a specified hosted zone, starting from a given record name. It prints the 295 | details of each record set, including the name, type, TTL, and value. If a record 296 | set has an unexpected format, it prints the JSON representation of the record set. 297 | 298 | :param p_zone_id: The ID of the hosted zone to search in. 299 | :param p_record_name: The name of the record to start listing from. 300 | :return: None 301 | """ 302 | try: 303 | paginator = route53.get_paginator("list_resource_record_sets") 304 | response_iterator = paginator.paginate( 305 | HostedZoneId=p_zone_id, StartRecordName=p_record_name 306 | ) 307 | for response in response_iterator: 308 | rrsets = response["ResourceRecordSets"] 309 | for rrset in rrsets: 310 | rrset_name = rrset["Name"].rstrip(".") 311 | try: 312 | rrs = rrset["ResourceRecords"] 313 | for rr in rrs: 314 | print( 315 | "Name: {}, Type: {}, TTL: {}, Value: {}".format( 316 | rrset_name, rrset["Type"], rrset["TTL"], rr["Value"] 317 | ) 318 | ) 319 | except ( 320 | KeyError 321 | ): # A records for ELB have an odd format, just print the JSON for them 322 | logger.error( 323 | "AWS route53-get-record-set returned unexpected json %s", rrset 324 | ) 325 | except boto3.exceptions.Boto3Error as e: 326 | logger.error("AWS route53-list-resource-record-sets error: %s", e) 327 | sys.exit() 328 | return None 329 | 330 | 331 | def change_rr(p_action, p_zone_id, p_record_type, p_record_name, p_value, p_ttl): 332 | """ 333 | Update a resource record set in a specified hosted zone. 334 | 335 | This function uses the AWS Route 53 API to update a resource record set in a 336 | specified hosted zone. The function takes in parameters for the action to 337 | perform (CREATE, UPDATE, DELETE), the ID of the hosted zone, the type of the 338 | record, the name of the record, the new value of the record, and the TTL of 339 | the record. It returns the response from the AWS API. 340 | 341 | :param p_action: The action to perform (CREATE, UPDATE, DELETE) 342 | :param p_zone_id: The ID of the hosted zone to update 343 | :param p_record_type: The type of the record to update (A, AAAA, MX, etc.) 344 | :param p_record_name: The name of the record to update 345 | :param p_value: The new value of the record 346 | :param p_ttl: The TTL of the record 347 | :return: The response from the AWS API 348 | """ 349 | try: 350 | response = route53.change_resource_record_sets( 351 | HostedZoneId=p_zone_id, 352 | ChangeBatch={ 353 | "Comment": "r53.py", 354 | "Changes": [ 355 | { 356 | "Action": p_action, 357 | "ResourceRecordSet": { 358 | "Name": p_record_name, 359 | "Type": p_record_type, 360 | "TTL": p_ttl, 361 | "ResourceRecords": [ 362 | {"Value": p_value}, 363 | ], 364 | }, 365 | }, 366 | ], 367 | }, 368 | ) 369 | except boto3.exceptions.Boto3Error as e: 370 | logger.error("AWS route53-change-resource-record-sets error: %s", e) 371 | sys.exit() 372 | return response 373 | 374 | 375 | #################################################################################################### 376 | # Begin main script 377 | #################################################################################################### 378 | 379 | # parse command line arguments 380 | parser = argparse.ArgumentParser( 381 | prog="r53", description="Manage resource records in AWS Route 53" 382 | ) 383 | 384 | parser.add_argument( 385 | "--profile", 386 | action="store", 387 | help="Use the specified AWS configuration profile instead of the default profile.", 388 | ) 389 | parser.add_argument( 390 | "--region", 391 | action="store", 392 | help="Targets AWS API calls against the specified region instead of the default region in AWS configuration.", 393 | ) 394 | parser.add_argument( 395 | "--delete", 396 | action="store_true", 397 | help="Delete a resource record from a zone.", 398 | ) 399 | # default operation is list. If a value is specified, operation is upsert. Delete must be explicit. 400 | parser.add_argument("--zone", action="store", help="DNS name of target zone") 401 | parser.add_argument("--name", action="store", help="name of resource record") 402 | parser.add_argument( 403 | "--type", 404 | action="store", 405 | help="Specifies the DNS resource record type", 406 | choices=["A", "AAAA", "CAA", "CNAME", "MX", "NAPTR", "SPF", "SRV", "TXT"], 407 | ) 408 | parser.add_argument( 409 | "--ttl", action="store", type=int, default=300, help="TTL (in seconds)" 410 | ) 411 | parser.add_argument( 412 | "--value", action="store", help="Specifies the value to set in the resource record" 413 | ) 414 | parser.add_argument( 415 | "--eip", 416 | action="store", 417 | help="Sets value to the IP address associated with the specified EIP. Type and value parameters are ignored if EIP is specified.", 418 | ) 419 | parser.add_argument( 420 | "--myip", 421 | action="store_true", 422 | help="Uses the calling computer's public IP address. Type and value parameters are ignored if --myip is specified.", 423 | ) 424 | # noinspection SpellCheckingInspection,SpellCheckingInspection 425 | parser.add_argument( 426 | "--instanceid", 427 | action="store", 428 | help="Sets value to the public IP address of the specified EC2 instance. Type and value parameters are ignored if instance ID is specified.", 429 | ) 430 | 431 | args = parser.parse_args() 432 | logger.debug("Arguments: %s", str(args)) 433 | 434 | # set up the boto3 clients 435 | if args.profile is not None: 436 | logger.info("Using AWS profile: %s", args.profile) 437 | try: 438 | boto3.setup_default_session(profile_name=args.profile) 439 | except boto3.exceptions.Boto3Error as e: 440 | logger.error( 441 | "Boto error %s creating session using specified profile %s", e, args.profile 442 | ) 443 | sys.exit() 444 | 445 | # AWS Route 53 is global, not regional, so we can ignore region for Route 53 connection. 446 | try: 447 | route53 = boto3.client("route53") 448 | except boto3.exceptions.Boto3Error as e: 449 | logger.error("Boto error %s creating Route 53 client", e) 450 | sys.exit() 451 | 452 | # EC2 is regional, so we need to use region if specified 453 | # r53 uses EC2 to look up instance IP addresses & EIPs 454 | if args.region is not None: 455 | logger.info("Using AWS region: %s", args.region) 456 | try: 457 | ec2 = boto3.client("ec2", region_name=args.region) 458 | except boto3.exceptions.Boto3Error as e: 459 | logger.error( 460 | "Boto error %s creating EC2 client in specified region %s", e, args.region 461 | ) 462 | sys.exit() 463 | else: 464 | try: 465 | ec2 = boto3.client("ec2") 466 | except boto3.exceptions.Boto3Error as e: 467 | logger.error("Boto error %s creating EC2 client in default region", e) 468 | sys.exit() 469 | 470 | # Validate and process the provided parameters 471 | ZONE_NAME = args.zone 472 | RECORD_NAME = args.name 473 | RECORD_TYPE = args.type 474 | value = args.value 475 | eip = args.eip 476 | myip = args.myip 477 | instanceid = args.instanceid 478 | ttl = args.ttl 479 | 480 | # only allow one of (value, eip, myip, instanceid) to be specified 481 | NUMVALUES = 0 482 | 483 | if value is not None: 484 | NUMVALUES += 1 485 | 486 | if eip is not None: 487 | NUMVALUES += 1 488 | value = get_ip_from_eip(args.eip) 489 | logger.debug("Calculated value from eip: %s", value) 490 | 491 | if myip: 492 | NUMVALUES += 1 493 | value = get_my_ip() 494 | logger.debug("Calculated value from IP lookup: %s", value) 495 | 496 | if instanceid is not None: 497 | NUMVALUES += 1 498 | value = get_instance_ip(args.instanceid) 499 | logger.debug("Calculated value from EC2 instance ID: %s", value) 500 | 501 | if NUMVALUES > 1: 502 | raise ValueError( 503 | "Specify only one of value, eip, myip, or instanceid" 504 | ) # only one of value, eip, myip, or instanceid can be specified 505 | 506 | # figure out the record type if not explicitly specified 507 | if RECORD_TYPE is None: 508 | if is_valid_ipv4_address(value): 509 | RECORD_TYPE = "A" 510 | elif is_valid_ipv6_address(value): 511 | RECORD_TYPE = "AAAA" 512 | elif is_valid_dns_name(value): 513 | RECORD_TYPE = "CNAME" 514 | logger.info("Inferred record type: %s", RECORD_TYPE) 515 | 516 | # look up zone id if zone name provided 517 | ZONE_ID = None 518 | if ZONE_NAME is not None: 519 | if is_valid_dns_name(args.zone) is False: 520 | raise ValueError("Invalid zone name: %s" % args.zone) 521 | ZONE_ID = get_hosted_zone_id_from_name(args.zone) 522 | logger.debug("Matched zone name %s to zone ID %s", ZONE_NAME, ZONE_ID) 523 | 524 | # append the zone name to the record name if required 525 | if RECORD_NAME is not None and ZONE_NAME is not None: 526 | RECORD_NAME = str(args.name) + "." + str(args.zone) 527 | logger.debug("Concatenated record name: %s", RECORD_NAME) 528 | 529 | # figure out the action to take 530 | # logic is described in Google sheet: https://bit.ly/r53params 531 | if ZONE_ID is None: 532 | ACTION = "LISTZONES" 533 | else: 534 | if RECORD_NAME is None: 535 | ACTION = "LIST" 536 | else: 537 | if RECORD_TYPE is None: 538 | ACTION = "DESCRIBE" 539 | else: 540 | if value is None: 541 | if args.delete: 542 | ACTION = "DELETE" 543 | else: 544 | raise ValueError("Must specify value for upserts") 545 | else: 546 | ACTION = "UPSERT" 547 | 548 | if ACTION is None: 549 | raise ValueError( 550 | "Invalid parameter combination and/or values" 551 | ) # if we got here, there was a bug in the parameter validation code above 552 | else: 553 | logger.info("Inferred action: %s", ACTION) 554 | 555 | # execute the action 556 | match ACTION: 557 | case "LISTZONES": 558 | logger.debug("Executing action: action:%s", ACTION) 559 | list_hosted_zones() 560 | case "LIST": 561 | logger.debug("Executing action: action:%s zoneid:%s", ACTION, ZONE_ID) 562 | list_rr(ZONE_ID, ".") 563 | case "DESCRIBE": 564 | logger.debug( 565 | "Executing action: action:%s zoneid:%s recordname:%s", 566 | ACTION, 567 | ZONE_ID, 568 | RECORD_NAME, 569 | ) 570 | list_rr(ZONE_ID, RECORD_NAME) 571 | case "UPSERT": 572 | logger.debug( 573 | "Executing action: action:%s zoneid:%s recordname:%s value:%s ttl:%s", 574 | ACTION, 575 | ZONE_ID, 576 | RECORD_NAME, 577 | value, 578 | args.ttl, 579 | ) 580 | change_rr(ACTION, ZONE_ID, RECORD_TYPE, RECORD_NAME, value, args.ttl) 581 | case "DELETE": 582 | current_record = get_current_record(ZONE_ID, RECORD_NAME) 583 | logger.debug( 584 | "Executing action: action:%s zoneid:%s recordname:%s value:%s ttl:%s", 585 | ACTION, 586 | ZONE_ID, 587 | RECORD_NAME, 588 | value, 589 | current_record["TTL"], 590 | ) 591 | value = current_record["Value"] 592 | logger.debug("Validated current record exists before attempting delete") 593 | try: 594 | change_rr( 595 | ACTION, ZONE_ID, RECORD_TYPE, RECORD_NAME, value, current_record["TTL"] 596 | ) 597 | except KeyError: # trying to delete nonexistent record 598 | logger.error("Cannot delete nonexistent record.") 599 | sys.exit() 600 | case _: 601 | raise ValueError( 602 | "Invalid parameter combination and/or values." 603 | ) # if we got here, there was a bug in the parameter validation code above 604 | 605 | logger.info("Success") 606 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | argparse 3 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.13" 3 | 4 | [[package]] 5 | name = "argparse" 6 | version = "1.4.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/18/dd/e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/argparse-1.4.0.tar.gz", hash = "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4", size = 70508 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/f2/94/3af39d34be01a24a6e65433d19e107099374224905f1e0cc6bbe1fd22a2f/argparse-1.4.0-py2.py3-none-any.whl", hash = "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314", size = 23000 }, 11 | ] 12 | 13 | [[package]] 14 | name = "boto3" 15 | version = "1.35.81" 16 | source = { registry = "https://pypi.org/simple" } 17 | dependencies = [ 18 | { name = "botocore" }, 19 | { name = "jmespath" }, 20 | { name = "s3transfer" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/d9/a5/8e610a7c230326b6a766758ce290233a8d0ec88bef4f5afe09e2313d2def/boto3-1.35.81.tar.gz", hash = "sha256:d2e95fa06f095b8e0c545dd678c6269d253809b2997c30f5ce8a956c410b4e86", size = 111013 } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/b4/db/e6bf2a34d7e8440800fcd11f2b42efd4ba18cce56d5a213bb93bd62aaa0e/boto3-1.35.81-py3-none-any.whl", hash = "sha256:742941b2424c0223d2d94a08c3485462fa7c58d816b62ca80f08e555243acee1", size = 139178 }, 25 | ] 26 | 27 | [[package]] 28 | name = "botocore" 29 | version = "1.35.81" 30 | source = { registry = "https://pypi.org/simple" } 31 | dependencies = [ 32 | { name = "jmespath" }, 33 | { name = "python-dateutil" }, 34 | { name = "urllib3" }, 35 | ] 36 | sdist = { url = "https://files.pythonhosted.org/packages/3d/a8/b44d94c14ee4eb13db6dc549269c79199b43bddd70982e192aefd6ca6279/botocore-1.35.81.tar.gz", hash = "sha256:564c2478e50179e0b766e6a87e5e0cdd35e1bc37eb375c1cf15511f5dd13600d", size = 13460205 } 37 | wheels = [ 38 | { url = "https://files.pythonhosted.org/packages/1a/ad/00dfec368dd4e957063ed1126b5511238b0900c1014dfe539af93fc0ac29/botocore-1.35.81-py3-none-any.whl", hash = "sha256:a7b13bbd959bf2d6f38f681676aab408be01974c46802ab997617b51399239f7", size = 13265330 }, 39 | ] 40 | 41 | [[package]] 42 | name = "jmespath" 43 | version = "1.0.1" 44 | source = { registry = "https://pypi.org/simple" } 45 | sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } 46 | wheels = [ 47 | { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, 48 | ] 49 | 50 | [[package]] 51 | name = "python-dateutil" 52 | version = "2.9.0.post0" 53 | source = { registry = "https://pypi.org/simple" } 54 | dependencies = [ 55 | { name = "six" }, 56 | ] 57 | sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } 58 | wheels = [ 59 | { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, 60 | ] 61 | 62 | [[package]] 63 | name = "r53" 64 | version = "0.1.0" 65 | source = { virtual = "." } 66 | dependencies = [ 67 | { name = "argparse" }, 68 | { name = "boto3" }, 69 | ] 70 | 71 | [package.metadata] 72 | requires-dist = [ 73 | { name = "argparse", specifier = ">=1.4.0" }, 74 | { name = "boto3", specifier = ">=1.35.81" }, 75 | ] 76 | 77 | [[package]] 78 | name = "s3transfer" 79 | version = "0.10.4" 80 | source = { registry = "https://pypi.org/simple" } 81 | dependencies = [ 82 | { name = "botocore" }, 83 | ] 84 | sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287 } 85 | wheels = [ 86 | { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175 }, 87 | ] 88 | 89 | [[package]] 90 | name = "six" 91 | version = "1.17.0" 92 | source = { registry = "https://pypi.org/simple" } 93 | sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } 94 | wheels = [ 95 | { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, 96 | ] 97 | 98 | [[package]] 99 | name = "urllib3" 100 | version = "2.2.3" 101 | source = { registry = "https://pypi.org/simple" } 102 | sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } 103 | wheels = [ 104 | { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, 105 | ] 106 | --------------------------------------------------------------------------------