|
| 1 | +import argparse |
| 2 | +import csv |
| 3 | +import logging |
| 4 | +import os |
| 5 | +import json |
| 6 | +import sys |
| 7 | +from typing import Any |
| 8 | +from datetime import datetime |
| 9 | +from vinyldns.client import VinylDNSClient |
| 10 | + |
| 11 | +""" |
| 12 | +Fetches matching DNS records from VinylDNS using a record name filter. |
| 13 | +Outputs result in JSON format to stdout and writes to a timestamped CSV file. |
| 14 | +
|
| 15 | +Environment variables must be set for VinylDNS authentication: |
| 16 | + - VINYLDNS_HOST |
| 17 | + - VINYLDNS_ACCESS_KEY |
| 18 | + - VINYLDNS_SECRET_KEY |
| 19 | +
|
| 20 | +Usage: |
| 21 | + python search_records_by_name.py <record_name_filter> |
| 22 | + |
| 23 | +Output example: |
| 24 | + fqdn,type,record_data |
| 25 | + test.example.com.,A,1.2.3.4 |
| 26 | + 4.3.2.1.in-addr.arpa.,PTR,test.example.com. |
| 27 | +""" |
| 28 | + |
| 29 | +REQUIRED_ENV_VARS = ["VINYLDNS_HOST", "VINYLDNS_ACCESS_KEY", "VINYLDNS_SECRET_KEY"] |
| 30 | + |
| 31 | +logging.basicConfig( |
| 32 | + level=logging.INFO, |
| 33 | + format="%(asctime)s [%(levelname)s] %(message)s", |
| 34 | + datefmt="%Y-%m-%d %H:%M:%S" |
| 35 | +) |
| 36 | + |
| 37 | + |
| 38 | +def safe_get_env_vars(env_vars: list[str]) -> dict[str, str]: |
| 39 | + """ |
| 40 | + Validate that all required environment variables exist and return their values. |
| 41 | +
|
| 42 | + Args: |
| 43 | + env_vars (list[str]): List of environment variable names (strings) to check and retrieve. |
| 44 | +
|
| 45 | + Returns: |
| 46 | + dict[str, str]: Dictionary mapping environment variable names to their string values. |
| 47 | +
|
| 48 | + Raises: |
| 49 | + EnvironmentError: If any of the required environment variables are missing. |
| 50 | + """ |
| 51 | + missing = [v for v in env_vars if v not in os.environ] |
| 52 | + if missing: |
| 53 | + raise EnvironmentError(f"Missing env vars: {missing}") |
| 54 | + return {v: os.environ[v] for v in env_vars} |
| 55 | + |
| 56 | + |
| 57 | +def format_record_data(record_type: str, record: Any) -> str: |
| 58 | + """ |
| 59 | + Format DNS record data into a human-readable string based on its type. |
| 60 | +
|
| 61 | + Args: |
| 62 | + record_type (str): The DNS record type (e.g., 'A', 'MX', 'TXT'). |
| 63 | + record (Any): The record object containing fields relevant to the type. |
| 64 | +
|
| 65 | + Returns: |
| 66 | + str: A formatted string describing the record contents. |
| 67 | + Returns an informative message if the record type is unsupported. |
| 68 | + """ |
| 69 | + formatters = { |
| 70 | + "A": lambda r: r.address, |
| 71 | + "AAAA": lambda r: r.address, |
| 72 | + "PTR": lambda r: r.ptrdname, |
| 73 | + "CNAME": lambda r: r.cname, |
| 74 | + "NS": lambda r: r.nsdname, |
| 75 | + "TXT": lambda r: r.text, |
| 76 | + "SPF": lambda r: r.text, |
| 77 | + "SRV": lambda r: ( |
| 78 | + f"priority: {r.priority}, weight: {r.weight}, " |
| 79 | + f"target: {r.target}, port: {r.port}" |
| 80 | + ), |
| 81 | + "MX": lambda r: ( |
| 82 | + f"preference: {r.preference}, exchange: {r.exchange}" |
| 83 | + ), |
| 84 | + "SOA": lambda r: ( |
| 85 | + f"mname: {r.mname}, rname: {r.rname}, serial: {r.serial}, " |
| 86 | + f"refresh: {r.refresh}, retry: {r.retry}, expire: {r.expire}, " |
| 87 | + f"minimum: {r.minimum}" |
| 88 | + ), |
| 89 | + "SSHFP": lambda r: ( |
| 90 | + f"algorithm: {r.algorithm}, type: {r.type}, fingerprint: {r.fingerprint}" |
| 91 | + ) |
| 92 | + } |
| 93 | + if record_type not in formatters: |
| 94 | + logging.warning(f"Could not retrieve record data for unsupported record type: {record_type}") |
| 95 | + return f"could not retrieve record data for record type: {record_type}" |
| 96 | + |
| 97 | + return formatters[record_type](record) |
| 98 | + |
| 99 | + |
| 100 | +def search_records(client: VinylDNSClient, record_name_filter: str) -> list[dict[str, str]]: |
| 101 | + """ |
| 102 | + Search VinylDNS for DNS records matching the given record name filter. |
| 103 | +
|
| 104 | + Args: |
| 105 | + client (VinylDNSClient): Initialized VinylDNS client instance. |
| 106 | + record_name_filter (str): Filter string to match record names/domains. |
| 107 | +
|
| 108 | + Returns: |
| 109 | + list[dict[str, str]]: List of dictionaries where each dictionary contains: |
| 110 | + - 'fqdn': Fully qualified domain name of the record set. |
| 111 | + - 'type': DNS record type as a string. |
| 112 | + - 'record_data': Formatted string with record-specific data. |
| 113 | + """ |
| 114 | + all_records = [] |
| 115 | + seen_records = set() |
| 116 | + next_id = None |
| 117 | + |
| 118 | + while True: |
| 119 | + response = client.search_record_sets(start_from=next_id, record_name_filter=record_name_filter) |
| 120 | + for record_set in response.record_sets: |
| 121 | + record_type = record_set.type |
| 122 | + for record in record_set.records: |
| 123 | + # check for duplicate records |
| 124 | + key = (record_set.fqdn, record_set.type, format_record_data(record_type, record)) |
| 125 | + if key in seen_records: |
| 126 | + continue |
| 127 | + seen_records.add(key) |
| 128 | + record_info = { |
| 129 | + "fqdn": record_set.fqdn, |
| 130 | + "type": record_set.type, |
| 131 | + "record_data": format_record_data(record_type, record) |
| 132 | + } |
| 133 | + all_records.append(record_info) |
| 134 | + next_id = response.next_id |
| 135 | + if not next_id: |
| 136 | + break |
| 137 | + if not all_records: |
| 138 | + logging.error(f"No records found matching filter {record_name_filter}") |
| 139 | + |
| 140 | + return all_records |
| 141 | + |
| 142 | + |
| 143 | +def write_records_to_csv(records: list[dict[str, str]], record_name_filter: str, output_dir: str = "output") -> str: |
| 144 | + """ |
| 145 | + Write a list of DNS record dictionaries to a timestamped CSV file. |
| 146 | +
|
| 147 | + Args: |
| 148 | + records (list[dict[str, str]]): List of record dictionaries to write to CSV. |
| 149 | + record_name_filter (str): Filter string used to name the CSV file safely. |
| 150 | + output_dir (str, optional): Directory to save the CSV file. Defaults to "output". |
| 151 | +
|
| 152 | + Returns: |
| 153 | + str: Full file path of the written CSV file. |
| 154 | +
|
| 155 | + Raises: |
| 156 | + OSError: If there is an error creating the directory or writing the CSV file, |
| 157 | + such as permission errors or disk IO problems. |
| 158 | + """ |
| 159 | + os.makedirs(output_dir, exist_ok=True) |
| 160 | + |
| 161 | + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| 162 | + safe_record_name_filter = record_name_filter.rstrip('.').replace('.', '_') |
| 163 | + filename = f"{safe_record_name_filter}_records_{timestamp}.csv" |
| 164 | + filepath = os.path.join(output_dir, filename) |
| 165 | + |
| 166 | + fieldnames = ["fqdn", "type", "record_data"] |
| 167 | + try: |
| 168 | + with open(filepath, 'w', newline='', encoding='utf-8') as csvfile: |
| 169 | + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) |
| 170 | + writer.writeheader() |
| 171 | + for record in records: |
| 172 | + writer.writerow(record) |
| 173 | + logging.info(f"CSV file successfully written: {filepath}") |
| 174 | + except OSError as e: |
| 175 | + logging.error(f"Failed to write CSV file {filepath}: {e}") |
| 176 | + raise |
| 177 | + |
| 178 | + return filepath |
| 179 | + |
| 180 | + |
| 181 | +def main() -> None: |
| 182 | + parser = argparse.ArgumentParser( |
| 183 | + description="Search VinylDNS records by name filter and output results." |
| 184 | + ) |
| 185 | + parser.add_argument( |
| 186 | + "record_name_filter", |
| 187 | + type=str, |
| 188 | + help="Record name filter for DNS query" |
| 189 | + ) |
| 190 | + args = parser.parse_args() |
| 191 | + |
| 192 | + try: |
| 193 | + env_vars = safe_get_env_vars(REQUIRED_ENV_VARS) |
| 194 | + client = VinylDNSClient( |
| 195 | + env_vars["VINYLDNS_HOST"], |
| 196 | + env_vars["VINYLDNS_ACCESS_KEY"], |
| 197 | + env_vars["VINYLDNS_SECRET_KEY"], |
| 198 | + ) |
| 199 | + |
| 200 | + records = search_records(client, args.record_name_filter) |
| 201 | + |
| 202 | + print(json.dumps(records, indent=2)) |
| 203 | + |
| 204 | + write_records_to_csv(records, args.record_name_filter) |
| 205 | + |
| 206 | + except EnvironmentError as env_err: |
| 207 | + logging.error(f"Environment error: {env_err}") |
| 208 | + sys.exit(3) |
| 209 | + except Exception as e: |
| 210 | + logging.error(f"Unexpected error: {e}", exc_info=True) |
| 211 | + sys.exit(4) |
| 212 | + |
| 213 | + |
| 214 | +if __name__ == "__main__": |
| 215 | + main() |
0 commit comments