-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
964 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| describe_zone.py v1.0.0 | ||
| Describe a Route 53 hosted zone in detail: metadata, record count, | ||
| delegation set, associated VPCs, tags, and DNSSEC status. | ||
| Usage: | ||
| python describe_zone.py --zone-id Z1234567890ABC [--profile myprofile] [--output-json zone_info.json] | ||
| """ | ||
|
|
||
| import argparse | ||
| import boto3 | ||
| import json | ||
| import sys | ||
| from typing import Optional | ||
|
|
||
| VERSION = "1.0.0" | ||
|
|
||
|
|
||
| def get_client(profile: Optional[str], region: str): | ||
| session = boto3.Session(profile_name=profile, region_name=region) | ||
| return session.client('route53') | ||
|
|
||
|
|
||
| def get_zone_details(client, zone_id: str) -> dict: | ||
| """Fetch core hosted zone metadata.""" | ||
| try: | ||
| response = client.get_hosted_zone(Id=zone_id) | ||
| except client.exceptions.NoSuchHostedZone: | ||
| print(f"ERROR: Hosted zone '{zone_id}' not found.", file=sys.stderr) | ||
| sys.exit(1) | ||
| except Exception as e: | ||
| print(f"ERROR fetching zone details: {e}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| return { | ||
| 'HostedZone': response['HostedZone'], | ||
| 'DelegationSet': response.get('DelegationSet', {}), | ||
| 'VPCs': response.get('VPCs', []), | ||
| } | ||
|
|
||
|
|
||
| def get_record_count(client, zone_id: str) -> int: | ||
| """ | ||
| Return the exact record count by paginating all RRs. | ||
| The ResourceRecordSetCount in GetHostedZone is an estimate and can lag. | ||
| """ | ||
| count = 0 | ||
| paginator = client.get_paginator('list_resource_record_sets') | ||
| for page in paginator.paginate(HostedZoneId=zone_id): | ||
| count += len(page['ResourceRecordSets']) | ||
| return count | ||
|
|
||
|
|
||
| def get_tags(client, zone_id: str) -> dict: | ||
| """Fetch tags for the hosted zone.""" | ||
| try: | ||
| response = client.list_tags_for_resource( | ||
| ResourceType='hostedzone', | ||
| ResourceId=zone_id | ||
| ) | ||
| return {tag['Key']: tag['Value'] for tag in response['ResourceTagSet']['Tags']} | ||
| except Exception as e: | ||
| print(f"WARNING: Could not fetch tags: {e}", file=sys.stderr) | ||
| return {} | ||
|
|
||
|
|
||
| def get_dnssec_status(client, zone_id: str) -> dict: | ||
| """Fetch DNSSEC signing status (public zones only).""" | ||
| try: | ||
| response = client.get_dnssec(HostedZoneId=zone_id) | ||
| return { | ||
| 'Status': response['Status'].get('ServeSignature', 'UNKNOWN'), | ||
| 'KeySigningKeys': [ | ||
| { | ||
| 'Name': ksk['Name'], | ||
| 'Status': ksk['Status'], | ||
| 'Algorithm': ksk.get('SigningAlgorithmMnemonic', ''), | ||
| 'CreatedOn': str(ksk.get('CreatedDate', '')), | ||
| } | ||
| for ksk in response.get('KeySigningKeys', []) | ||
| ] | ||
| } | ||
| except client.exceptions.NoSuchHostedZone: | ||
| return {'Status': 'NOT_APPLICABLE', 'KeySigningKeys': []} | ||
| except Exception: | ||
| # Private zones and GovCloud may not support DNSSEC | ||
| return {'Status': 'NOT_APPLICABLE', 'KeySigningKeys': []} | ||
|
|
||
|
|
||
| def get_account_id(profile: Optional[str]) -> str: | ||
| """Resolve the AWS account ID for the active credentials.""" | ||
| try: | ||
| session = boto3.Session(profile_name=profile) | ||
| sts = session.client('sts') | ||
| return sts.get_caller_identity()['Account'] | ||
| except Exception as e: | ||
| return f"UNKNOWN ({e})" | ||
|
|
||
|
|
||
| def build_report(zone_id: str, details: dict, record_count: int, tags: dict, | ||
| dnssec: dict, account_id: str) -> dict: | ||
| """Assemble all collected data into a structured report dict.""" | ||
| hz = details['HostedZone'] | ||
| config = hz.get('Config', {}) | ||
| delegation = details.get('DelegationSet', {}) | ||
| vpcs = details.get('VPCs', []) | ||
|
|
||
| return { | ||
| 'SchemaVersion': '1.0', | ||
| 'ToolVersion': VERSION, | ||
| 'AccountId': account_id, | ||
| 'HostedZone': { | ||
| 'Id': zone_id, | ||
| 'Name': hz.get('Name', ''), | ||
| 'Comment': config.get('Comment', ''), | ||
| 'PrivateZone': config.get('PrivateZone', False), | ||
| 'CallerReference': hz.get('CallerReference', ''), | ||
| }, | ||
| 'RecordCount': { | ||
| 'Exact': record_count, | ||
| 'Estimate': hz.get('ResourceRecordSetCount', 'N/A'), | ||
| }, | ||
| 'DelegationSet': { | ||
| 'Id': delegation.get('Id', ''), | ||
| 'CallerReference': delegation.get('CallerReference', ''), | ||
| 'NameServers': delegation.get('NameServers', []), | ||
| }, | ||
| 'AssociatedVPCs': [ | ||
| {'VPCId': vpc.get('VPCId', ''), 'VPCRegion': vpc.get('VPCRegion', '')} | ||
| for vpc in vpcs | ||
| ], | ||
| 'Tags': tags, | ||
| 'DNSSEC': dnssec, | ||
| } | ||
|
|
||
|
|
||
| def print_report(report: dict): | ||
| """Print a human-readable summary of the zone report.""" | ||
| hz = report['HostedZone'] | ||
| rc = report['RecordCount'] | ||
| ds = report['DelegationSet'] | ||
| vpcs = report['AssociatedVPCs'] | ||
| dnssec = report['DNSSEC'] | ||
|
|
||
| print(f"\ndescribe_zone.py v{VERSION}") | ||
| print("=" * 60) | ||
| print("HOSTED ZONE SUMMARY") | ||
| print("=" * 60) | ||
|
|
||
| print(f"\n{'Zone ID':<22} {hz['Id']}") | ||
| print(f"{'Zone Name':<22} {hz['Name']}") | ||
| print(f"{'Account ID':<22} {report['AccountId']}") | ||
| print(f"{'Comment':<22} {hz['Comment'] or '(none)'}") | ||
| print(f"{'Private Zone':<22} {hz['PrivateZone']}") | ||
| print(f"{'Caller Reference':<22} {hz['CallerReference']}") | ||
|
|
||
| print(f"\n--- Record Count {'---':-<42}") | ||
| print(f" {'Exact (paginated)':<22} {rc['Exact']}") | ||
| print(f" {'AWS Estimate':<22} {rc['Estimate']}") | ||
|
|
||
| print(f"\n--- Delegation Set {'---':-<41}") | ||
| if ds['NameServers']: | ||
| print(f" {'Set ID':<22} {ds['Id'] or '(default)'}") | ||
| for i, ns in enumerate(ds['NameServers']): | ||
| label = 'Name Servers' if i == 0 else '' | ||
| print(f" {label:<22} {ns}") | ||
| else: | ||
| print(" (Private zone — no delegation set)") | ||
|
|
||
| print(f"\n--- Associated VPCs {'---':-<40}") | ||
| if vpcs: | ||
| print(f" {'VPC ID':<24} {'Region'}") | ||
| print(f" {'-'*24} {'-'*15}") | ||
| for vpc in vpcs: | ||
| print(f" {vpc['VPCId']:<24} {vpc['VPCRegion']}") | ||
| else: | ||
| print(" (Public zone — no VPC associations)") | ||
|
|
||
| print(f"\n--- Tags {'---':-<51}") | ||
| if report['Tags']: | ||
| for k, v in sorted(report['Tags'].items()): | ||
| print(f" {k:<30} {v}") | ||
| else: | ||
| print(" (no tags)") | ||
|
|
||
| print(f"\n--- DNSSEC {'---':-<49}") | ||
| print(f" {'Status':<22} {dnssec['Status']}") | ||
| if dnssec['KeySigningKeys']: | ||
| for ksk in dnssec['KeySigningKeys']: | ||
| print(f" {'KSK':<22} {ksk['Name']} | {ksk['Status']} | {ksk['Algorithm']} | created {ksk['CreatedOn']}") | ||
| else: | ||
| print(" (no key signing keys)") | ||
|
|
||
| print() | ||
|
|
||
|
|
||
| def export_json(report: dict, output_file: str): | ||
| with open(output_file, 'w') as f: | ||
| json.dump(report, f, indent=2, default=str) | ||
| print(f"Report exported to {output_file}") | ||
|
|
||
|
|
||
| def main(): | ||
| parser = argparse.ArgumentParser( | ||
| description=f'Describe a Route 53 hosted zone in detail. v{VERSION}' | ||
| ) | ||
| parser.add_argument('--zone-id', required=True, help='Hosted Zone ID (e.g., Z1234567890ABC)') | ||
| parser.add_argument('--profile', help='AWS profile name') | ||
| parser.add_argument('--region', default='us-gov-east-1', help='AWS region (default: us-gov-east-1)') | ||
| parser.add_argument('--output-json', metavar='FILE', help='Export report to JSON file') | ||
| parser.add_argument('--quiet', action='store_true', help='Suppress console output (use with --output-json)') | ||
| args = parser.parse_args() | ||
|
|
||
| zone_id = args.zone_id.split('/')[-1] | ||
|
|
||
| client = get_client(args.profile, args.region) | ||
| account_id = get_account_id(args.profile) | ||
| details = get_zone_details(client, zone_id) | ||
| record_count = get_record_count(client, zone_id) | ||
| tags = get_tags(client, zone_id) | ||
| dnssec = get_dnssec_status(client, zone_id) | ||
|
|
||
| report = build_report(zone_id, details, record_count, tags, dnssec, account_id) | ||
|
|
||
| if not args.quiet: | ||
| print_report(report) | ||
|
|
||
| if args.output_json: | ||
| export_json(report, args.output_json) | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| main() |
Oops, something went wrong.