Cybersecurity-Projects/PROJECTS/beginner/dns-lookup/learn/03-IMPLEMENTATION.md

636 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Implementation Details
## Core DNS Resolution Implementation
### Single Record Type Query (`resolver.py:191-209`)
```python
async def query_record_type(
domain: str,
record_type: RecordType,
resolver: dns.asyncresolver.Resolver,
) -> list[DNSRecord]:
records = []
try:
answers = await resolver.resolve(domain, record_type.value)
for rdata in answers:
value, priority = extract_record_value(rdata, record_type)
records.append(
DNSRecord(
record_type=record_type,
value=value,
ttl=answers.rrset.ttl,
priority=priority,
)
)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer,
dns.resolver.NoNameservers):
pass # Return empty list for these
except dns.exception.Timeout:
pass # Also return empty list
return records
```
**Key design decisions:**
1. **Exceptions as control flow**: NXDOMAIN and NoAnswer aren't errors, they're valid responses. The function returns an empty list rather than raising.
2. **TTL from rrset**: `answers.rrset.ttl` gives the TTL for the entire resource record set. All records in an rrset have the same TTL.
3. **Value extraction delegated**: The `extract_record_value()` function handles type-specific parsing. This keeps the query logic clean.
### Multi-Type Concurrent Query (`resolver.py:213-250`)
```python
async def lookup(
domain: str,
record_types: list[RecordType] | None = None,
nameserver: str | None = None,
timeout: float = 5.0,
) -> DNSResult:
if record_types is None:
record_types = ALL_RECORD_TYPES
resolver = create_resolver(nameserver, timeout)
result = DNSResult(domain=domain, nameserver=nameserver)
start_time = time.perf_counter()
# Create all tasks upfront
tasks = [
query_record_type(domain, rt, resolver) for rt in record_types
]
# Execute all concurrently
query_results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
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)
result.query_time_ms = (time.perf_counter() - start_time) * 1000
return result
```
**Why `return_exceptions=True`?**
Without this flag, if one query raises an exception, `asyncio.gather()` cancels all other tasks and re-raises. With the flag, exceptions are returned as values, allowing partial results.
**Timing measurement:**
`time.perf_counter()` provides high-resolution timing. The difference is multiplied by 1000 to get milliseconds.
### Reverse DNS Lookup (`resolver.py:253-290`)
```python
async def reverse_lookup(
ip_address: str,
nameserver: str | None = None,
timeout: float = 5.0,
) -> DNSResult:
resolver = create_resolver(nameserver, timeout)
result = DNSResult(domain=ip_address, nameserver=nameserver)
start_time = time.perf_counter()
try:
# resolve_address handles both IPv4 and IPv6
answers = await resolver.resolve_address(ip_address)
for rdata in answers:
result.records.append(
DNSRecord(
record_type=RecordType.PTR,
value=str(rdata.target).rstrip("."),
ttl=answers.rrset.ttl,
)
)
except dns.resolver.NXDOMAIN:
result.errors.append("No PTR record found")
except dns.resolver.NoAnswer:
result.errors.append("No answer from nameserver")
except dns.resolver.NoNameservers:
result.errors.append("No nameservers available")
except dns.exception.Timeout:
result.errors.append("Query timed out")
except dns.exception.DNSException as e:
result.errors.append(str(e))
result.query_time_ms = (time.perf_counter() - start_time) * 1000
return result
```
**PTR record details:**
Reverse DNS uses special `.in-addr.arpa` (IPv4) or `.ip6.arpa` (IPv6) zones. For IP `8.8.8.8`, the query is for `8.8.8.8.in-addr.arpa` (reversed octets).
The `resolve_address()` method handles this conversion automatically.
## DNS Trace Implementation
The trace function (`resolver.py:293-426`) is the most complex. It implements iterative resolution, querying each layer of the DNS hierarchy.
### Initialization (`resolver.py:298-314`)
```python
result = TraceResult(domain=domain)
try:
name = dns.name.from_text(domain)
rdtype = dns.rdatatype.from_text(record_type)
# Hardcoded root server IPs
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 = "."
```
**Why hardcode root servers?**
Bootstrapping problem. To resolve `a.root-servers.net`, you need a working DNS resolver. These IPs are essentially the "root of trust" for DNS.
### Main Query Loop (`resolver.py:316-420`)
```python
while True:
server_name, server_ip = current_servers[0]
try:
# Build DNS query packet
query = dns.message.make_query(name, rdtype)
# Send UDP packet directly to server
response = dns.query.udp(query, server_ip, timeout=3.0)
rcode = response.rcode()
if rcode != dns.rcode.NOERROR:
result.error = f"DNS error: {dns.rcode.to_text(rcode)}"
break
```
This uses low-level dnspython APIs:
- `dns.message.make_query()`: Build DNS query packet
- `dns.query.udp()`: Send packet via UDP
Unlike high-level `resolver.resolve()`, this gives full control over which server to query.
### Handling Answers (`resolver.py:329-348`)
```python
if response.answer:
# We got the answer!
for rrset in response.answer:
for rdata in rrset:
result.final_answer = str(rdata)
break
result.hops.append(
TraceHop(
zone=current_zone,
server=server_name,
server_ip=server_ip,
response=f"{record_type}: {result.final_answer}",
is_authoritative=True,
)
)
break # Done tracing
```
The `answer` section contains the actual answer. This only appears when querying authoritative nameservers.
### Following Referrals (`resolver.py:350-404`)
```python
if response.authority:
ns_records = []
# Extract NS records from authority section
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)
# Get the zone these NS records are for
new_zone = str(rrset.name).rstrip(".")
if not new_zone:
new_zone = "."
```
The `authority` section contains NS records pointing to the next layer in the hierarchy.
### Resolving Glue Records (`resolver.py:369-403`)
```python
# Check for glue records in additional section
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
# Build list of next servers to query
new_servers = []
for ns in ns_records:
if ns in glue_ips:
# Use glue record
new_servers.append((ns, glue_ips[ns]))
else:
# Must resolve NS hostname separately
try:
answers = dns.resolver.resolve(ns, "A")
for rdata in answers:
new_servers.append((ns, rdata.address))
break
except dns.exception.DNSException:
continue
```
**Glue records solve circular dependency:**
If querying `example.com` and the NS records point to `ns1.example.com`, you'd need to resolve `ns1.example.com` to get its IP. But to resolve that, you need to query... the same NS server. Glue records provide the IP directly.
## CLI Command Implementation
### Argument Parsing (`cli.py:70-87`)
```python
def parse_record_types(types_str: str) -> list[RecordType]:
if types_str.upper() == "ALL":
return list(ALL_RECORD_TYPES)
types = []
for t in types_str.upper().split(","):
t = t.strip()
try:
types.append(RecordType(t))
except ValueError:
console.print(
f"[yellow]Warning:[/yellow] Unknown record type '{t}', skipping"
)
return types if types else list(ALL_RECORD_TYPES)
```
This handles user input like `"A,MX,NS"` or `"all"`. Invalid types generate warnings but don't fail.
### Progress Indicators (`cli.py:146-154`)
```python
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
progress.add_task(f"Querying {domain}...", total=None)
result = asyncio.run(lookup(domain, record_types, server, timeout))
```
`transient=True` makes the spinner disappear after completion. `total=None` creates an indefinite spinner (we don't know query duration upfront).
### Batch File Processing (`cli.py:297-312`)
```python
domains = []
with open(file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
domains.append(line)
if not domains:
console.print("[yellow]Warning:[/yellow] No domains found in file")
raise typer.Exit(0)
```
Simple file format:
- One domain per line
- `#` for comments
- Empty lines ignored
**Security note:** No input validation on domain names. This trusts file input. For user-generated files, add validation to prevent malicious input.
## Output Formatting Implementation
### Table Generation (`output.py:83-127`)
```python
def print_results_table(result: DNSResult) -> None:
if not result.records:
console.print(
Panel(
f"[yellow]No records found for {result.domain}[/yellow]",
title="[yellow]Warning[/yellow]",
border_style="yellow",
expand=False,
)
)
return
table = Table(
title="[bold]DNS Records[/bold]",
box=box.ROUNDED,
border_style="blue",
row_styles=["", "dim"], # Alternate row shading
show_header=True,
header_style="bold cyan",
)
table.add_column("Type", width=8, no_wrap=True)
table.add_column("Value", style="green", min_width=30)
table.add_column("TTL", justify="right", style="dim", width=8)
for record in result.records:
color = get_record_color(record.record_type)
value = record.value
# Add priority annotation for MX records
if record.priority is not None:
value = f"{value} [dim](priority: {record.priority})[/dim]"
table.add_row(
f"[{color}]{record.record_type}[/{color}]",
value,
format_ttl(record.ttl),
)
console.print(table)
```
**Rich formatting features:**
- `box=box.ROUNDED`: Rounded corners
- `row_styles=["", "dim"]`: Alternating row colors for readability
- `min_width=30`: Prevents column from being too narrow
- `justify="right"`: Right-align TTL column
### Tree Visualization (`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:
# Create zone node if not exists
if hop.zone not in zone_nodes:
if hop.zone == ".":
zone_display = "[bold yellow][.] Root[/bold yellow]"
elif hop.zone.endswith("."):
zone_display = f"[bold yellow][{hop.zone}] TLD[/bold yellow]"
else:
zone_display = f"[bold yellow][{hop.zone}.] Authoritative[/bold yellow]"
zone_node = tree.add(zone_display)
zone_nodes[hop.zone] = zone_node
else:
zone_node = zone_nodes[hop.zone]
# Add server under zone
server_style = "green" if hop.is_authoritative else "cyan"
server_branch = zone_node.add(
f"[{server_style}]:arrow_right: {hop.server}[/{server_style}] "
f"[dim]({hop.server_ip})[/dim]"
)
# Add response under server
server_branch.add(f"[dim]{hop.response}[/dim]")
console.print(tree)
```
**Zone grouping:**
Multiple hops can query the same zone (trying different servers). The dict ensures one zone node with multiple server children.
### JSON Serialization (`output.py:379-410`)
```python
def results_to_json(results: list[DNSResult] | DNSResult) -> str:
if isinstance(results, DNSResult):
results = [results]
data = []
for result in results:
record_data = [
{
"type": r.record_type.value,
"value": r.value,
"ttl": r.ttl,
"priority": r.priority,
} for r in result.records
]
data.append({
"domain": result.domain,
"records": record_data,
"errors": result.errors,
"query_time_ms": round(result.query_time_ms, 2),
"nameserver": result.nameserver,
})
# Single result: return object. Multiple: return array
if len(data) == 1:
return json.dumps(data[0], indent=2)
return json.dumps(data, indent=2)
```
**Single vs batch output:**
Single domain query returns `{ domain, records }` while batch returns `[{ domain, records }, ...]`. This is more ergonomic for consumers.
## WHOIS Implementation
### WHOIS Lookup (`whois_lookup.py:60-119`)
```python
def lookup_whois(domain: str) -> WhoisResult:
result = WhoisResult(domain=domain)
try:
w = whois.whois(domain)
# Check if domain exists
if w is None or (hasattr(w, "domain_name") and w.domain_name is None):
result.error = "Domain not found or WHOIS data unavailable"
return result
# Extract available fields (not all present for all domains)
result.registrar = w.registrar if hasattr(w, "registrar") else None
result.creation_date = w.creation_date if hasattr(w, "creation_date") else None
result.expiration_date = w.expiration_date if hasattr(w, "expiration_date") else None
result.updated_date = w.updated_date if hasattr(w, "updated_date") else None
# Handle status field (can be string or list)
if hasattr(w, "status"):
status = w.status
if isinstance(status, str):
result.status = [status]
elif isinstance(status, list):
result.status = status
else:
result.status = []
```
**WHOIS data inconsistency:**
Different TLD registries return different fields. The `python-whois` library normalizes somewhat, but we still need defensive `hasattr()` checks.
**Status field complexity:**
Some registries return a single status string, others return a list. Normalize to always use a list.
### Date Formatting (`whois_lookup.py:41-57`)
```python
def format_date(dt: datetime | list | None) -> str:
if dt is None:
return "[dim]-[/dim]"
# Some registries return lists of datetimes
if isinstance(dt, list):
dt = dt[0] if dt else None
if dt is None:
return "[dim]-[/dim]"
if isinstance(dt, datetime):
return dt.strftime("%Y-%m-%d")
return str(dt)
```
**Why list of datetimes?**
Some WHOIS servers include timezone-normalized versions. We take the first.
## Testing Implementation
### Async Test Setup (`test_resolver.py:1-20`)
```python
import pytest
from dnslookup.resolver import (
ALL_RECORD_TYPES,
DNSRecord,
DNSResult,
RecordType,
TraceResult,
batch_lookup,
create_resolver,
lookup,
reverse_lookup,
trace_dns,
)
```
Tests import from actual modules, not mocks. This tests real DNS queries.
### Testing Async Functions (`test_resolver.py:138-146`)
```python
class TestLookup:
@pytest.mark.asyncio
async def test_lookup_real_domain(self) -> None:
result = await lookup("example.com", [RecordType.A])
assert result.domain == "example.com"
assert result.query_time_ms > 0
```
`@pytest.mark.asyncio` decorator allows async test functions. `pytest-asyncio` plugin handles event loop management.
### Testing Error Conditions (`test_resolver.py:148-156`)
```python
@pytest.mark.asyncio
async def test_lookup_nonexistent_domain(self) -> None:
result = await lookup(
"this-domain-definitely-does-not-exist-12345.com",
[RecordType.A]
)
assert result.domain == "this-domain-definitely-does-not-exist-12345.com"
assert len(result.records) == 0
```
**No mocking:** Uses a domain extremely unlikely to exist. More fragile than mocking but tests real behavior.
## Common Implementation Patterns
### Pattern 1: Exceptions to Results
```python
try:
# Operation that might fail
answers = await resolver.resolve(domain, record_type)
# Process answers
except ExpectedException:
# Convert to empty result
return []
except UnexpectedException as e:
# Log to errors list
result.errors.append(str(e))
```
This keeps the API clean (no exceptions for expected failures) while preserving error information.
### Pattern 2: Defensive Attribute Access
```python
result.registrar = w.registrar if hasattr(w, "registrar") else None
```
WHOIS responses vary. Use `hasattr()` before accessing attributes that might not exist.
### Pattern 3: Type Narrowing with isinstance
```python
if isinstance(query_result, Exception):
result.errors.append(f"{record_types[i]}: {query_result}")
else:
result.records.extend(query_result)
```
`asyncio.gather(return_exceptions=True)` returns exceptions as values. Use `isinstance()` to distinguish.
### Pattern 4: Rich Formatting Delegation
```python
# Never mix business logic and formatting
# BAD:
def lookup(...):
print(f"Querying {domain}")
result = resolve(domain)
print_table(result)
return result
# GOOD:
def lookup(...):
return resolve(domain)
# Caller handles formatting
result = lookup(domain)
print_table(result)
```
Keeps resolver reusable in non-CLI contexts.
## Performance Optimizations
### Concurrent Queries (`resolver.py:233-242`)
Sequential queries: `7 types × 50ms = 350ms`
Concurrent queries: `max(50ms) = 50ms`
7x speedup for multi-type queries.
### Batch Concurrency (`resolver.py:428-440`)
```python
tasks = [lookup(domain, record_types, nameserver, timeout) for domain in domains]
return await asyncio.gather(*tasks)
```
For 100 domains:
- Sequential: `100 × 50ms = 5000ms`
- Concurrent: `~200-500ms` (limited by DNS server rate limits)
10-25x speedup.
### No Caching Trade-off
Every query hits DNS. This means:
- **Slower**: No cache hits
- **More accurate**: Never stale data
- **Higher load**: More packets to DNS servers
For a reconnaissance tool, accuracy matters more than speed.
Next, see `04-CHALLENGES.md` for ways to extend this project.