diff --git a/code/ddns-lambda.py b/code/ddns-lambda.py index df7fb4c..e3ba763 100755 --- a/code/ddns-lambda.py +++ b/code/ddns-lambda.py @@ -19,22 +19,29 @@ The following tag combination will result in the A/PTR record. The order matters since first match will skip the rest of the condition. -1. If Custom hostname Tag AND Custom Zone Tags exist and valid +1. If Custom hostname Tag AND Custom Zone Tags exist and are valid - hostname: Custom Hostname - zonename: Custom Zonename -2. If Custom hostname Tag is valid AND there's NO Custom ZOone Tag AND VPC Domain name is Valid +2. If Custom hostname Tag is valid AND there's NO Custom Zone Tag AND VPC Domain name is Valid - hostname: Custom Hostname -- zonename: VPC Domain name given via DHCP option -3. If Name Tag is valid AND VPC Domain name is Valid +- zonename: VPC Domain name given via DHCP option + (if custom hostname value contains fqdn, the zone is ignored) +3. If Name Tag is valid (both hostname and zonename portion) +- hostname: Name (minus the zone name) +- zonename: Name (minus the host name) +4. If Name Tag is valid (only hostname portion) AND Custom Zone Tag is valid +- hostname: Name (minus the zone name) +- zonename: Custom Zonename +5. If Name Tag is valid (only hostname portion) AND VPC Domain name is Valid - hostname: Name (minus the zone name) - zonename: VPC Domain name given via DHCP option -4. Custom Zone Tags is valid AND Custom Hostname is NOT Valid (or empty) +6. Custom Zone Tags is valid AND (Name hostname and custom hostname not Valid) - hostname: ip-1-2-3-4 format - zonename: Custom Zonename -5. No Custom Tags +7. No Custom Tags - hostname: ip-1-2-3-4 format - zonename: VPC Domain name given via DHCP option -6. If no match above then exit out script (no A/PTR record) +8. If no match above then exit out script (no A/PTR record) Once the assignment is made above, the script will either create the A/PTR record (if running) or delete the A/PTR record (if shutting down) @@ -45,6 +52,7 @@ Finally, it will clean up the DynamoDB entry if the instance is shutting down. """ import json +from lib2to3.pgen2.pgen import DFAState import sys import datetime import random @@ -62,7 +70,7 @@ LOGGER = logging.getLogger() ACCOUNT = None REGION = None -VERSION = '0.0.10' +VERSION = '0.0.11' # Adjust the logging level [logging.INFO, logging.DEBUG, logging.WARNING, etc] LOGGER.setLevel(logging.DEBUG) @@ -169,17 +177,32 @@ def lambda_handler( # Only doing something if the state is running if state == 'running': - LOGGER.debug("sleeping for {} seconds {}".format(SLEEPTIME, lineno())) + LOGGER.debug("sleeping for maximum {} seconds {}".format(SLEEPTIME, lineno())) - if "pytest" in sys.modules: - # called from within a test run + # wait increment and wait until maximum sleeptime + i = 1 + while i < SLEEPTIME: + LOGGER.debug("waiting count: %s", str(i) + lineno()) time.sleep(1) - else: - # called "normally" - time.sleep(SLEEPTIME) + i += 1 + + try: + # Get instance information + instance = get_instances(compute, instance_id) + + t_private_ip = instance['Reservations'][0]['Instances'][0]['PrivateIpAddress'] + t_private_dns_name = instance['Reservations'][0]['Instances'][0]['PrivateDnsName'] + t_subnet_id = instance['Reservations'][0]['Instances'][0]['SubnetId'] + t_vpc_id = instance['Reservations'][0]['Instances'][0]['VpcId'] + + # if key attributes are found, then break out of the loop + if all([t_private_ip, t_private_dns_name, t_subnet_id, t_vpc_id]): + LOGGER.debug ("instance data found, exiting while loop: " + "%s", t_private_dns_name + ","+ t_private_ip + ","+ t_subnet_id + ","+ t_vpc_id + lineno()) + break + except: + LOGGER.info("no instance data, repeat check: %s", lineno()) - # Get instance information - instance = get_instances(compute, instance_id) # Remove response metadata from the response if 'ResponseMetadata' in instance: instance.pop('ResponseMetadata') @@ -285,7 +308,7 @@ def lambda_handler( if reverse_zone: LOGGER.debug("Reverse lookup zone found: %s", str(reversed_lookup_zone) + lineno()) - reverse_lookup_zone_id = get_zone_id(route53, reversed_lookup_zone) + reverse_lookup_zone_id = get_zone_id(reversed_lookup_zone, hosted_zones) LOGGER.debug("reverse_lookup_zone_id: %s", str( reverse_lookup_zone_id) + lineno()) @@ -309,7 +332,14 @@ def lambda_handler( # Wait a random amount of time. This is a poor-mans back-off # if a lot of instances are launched all at once. - time.sleep(random.random()) + # randomize shutdown/terminate more since it they have higher probability of collision + # route 53 has 5 ChangeResourceRecordSets API/s limit. SDK has built-in retry but only up to 5. + if state == 'running': + # up to 11 seconds with min of 1 + time.sleep(random.random() * 10 + 1) + else: + # up to 20 seconds with min of 1 + time.sleep(random.random() * 20 + 1) # Is there a DHCP option set? # Get DHCP option set configuration @@ -330,6 +360,9 @@ def lambda_handler( # the VPC. Obtain the zone name, and check if there is a Private Hosted Zone # associated with the VPC. If so, it will set the zone name to be used later. has_dhcp_dns_zone_associated_vpc = False + + # store verified valid dns zones so to speed up the script. + valid_dns_zones = [] for configuration in dhcp_configurations: LOGGER.debug("configuration: %s", str(configuration) + lineno()) @@ -338,59 +371,61 @@ def lambda_handler( if configuration in private_hosted_zone_collection: private_hosted_zone_name = configuration - LOGGER.debug("Private zone found %s", str( - private_hosted_zone_name) + lineno()) - private_hosted_zone_id = get_zone_id(route53, private_hosted_zone_name) - LOGGER.debug("Private_hosted_zone_id: %s", str( - private_hosted_zone_id) + lineno()) - private_hosted_zone_properties = get_hosted_zone_properties( - route53, - private_hosted_zone_id - ) - - LOGGER.debug("private_hosted_zone_properties:" - " %s", str(private_hosted_zone_properties) + lineno()) - - has_dhcp_dns_zone_associated_vpc = True + # check if the zone is already validated, if not check + if private_hosted_zone_name in valid_dns_zones: + has_dhcp_dns_zone_associated_vpc = True + LOGGER.debug("private_hosted_zone_name already valid: %s", str( + private_hosted_zone_name) + lineno()) + elif is_valid_zone(route53, private_hosted_zone_name, hosted_zones, vpc_id, private_hosted_zone_collection,): + has_dhcp_dns_zone_associated_vpc = True + valid_dns_zones.append(private_hosted_zone_name) + # Loop through the instance's tags LOGGER.debug("iterating through tags %s", lineno()) - # Loop through the instance's tags # validate the tag value and set certain boolean values has_valid_zone_tag = False has_valid_cname_tag = False has_valid_hostname_tag = False - has_valid_Name_tag = False + has_valid_Name_tag_hostname = False + has_valid_Name_tag_zonename = False for tag in tags: LOGGER.debug("#### tag: %s", str(tag) + lineno()) if tag.get('Key').lstrip().lower() == TAGKEY_ZONE.lower(): LOGGER.debug("Zone Tag key: %s", tag.get('Key') + lineno()) - + + # pause 1s to spread out API calls + time.sleep(1) custom_zone_name = tag.get('Value').lstrip().lower() # add a trailing period if it does not have it. if custom_zone_name[-1] != '.': custom_zone_name = custom_zone_name + '.' - # check if the zone is PHZ VPC associated - if custom_zone_name in private_hosted_zone_collection: - LOGGER.debug("Private zone found: %s", - str(custom_zone_name) + lineno()) + LOGGER.debug("Checking if custom_zone_name is valid: %s", + str(custom_zone_name) + lineno()) + + # check if the zone is already validated, if not check + if custom_zone_name in valid_dns_zones: + LOGGER.debug("custom_zone_name already valid: %s", str( + custom_zone_name) + lineno()) + has_valid_zone_tag = True + elif is_valid_zone(route53, custom_zone_name, hosted_zones, vpc_id, private_hosted_zone_collection): zone_tag_hosted_zone_name = custom_zone_name + valid_dns_zones.append(zone_tag_hosted_zone_name) LOGGER.debug("zone_tag_hosted_zone_name: %s", str( zone_tag_hosted_zone_name) + lineno()) - has_valid_zone_tag = True - else: - LOGGER.debug("Private zone NOT found: %s", - str(custom_zone_name) + lineno()) elif tag.get('Key').lstrip().lower() == TAGKEY_CNAME.lower(): LOGGER.debug("CNAME Tag key: %s", tag.get('Key') + lineno()) + # pause 1s to spread out API calls + time.sleep(1) + if is_valid_hostname(tag.get('Value')): LOGGER.debug("CNAME hostname of %s is valid %s", @@ -412,24 +447,26 @@ def lambda_handler( LOGGER.debug("cname_domain_suffix: %s", str( cname_domain_suffix) + lineno()) - # Try and find the hosted zone with the cname suffix - cname_domain_suffix_id = get_zone_id(route53, cname_domain_suffix) - - LOGGER.debug("cname_domain_suffix_id: %s", str(cname_domain_suffix_id)) - - # check if the zone is PHZ VPC associated - if cname_domain_suffix.lower() in private_hosted_zone_collection: - LOGGER.debug("Private zone found: %s", - str(tag.get('Value')) + lineno()) - + # check if the zone is already validated, if not check + if cname_domain_suffix in valid_dns_zones: + has_valid_cname_tag = True + LOGGER.debug("cname_domain_suffix already valid: %s", str( + cname_domain_suffix) + lineno()) + elif is_valid_zone(route53, cname_domain_suffix, hosted_zones, vpc_id, private_hosted_zone_collection): + LOGGER.debug("cname domain is valid: %s", + cname_domain_suffix + lineno()) + valid_dns_zones.append(cname_domain_suffix) has_valid_cname_tag = True else: - LOGGER.debug("cname domain is not associated with vpc: %s", + LOGGER.debug("cname domain is not valid: %s", cname_domain_suffix + lineno()) elif tag.get('Key').lstrip().lower() == TAGKEY_HOSTNAME.lower(): LOGGER.debug("Custom Hostname Tag key: %s", tag.get('Key') + lineno()) + # pause 1s to spread out API calls + time.sleep(1) + if is_valid_hostname(tag.get('Value')): LOGGER.debug("Custom hostname of %s is valid %s", str(tag.get('Value')), lineno()) @@ -449,6 +486,10 @@ def lambda_handler( elif tag.get('Key') == 'Name': LOGGER.debug("Name Tag key: %s", tag.get('Key') + lineno()) + # pause 1s to spread out API calls + time.sleep(1) + + # if name exist, split into hostname/domain if is_valid_hostname(tag.get('Value')): LOGGER.debug("Name of %s is valid %s", str(tag.get('Value')), lineno()) @@ -459,7 +500,7 @@ def lambda_handler( if name_value[-1] != '.': name_value = name_value + '.' - LOGGER.debug("Name: %s", str(name_value) + lineno()) + LOGGER.debug("Name Tag Value: %s", str(name_value) + lineno()) # Gets the host and the zone name (split up based) name_host = name_value.split('.')[0] @@ -469,14 +510,25 @@ def lambda_handler( LOGGER.debug("name_domain_suffix: %s", str( name_domain_suffix) + lineno()) - # check if the zone name == dhcp zone - if name_domain_suffix == private_hosted_zone_name: - LOGGER.debug("name_domain_suffix is the same as DHCP dns: %s", str( - name_domain_suffix) + lineno()) - has_valid_Name_tag = True - else: - LOGGER.debug("name_domain_suffix is NOT the same as DHCP dns: %s", str( - name_domain_suffix) + lineno()) + # recheck the host portion + if is_valid_hostname(name_host): + has_valid_Name_tag_hostname = True + + LOGGER.debug("has_valid_Name_tag_hostname: %s", str( + has_valid_Name_tag_hostname) + lineno()) + + # check if the zone is already validated, if not check + if name_domain_suffix in valid_dns_zones: + has_valid_Name_tag_zonename = True + LOGGER.debug("name_domain_suffix already valid: %s", str( + name_domain_suffix) + lineno()) + elif is_valid_zone(route53, name_domain_suffix, hosted_zones, vpc_id, private_hosted_zone_collection): + valid_dns_zones.append(name_domain_suffix) + has_valid_Name_tag_zonename = True + + LOGGER.debug("has_valid_Name_tag_zonename: %s", str( + has_valid_Name_tag_zonename) + lineno()) + else: LOGGER.debug("Name of %s is invalid %s", str(tag.get('Value')), lineno()) @@ -489,15 +541,23 @@ def lambda_handler( final_private_hostname = custom_host_name final_hosted_zone_name = zone_tag_hosted_zone_name elif has_valid_hostname_tag and not (has_valid_zone_tag) and has_dhcp_dns_zone_associated_vpc: # 3 - LOGGER.info("custom hostname valid only.") + LOGGER.info("custom hostname tag valid only.") final_private_hostname = custom_host_name final_hosted_zone_name = private_hosted_zone_name - elif has_valid_Name_tag and has_dhcp_dns_zone_associated_vpc: - LOGGER.info("Name tag valid.") + elif has_valid_Name_tag_hostname and has_valid_Name_tag_zonename: + LOGGER.info("Name tag hostname valid and Name tag zonename valid.") + final_private_hostname = name_host + final_hosted_zone_name = name_domain_suffix + elif has_valid_Name_tag_hostname and has_valid_zone_tag: + LOGGER.info("Name tag hostname valid and custom zone tag valid.") + final_private_hostname = name_host + final_hosted_zone_name = zone_tag_hosted_zone_name + elif has_valid_Name_tag_hostname and has_dhcp_dns_zone_associated_vpc: + LOGGER.info("Name tag hostname valid and DHCP zone is valid.") final_private_hostname = name_host final_hosted_zone_name = private_hosted_zone_name - elif has_valid_zone_tag and not (has_valid_hostname_tag): - LOGGER.info("custom zone tag valid.") + elif has_valid_zone_tag and not (has_valid_hostname_tag) and not(has_valid_Name_tag_hostname): + LOGGER.info("custom zone tag valid but no custom hostname, using IP address.") final_private_hostname = private_host_name final_hosted_zone_name = zone_tag_hosted_zone_name elif has_dhcp_dns_zone_associated_vpc: @@ -507,7 +567,8 @@ def lambda_handler( else: # none of the use-casem and no suitable zone to create the A record LOGGER.info("No DHCP Associated for VPC and no custom tags. Exiting Script") # nothing to do, exit out script - exit(-1) + caller_response.append ('No DHCP Associated for VPC and no custom tags. Exiting Script') + return caller_response # put together the FQDN of the dns name... final_private_dns_name = final_private_hostname + '.' + final_hosted_zone_name @@ -515,11 +576,13 @@ def lambda_handler( str(final_private_dns_name) + lineno()) # Get the PHZ ID for the Zone - final_hosted_zone_id = get_zone_id( - route53, final_hosted_zone_name) + final_hosted_zone_id = get_zone_id(final_hosted_zone_name, hosted_zones) LOGGER.debug("private_hosted_zone_id:" " %s", str(final_hosted_zone_id) + lineno()) + LOGGER.debug("valid_dns_zones:" + " %s", str(valid_dns_zones) + lineno()) + # Create OR Delete the A / PTR Record if state == 'running': # create the records @@ -564,6 +627,9 @@ def lambda_handler( else: # not running so delete the records try: + + # pause 1 before deleting to avoid API limit + time.sleep(1) delete_resource_record( route53, final_hosted_zone_id, @@ -581,6 +647,8 @@ def lambda_handler( ' with value: ' + str(private_ip)) + # pause 1 before deleting to avoid API limit + time.sleep(1) delete_resource_record( route53, reverse_lookup_zone_id, @@ -606,6 +674,10 @@ def lambda_handler( LOGGER.debug("cname record is valid - creating CNAME record:" " %s", str(cname_host_name) + "." + str(cname_domain_suffix) + lineno()) + # Try and find the hosted zone with the cname suffix + cname_domain_suffix_id = get_zone_id(cname_domain_suffix, hosted_zones) + LOGGER.debug("cname_domain_suffix_id: %s", str(cname_domain_suffix_id)) + # create CNAME record in private zone if state == 'running': try: @@ -639,6 +711,8 @@ def lambda_handler( try: LOGGER.debug( "deleting resource record %s", lineno()) + # pause 1 before deleting to avoid API limit + time.sleep(1) delete_resource_record( route53, cname_domain_suffix_id, @@ -1030,17 +1104,16 @@ def delete_resource_record(client, zone_id, host_name, hosted_zone_name, record_ LOGGER.info("unexpected error. %s\n", str(err) + lineno()) -def get_zone_id(client, zone_name, private_zone=True): +def get_zone_id(zone_name, hosted_zones, private_zone=True): """ This function returns the zone id for the zone name that's passed into the function. - :param client: :param zone_name: + :param hosted_zones: :return: """ try: if zone_name[-1] != '.': zone_name = zone_name + '.' - hosted_zones = list_hosted_zones(client) LOGGER.debug("zone name: %s", str(zone_name) + lineno()) LOGGER.debug("hosted_zones: %s", str(hosted_zones) + lineno()) @@ -1081,6 +1154,57 @@ def is_valid_hostname(hostname): except: LOGGER.info("unexpected error. %s\n", str(sys.exc_info()[0]) + lineno()) +def is_valid_zone(route53, zonename, hosted_zones, vpc_id, private_hosted_zone_collection): + """ + This function checks to see whether the zone "name" entered + is valid (PHZ zone exists and is associated with the VPC + where instance is lauched in) + :param zonename: + :param vpc_id: + :param route53: + :param private_hosted_zone_collection: + :return: + """ + + LOGGER.debug("in function is_valid_zone") + LOGGER.debug("Looking to validate zone: %s", + zonename + lineno()) + + try: + # check if the zone is PHZ + if zonename.lower() in private_hosted_zone_collection: + LOGGER.debug("Private zone found: %s", + zonename + lineno()) + + hosted_zone_id = get_zone_id(zonename, hosted_zones) + LOGGER.debug("hosted_zone_id: %s", + hosted_zone_id + lineno()) + + private_hosted_zone_properties = get_hosted_zone_properties( + route53, + hosted_zone_id + ) + LOGGER.debug("private_hosted_zone_properties:" + " %s", str(private_hosted_zone_properties) + lineno()) + + # check if the VPC is associated with the PHZ + if vpc_id in map(lambda x: x['VPCId'], private_hosted_zone_properties['VPCs']): + LOGGER.debug("Privated Hosted Zone associated with VPC: %s", + zonename + lineno()) + return True + else: + LOGGER.debug("Private Hosted Zone is NOT associated with vpc: %s", + zonename + lineno()) + else: + LOGGER.debug("Domain Name does not match Private Hosted Zones: %s", + zonename + lineno()) + + # if returned with True, return false + return False + + except: + LOGGER.info("unexpected error. %s\n", str(sys.exc_info()[0]) + lineno()) + def get_dhcp_configurations(client, dhcp_options_id): """