Skip to content

Commit 1493e6f

Browse files
committed
added support for DoT (DNS over TLS)
1 parent 54cdc8f commit 1493e6f

8 files changed

Lines changed: 104 additions & 13 deletions

File tree

README.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
<p align="center">
2-
<img alt="Version Badge" src="https://img.shields.io/badge/dev--version-v5.1.1-16a085">
3-
<img alt="Version Badge" src="https://img.shields.io/badge/release-v5.1.1-16a085">
2+
<img alt="Version Badge" src="https://img.shields.io/badge/dev--version-v5.2.0-16a085">
3+
<img alt="Version Badge" src="https://img.shields.io/badge/release-v5.2.0-16a085">
44
<img alt="Docker Image Version" src="https://img.shields.io/docker/v/ryzeondev/bmdns?label=docker-version&color=16a085">
55
<img alt="License Badge" src="https://img.shields.io/github/license/ryzeon-dev/bmdns?color=16a085">
66
<img alt="Language Badge" src="https://img.shields.io/badge/python3-16a085?logo=python&logoColor=16a085&labelColor=5a5a5a">
77
</p>
88

99
# Bare-Metal DNS
10-
Lightweight DNS written in Python. No fancy UI. No extensive analysis. Just a bare metal DNS server
10+
A lightweight DNS server for homelabs: local records, ad-blocking, VLAN segmentation, and DoT support. No database, no web UI, just DNS.
11+
12+
## Why BMDNS?
13+
BMDNS is a self-hosted DNS server for homelabs and small networks. It gives you local DNS resolution, ad-blocking via blocklists, and VLAN segmentation—without the overhead of a web UI or database.
14+
15+
- **Simple configuration** — Single YAML file
16+
- **VLAN-aware** — Serve different records to different networks
17+
- **Blocklist support** — Block ads and trackers at the DNS level
18+
- **DoT support** — Encrypted upstream queries to protect your privacy
19+
- **Cross-platform** — Linux, Windows, Docker
1120

1221
# Index
1322
- [Supported OS](#supported-os)
@@ -28,6 +37,7 @@ Lightweight DNS written in Python. No fancy UI. No extensive analysis. Just a ba
2837
- [Static Remaps](#static-remaps)
2938
- [VLANs](#vlans)
3039
- [Root Servers](#root-servers)
40+
- [DoT Root Servers](#dot-root-servers)
3141
- [Blocklists](#blocklists)
3242
- [Cascading Search](#cascading-search)
3343
- [Log](#log)
@@ -38,7 +48,7 @@ Lightweight DNS written in Python. No fancy UI. No extensive analysis. Just a ba
3848
The software officially supports GNU/Linux and Windows.
3949

4050
It should work on FreeBSD and MacOS systems, but it is untested.
41-
The `install`/`update`/`uninstall` scripts are specifically written for GNU/Linux and Windows, any attempt of running them in other OS may lead to errors and unexpected behaviour.
51+
The `install`/`update`/`uninstall` scripts are specifically written for GNU/Linux and Windows, any attempt of running them in other OS may lead to errors and unexpected behavior.
4252

4353
## OS requirements
4454
Compilation requires `python3 python3-venv python3-pip` packages to be installed. Install them using your OS's package manager
@@ -170,7 +180,7 @@ static:
170180

171181
Wildcard static resolution is supported, and only requires the user to add a "*" in the position associated with the wildcard.
172182

173-
e.g. you have a server named `my-server` with ip address `192.168.0.2` wich provides multiple web services, \
183+
e.g. you have a server named `my-server` with ip address `192.168.0.2` wich provides multiple web services, \
174184
each one identified as a subdomain of the server name itself
175185
```yaml
176186
static:
@@ -276,6 +286,17 @@ root-servers:
276286
- 94.140.15.15
277287
```
278288

289+
#### DoT Root Servers
290+
291+
BMDNS supports wrapping request in DoT, so it is possible to use tls root servers
292+
293+
e.g. AdGuard and Cloudflare tls root servers
294+
```yaml
295+
root-servers:
296+
- tls://dns.adguard-dns.com
297+
- tls://1.1.1.1
298+
```
299+
279300
### Blocklists
280301
List of text files which contain static mappings for websites you want to block
281302

src/Conf.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from StaticRemap import StaticRemap
88
from qtype import QTYPE
99
from utils import logFatalError
10-
from validation import validateIPv4
10+
from validation import validateIPv4, validateTlsIPv4, validateDomainName, validateTlsDomainName
1111

1212

1313
class Conf:
@@ -66,9 +66,7 @@ def __parseConf(self):
6666
logFatalError(f'Fatal: `root-servers` field must contain a list of strings')
6767
sys.exit(1)
6868

69-
if any(map(lambda e: not validateIPv4(e), self.rootServers)):
70-
logFatalError(f'Fatal: `root-servers` field must contain a list of valid IPv4 addresses')
71-
sys.exit(1)
69+
self.__validateRootServers()
7270

7371
self.blocklists = yconf.get('blocklists')
7472
if self.blocklists is None:
@@ -150,6 +148,17 @@ def __parseBlocklistFile(self, filePath: str):
150148

151149
self.blocklist[hostnameRegex] = ip
152150

151+
def __validateRootServers(self):
152+
for server in self.rootServers:
153+
ipv4Validation = validateIPv4(server)
154+
tlsIpv4Validation = validateTlsIPv4(server)
155+
domainNameValidation = validateDomainName(server)
156+
tlsDomainNameValidation = validateTlsDomainName(server)
157+
158+
if not any([ipv4Validation, tlsIpv4Validation, domainNameValidation, tlsDomainNameValidation]):
159+
logFatalError(f'Fatal: `{server}` is not a valid root-server')
160+
sys.exit(1)
161+
153162
def search(self, target: str, qtype: int) -> str|None:
154163
remap = self.remaps.get(target)
155164
if remap is not None:

src/DNS.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import random
22
import socket as sk
33
from _thread import start_new_thread
4+
import ssl
45

56
from Cache import Cache
67
from DnsHeader import DnsHeader
@@ -136,6 +137,7 @@ def assembleResponse(data, type=QTYPE.A):
136137

137138
return
138139

140+
print(self.caches)
139141
# searches in cache
140142
if qtype in self.qtypes:
141143
if responseBytes := self.caches[qtype].getForId(qname, requestId):
@@ -151,7 +153,12 @@ def assembleResponse(data, type=QTYPE.A):
151153

152154
# asks configured root servers
153155
for server in self.conf.rootServers:
154-
responseBytes = self.askRootServer(server, bytes)
156+
if server.startswith('tls://'):
157+
print('tls server detected')
158+
responseBytes = self.askTlsRootServer(server, bytes)
159+
160+
else:
161+
responseBytes = self.askRootServer(server, bytes)
155162

156163
if responseBytes is None:
157164
continue
@@ -165,6 +172,7 @@ def assembleResponse(data, type=QTYPE.A):
165172
except:
166173
self.logger.error(f'{strRequestId} | Error: cannot send response to {fmtClientAddress}')
167174

175+
print(f'{qtype = } ; {qtype in self.qtypes = }')
168176
if qtype in self.qtypes:
169177
self.caches[qtype].append(responseBytes)
170178

@@ -191,4 +199,24 @@ def askRootServer(self, server, questionBytes):
191199
except:
192200
return None
193201

202+
return responseBytes
203+
204+
def askTlsRootServer(self, server, questionBytes):
205+
sslContext = ssl.create_default_context()
206+
targetServer = server.removeprefix('tls://')
207+
print(f'{targetServer = }')
208+
209+
try:
210+
with sk.create_connection((targetServer, STD_DoT_PORT), timeout=5) as sock:
211+
with sslContext.wrap_socket(sock, server_hostname=targetServer) as tlsSocket:
212+
querySize = len(questionBytes).to_bytes(2, 'big')
213+
tlsSocket.sendall(querySize + questionBytes)
214+
215+
responseLength = int.from_bytes(tlsSocket.recv(2), 'big')
216+
responseBytes = tlsSocket.recv(responseLength)
217+
218+
except Exception as e:
219+
print(e)
220+
return
221+
194222
return responseBytes

src/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
RECV_SIZE = 2048
2121

2222
STD_DNS_PORT = 53
23+
STD_DoT_PORT = 853
2324

2425
QNAME_STD_POINTER = b'\xc0\x0c'
2526
QNAME_POINTER_FLAG = 0b11000000

src/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from DNS import DNS
66
from utils import logFatalError
77

8-
VERSION = 'v5.1.0'
8+
VERSION = 'v5.2.0'
99

1010
if __name__ == '__main__':
1111
if os.sys.platform == 'linux':

src/unit_testing.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ def test_conf_from_yaml(self):
253253
root-servers:
254254
- 1.1.1.1
255255
- 1.0.0.1
256+
- tls://1.1.1.1
257+
- tls://dns.cloudflare.com
256258
257259
blocklists:
258260
-'''
@@ -269,7 +271,7 @@ def test_conf_from_yaml(self):
269271
self.assertEqual(conf.port, 53)
270272
self.assertEqual(conf.logging, True)
271273
self.assertEqual(conf.persistentLog, False)
272-
self.assertEqual(conf.rootServers, ['1.1.1.1', '1.0.0.1'])
274+
self.assertEqual(conf.rootServers, ['1.1.1.1', '1.0.0.1', 'tls://1.1.1.1', 'tls://dns.cloudflare.com'])
273275
self.assertEqual(conf.search('me.local', QTYPE.A), '0.0.0.0')
274276
self.assertEqual(conf.search('qname', QTYPE.TXT), 'txt record')
275277
self.assertIsNone(conf.search('name', QTYPE.CNAME))
@@ -285,6 +287,12 @@ def test_ipv4_success(self):
285287
def test_ipv4_fail(self):
286288
self.assertFalse(validateIPv4('10.2.0.258'))
287289

290+
def test_tls_ipv4_success(self):
291+
self.assertTrue(validateTlsIPv4('tls://1.1.1.1'))
292+
293+
def test_tls_ipv4_fail(self):
294+
self.assertFalse(validateTlsIPv4('tsl://1.1.1.1'))
295+
288296
def test_ipv6_success(self):
289297
self.assertTrue(validateIPv6('fe80:beef::deeb'))
290298

@@ -303,6 +311,12 @@ def test_cname_success(self):
303311
def test_cname_fail(self):
304312
self.assertFalse(validateDomainName('wrong_domain.name.a'))
305313

314+
def test_tls_domain_name_success(self):
315+
self.assertTrue(validateTlsDomainName('tls://dns.cloudflare.com'))
316+
317+
def test_tls_domain_name_fail(self):
318+
self.assertFalse(validateTlsDomainName('tsl://dns.cloudflare.com'))
319+
306320
def test_vlanmask_success(self):
307321
self.assertTrue(validateVlanMask('192.168.0.1/24'))
308322
self.assertTrue(validateVlanMask('192.168.0.1'))

src/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def u16ToBytes(value):
1010
return struct.pack(U16, value)
1111

1212
def bytesToU32(bytes):
13-
return struct.unpack(U16, bytes[0:4])[0]
13+
return struct.unpack(U32, bytes[0:4])[0]
1414

1515
def u32ToBytes(value):
1616
return struct.pack(U32, value)

src/validation.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ def validateIPv4(ip):
77
ip
88
) is not None
99

10+
def validateTlsIPv4(ip):
11+
return re.fullmatch(
12+
'^tls\\:\\/\\/((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]|[0-9])$',
13+
ip
14+
) is not None
15+
1016
def validateIPv6(ip):
1117
return re.fullmatch(
1218
'^(?:(?:[a-f0-9]*)?:){1,7}[a-f0-9]*$',
@@ -25,6 +31,18 @@ def validateDomainName(name):
2531

2632
return False
2733

34+
def validateTlsDomainName(name):
35+
if re.fullmatch(
36+
'(?i)^tls\\:\\/\\/[a-z0-9]\\.?([a-z0-9\\-]*\\.)*[a-z0-9\\-]+$',
37+
name
38+
) is None:
39+
return False
40+
41+
if 0 < len(name.removesuffix('.')) <= 253:
42+
return True
43+
44+
return False
45+
2846
def validateTxtRecord(record):
2947
return 0 <= len(record) <= TXT_RECORD_MAX_LENGTH
3048

0 commit comments

Comments
 (0)