dns-is-reverse 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """dns-is-reverse - Python reimplementation of all-knowing-dns."""
2
+
3
+ __version__ = "1.0.0"
dns_is_reverse/cli.py ADDED
@@ -0,0 +1,66 @@
1
+ """Command-line interface for DNS-is-reverse."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .dns_server import DNSServer
8
+ from .parser import parse_config
9
+
10
+
11
+ def main() -> None:
12
+ """Main entry point."""
13
+ parser = argparse.ArgumentParser(description="DNS-is-reverse - IPv6 reverse DNS synthesizer")
14
+ parser.add_argument(
15
+ "--configfile",
16
+ default="/etc/dns-is-reverse.conf",
17
+ help="Configuration file path (default: /etc/dns-is-reverse.conf)"
18
+ )
19
+ parser.add_argument(
20
+ "--listen",
21
+ action="append",
22
+ help="Additional listen address (can be used multiple times)"
23
+ )
24
+ parser.add_argument(
25
+ "--port",
26
+ type=int,
27
+ default=53,
28
+ help="Listen port (default: 53)"
29
+ )
30
+ parser.add_argument(
31
+ "--querylog",
32
+ action="store_true",
33
+ help="Enable query logging to stdout"
34
+ )
35
+
36
+ args = parser.parse_args()
37
+
38
+ # Read config file
39
+ config_path = Path(args.configfile)
40
+ if not config_path.exists():
41
+ print(f"Error: Config file not found: {config_path}", file=sys.stderr)
42
+ sys.exit(1)
43
+
44
+ try:
45
+ config_text = config_path.read_text()
46
+ config = parse_config(config_text)
47
+ except Exception as e:
48
+ print(f"Error parsing config: {e}", file=sys.stderr)
49
+ sys.exit(1)
50
+
51
+ # Override config with CLI args
52
+ if args.listen:
53
+ config.listen_addresses.extend(args.listen)
54
+ config.port = args.port
55
+ config.query_log = args.querylog
56
+
57
+ # Start server
58
+ server = DNSServer(config)
59
+ try:
60
+ server.start()
61
+ except KeyboardInterrupt:
62
+ print("\nShutting down...")
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
@@ -0,0 +1,27 @@
1
+ """Configuration data structures for dns-is-reverse."""
2
+
3
+ from dataclasses import dataclass
4
+ from ipaddress import IPv6Network
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class NetworkConfig:
10
+ """Configuration for a single network."""
11
+ network: IPv6Network
12
+ template: str
13
+ upstream: Optional[str] = None
14
+
15
+ def __post_init__(self) -> None:
16
+ """Validate template contains exactly one %DIGITS%."""
17
+ if self.template.count('%DIGITS%') != 1:
18
+ raise ValueError(f"Template must contain exactly one %DIGITS%, got: {self.template}")
19
+
20
+
21
+ @dataclass
22
+ class Config:
23
+ """Main configuration."""
24
+ listen_addresses: list[str]
25
+ networks: list[NetworkConfig]
26
+ port: int = 53
27
+ query_log: bool = False
@@ -0,0 +1,155 @@
1
+ """DNS server implementation."""
2
+
3
+ import socket
4
+ import threading
5
+ from typing import Optional
6
+
7
+ import dnslib # type: ignore
8
+
9
+ from .config import Config
10
+ from .reverse import ptr_qname_to_ipv6
11
+ from .synth import find_matching_network, find_matching_template, synthesize_ptr_hostname, synthesize_aaaa_address
12
+ from .upstream import query_upstream
13
+
14
+
15
+ class DNSServer:
16
+ """UDP DNS server."""
17
+
18
+ def __init__(self, config: Config):
19
+ self.config = config
20
+ self.running = False
21
+
22
+ def handle_request(self, data: bytes, addr: tuple[str, int]) -> bytes:
23
+ """Handle a single DNS request."""
24
+ try:
25
+ request = dnslib.DNSRecord.parse(data)
26
+ except Exception:
27
+ # Malformed request
28
+ response = dnslib.DNSRecord()
29
+ response.header.rcode = dnslib.RCODE.FORMERR
30
+ return response.pack() # type: ignore
31
+
32
+ if self.config.query_log:
33
+ print(f"Query from {addr[0]}: {request.q.qname} {dnslib.QTYPE[request.q.qtype]}")
34
+
35
+ response = dnslib.DNSRecord()
36
+ response.header.id = request.header.id
37
+ response.header.qr = 1 # Response
38
+ response.header.aa = 1 # Authoritative
39
+ response.header.rcode = dnslib.RCODE.NOERROR # Default to success
40
+ response.add_question(request.q)
41
+
42
+ qname = str(request.q.qname).rstrip('.')
43
+ qtype = request.q.qtype
44
+
45
+ if qtype == dnslib.QTYPE.PTR:
46
+ self._handle_ptr(qname, response)
47
+ elif qtype == dnslib.QTYPE.AAAA:
48
+ self._handle_aaaa(qname, response)
49
+ else:
50
+ response.header.rcode = dnslib.RCODE.NXDOMAIN
51
+
52
+ return response.pack() # type: ignore
53
+
54
+ def _handle_ptr(self, qname: str, response: dnslib.DNSRecord) -> None:
55
+ """Handle PTR query."""
56
+ # Convert PTR qname to IPv6
57
+ addr = ptr_qname_to_ipv6(qname)
58
+ if addr is None:
59
+ response.header.rcode = dnslib.RCODE.NXDOMAIN
60
+ return
61
+
62
+ # Find matching network
63
+ network_config = find_matching_network(addr, self.config.networks)
64
+ if network_config is None:
65
+ response.header.rcode = dnslib.RCODE.NXDOMAIN
66
+ return
67
+
68
+ # Try upstream first if configured
69
+ if network_config.upstream:
70
+ upstream_qname = f"{qname}.upstream"
71
+ ptr_values = query_upstream(network_config.upstream, upstream_qname)
72
+ if ptr_values:
73
+ for ptr_value in ptr_values:
74
+ # Strip trailing dot from upstream response
75
+ ptr_value = ptr_value.rstrip('.')
76
+ rr = dnslib.RR(
77
+ rname=qname,
78
+ rtype=dnslib.QTYPE.PTR,
79
+ rdata=dnslib.PTR(ptr_value),
80
+ ttl=60
81
+ )
82
+ response.add_answer(rr)
83
+ return
84
+
85
+ # Synthesize locally
86
+ hostname = synthesize_ptr_hostname(addr, network_config)
87
+ rr = dnslib.RR(
88
+ rname=qname,
89
+ rtype=dnslib.QTYPE.PTR,
90
+ rdata=dnslib.PTR(hostname),
91
+ ttl=60
92
+ )
93
+ response.add_answer(rr)
94
+
95
+ def _handle_aaaa(self, qname: str, response: dnslib.DNSRecord) -> None:
96
+ """Handle AAAA query."""
97
+ # Find matching template
98
+ network_config = find_matching_template(qname, self.config.networks)
99
+ if network_config is None:
100
+ response.header.rcode = dnslib.RCODE.NXDOMAIN
101
+ return
102
+
103
+ # Synthesize address
104
+ addr = synthesize_aaaa_address(qname, network_config)
105
+ if addr is None:
106
+ response.header.rcode = dnslib.RCODE.NXDOMAIN
107
+ return
108
+
109
+ rr = dnslib.RR(
110
+ rname=qname,
111
+ rtype=dnslib.QTYPE.AAAA,
112
+ rdata=dnslib.AAAA(str(addr)),
113
+ ttl=60
114
+ )
115
+ response.add_answer(rr)
116
+
117
+ def start(self) -> None:
118
+ """Start the DNS server."""
119
+ self.running = True
120
+ threads = []
121
+
122
+ for listen_addr in self.config.listen_addresses:
123
+ thread = threading.Thread(target=self._serve_address, args=(listen_addr,))
124
+ thread.daemon = True
125
+ thread.start()
126
+ threads.append(thread)
127
+
128
+ try:
129
+ for thread in threads:
130
+ thread.join()
131
+ except KeyboardInterrupt:
132
+ self.running = False
133
+
134
+ def _serve_address(self, listen_addr: str) -> None:
135
+ """Serve DNS requests on a single address."""
136
+ family = socket.AF_INET6 if ':' in listen_addr else socket.AF_INET
137
+ sock = socket.socket(family, socket.SOCK_DGRAM)
138
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
139
+
140
+ try:
141
+ sock.bind((listen_addr, self.config.port))
142
+ print(f"Listening on {listen_addr}:{self.config.port}")
143
+
144
+ while self.running:
145
+ try:
146
+ data, addr = sock.recvfrom(4096)
147
+ response_data = self.handle_request(data, addr)
148
+ sock.sendto(response_data, addr)
149
+ except socket.timeout:
150
+ continue
151
+ except Exception as e:
152
+ if self.running:
153
+ print(f"Error handling request: {e}")
154
+ finally:
155
+ sock.close()
@@ -0,0 +1,60 @@
1
+ """Configuration file parser."""
2
+
3
+ import re
4
+ from ipaddress import IPv6Network
5
+ from typing import Iterator
6
+
7
+ from .config import Config, NetworkConfig
8
+
9
+
10
+ def parse_config(text: str) -> Config:
11
+ """Parse configuration from text."""
12
+ lines = [line.rstrip() for line in text.splitlines()]
13
+ listen_addresses: list[str] = []
14
+ networks: list[NetworkConfig] = []
15
+
16
+ i = 0
17
+ while i < len(lines):
18
+ line = lines[i].strip()
19
+ if not line or line.startswith('#'):
20
+ i += 1
21
+ continue
22
+
23
+ if line.startswith('listen '):
24
+ address = line[7:].strip()
25
+ listen_addresses.append(address)
26
+ i += 1
27
+ elif line.startswith('network '):
28
+ network_str = line[8:].strip()
29
+ network = IPv6Network(network_str)
30
+ i += 1
31
+
32
+ # Parse indented block
33
+ template = None
34
+ upstream = None
35
+
36
+ while i < len(lines):
37
+ if not lines[i] or lines[i].startswith('#'):
38
+ i += 1
39
+ continue
40
+ if not lines[i].startswith((' ', '\t')):
41
+ break
42
+
43
+ subline = lines[i].strip()
44
+ if subline.startswith('resolves to '):
45
+ template = subline[12:].strip()
46
+ elif subline.startswith('with upstream '):
47
+ upstream = subline[14:].strip()
48
+ i += 1
49
+
50
+ if template is None:
51
+ raise ValueError(f"Network {network_str} missing 'resolves to' directive")
52
+
53
+ networks.append(NetworkConfig(network, template, upstream))
54
+ else:
55
+ raise ValueError(f"Unknown directive: {line}")
56
+
57
+ if not listen_addresses:
58
+ listen_addresses = ["::", "0.0.0.0"]
59
+
60
+ return Config(listen_addresses, networks)
@@ -0,0 +1,77 @@
1
+ """IPv6 reverse DNS utilities."""
2
+
3
+ import ipaddress
4
+ from typing import Optional
5
+
6
+
7
+ def ipv6_to_nibbles(addr: ipaddress.IPv6Address) -> str:
8
+ """Convert IPv6 address to nibble format (32 hex chars)."""
9
+ # Get 128-bit integer, format as 32 hex chars
10
+ return f"{int(addr):032x}"
11
+
12
+
13
+ def nibbles_to_ipv6(nibbles: str) -> ipaddress.IPv6Address:
14
+ """Convert 32 hex nibbles to IPv6 address."""
15
+ if len(nibbles) != 32:
16
+ raise ValueError(f"Expected 32 nibbles, got {len(nibbles)}")
17
+ return ipaddress.IPv6Address(int(nibbles, 16))
18
+
19
+
20
+ def ptr_qname_to_ipv6(qname: str) -> Optional[ipaddress.IPv6Address]:
21
+ """Convert PTR qname (ip6.arpa format) to IPv6 address."""
22
+ # Remove trailing dot and .ip6.arpa suffix
23
+ qname = qname.rstrip('.')
24
+ if not qname.endswith('.ip6.arpa'):
25
+ return None
26
+
27
+ nibble_part = qname[:-9] # Remove .ip6.arpa
28
+ nibbles = nibble_part.split('.')
29
+
30
+ if len(nibbles) != 32:
31
+ return None
32
+
33
+ # Reverse nibbles and join
34
+ try:
35
+ hex_str = ''.join(reversed(nibbles))
36
+ return nibbles_to_ipv6(hex_str)
37
+ except ValueError:
38
+ return None
39
+
40
+
41
+ def ipv6_to_ptr_qname(addr: ipaddress.IPv6Address) -> str:
42
+ """Convert IPv6 address to PTR qname."""
43
+ nibbles = ipv6_to_nibbles(addr)
44
+ # Reverse nibbles and join with dots
45
+ reversed_nibbles = '.'.join(reversed(nibbles))
46
+ return f"{reversed_nibbles}.ip6.arpa"
47
+
48
+
49
+ def extract_host_digits(addr: ipaddress.IPv6Address, network: ipaddress.IPv6Network) -> str:
50
+ """Extract host portion digits from IPv6 address in network."""
51
+ if addr not in network:
52
+ raise ValueError(f"Address {addr} not in network {network}")
53
+
54
+ host_bits = 128 - network.prefixlen
55
+ digits_len = host_bits // 4
56
+
57
+ # Get host portion by masking out network bits
58
+ host_mask = (1 << host_bits) - 1
59
+ host_int = int(addr) & host_mask
60
+ return f"{host_int:0{digits_len}x}"
61
+
62
+
63
+ def digits_to_ipv6(digits: str, network: ipaddress.IPv6Network) -> ipaddress.IPv6Address:
64
+ """Convert hex digits to IPv6 address in network."""
65
+ host_bits = 128 - network.prefixlen
66
+ expected_len = host_bits // 4
67
+
68
+ if len(digits) != expected_len:
69
+ raise ValueError(f"Expected {expected_len} digits for /{network.prefixlen}, got {len(digits)}")
70
+
71
+ try:
72
+ host_int = int(digits, 16)
73
+ except ValueError:
74
+ raise ValueError(f"Invalid hex digits: {digits}")
75
+
76
+ addr_int = int(network.network_address) | host_int
77
+ return ipaddress.IPv6Address(addr_int)
@@ -0,0 +1,57 @@
1
+ """DNS record synthesis utilities."""
2
+
3
+ import ipaddress
4
+ import re
5
+ from typing import Optional, Tuple
6
+
7
+ from .config import NetworkConfig
8
+ from .reverse import extract_host_digits, digits_to_ipv6
9
+
10
+
11
+ def synthesize_ptr_hostname(addr: ipaddress.IPv6Address, network_config: NetworkConfig) -> str:
12
+ """Synthesize PTR hostname from IPv6 address."""
13
+ digits = extract_host_digits(addr, network_config.network)
14
+ return network_config.template.replace('%DIGITS%', digits)
15
+
16
+
17
+ def parse_aaaa_hostname(hostname: str, network_config: NetworkConfig) -> Optional[str]:
18
+ """Extract digits from hostname using template. Returns None if no match."""
19
+ # Escape template and replace %DIGITS% with capture group
20
+ escaped = re.escape(network_config.template)
21
+ escaped_digits = re.escape('%DIGITS%')
22
+ pattern = escaped.replace(escaped_digits, r'([0-9a-fA-F]+)')
23
+ pattern = f"^{pattern}$"
24
+
25
+ match = re.match(pattern, hostname, re.IGNORECASE)
26
+ if not match:
27
+ return None
28
+
29
+ return match.group(1).lower()
30
+
31
+
32
+ def synthesize_aaaa_address(hostname: str, network_config: NetworkConfig) -> Optional[ipaddress.IPv6Address]:
33
+ """Synthesize IPv6 address from hostname. Returns None if hostname doesn't match template."""
34
+ digits = parse_aaaa_hostname(hostname, network_config)
35
+ if digits is None:
36
+ return None
37
+
38
+ try:
39
+ return digits_to_ipv6(digits, network_config.network)
40
+ except ValueError:
41
+ return None
42
+
43
+
44
+ def find_matching_network(addr: ipaddress.IPv6Address, networks: list[NetworkConfig]) -> Optional[NetworkConfig]:
45
+ """Find network config that contains the given address."""
46
+ for network_config in networks:
47
+ if addr in network_config.network:
48
+ return network_config
49
+ return None
50
+
51
+
52
+ def find_matching_template(hostname: str, networks: list[NetworkConfig]) -> Optional[NetworkConfig]:
53
+ """Find network config whose template matches the hostname."""
54
+ for network_config in networks:
55
+ if parse_aaaa_hostname(hostname, network_config) is not None:
56
+ return network_config
57
+ return None
@@ -0,0 +1,43 @@
1
+ """Upstream DNS client for fallback queries."""
2
+
3
+ import socket
4
+ from typing import Optional
5
+
6
+ import dnslib # type: ignore
7
+
8
+
9
+ def query_upstream(upstream_ip: str, qname: str, timeout: float = 2.0) -> Optional[list[str]]:
10
+ """Query upstream DNS server for PTR record. Returns list of PTR values or None."""
11
+ try:
12
+ # Create PTR query
13
+ query = dnslib.DNSRecord.question(qname, 'PTR')
14
+ query_data = query.pack()
15
+
16
+ # Send UDP query
17
+ sock = socket.socket(socket.AF_INET6 if ':' in upstream_ip else socket.AF_INET, socket.SOCK_DGRAM)
18
+ sock.settimeout(timeout)
19
+
20
+ try:
21
+ sock.sendto(query_data, (upstream_ip, 53))
22
+ response_data, _ = sock.recvfrom(4096)
23
+ finally:
24
+ sock.close()
25
+
26
+ # Parse response
27
+ response = dnslib.DNSRecord.parse(response_data)
28
+
29
+ # Check for NOERROR with answers
30
+ if response.header.rcode == dnslib.RCODE.NOERROR and response.rr:
31
+ ptr_values = []
32
+ for rr in response.rr:
33
+ if rr.rtype == dnslib.QTYPE.PTR:
34
+ # Strip trailing dot from PTR response
35
+ ptr_value = str(rr.rdata).rstrip('.')
36
+ ptr_values.append(ptr_value)
37
+ return ptr_values if ptr_values else None
38
+
39
+ return None
40
+
41
+ except Exception:
42
+ # Timeout, network error, parse error, etc.
43
+ return None
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: dns-is-reverse
3
+ Version: 1.0.0
4
+ Summary: Python reimplementation of all-knowing-dns: authoritative DNS server for IPv6 reverse DNS synthesis
5
+ Author: DNS Reverse Team
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: dnslib>=0.9.23
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+ Requires-Dist: mypy>=1.0; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # DNS-is-reverse
17
+
18
+ A Python reimplementation of [all-knowing-dns](https://github.com/raumzeitlabor/all-knowing-dns): a tiny authoritative DNS server that synthesizes IPv6 reverse DNS (PTR) and matching forward AAAA records on the fly for SLAAC-style networks, avoiding gigantic zone files.
19
+
20
+ ## What it does
21
+
22
+ DNS-is-reverse answers DNS queries for IPv6 networks by synthesizing responses based on templates:
23
+
24
+ - **PTR queries**: For reverse DNS lookups (ip6.arpa), it extracts the host portion of the IPv6 address and generates a hostname using a configurable template
25
+ - **AAAA queries**: For forward DNS lookups, it parses hostnames matching the template and returns the corresponding IPv6 address within the configured network
26
+ - **Upstream fallback**: For PTR queries, it can optionally query an upstream DNS server first before synthesizing locally
27
+
28
+ ## Configuration Format
29
+
30
+ The configuration file uses a simple line-based format:
31
+
32
+ ```
33
+ listen <address>
34
+ network <CIDR>
35
+ resolves to <template>
36
+ with upstream <address> # optional
37
+ ```
38
+
39
+ ### Example Configuration
40
+
41
+ ```
42
+ # Listen on IPv6 and IPv4
43
+ listen ::1
44
+ listen 127.0.0.1
45
+
46
+ # Configure a /64 network
47
+ network 2001:4d88:100e:ccc0::/64
48
+ resolves to ipv6-%DIGITS%.nutzer.raumzeitlabor.de
49
+ with upstream 2001:4860:4860::8888
50
+
51
+ # Configure a /56 network without upstream
52
+ network 2001:db8:100::/56
53
+ resolves to host-%DIGITS%.example.com
54
+ ```
55
+
56
+ ## How %DIGITS% Works
57
+
58
+ The `%DIGITS%` placeholder in templates represents the host portion of IPv6 addresses as hexadecimal digits:
59
+
60
+ - For a `/64` network: 64 host bits = 16 hex digits
61
+ - For a `/56` network: 72 host bits = 18 hex digits
62
+ - For a `/80` network: 48 host bits = 12 hex digits
63
+
64
+ ### Example for /64 network `2001:4d88:100e:ccc0::/64`:
65
+
66
+ - IPv6 address: `2001:4d88:100e:ccc0:216:eaff:fecb:826`
67
+ - Host portion: `0216eafffecb0826` (16 hex digits, zero-padded)
68
+ - Template: `ipv6-%DIGITS%.example.com`
69
+ - Generated hostname: `ipv6-0216eafffecb0826.example.com`
70
+
71
+ ## Upstream Fallback
72
+
73
+ When a network has `with upstream <address>` configured:
74
+
75
+ 1. For PTR queries, DNS-is-reverse first queries the upstream server for `<original_ptr_qname>.upstream`
76
+ 2. If the upstream returns a PTR answer, that answer is relayed to the client
77
+ 3. If the upstream returns NXDOMAIN/timeout/no-answer, DNS-is-reverse synthesizes the response locally
78
+ 4. AAAA queries are always synthesized locally (no upstream fallback)
79
+
80
+ ## Installation
81
+
82
+ ### Native Installation
83
+
84
+ ```bash
85
+ pip install -e .
86
+ ```
87
+
88
+ ### Docker Installation
89
+
90
+ ```bash
91
+ # Using docker-compose (recommended)
92
+ docker-compose up --build
93
+
94
+ # Or build and run manually
95
+ docker build -t dns-is-reverse .
96
+ docker run -p 53:53/udp -v ./test.conf:/etc/dns-is-reverse.conf:ro dns-is-reverse
97
+ ```
98
+
99
+ ## Usage
100
+
101
+ ### Command Line Options
102
+
103
+ ```bash
104
+ dns-is-reverse [options]
105
+
106
+ Options:
107
+ --configfile PATH Configuration file path (default: /etc/all-knowing-dns.conf)
108
+ --listen ADDRESS Additional listen address (can be used multiple times)
109
+ --port PORT Listen port (default: 53)
110
+ --querylog Enable query logging to stdout
111
+ ```
112
+
113
+ ### Running on High Port (Non-root)
114
+
115
+ For development or non-root usage:
116
+
117
+ ```bash
118
+ dns-is-reverse --configfile ./test.conf --port 5353 --querylog
119
+ ```
120
+
121
+ ### Running with Docker
122
+
123
+ The Docker container runs on port 53 by default:
124
+
125
+ ```bash
126
+ # Start with docker-compose
127
+ docker-compose up
128
+
129
+ # Or run directly with custom config
130
+ docker run -p 53:53/udp -v /path/to/config.conf:/etc/dns-is-reverse.conf:ro dns-is-reverse
131
+ ```
132
+
133
+ ### Example Configuration File
134
+
135
+ Create `test.conf`:
136
+
137
+ ```
138
+ listen ::1
139
+ listen 127.0.0.1
140
+
141
+ network 2001:db8::/64
142
+ resolves to test-%DIGITS%.local
143
+
144
+ network 2001:db8:100::/56
145
+ resolves to server-%DIGITS%.example.com
146
+ with upstream 8.8.8.8
147
+ ```
148
+
149
+ ### Testing with dig
150
+
151
+ ```bash
152
+ # Test AAAA query
153
+ dig @::1 -p 5353 test-1234567890abcdef.local AAAA
154
+
155
+ # Test PTR query
156
+ dig @::1 -p 5353 -x 2001:db8::1234:5678:9abc:def0
157
+ ```
158
+
159
+ ## Supported Network Sizes
160
+
161
+ DNS-is-reverse works with any IPv6 network size where the host portion is a multiple of 4 bits:
162
+
163
+ - `/64` networks: 16 hex digits (most common for SLAAC)
164
+ - `/56` networks: 18 hex digits
165
+ - `/48` networks: 20 hex digits
166
+ - `/80` networks: 12 hex digits
167
+ - etc.
168
+
169
+ ## DNS Behavior
170
+
171
+ - **Authoritative**: All synthesized responses have the AA (Authoritative Answer) flag set
172
+ - **TTL**: All responses use a 60-second TTL
173
+ - **Error handling**: Returns NXDOMAIN for out-of-network queries or template mismatches
174
+ - **Malformed queries**: Returns FORMERR for unparseable DNS requests
175
+ - **Query types**: Only PTR and AAAA queries are supported; all others return NXDOMAIN
176
+
177
+ ## Development
178
+
179
+ ### Running Tests
180
+
181
+ ```bash
182
+ pip install -e ".[dev]"
183
+ pytest
184
+ ```
185
+
186
+ ### Type Checking
187
+
188
+ ```bash
189
+ mypy dns_is_reverse/
190
+ ```
191
+
192
+ ## License
193
+
194
+ MIT License
@@ -0,0 +1,14 @@
1
+ dns_is_reverse/__init__.py,sha256=WA23yHguydtRBdLQQ0rhMUAbonKJg28WZpEh-hgIJpU,89
2
+ dns_is_reverse/cli.py,sha256=zJZkjkLsWAsnVB0Kfmn2kQdeRegEHO0_bwLpHMoWleY,1711
3
+ dns_is_reverse/config.py,sha256=-D2-mvJh7XAGvaW50O8LsbPCeL6VmcCjY75u0s7KffM,732
4
+ dns_is_reverse/dns_server.py,sha256=XqqZYiHM2mIqbeLo_yUujuR1Bi6cC4IZRAGUvVYurlE,5442
5
+ dns_is_reverse/parser.py,sha256=n1NrQqembMJPNJtbjYw5ZNVHiA7VtsIUBhEbPej55I8,1909
6
+ dns_is_reverse/reverse.py,sha256=owbMNcCrlNzrzXv8PYaW0sEnncMnH4yLtSQWkaem_Po,2514
7
+ dns_is_reverse/synth.py,sha256=CEQF4xqomttx3aUOeIsXRCXrE0HgfIsg4aWKwpOwUeE,2090
8
+ dns_is_reverse/upstream.py,sha256=lc-8fjV1BasqXRqxQ1yi_1K_9pR-uUI9XBiizhL2bbo,1455
9
+ dns_is_reverse-1.0.0.dist-info/licenses/LICENSE,sha256=NdpxQPPB_U4GPjqb5lQRedx35Vw8nfrWTTtKso6a57o,1071
10
+ dns_is_reverse-1.0.0.dist-info/METADATA,sha256=pb2Rxb8gPrCflZB0hQrb9kugJ24Gx8qsULOLDhnvohQ,5190
11
+ dns_is_reverse-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
+ dns_is_reverse-1.0.0.dist-info/entry_points.txt,sha256=wECnU-0D4koz83M0opr5nKHePBQK4G-JdvWGT2X6PFo,59
13
+ dns_is_reverse-1.0.0.dist-info/top_level.txt,sha256=xgq6zAUF0e_HOIIZ9NomWvvVUV4xnmRxeQduM-BcHvI,15
14
+ dns_is_reverse-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dns-is-reverse = dns_is_reverse.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Yaacobi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ dns_is_reverse