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.
- dns_is_reverse/__init__.py +3 -0
- dns_is_reverse/cli.py +66 -0
- dns_is_reverse/config.py +27 -0
- dns_is_reverse/dns_server.py +155 -0
- dns_is_reverse/parser.py +60 -0
- dns_is_reverse/reverse.py +77 -0
- dns_is_reverse/synth.py +57 -0
- dns_is_reverse/upstream.py +43 -0
- dns_is_reverse-1.0.0.dist-info/METADATA +194 -0
- dns_is_reverse-1.0.0.dist-info/RECORD +14 -0
- dns_is_reverse-1.0.0.dist-info/WHEEL +5 -0
- dns_is_reverse-1.0.0.dist-info/entry_points.txt +2 -0
- dns_is_reverse-1.0.0.dist-info/licenses/LICENSE +21 -0
- dns_is_reverse-1.0.0.dist-info/top_level.txt +1 -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()
|
dns_is_reverse/config.py
ADDED
|
@@ -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()
|
dns_is_reverse/parser.py
ADDED
|
@@ -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)
|
dns_is_reverse/synth.py
ADDED
|
@@ -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,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
|