dns-is-reverse 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- dns_is_reverse-1.0.0/PKG-INFO +194 -0
- dns_is_reverse-1.0.0/README.md +179 -0
- dns_is_reverse-1.0.0/dns_is_reverse/__init__.py +3 -0
- dns_is_reverse-1.0.0/dns_is_reverse/cli.py +66 -0
- dns_is_reverse-1.0.0/dns_is_reverse/config.py +27 -0
- dns_is_reverse-1.0.0/dns_is_reverse/dns_server.py +155 -0
- dns_is_reverse-1.0.0/dns_is_reverse/parser.py +60 -0
- dns_is_reverse-1.0.0/dns_is_reverse/reverse.py +77 -0
- dns_is_reverse-1.0.0/dns_is_reverse/synth.py +57 -0
- dns_is_reverse-1.0.0/dns_is_reverse/upstream.py +43 -0
- dns_is_reverse-1.0.0/dns_is_reverse.egg-info/PKG-INFO +194 -0
- dns_is_reverse-1.0.0/dns_is_reverse.egg-info/SOURCES.txt +24 -0
- dns_is_reverse-1.0.0/dns_is_reverse.egg-info/dependency_links.txt +1 -0
- dns_is_reverse-1.0.0/dns_is_reverse.egg-info/entry_points.txt +2 -0
- dns_is_reverse-1.0.0/dns_is_reverse.egg-info/requires.txt +5 -0
- dns_is_reverse-1.0.0/dns_is_reverse.egg-info/top_level.txt +1 -0
- dns_is_reverse-1.0.0/pyproject.toml +32 -0
- dns_is_reverse-1.0.0/setup.cfg +4 -0
- dns_is_reverse-1.0.0/tests/test_aaaa_synthesis.py +135 -0
- dns_is_reverse-1.0.0/tests/test_config_parser.py +127 -0
- dns_is_reverse-1.0.0/tests/test_end_to_end_handler.py +217 -0
- dns_is_reverse-1.0.0/tests/test_ip6arpa_codec.py +108 -0
- dns_is_reverse-1.0.0/tests/test_ptr_synthesis.py +76 -0
- dns_is_reverse-1.0.0/tests/test_template_matching.py +126 -0
- dns_is_reverse-1.0.0/tests/test_upstream_fallback.py +180 -0
|
@@ -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,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,179 @@
|
|
|
1
|
+
# DNS-is-reverse
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
DNS-is-reverse answers DNS queries for IPv6 networks by synthesizing responses based on templates:
|
|
8
|
+
|
|
9
|
+
- **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
|
|
10
|
+
- **AAAA queries**: For forward DNS lookups, it parses hostnames matching the template and returns the corresponding IPv6 address within the configured network
|
|
11
|
+
- **Upstream fallback**: For PTR queries, it can optionally query an upstream DNS server first before synthesizing locally
|
|
12
|
+
|
|
13
|
+
## Configuration Format
|
|
14
|
+
|
|
15
|
+
The configuration file uses a simple line-based format:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
listen <address>
|
|
19
|
+
network <CIDR>
|
|
20
|
+
resolves to <template>
|
|
21
|
+
with upstream <address> # optional
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Example Configuration
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
# Listen on IPv6 and IPv4
|
|
28
|
+
listen ::1
|
|
29
|
+
listen 127.0.0.1
|
|
30
|
+
|
|
31
|
+
# Configure a /64 network
|
|
32
|
+
network 2001:4d88:100e:ccc0::/64
|
|
33
|
+
resolves to ipv6-%DIGITS%.nutzer.raumzeitlabor.de
|
|
34
|
+
with upstream 2001:4860:4860::8888
|
|
35
|
+
|
|
36
|
+
# Configure a /56 network without upstream
|
|
37
|
+
network 2001:db8:100::/56
|
|
38
|
+
resolves to host-%DIGITS%.example.com
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## How %DIGITS% Works
|
|
42
|
+
|
|
43
|
+
The `%DIGITS%` placeholder in templates represents the host portion of IPv6 addresses as hexadecimal digits:
|
|
44
|
+
|
|
45
|
+
- For a `/64` network: 64 host bits = 16 hex digits
|
|
46
|
+
- For a `/56` network: 72 host bits = 18 hex digits
|
|
47
|
+
- For a `/80` network: 48 host bits = 12 hex digits
|
|
48
|
+
|
|
49
|
+
### Example for /64 network `2001:4d88:100e:ccc0::/64`:
|
|
50
|
+
|
|
51
|
+
- IPv6 address: `2001:4d88:100e:ccc0:216:eaff:fecb:826`
|
|
52
|
+
- Host portion: `0216eafffecb0826` (16 hex digits, zero-padded)
|
|
53
|
+
- Template: `ipv6-%DIGITS%.example.com`
|
|
54
|
+
- Generated hostname: `ipv6-0216eafffecb0826.example.com`
|
|
55
|
+
|
|
56
|
+
## Upstream Fallback
|
|
57
|
+
|
|
58
|
+
When a network has `with upstream <address>` configured:
|
|
59
|
+
|
|
60
|
+
1. For PTR queries, DNS-is-reverse first queries the upstream server for `<original_ptr_qname>.upstream`
|
|
61
|
+
2. If the upstream returns a PTR answer, that answer is relayed to the client
|
|
62
|
+
3. If the upstream returns NXDOMAIN/timeout/no-answer, DNS-is-reverse synthesizes the response locally
|
|
63
|
+
4. AAAA queries are always synthesized locally (no upstream fallback)
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
### Native Installation
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install -e .
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Docker Installation
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Using docker-compose (recommended)
|
|
77
|
+
docker-compose up --build
|
|
78
|
+
|
|
79
|
+
# Or build and run manually
|
|
80
|
+
docker build -t dns-is-reverse .
|
|
81
|
+
docker run -p 53:53/udp -v ./test.conf:/etc/dns-is-reverse.conf:ro dns-is-reverse
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
### Command Line Options
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
dns-is-reverse [options]
|
|
90
|
+
|
|
91
|
+
Options:
|
|
92
|
+
--configfile PATH Configuration file path (default: /etc/all-knowing-dns.conf)
|
|
93
|
+
--listen ADDRESS Additional listen address (can be used multiple times)
|
|
94
|
+
--port PORT Listen port (default: 53)
|
|
95
|
+
--querylog Enable query logging to stdout
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Running on High Port (Non-root)
|
|
99
|
+
|
|
100
|
+
For development or non-root usage:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
dns-is-reverse --configfile ./test.conf --port 5353 --querylog
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Running with Docker
|
|
107
|
+
|
|
108
|
+
The Docker container runs on port 53 by default:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Start with docker-compose
|
|
112
|
+
docker-compose up
|
|
113
|
+
|
|
114
|
+
# Or run directly with custom config
|
|
115
|
+
docker run -p 53:53/udp -v /path/to/config.conf:/etc/dns-is-reverse.conf:ro dns-is-reverse
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Example Configuration File
|
|
119
|
+
|
|
120
|
+
Create `test.conf`:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
listen ::1
|
|
124
|
+
listen 127.0.0.1
|
|
125
|
+
|
|
126
|
+
network 2001:db8::/64
|
|
127
|
+
resolves to test-%DIGITS%.local
|
|
128
|
+
|
|
129
|
+
network 2001:db8:100::/56
|
|
130
|
+
resolves to server-%DIGITS%.example.com
|
|
131
|
+
with upstream 8.8.8.8
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Testing with dig
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# Test AAAA query
|
|
138
|
+
dig @::1 -p 5353 test-1234567890abcdef.local AAAA
|
|
139
|
+
|
|
140
|
+
# Test PTR query
|
|
141
|
+
dig @::1 -p 5353 -x 2001:db8::1234:5678:9abc:def0
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Supported Network Sizes
|
|
145
|
+
|
|
146
|
+
DNS-is-reverse works with any IPv6 network size where the host portion is a multiple of 4 bits:
|
|
147
|
+
|
|
148
|
+
- `/64` networks: 16 hex digits (most common for SLAAC)
|
|
149
|
+
- `/56` networks: 18 hex digits
|
|
150
|
+
- `/48` networks: 20 hex digits
|
|
151
|
+
- `/80` networks: 12 hex digits
|
|
152
|
+
- etc.
|
|
153
|
+
|
|
154
|
+
## DNS Behavior
|
|
155
|
+
|
|
156
|
+
- **Authoritative**: All synthesized responses have the AA (Authoritative Answer) flag set
|
|
157
|
+
- **TTL**: All responses use a 60-second TTL
|
|
158
|
+
- **Error handling**: Returns NXDOMAIN for out-of-network queries or template mismatches
|
|
159
|
+
- **Malformed queries**: Returns FORMERR for unparseable DNS requests
|
|
160
|
+
- **Query types**: Only PTR and AAAA queries are supported; all others return NXDOMAIN
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
### Running Tests
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pip install -e ".[dev]"
|
|
168
|
+
pytest
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Type Checking
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
mypy dns_is_reverse/
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT License
|
|
@@ -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)
|