# Architecture and Design ## System Architecture The DNS lookup tool follows a clean three-layer architecture: ``` ┌─────────────────────────────────────────────┐ │ User Interface Layer │ │ (CLI commands, argument parsing) │ │ │ │ File: cli.py │ │ Framework: Typer + Rich │ └──────────────┬──────────────────────────────┘ │ ├─> query() ├─> reverse() ├─> trace() ├─> batch() └─> whois() │ ┌──────────────▼──────────────────────────────┐ │ Business Logic Layer │ │ (DNS resolution, data processing) │ │ │ │ Files: resolver.py, whois_lookup.py │ │ Library: dnspython │ └──────────────┬──────────────────────────────┘ │ ├─> DNS Protocol (UDP:53) ├─> WHOIS Protocol (TCP:43) │ ┌──────────────▼──────────────────────────────┐ │ Presentation Layer │ │ (Output formatting, visualization) │ │ │ │ File: output.py │ │ Library: Rich │ └─────────────────────────────────────────────┘ ``` This separation allows: - Testing business logic without CLI - Switching output formats without changing resolution logic - Adding new commands without modifying core resolver ## Data Flow Architecture ### Single Domain Query Flow ``` User: dnslookup query example.com │ ▼ cli.py:112-167 (query command) │ ├─> Parse arguments ├─> parse_record_types() → [A, AAAA, MX, ...] │ ▼ resolver.py:213-250 (lookup function) │ ├─> create_resolver() → dns.asyncresolver.Resolver ├─> Start timer ├─> Create tasks for each record type │ ▼ resolver.py:191-209 (query_record_type) │ ├─> await resolver.resolve(domain, "A") ├─> await resolver.resolve(domain, "AAAA") ├─> ... (parallel execution) │ ▼ asyncio.gather() → collect results │ ▼ DNSResult object (records + metadata) │ ▼ output.py:83-127 (print_results_table) │ └─> Rich Table → Terminal ``` ### Batch Query Flow ``` User: dnslookup batch domains.txt │ ▼ cli.py:266-350 (batch command) │ ├─> Read file line by line ├─> Filter comments and empty lines │ ▼ resolver.py:428-440 (batch_lookup) │ └─> [lookup(d1), lookup(d2), ..., lookup(dn)] │ ▼ asyncio.gather() → parallel execution │ ▼ [DNSResult, DNSResult, DNSResult, ...] │ ▼ output.py:340-377 (print_batch_results) ``` The key optimization: all domains queried concurrently (`resolver.py:432-440`). ### DNS Trace Flow ``` User: dnslookup trace example.com │ ▼ cli.py:219-263 (trace command) │ ▼ resolver.py:293-426 (trace_dns) │ ├─> Start at root servers [.] │ └─> Query a.root-servers.net:198.41.0.4 │ Response: "Refer to .com servers" │ ├─> Query .com TLD server │ └─> Get NS records for example.com │ Response: "Refer to ns1.example.com" │ ├─> Query authoritative server │ └─> ns1.example.com │ Response: "A 93.184.216.34" (answer!) │ └─> Build TraceResult with hops │ ▼ output.py:266-310 (print_trace_result) └─> Rich Tree visualization ``` This mimics how a real recursive resolver operates. ## Core Data Structures ### RecordType Enum (`resolver.py:24-33`) ```python class RecordType(StrEnum): A = "A" AAAA = "AAAA" MX = "MX" NS = "NS" TXT = "TXT" CNAME = "CNAME" SOA = "SOA" PTR = "PTR" ``` Using `StrEnum` provides type safety while allowing string comparison. The values match DNS protocol record type names exactly. ### DNSRecord Dataclass (`resolver.py:46-54`) ```python @dataclass class DNSRecord: record_type: RecordType value: str ttl: int priority: int | None = None ``` Represents a single DNS resource record. The `priority` field is `None` for most record types but populated for MX records (`resolver.py:148-150`). ### DNSResult Dataclass (`resolver.py:57-65`) ```python @dataclass class DNSResult: domain: str records: list[DNSRecord] = field(default_factory=list) errors: list[str] = field(default_factory=list) query_time_ms: float = 0.0 nameserver: str | None = None ``` Aggregates all information about a query. Using `field(default_factory=list)` prevents mutable default argument bugs. ### TraceHop and TraceResult (`resolver.py:68-88`) ```python @dataclass class TraceHop: zone: str # ".", ".com", "example.com" server: str # "a.root-servers.net" server_ip: str # "198.41.0.4" response: str # Human-readable response is_authoritative: bool # Final answer vs referral @dataclass class TraceResult: domain: str hops: list[TraceHop] = field(default_factory=list) final_answer: str | None = None error: str | None = None ``` Models the complete resolution path through DNS hierarchy. ## Component Interaction Patterns ### Resolver to Output Decoupling The resolver never imports output. It returns data structures. The CLI layer calls both: ```python # cli.py:155-167 result = asyncio.run(lookup(domain, record_types, server, timeout)) if json_output: console.print(results_to_json(result)) else: print_header(domain) print_results_table(result) print_errors(result) print_summary(result) ``` This allows: - Different output formats (JSON, table, CSV) - Testing resolver without terminal - Using resolver in other projects ### Error Handling Strategy The resolver catches exceptions and converts to error messages (`resolver.py:181-189`): ```python except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers): pass # Expected, not an error except dns.exception.Timeout: pass # Also expected ``` Errors are accumulated in `DNSResult.errors` list rather than raising exceptions. This allows partial results (some record types succeed, others fail). ### Async Execution Model The project uses `asyncio` throughout for I/O-bound DNS operations. Key pattern (`resolver.py:233-242`): ```python tasks = [query_record_type(domain, rt, resolver) for rt in record_types] query_results = await asyncio.gather(*tasks, return_exceptions=True) for i, query_result in enumerate(query_results): if isinstance(query_result, Exception): result.errors.append(f"{record_types[i]}: {query_result}") else: result.records.extend(query_result) ``` `return_exceptions=True` prevents one failure from canceling other tasks. ## DNS Protocol Implementation ### Creating a Resolver (`resolver.py:91-107`) ```python def create_resolver( nameserver: str | None = None, timeout: float = 5.0, ) -> dns.asyncresolver.Resolver: resolver = dns.asyncresolver.Resolver() resolver.timeout = timeout resolver.lifetime = timeout * 2 # Total query lifetime if nameserver: resolver.nameservers = [nameserver] return resolver ``` Two timeout values: - `timeout`: Per-query timeout - `lifetime`: Total time including retries ### Extracting Record Values (`resolver.py:110-158`) Different record types have different response structures: ```python if record_type == RecordType.A or record_type == RecordType.AAAA: value = rdata.address # Simple IP string elif record_type == RecordType.MX: value = str(rdata.exchange).rstrip(".") priority = rdata.preference # MX-specific elif record_type in (RecordType.NS, RecordType.CNAME, RecordType.PTR): value = str(rdata.target).rstrip(".") # FQDN ``` The `.rstrip(".")` removes trailing dot from fully qualified domain names. ### Trace Implementation Deep Dive The trace function (`resolver.py:293-426`) implements iterative DNS resolution. Key sections: **1. Start at root servers** (`resolver.py:307-314`): ```python root_servers = [ ("a.root-servers.net", "198.41.0.4"), ("b.root-servers.net", "170.247.170.2"), ("c.root-servers.net", "192.33.4.12"), ] current_servers = root_servers current_zone = "." ``` **2. Query loop** (`resolver.py:318-420`): Each iteration queries a server, processes the response, and follows referrals. **3. Check for answer** (`resolver.py:329-348`): ```python if response.answer: for rrset in response.answer: for rdata in rrset: result.final_answer = str(rdata) break # Record this hop as authoritative result.hops.append(TraceHop(..., is_authoritative=True)) break # Done! ``` **4. Follow referrals** (`resolver.py:350-404`): ```python if response.authority: ns_records = [] for rrset in response.authority: if rrset.rdtype == dns.rdatatype.NS: for rdata in rrset: ns_name = str(rdata.target).rstrip(".") ns_records.append(ns_name) ``` **5. Resolve glue records** (`resolver.py:374-403`): Glue records provide IP addresses for nameservers to avoid circular dependencies. ```python glue_ips = {} if response.additional: for rrset in response.additional: if rrset.rdtype == dns.rdatatype.A: for rdata in rrset: glue_ips[str(rrset.name).rstrip(".")] = rdata.address ``` ## Output Formatting Architecture ### Rich Console Integration All output goes through a single console instance (`output.py:19`): ```python console = Console() ``` This ensures consistent styling and supports color detection. ### Record Type Coloring (`output.py:22-32`) ```python RECORD_COLORS: dict[RecordType, str] = { RecordType.A: "green", RecordType.AAAA: "blue", RecordType.MX: "magenta", RecordType.NS: "cyan", RecordType.TXT: "yellow", RecordType.CNAME: "red", RecordType.SOA: "white", RecordType.PTR: "bright_cyan", } ``` Colors help visually distinguish record types in mixed-type queries. ### TTL Formatting (`output.py:45-61`) Converts seconds to human-readable format: ```python if ttl >= 86400: days = ttl // 86400 return f"{days}d" elif ttl >= 3600: hours = ttl // 3600 return f"{hours}h" ``` This makes TTL values easier to understand at a glance. ### Tree Visualization for Traces (`output.py:266-310`) ```python tree = Tree( "[bold blue]:globe_showing_americas: DNS Resolution Path[/bold blue]", guide_style="blue", ) zone_nodes: dict[str, Any] = {} for hop in result.hops: if hop.zone not in zone_nodes: zone_node = tree.add(zone_display) zone_nodes[hop.zone] = zone_node else: zone_node = zone_nodes[hop.zone] server_branch = zone_node.add(f"[{server_style}]:arrow_right: {hop.server}[/{server_style}]") ``` Groups hops by zone for clearer visualization. ## Configuration and Dependency Management ### Project Configuration (`pyproject.toml:1-36`) ```toml [project] name = "dnslookup-cli" version = "0.1.1" dependencies = [ "dnspython>=2.8.0", # DNS protocol library "rich>=14.2.0", # Terminal formatting "typer>=0.20.0", # CLI framework "python-whois>=0.9.6", # WHOIS lookups ] ``` Minimal dependencies keep the project lightweight. ### Development Tools (`pyproject.toml:38-45`) ```toml [project.optional-dependencies] dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.25.0", # Test async code "pytest-cov>=6.0.0", # Coverage reporting "ruff>=0.9.0", # Linting "mypy>=1.15.0", # Type checking ] ``` ## Task Automation (justfile) The justfile provides convenient commands (`justfile:17-150`): ```make run *ARGS: uv run dnslookup {{ARGS}} lookup domain *ARGS: uv run dnslookup {{domain}} {{ARGS}} ci: lint typecheck test ``` This simplifies development workflow without requiring knowledge of `uv` syntax. ## Security Considerations in Architecture **1. No persistent state**: Each query is independent - Prevents cache poisoning - No state to corrupt - Simple to reason about **2. Explicit DNS server selection**: User controls which resolver to trust - Can test against authoritative nameservers - Useful for validating DNS propagation **3. Timeout enforcement**: Network operations can't hang indefinitely - `timeout` per query (`resolver.py:99`) - `lifetime` for total operation (`resolver.py:100`) **4. Error transparency**: All errors surfaced to user - No silent failures - User can investigate issues **5. No local caching**: Fresh data every query - Prevents stale data issues - Higher load but more accurate Next, see `03-IMPLEMENTATION.md` for detailed code walkthroughs.