Admittedly, reverse DNS is not the most exciting topic ever, but it is essential in certain situations, such as Kerberos authentication. Reverse DNS (rDNS) is the process of mapping an IP address back to a hostname, which is the opposite of the more common forward DNS lookup that maps a hostname to an IP address.
AWS automatically enables the "Autodefined rules for reverse DNS resolution" feature for all VPCs. This feature creates reverse DNS entries for all IP addresses within a VPC, formatted as follows: ip-192-168-1-40.eu-central-1.compute.internal. However, this is not sufficient for scenarios like Kerberos authentication, where host.example.com should resolve to, for example, 192.168.1.40, and the reverse lookup should resolve back to host.example.com instead of ip-192-168-1-40.eu-central-1.compute.internal.
The big challenge is how to automate the creation of PTR records for reverse DNS, ideally for an entire AWS Organization.
In this article, I will demonstrate a solution to automate the creation of reverse DNS entries (PTR) across an entire AWS Organization. Whenever an A-record is created in a Route 53 Private Hosted Zone, the corresponding PTR in the reverse lookup zone (in-addr.arpa) is automatically generated. These PTR records are accessible across the entire AWS Organization and, if desired, from on-premises environments as well.
Quick overview of the solution
Event-Based Update Process:
1. An EventBridge Rule forwards the "ChangeResourceRecordSets" event from Route53 to the central Network Account.
2. The events are collected in an SQS queue and processed by a Lambda function.
3. The Lambda function checks if the event was an A-record and if here is a corresponding Reverse Lookup Zone for the IP address.
4. Depending on the event, the PTR record is created or deleted.
Scheduled Update Process
(This process is important to initially create all PTRs if A-records already exist in the AWS Organization)
1. EventBridge schedules the Lambda function in the Network Account to run once daily.
2. The Lambda function detects the detail type "Scheduled Event."
3. The Lambda iterates through all accounts in the AWS Organization and searches for Route53 Private Hosted Zones.
4. All found A-records are written into a Python list.
5. At the end, it checks if there is already a PTR for each found A-record. If not, it creates one.
Resolving PTR Records within the AWS Organization
1. The Route53 Private Hosted Zones for the reverse lookup zones, e.g., 168.192.in-addr.arpa., are associated with the Network Account VPC.
2. The Network Account has both a Route53 Inbound Resolver and an Outbound Resolver.
3. In the Network Account, there is a Route53 Resolver Rule that uses the Outbound Resolver and targets the Inbound Resolver’s IP address. This rule is shared with the entire AWS Organization using AWS Resource Access Manager (RAM).
4. The Project VPCs are associated with the Route53 Resolver Rules.
5. As a result, all reverse DNS queries for 168.192.in-addr.arpa. are sent to the Inbound Resolver, which can resolve them.
Resolving PTR Records from On-Premises
1. On the on-premises DNS server, conditional forwarding is configured for, e.g., 168.192.in-addr.arpa. The target is set to the Inbound Resolver.
2. The Inbound Resolver is aware of the Route53 Private Hosted Zones, so it can resolve the PTR records and respond to the on-premises DNS server.
Detailed Solution
Setting Up Route53 Private Hosted Zones for Reverse Lookup Zones
The Private Hosted Zones (PHZ) for reverse DNS lookups must follow the in-addr.arpa schema. This means the domain name is the IP address blocks in reverse order followed by .in-addr.arpa For example, 168.192.in-addr.arpa is the reverse lookup zone for 192.168.. Since CIDR notation cannot be used here, multiple PHZs need to be created if you want reverse lookup zones only for specific subnets like 192.168.1.0/24 and 192.168.2.0/24.
PHZ for 192.168.1.0/24:
PHZ for 192.168.2.0/24
ReverseLookup1921681:
Type: AWS::Route53::HostedZone
Properties:
HostedZoneConfig:
Comment: "Reverse Lookup Zone for 192.168.1.0/24"
Name: "1.168.192.in-addr.arpa"
VPCs:
- VPCId: !Ref NetworkVpc
VPCRegion: "eu-central-1"
Setting Up Route53 Resolver Rule and RAM Share
To enable VPCs to resolve reverse DNS entries (PTR), a Route53 resolver rule must be created in the network account. This rule will be shared across the entire organization using AWS Resource Access Manager (RAM). The resolver rules must be associated with each VPC that needs the capability to reverse resolve DNS names.
InboundResolverForwardingRuleReverseDNSLookup1921681:
Type: AWS::Route53Resolver::ResolverRule
Properties:
DomainName: "1.168.192.in-addr.arpa."
Name: "1-168-192.in-addr.arpa"
ResolverEndpointId: !Ref OutboundResolverEndpoint
RuleType: FORWARD
TargetIps:
- Ip:
Port: 53
- Ip:
Port: 53
ResolverForwardingRuleShare:
Type: AWS::RAM::ResourceShare
Properties:
AllowExternalPrincipals: False
Name: route-53-resolver-share
Principals:
- !Sub arn:aws:organizations::${OrgAccountId}:organization/${OrgId}
ResourceArns:
- !GetAtt InboundResolverForwardingRuleReverseDNSLookup1921681.Arn
Important! AWS Automatic Activation of "Autodefined Rules for Reverse DNS Resolution"
AWS automatically enables the "Autodefined rules for reverse DNS resolution" feature for all VPCs. This feature can be found under Route 53 -> Resolver -> VPCs.
Consequently, reverse DNS entries for all IP addresses within a VPC are automatically created, formatted as follows: ip-192-168-1-40.eu-central-1.compute.internal.
nslookup 192.168.1.40
192-168-1-40.in-addr.arpa name = ip-10-0-1-40.eu-central-1.compute.internal.
The issue here is that this automatic resolution can sometimes be preferred over the Route 53 resolver rule, preventing our PTRs from the Private Hosted Zone from being resolved. To ensure our PTRs from the Private Hosted Zone are resolved correctly, this feature should be disabled for all VPCs that want to use our reverse DNS solution. This can be automated using the following example Python Boto3 command, which can be executed for each VPC:
def update_resolver_config(vpcId, r53_client):
try:
resolverConfig = r53_client.get_resolver_config(ResourceId=vpcId)
if resolverConfig['ResolverConfig']['AutodefinedReverse'] != 'DISABLE':
r53_client.update_resolver_config(
ResourceId=vpcId,
AutodefinedReverseFlag='DISABLE'
)
print(f'Reverse DNS lookups disabled for VPC {vpcId}')
except ClientError as e:
print(f'Failed to update resolver config for VPC {vpcId}: {e}')
Creation of Inbound and Outbound Resolvers
The Route 53 Inbound and Outbound resolvers should be created in the network account. Additionally, the designated security group must have DNS port 53 open for both TCP and UDP traffic.
InboundResolverEndpoint:
Type: AWS::Route53Resolver::ResolverEndpoint
Properties:
Direction: INBOUND
IpAddresses:
- SubnetId: !Ref DnsSubnetA
Ip: !GetAtt IPAddressesCustomResource.FifthIpSubnetA
- SubnetId: !Ref DnsSubnetB
Ip: !GetAtt IPAddressesCustomResource.FifthIpSubnetB
Name: inbound-resolver-1
SecurityGroupIds:
- !Ref DnsSecurityGroup
OutboundResolverEndpoint:
Type: AWS::Route53Resolver::ResolverEndpoint
Properties:
Direction: OUTBOUND
IpAddresses:
- SubnetId: !Ref DnsSubnetA
Ip: !GetAtt IPAddressesCustomResource.SixthIpSubnetA
- SubnetId: !Ref DnsSubnetB
Ip: !GetAtt IPAddressesCustomResource.SixthIpSubnetB
Name: outbound-resolver-1
SecurityGroupIds:
- !Ref DnsSecurityGroup
DnsSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: String
GroupName: 'DnsSecurityGroup'
SecurityGroupEgress:
- IpProtocol: udp
FromPort: 53
ToPort: 53
CidrIp: 192.168.0.0/16
- IpProtocol: tcp
FromPort: 53
ToPort: 53
CidrIp: 192.168.0.0/16
SecurityGroupIngress:
- IpProtocol: udp
FromPort: 53
ToPort: 53
CidrIp: 192.168.0.0/16
- IpProtocol: tcp
FromPort: 53
ToPort: 53
CidrIp: 192.168.0.0/16
- IpProtocol: icmp
FromPort: -1
ToPort: -1
CidrIp: 192.168.0.0/16
VpcId: !Ref DnsVpc
Create EventBridge Rule in All Accounts
To forward the Route53 "ChangeResourceRecordSets" events from all accounts to the network account, an EventBridge rule needs to be created in each account. It is crucial to create this rule in the us-east-1 region because Route53 is a global service, and the CloudTrail events originate there. For the EventBridge target, the network account will be selected, and there is an option to choose a different region at this stage.
AutomatedPTRforReverseDNSlookup:
Type: "AWS::Events::Rule"
Condition: IsNotNetworkAccount
Properties:
Description: forwards the ChangeResourceRecordSets event to the Network Account
EventPattern:
source:
- "aws.route53"
detail-type:
- "AWS API Call via CloudTrail"
detail:
eventSource:
- "route53.amazonaws.com"
eventName:
- "ChangeResourceRecordSets"
Name: "AutomatedPTRforReverseDNSlookup"
Targets:
- Arn: !Sub "arn:aws:events:eu-central-1:${NetworkAccount}:event-bus/default"
Id: "AutomatedPTRforReverseDNSlookup"
RoleArn: !GetAtt EventBridgeRuleRole.Arn
EventBridgeRuleRole:
Type: "AWS::IAM::Role"
Properties:
RoleName: "EventBridgeRuleRole"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Principal:
Service:
- "events.amazonaws.com"
Action:
- "sts:AssumeRole"
Path: "/"
RolePolicies:
Type: "AWS::IAM::Policy"
Properties:
PolicyName: "EventBridgeRuleRolePolicy"
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action: "events:PutEvents"
Resource: !Sub "arn:aws:events:eu-central-1:${NetworkAccount}:event-bus/default"
Roles:
- Ref: "EventBridgeRuleRole"
Development of Central Update PTR Logic
In the Network Account, an EventBridge rule for "ChangeResourceRecordSets" events needs to be set up. The target for this rule should be an SQS queue. The Lambda function will be triggered by this SQS queue using an EventSourceMapping.
EventBridge rule for event-based and scheduled:
EventBridgeRule:
Type: "AWS::Events::Rule"
Properties:
Description: "Create a PTR record in reverse DNS zones whenever an A record in a PHZ is created, modified, or deleted."
State: ENABLED
EventPattern: !Sub
- |
{
"source": [
"aws.route53"
],
"detail-type": [
"AWS API Call via CloudTrail"
],
"detail": {
"eventSource": [
"route53.amazonaws.com"
],
"eventName": [
"ChangeResourceRecordSets"
]
}
}
- { eventNames: !Join [ '", "', !Ref Events ] }
Name: AutomatedPTRforReverseDNSlookup
Targets:
- Arn: !GetAtt SQSQueue.Arn
Id: AutomatedPTRforReverseDNSlookup
ScheduledEventBridgeRule:
Type: "AWS::Events::Rule"
Properties:
Description: "Checks once a day if all PTR Records are set up correctly"
State: ENABLED
ScheduleExpression: !Ref Schedule
Name: !Sub "AutomatedPTRforReverseDNSlookupScheduled"
Targets:
- Arn: !GetAtt SQSQueue.Arn
Id: AutomatedPTRforReverseDNSlookup
SQS Queue:
SQSQueue:
Type: "AWS::SQS::Queue"
Properties:
QueueName: AutomatedPTRforReverseDNSlookup
MessageRetentionPeriod: 1000
ReceiveMessageWaitTimeSeconds: 0
VisibilityTimeout: 950
SQSQueuePolicy:
Type: "AWS::SQS::QueuePolicy"
Properties:
PolicyDocument: !Sub |
{
"Version": "2012-10-17",
"Id": "${SQSQueue.Arn}/SQSDefaultPolicy",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "events.amazonaws.com"
},
"Action": "sqs:SendMessage",
"Resource": "${SQSQueue.Arn}",
"Condition": {
"ArnEquals": {
"aws:SourceArn": [
"${CloudWatchRule.Arn}",
"${ScheduledCloudWatchRule.Arn}"
]
}
}
}
]
}
Queues:
- !Ref SQSQueue
Lambda Function, Lambda Role and EventSourceMapping:
LambdaFunction:
Type: "AWS::Lambda::Function"
Properties:
FunctionName: AutomatedPTRforReverseDNSlookup
Description: Creates a PTR record in reverse DNS zones when an A record in a PHZ is created, modified, or deleted.
Handler: !Sub ${PythonFileName}.lambda_handler
Role: !GetAtt LambdaRole.Arn
MemorySize: 128
Timeout: 900
Runtime: "python3.12"
Code:
S3Bucket: !Ref S3Bucket
S3Key: !Ref S3Key
Environment:
Variables:
LambdaRole: !Ref CrossAccountLambdaRole
OrganizationAccountId: !Ref OrganizationAccount
ReverseLookupZone1921681Id: !Ref ReverseLookupZone1921681
LambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: AutomatedPTRforReverseDNSlookupRole
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
Policies:
- PolicyName: AutomatedPTRforReverseDNSlookupRolePolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup
Resource: "*"
- Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
Resource:
- Fn::Sub: 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*'
- Effect: Allow
Action:
- sqs:ReceiveMessage
- sqs:DeleteMessage
- sqs:GetQueueAttributes
Resource: !Sub "arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:AutomatedPTRforReverseDNSlookup"
- Effect: Allow
Action:
- sts:AssumeRole
Resource: !Sub "arn:aws:iam::*:role/${CrossAccountLambdaRole}"
- Effect: "Allow"
Action:
- route53:ChangeResourceRecordSets
- route53:ListResourceRecordSets
- route53:GetHostedZone
Resource:
- !Sub "arn:aws:route53:::hostedzone/${ReverseLookupZone1921681}"
- Effect: Allow
Action:
- logs:CreateLogDelivery
- logs:DeleteLogDelivery
Resource: "*"
EventSourceMapping:
Type: "AWS::Lambda::EventSourceMapping"
Properties:
BatchSize: 10
Enabled: true
EventSourceArn: !GetAtt SQSQueue.Arn
FunctionName: !Ref LambdaFunction
The Lambda function processes CloudTrail events for the "ChangeResourceRecordSets" action. It checks if the event involves an A-record and verifies whether the IP address is within one of the predefined Route53 Private Hosted Zones (PHZ) for reverse lookup. If the IP address matches, the function creates or deletes the corresponding PTR record based on the event action.
Additionally, the Lambda processes a scheduled event triggered daily by EventBridge. This scheduled event iterates through all accounts in the AWS Organization to find Route53 Private Hosted Zones. It collects all A-records into a Python list. At the end of the iteration, the Lambda checks if a PTR record exists for each A-record found; if not, it creates one.
Below is the Python code for the Lambda function:
import json
import boto3
import os
from ipaddress import ip_address
from botocore.exceptions import ClientError
# Environment variables for the zone IDs
REVERSELOOKUPZONE1921681ID = os.environ['ReverseLookupZone1921681Id']
# Environment variable for ORG AccountID
ORGACCOUNTID = os.environ['OrganizationAccountId']
# Mapping IP prefixes to zone IDs
zone_mapping = {
'192.168.1': REVERSELOOKUPZONE10200ID
}
# Initialize the Route53 client
route53_client = boto3.client('route53')
def lambda_handler(event, context):
messages = [json.loads(record['body']) for record in event['Records']]
for message in messages:
try:
cleanedEvent = json.loads(message['Message'])
except:
cleanedEvent = message
print(f'CleanedEvent: {cleanedEvent}')
if cleanedEvent["detail-type"] == "AWS API Call via CloudTrail":
print('API Call')
# Nur aufrufen, wenn der Event ein A-Record betrifft
if is_a_record_event(cleanedEvent):
handle_called_event(cleanedEvent, context)
elif cleanedEvent["detail-type"] == "Scheduled Event":
print('Scheduled')
handle_scheduled_event(cleanedEvent, context)
def is_a_record_event(event_detail):
eventName = event_detail['detail']['eventName']
if eventName == 'ChangeResourceRecordSets':
changes = event_detail['detail']['requestParameters']['changeBatch']['changes']
for change in changes:
if change.get('resourceRecordSet', {}).get('type') == 'A':
return True
return False
def handle_called_event(event, context):
eventName = event['detail']['eventName']
if eventName == 'ChangeResourceRecordSets':
changes = event['detail']['requestParameters']['changeBatch']['changes']
for change in changes:
action = change['action']
resource_record_set = change.get('resourceRecordSet', {})
resource_records = resource_record_set.get('resourceRecords', [])
dns_name = resource_record_set.get('name', 'Unknown DNS Name')
for record in resource_records:
ip_address = record['value']
record_info = {'DNS Name': dns_name, 'IP Address': ip_address}
if action == 'CREATE':
manage_ptr_record(record_info, action='CREATE')
elif action == 'DELETE':
manage_ptr_record(record_info, action='DELETE')
def manage_ptr_record(record_info, action):
ip_addr_str = record_info['IP Address']
dns_name = record_info['DNS Name']
if not ip_addr_str.startswith(('192.168.1'')):
print(f'The IP {ip_addr_str} is not in the valid range.')
return
zone_prefix = ip_addr_str.split('.')[1]
zone_id = zone_mapping.get('192.' + zone_prefix)
if not zone_id:
print(f"No zone ID found for the IP prefix 192.{zone_prefix}.")
return
ip_blocks = ip_addr_str.split('.')
reversed_ip_blocks = ip_blocks[::-1]
reversed_ip = '.'.join(reversed_ip_blocks) + '.in-addr.arpa.'
ptr_record_name = dns_name
# Überprüfe, ob der PTR-Eintrag bereits existiert
paginator = route53_client.get_paginator('list_resource_record_sets')
record_exists = False
try:
for page in paginator.paginate(HostedZoneId=zone_id):
for record_set in page['ResourceRecordSets']:
if (record_set['Type'] == 'PTR' and
record_set['Name'].rstrip('.') == reversed_ip.rstrip('.') and
any(rr['Value'].rstrip('.') == ptr_record_name.rstrip('.') for rr in record_set.get('ResourceRecords', []))):
record_exists = True
break
if record_exists:
# Schleife verlassen, wenn der Eintrag gefunden wurde
break
except ClientError as e:
print(f"An error occurred during PTR record check: {e}")
return
if not record_exists and action == 'DELETE':
print(f"No existing PTR record for {ip_addr_str} to delete.")
return
# If it does not exist and the action is CREATE, or if it exists and the action is DELETE, implement the change.
if (not record_exists and action == 'CREATE') or (record_exists and action == 'DELETE'):
change_batch = {
'Comment': f'{action} PTR record for {ip_addr_str}',
'Changes': [{
'Action': action,
'ResourceRecordSet': {
'Name': reversed_ip,
'Type': 'PTR',
'TTL': 300,
'ResourceRecords': [{'Value': ptr_record_name}]
}
}]
}
try:
response = route53_client.change_resource_record_sets(
HostedZoneId=zone_id,
ChangeBatch=change_batch
)
print(f'Successfully {action} PTR record for {ip_addr_str} to name {ptr_record_name} : {response}')
except ClientError as e:
print(f'An error occurred: {e}')
else:
if action == 'CREATE':
print(f"PTR record for {ip_addr_str} to {ptr_record_name} already exists. Skipping CREATE.")
elif action == 'DELETE':
print(f"PTR record for {ip_addr_str} to {ptr_record_name} does not exist. Skipping DELETE.")
def handle_scheduled_event(event, context):
# List to store all A-Record data
a_records_list = []
## Iterate through all accounts
for accountId in get_accounts():
print(f'############## CHECKING Account {accountId} ##############')
region = 'eu-central-1'
roleName = os.environ['LambdaRole']
route53_client = get_session(accountId, region, roleName).client('route53')
# Paginate through all the hosted zones
hosted_zones_paginator = route53_client.get_paginator('list_hosted_zones')
for hosted_zones_page in hosted_zones_paginator.paginate():
for hosted_zone in hosted_zones_page['HostedZones']:
hosted_zone_id = hosted_zone['Id']
print(f'############## Checking Hosted Zone {hosted_zone_id} ##############')
record_sets_paginator = route53_client.get_paginator('list_resource_record_sets')
for record_sets_page in record_sets_paginator.paginate(HostedZoneId=hosted_zone_id):
for record_set in record_sets_page['ResourceRecordSets']:
if record_set['Type'] == 'A': # Check for 'A' records
# Use get() with a default value to avoid KeyError
resource_records = record_set.get('ResourceRecords', [])
# Store the A-Record data in the list
for ip_record in resource_records:
a_records_list.append({
'DNS Name': record_set['Name'],
'IP Address': ip_record['Value']
})
print(f"A-Record found: {record_set['Name']} - {ip_record}")
# Process each A-Record in the list using the function 'manage_ptr_record'
for a_record in a_records_list:
manage_ptr_record(a_record, action='CREATE')
def get_accounts():
roleName = os.environ['LambdaRole']
org_client = get_session(ORGACCOUNTID, 'eu-central-1', roleName).client('organizations')
accountList = []
accountPaginator = org_client.get_paginator('list_accounts')
accountIterator = accountPaginator.paginate()
for accounts in accountIterator:
for account in accounts['Accounts']:
if account['Status'] == "ACTIVE":
accountList.append(account['Id'])
return accountList
def get_session(accountId, region, roleName):
sts_client = boto3.client('sts')
assumed_role_object = sts_client.assume_role(
RoleSessionName='xyz', RoleArn=f'arn:aws:iam::{accountId}:role/{roleName}')
credentials = assumed_role_object['Credentials']
access_key_id = credentials['AccessKeyId']
secret_access_key = credentials['SecretAccessKey']
session_token = credentials['SessionToken']
new_session = boto3.Session(
aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
aws_session_token=session_token,
region_name=region
)
return new_session
The IAM role for each account, used by the Lambda function in the network account for the daily scheduler, requires the following permissions:
ReverseDNSLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: CrossAccountReverseDNSLambdaRoleRole
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
AWS:
- !Sub 'arn:aws:iam::${NetworkAccountId}:root'
Action:
- sts:AssumeRole
MaxSessionDuration: 3600
Path: /
Policies:
- PolicyName: CrossAccountReverseDNSLambdaRoleRolePolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- organizations:ListAccounts
- organizations:DescribeAccount
Resource: "*"
- Effect: Allow
Action:
- route53:ListResourceRecordSets
- route53:GetHostedZone
- route53:ListHostedZones
Resource: "*"
- Effect: Allow
Action:
- iam:PassRole
Resource: 'arn:aws:iam::*:role/*'
Final Remarks
Some of the CloudFormation stacks need to be deployed as StackSets across the entire AWS Organization. Therefore, it is recommended to use tools such as CfCT or LZA, especially if ControlTower is in use.
I hope I was able to demonstrate a potential solution to automate the creation of reverse DNS entries (PTR) across an entire AWS Organization. Additionally, how the reverse DNS entries can be resolved within the AWS Organization and from on-premises.