├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── dnserver.py ├── docker-compose.yml ├── dockertest ├── Dockerfile ├── requirements.txt └── run.py ├── example_zones.txt └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | env/ 3 | .idea 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | LABEL maintainer "s@muelcolvin.com" 4 | 5 | RUN pip install dnslib==0.9.7 6 | 7 | RUN mkdir /zones 8 | ADD ./example_zones.txt /zones/zones.txt 9 | 10 | ADD ./dnserver.py /home/root/dnserver.py 11 | EXPOSE 53/tcp 12 | EXPOSE 53/udp 13 | CMD ["/home/root/dnserver.py"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Samuel Colvin 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dnserver 2 | 3 | A modification of samuelcolvin's simple DNS server written in python for use in development and testing. 4 | 5 | The DNS serves it's own records, if none are found it proxies the request to and upstream DNS server 6 | eg. google at `8.8.8.8`. 7 | 8 | If the upstream can't find it, then return a spoofed record from the local zone (aka own records). 9 | 10 | You can setup records you want to serve with a custom `zones.txt` file, 11 | see [example_zones.txt](example_zones.txt) for the format. 12 | 13 | To use with docker: 14 | 15 | docker run -p 5053:53/udp -p 5053:53/tcp --rm samuelcolvin/dnserver 16 | 17 | (See [dnserver on hub.docker.com](https://hub.docker.com/r/samuelcolvin/dnserver/)) 18 | 19 | Or with a custom zone file 20 | 21 | docker run -p 5053:53/udp -v `pwd`/zones.txt:/zones/zones.txt --rm samuelcolvin/dnserver 22 | 23 | (assuming you have your zone records at `./zones.txt`, 24 | TCP isn't required to use `dig`, hence why it's omitted in this case.) 25 | 26 | Or see [docker-compose.yml](docker-compose.yml) for example of using dnserver with docker compose. 27 | It demonstrates using dnserver as the DNS server for another container with then tries to make DNS queries 28 | for numerous domains. 29 | 30 | To run without docker (assuming you have `dnslib==0.9.7` and python 3.6 installed): 31 | 32 | PORT=5053 ZONE_FILE='./example_zones.txt' ./dnserver.py 33 | 34 | You can then test (either of the above) with 35 | 36 | ```shell 37 | ~ ➤ dig @localhost -p 5053 example.com MX 38 | ... 39 | ;; ANSWER SECTION: 40 | example.com. 300 IN MX 5 whatever.com. 41 | example.com. 300 IN MX 10 mx2.whatever.com. 42 | example.com. 300 IN MX 20 mx3.whatever.com. 43 | 44 | ;; Query time: 2 msec 45 | ;; SERVER: 127.0.0.1#5053(127.0.0.1) 46 | ;; WHEN: Sun Feb 26 18:14:52 GMT 2017 47 | ;; MSG SIZE rcvd: 94 48 | 49 | ~ ➤ dig @localhost -p 5053 tutorcruncher.com MX 50 | ... 51 | ;; ANSWER SECTION: 52 | tutorcruncher.com. 299 IN MX 10 aspmx2.googlemail.com. 53 | tutorcruncher.com. 299 IN MX 5 alt1.aspmx.l.google.com. 54 | tutorcruncher.com. 299 IN MX 5 alt2.aspmx.l.google.com. 55 | tutorcruncher.com. 299 IN MX 1 aspmx.l.google.com. 56 | tutorcruncher.com. 299 IN MX 10 aspmx3.googlemail.com. 57 | 58 | ;; Query time: 39 msec 59 | ;; SERVER: 127.0.0.1#5053(127.0.0.1) 60 | ;; WHEN: Sun Feb 26 18:14:48 GMT 2017 61 | ;; MSG SIZE rcvd: 176 62 | ``` 63 | 64 | You can see that the first query took 2ms and returned results from [example_zones.txt](example_zones.txt), 65 | the second query took 39ms as dnserver didn't have any records for the domain so had to proxy the query to 66 | the upstream DNS server. 67 | -------------------------------------------------------------------------------- /dnserver.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.6 2 | import json 3 | import logging 4 | import os 5 | import signal 6 | from datetime import datetime 7 | from pathlib import Path 8 | from textwrap import wrap 9 | from time import sleep 10 | from copy import copy 11 | 12 | from dnslib import DNSLabel, QTYPE, RR, dns 13 | from dnslib.proxy import ProxyResolver 14 | from dnslib.server import DNSServer 15 | 16 | SERIAL_NO = int((datetime.utcnow() - datetime(1970, 1, 1)).total_seconds()) 17 | 18 | handler = logging.StreamHandler() 19 | handler.setLevel(logging.INFO) 20 | handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s', datefmt='%H:%M:%S')) 21 | 22 | logger = logging.getLogger(__name__) 23 | logger.addHandler(handler) 24 | logger.setLevel(logging.INFO) 25 | 26 | TYPE_LOOKUP = { 27 | 'A': (dns.A, QTYPE.A), 28 | 'AAAA': (dns.AAAA, QTYPE.AAAA), 29 | 'CAA': (dns.CAA, QTYPE.CAA), 30 | 'CNAME': (dns.CNAME, QTYPE.CNAME), 31 | 'DNSKEY': (dns.DNSKEY, QTYPE.DNSKEY), 32 | 'MX': (dns.MX, QTYPE.MX), 33 | 'NAPTR': (dns.NAPTR, QTYPE.NAPTR), 34 | 'NS': (dns.NS, QTYPE.NS), 35 | 'PTR': (dns.PTR, QTYPE.PTR), 36 | 'RRSIG': (dns.RRSIG, QTYPE.RRSIG), 37 | 'SOA': (dns.SOA, QTYPE.SOA), 38 | 'SRV': (dns.SRV, QTYPE.SRV), 39 | 'TXT': (dns.TXT, QTYPE.TXT), 40 | 'SPF': (dns.TXT, QTYPE.TXT), 41 | } 42 | 43 | 44 | class Record: 45 | def __init__(self, rname, rtype, args): 46 | self._rname = DNSLabel(rname) 47 | 48 | rd_cls, self._rtype = TYPE_LOOKUP[rtype] 49 | 50 | if self._rtype == QTYPE.SOA and len(args) == 2: 51 | # add sensible times to SOA 52 | args += (SERIAL_NO, 3600, 3600 * 3, 3600 * 24, 3600), 53 | 54 | if self._rtype == QTYPE.TXT and len(args) == 1 and isinstance(args[0], str) and len(args[0]) > 255: 55 | # wrap long TXT records as per dnslib's docs. 56 | args = wrap(args[0], 255), 57 | 58 | if self._rtype in (QTYPE.NS, QTYPE.SOA): 59 | ttl = 3600 * 24 60 | else: 61 | ttl = 300 62 | 63 | self.rr = RR( 64 | rname=self._rname, 65 | rtype=self._rtype, 66 | rdata=rd_cls(*args), 67 | ttl=ttl, 68 | ) 69 | 70 | def match(self, q): 71 | return q.qname == self._rname and (q.qtype == QTYPE.ANY or q.qtype == self._rtype) 72 | 73 | def sub_match(self, q): 74 | return self._rtype == QTYPE.SOA and q.qname.matchSuffix(self._rname) 75 | 76 | def __str__(self): 77 | return str(self.rr) 78 | 79 | 80 | class Resolver(ProxyResolver): 81 | def __init__(self, upstream, zone_file): 82 | super().__init__(upstream, 53, 5) 83 | self.records = self.load_zones(zone_file) 84 | 85 | def zone_lines(self): 86 | current_line = '' 87 | for line in zone_file.open(): 88 | if line.startswith('#'): 89 | continue 90 | line = line.rstrip('\r\n\t ') 91 | if not line.startswith(' ') and current_line: 92 | yield current_line 93 | current_line = '' 94 | current_line += line.lstrip('\r\n\t ') 95 | if current_line: 96 | yield current_line 97 | 98 | def load_zones(self, zone_file): 99 | assert zone_file.exists(), f'zone files "{zone_file}" does not exist' 100 | logger.info('loading zone file "%s":', zone_file) 101 | zones = [] 102 | for line in self.zone_lines(): 103 | try: 104 | rname, rtype, args_ = line.split(maxsplit=2) 105 | 106 | if args_.startswith('['): 107 | args = tuple(json.loads(args_)) 108 | else: 109 | args = (args_,) 110 | record = Record(rname, rtype, args) 111 | zones.append(record) 112 | logger.info(' %2d: %s', len(zones), record) 113 | except Exception as e: 114 | raise RuntimeError(f'Error processing line ({e.__class__.__name__}: {e}) "{line.strip()}"') from e 115 | logger.info('%d zone resource records generated from zone file', len(zones)) 116 | return zones 117 | 118 | def resolve(self, request, handler): 119 | type_name = QTYPE[request.q.qtype] 120 | reply = request.reply() 121 | for record in self.records: 122 | if record.match(request.q): 123 | reply.add_answer(record.rr) 124 | 125 | if reply.rr: 126 | logger.info('found zone for %s[%s], %d replies', request.q.qname, type_name, len(reply.rr)) 127 | return reply 128 | 129 | # no direct zone so look for an SOA record for a higher level zone 130 | for record in self.records: 131 | if record.sub_match(request.q): 132 | reply.add_answer(record.rr) 133 | 134 | if reply.rr: 135 | logger.info('found higher level SOA resource for %s[%s]', request.q.qname, type_name) 136 | return reply 137 | 138 | logger.info('no local zone found, proxying %s[%s]', request.q.qname, type_name) 139 | response = super().resolve(request, handler) 140 | if response.header.get_rcode() == 3: #NXERROR 141 | for record in self.records: 142 | #Check the query type (e.g. A or MX) matches 143 | if record.rr.rtype == response.q.qtype: 144 | newrec = copy(record.rr) #Copy the record so we can change it safely 145 | newrec.rname = request.q.qname #Overwrite the name with the request's name 146 | reply.add_answer(newrec) 147 | if reply.rr: 148 | logger.info('no proxying zone, returning spoof local zone %s[%s]', request.q.qname, type_name) 149 | return reply 150 | else: 151 | return response 152 | else: 153 | return response 154 | 155 | def handle_sig(signum, frame): 156 | logger.info('pid=%d, got signal: %s, stopping...', os.getpid(), signal.Signals(signum).name) 157 | exit(0) 158 | 159 | 160 | if __name__ == '__main__': 161 | signal.signal(signal.SIGTERM, handle_sig) 162 | 163 | port = int(os.getenv('PORT', 53)) 164 | upstream = os.getenv('UPSTREAM', '8.8.8.8') 165 | zone_file = Path(os.getenv('ZONE_FILE', '/zones/zones.txt')) 166 | resolver = Resolver(upstream, zone_file) 167 | udp_server = DNSServer(resolver, port=port) 168 | tcp_server = DNSServer(resolver, port=port, tcp=True) 169 | 170 | logger.info('starting DNS server on port %d, upstream DNS server "%s"', port, upstream) 171 | udp_server.start_thread() 172 | tcp_server.start_thread() 173 | 174 | try: 175 | while udp_server.isAlive(): 176 | sleep(1) 177 | except KeyboardInterrupt: 178 | pass 179 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | networks: 4 | default: 5 | ipam: 6 | config: 7 | - subnet: 172.25.0.0/24 8 | 9 | services: 10 | ns: 11 | image: samuelcolvin/dnserver 12 | networks: 13 | default: 14 | ipv4_address: 172.25.0.101 15 | 16 | test: 17 | build: dockertest 18 | dns: 172.25.0.101 19 | depends_on: 20 | - ns 21 | -------------------------------------------------------------------------------- /dockertest/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | LABEL maintainer "s@muelcolvin.com" 4 | 5 | RUN apk --update --no-cache add gcc musl-dev \ 6 | && rm -rf /var/cache/apk/* 7 | 8 | ADD ./requirements.txt /home/root/requirements.txt 9 | RUN pip install -r /home/root/requirements.txt 10 | 11 | WORKDIR /home/root 12 | ADD ./run.py /home/root/run.py 13 | CMD ["./run.py"] 14 | -------------------------------------------------------------------------------- /dockertest/requirements.txt: -------------------------------------------------------------------------------- 1 | aiodns==1.1.1 2 | -------------------------------------------------------------------------------- /dockertest/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.6 2 | import asyncio 3 | import logging 4 | from pathlib import Path 5 | from time import sleep 6 | 7 | import aiodns 8 | from aiodns.error import DNSError 9 | 10 | handler = logging.StreamHandler() 11 | handler.setLevel(logging.INFO) 12 | handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s', datefmt='%H:%M:%S')) 13 | 14 | logger = logging.getLogger(__name__) 15 | logger.addHandler(handler) 16 | logger.setLevel(logging.INFO) 17 | 18 | 19 | async def query(resolver, domain, qtype): 20 | logger.info('%s %s:', domain, qtype) 21 | try: 22 | ans = await resolver.query(domain, qtype) 23 | except DNSError as e: 24 | logger.info(' %s: %s', e.__class__.__name__, e) 25 | else: 26 | for v in ans: 27 | logger.info(' %s', v) 28 | 29 | 30 | async def main(loop): 31 | resolver = aiodns.DNSResolver(loop=loop) 32 | for domain in ('example.com', 'google.com', 'ns', 'test', 'fails'): 33 | await query(resolver, domain, 'A') 34 | await query(resolver, 'example.com', 'MX') 35 | await query(resolver, 'foobar.example.com', 'A') 36 | await query(resolver, 'testing.com', 'TXT') 37 | 38 | 39 | if __name__ == '__main__': 40 | sleep(1) 41 | logger.info('/etc/hosts:\n%s', Path('/etc/hosts').read_text()) 42 | logger.info('/etc/resolv.conf:\n%s', Path('/etc/resolv.conf').read_text()) 43 | logger.info('starting dns tests') 44 | loop = asyncio.get_event_loop() 45 | loop.run_until_complete(main(loop)) 46 | 47 | -------------------------------------------------------------------------------- /example_zones.txt: -------------------------------------------------------------------------------- 1 | # this is an example zones file 2 | # each line with parts split on white space are considered thus: 3 | # 1: the host 4 | # 2: the record type 5 | # everything else: either a single string or json list if it starts with "[" 6 | # lines starting with white space are striped of white space (including "\n") 7 | # and added to the previous line 8 | example.com A 1.2.3.4 9 | example.com CNAME whatever.com 10 | example.com MX ["whatever.com.", 5] 11 | example.com MX ["mx2.whatever.com.", 10] 12 | example.com MX ["mx3.whatever.com.", 20] 13 | example.com NS ns1.whatever.com. 14 | example.com NS ns2.whatever.com. 15 | example.com TXT hello this is some text 16 | example.com SOA ["ns1.example.com", "dns.example.com"] 17 | # because the next record exceeds 255 in length dnserver will automatically 18 | # split it into a multipart record, the new lines here have no effect on that 19 | testing.com TXT one long value: IICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAg 20 | FWZUed1qcBziAsqZ/LzT2ASxJYuJ5sko1CzWFhFuxiluNnwKjSknSjanyYnm0vro4dhAtyiQ7O 21 | PVROOaNy9Iyklvu91KuhbYi6l80Rrdnuq1yjM//xjaB6DGx8+m1ENML8PEdSFbKQbh9akm2bkN 22 | w5DC5a8Slp7j+eEVHkgV3k3oRhkPcrKyoPVvniDNH+Ln7DnSGC+Aw5Sp+fhu5aZmoODhhX5/1m 23 | ANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA26JaFWZUed1qcBziAsqZ/LzTF2ASxJYuJ5sk 24 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dnslib==0.9.7 2 | --------------------------------------------------------------------------------