Skip to content

Instantly share code, notes, and snippets.

@VehpuS
Created December 11, 2017 16:38
Show Gist options
  • Save VehpuS/ba64b146312a398e63b0eaca19ad09bf to your computer and use it in GitHub Desktop.
Save VehpuS/ba64b146312a398e63b0eaca19ad09bf to your computer and use it in GitHub Desktop.
Based on https://github.com/samuelcolvin/dnserver - a DNS server in python with convenient parameters, and lots of documentation - for use and learning.
#!/usr/local/bin/python3
# -*- python -*-
'''
Owner : Moshe J. Gordon Radian
Description : DNS utilities using dnslib.
Based on: https://github.com/samuelcolvin/dnserver
Some resources about DNS:
- http://www.slashroot.in/what-dns-zone-file-complete-tutorial-zone-file-and-its-contents
- https://computer.howstuffworks.com/dns.htm
- https://technet.microsoft.com/en-us/library/bb727018.aspx
Test using:
dig @localhost -p 53 example.com MX
'''
#############################
# Global imports
#############################
import argparse
from datetime import datetime
from dnslib import DNSLabel, QTYPE, RR, dns
from dnslib.server import DNSServer
from dnslib.proxy import ProxyResolver
import json
import logging
import os
import signal
# Wraps is a decorator for updating the docstring of the wrapping function to those of the original function
from textwrap import wrap
from time import sleep
import traceback
#######################################################################
# global parameters
#######################################################################
# DNS Entry types map from name to dnslib objects
# See links above for details about the different entry types in the protocol
# Some basic explenations can be found in the Record class documentation
TYPE_LOOKUP = {
'A': (dns.A, QTYPE.A),
'AAAA': (dns.AAAA, QTYPE.AAAA),
'CAA': (dns.CAA, QTYPE.CAA),
'CNAME': (dns.CNAME, QTYPE.CNAME),
'DNSKEY': (dns.DNSKEY, QTYPE.DNSKEY),
'MX': (dns.MX, QTYPE.MX
),
'NAPTR': (dns.NAPTR, QTYPE.NAPTR),
'NS': (dns.NS, QTYPE.NS),
'PTR': (dns.PTR, QTYPE.PTR),
'RRSIG': (dns.RRSIG, QTYPE.RRSIG),
'SOA': (dns.SOA, QTYPE.SOA),
'SRV': (dns.SRV, QTYPE.SRV),
'TXT': (dns.TXT, QTYPE.TXT),
'SPF': (dns.TXT, QTYPE.TXT),
}
# When running the file as a process, we will try to read a zone file.
SAMPLE_ZONE_FILE_CONTENTS = '''
# this is an example zones file
# each line with parts split on white space are considered thus:
# 1: the host
# 2: the record type
# everything else: either a single string or json list if it starts with "["
# lines starting with white space are striped of white space (including "\n")
# and added to the previous line
example.com A 1.2.3.4
example.com CNAME whatever.com
example.com MX ["whatever.com.", 5]
example.com MX ["mx2.whatever.com.", 10]
example.com MX ["mx3.whatever.com.", 20]
example.com NS ns1.whatever.com.
example.com NS ns2.whatever.com.
example.com TXT hello this is some text
example.com SOA ["ns1.example.com", "dns.example.com"]
# because the next record exceeds 255 in length dnserver will automatically
# split it into a multipart record, the new lines here have no effect on that
testing.com TXT one long value: IICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAg
FWZUed1qcBziAsqZ/LzT2ASxJYuJ5sko1CzWFhFuxiluNnwKjSknSjanyYnm0vro4dhAtyiQ7O
PVROOaNy9Iyklvu91KuhbYi6l80Rrdnuq1yjM//xjaB6DGx8+m1ENML8PEdSFbKQbh9akm2bkN
w5DC5a8Slp7j+eEVHkgV3k3oRhkPcrKyoPVvniDNH+Ln7DnSGC+Aw5Sp+fhu5aZmoODhhX5/1m
ANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA26JaFWZUed1qcBziAsqZ/LzTF2ASxJYuJ5sk
'''
DEFAULT_ZONE_FILE = 'zones.txt'
log = logging.getLogger(__name__)
#############################
# Module classes
#############################
# A wrapper for the DNS Resource Record class
class Record(RR):
'''
@summary:
You can extend and modify your DNS settings to add sub-domains, redirect e-mail and control other services.
This information is kept in a zone file on the DNS server.
If you're running your own server, you'll probably need to manually edit the zone file in a text editor.
Each new configuration you add is called a record.
'''
def __init__(self, rname, rtype, args):
'''
@summary:
A record in a zone file.
@param rname:
The string containing a name for a record or @. It's meaning will depend on the record type (rtype).
@param rtype:
A string representing a record type.
The following are the most common types of records you can configure for your DNS server:
Host (A) --
This is the basic mapping of IP address to host name, the essential component for any domain name.
Canonical Name (CNAME) --
This is an alias for your domain. Anyone accessing that alias will be automatically directed to the
server indicated in the A record.
Mail Exchanger (MX) --
This maps e-mail traffic to a specific server. It could indicate another host name or an IP
address.
For example, people who use Google for the e-mail for their domain will create an MX record that
points to ghs.google.com.
Name Server (NS) --
This contains the name server information for the zone. If you configure this, your server will let
other DNS servers know that yours is the ultimate authority (SOA) for your domain when caching
lookup information on your domain from other DNS servers around the world.
Start of Authority (SOA) --
An SOA record is a Start of Authority.
Every domain must have a Start of Authority record at the cutover point where the domain is
delegated from its parent domain.
This is one larger record at the beginning of every zone file with the primary name server for the
zone and some other information.
If your registrar or hosting company is running your DNS server, you won't need to manage this.
Typical users will probably get the most use out of MX and CNAME records.
The MX records allows you to point your mail services somewhere other than your hosting company if you
choose to use something like Google Apps for your domain.
The CNAME records let you point host names for your domain to various other locations.
This could include setting google.example.com
to redirect to google.com, or setting up a dedicated game
server with its own IP address and pointing it to something like gameserver.example.com
.
HowStuffWorks' parent company, Discovery, does this: dsc.discovery.com
is the main Web site,
science.discovery.com
is The Science Channel Web site, and so on.
For more info about the different types:
http://www.slashroot.in/what-dns-zone-file-complete-tutorial-zone-file-and-its-contents
@param args:
Arguments for the record type (which will, again, depend on the type itself.
@example:
Here are some zone file records will be translated into __init__'s parameter
@ NS auth-ns1.howstuffworks.com
--> rname=@, rtype=NS, args=auth-ns1.howstuffworks.com
@ MX 10 mail
--> rname=@, rtype=MX, args=10 mail
mail A 209.170.137.42
--> rname=mail, rtype=A, args=209.170.137.42
www CNAME vip1
--> rname=www, rtype=CNAME, args=vip
'''
self._rname = DNSLabel(rname)
# Conver rtype string to Python class type and numerical type representation
rd_cls, self._rtype = TYPE_LOOKUP[rtype]
if self._rtype == QTYPE.SOA and len(args) == 2:
# add sensible times to SOA
serial_no = int((datetime.utcnow() - datetime(1970, 1, 1)).total_seconds())
args += (serial_no, 3600, 3600 * 3, 3600 * 24, 3600),
# wrap long TXT records as per dnslib's docs.
if self._rtype == QTYPE.TXT and len(args) == 1 and isinstance(args[0], str) and len(args[0]) > 255:
args = wrap(args[0], 255),
# Time to Live (TTL) statically defined rather than being read from the file.
if self._rtype in (QTYPE.NS, QTYPE.SOA):
ttl = 3600 * 24
else:
ttl = 300
# initialize a DNS Resource Record internal representation.
super(Record, self).__init__(
rname=self._rname,
rtype=self._rtype,
rdata=rd_cls(*args),
ttl=ttl,
)
def match(self, query_question):
'''
@summary:
Check if a query matches the current record name and type.
@param query_question:
A query - a DNSQuestion object.
'''
return (query_question.qname == self._rname and
(query_question.qtype == QTYPE.ANY or query_question.qtype == self._rtype))
def sub_match(self, query_question):
'''
@summary:
Checks if the record is an SOA record (Start of Authority - see above) and if so - checks if the query's
name belongs to the SOA.
@param query_question:
A query - a DNSQuestion object.
'''
return self._rtype == QTYPE.SOA and query_question.qname.matchSuffix(self._rname)
class LocalResolver(ProxyResolver):
'''
A class to initialize a resolver that uses local Records for DNS responses, and then forwards them to an upstream
server.
'''
def __init__(self, upstream, records):
'''
@summary:
Start a proxy resolver pointing towards the upstream server and parse the records file for local responses.
@param upstream:
The IP or hostname of the upstream DNS server we want to use, i.e. the site's DNS server.
@param records:
A list of Record objects used for local DNS responses.
'''
ProxyResolver.__init__(self, address=upstream, port=53, timeout=5)
self.records = records
def resolve(self, request, handler):
'''
@summary:
Match a request query with a zone file record.
Wraps the ProxyResolver's resolve logic.
@param request;
A DNSRecord object.
@param handler:
A DNSHandler object.
'''
type_name = QTYPE[request.q.qtype]
reply = request.reply()
for record in self.records:
query = request.q
if record.match(query):
reply.add_answer(record)
if reply.rr:
log.info('found zone for {0}[{1}], {2} replies'.format(request.q.qname, type_name, len(reply.rr)))
return reply
# no direct zone so look for an SOA record for a higher level zone
for record in self.records:
if record.sub_match(request.q):
reply.add_answer(record)
if reply.rr:
log.info('found higher level SOA resource for {0}[{1}]'.format(request.q.qname, type_name))
return reply
log.info('no local zone found, proxying {0}[{1}]'.format(request.q.qname, type_name))
return ProxyResolver.resolve(self, request, handler)
# #############################
# Module functions
# #############################
def parse_record_from_zone_line(zone_file_line):
'''
@summary:
Parse a line in the zone file containing a zone record.
@param zone_file_line:
Contents of a line in the zone file containing a zone record.
@return:
A Record using the infromation from the line.
@example:
Here are some zone file lines and their translation
@ NS auth-ns1.howstuffworks.com
--> rname=@, rtype=NS, args=auth-ns1.howstuffworks.com
@ MX 10 mail
--> rname=@, rtype=MX, args=10 mail
mail A 209.170.137.42
--> rname=mail, rtype=A, args=209.170.137.42
www CNAME vip1
--> rname=www, rtype=CNAME, args=vip
'''
try:
# Use default split and look for 3 separate parameters - split([sep[, maxsplit]])
# Each line in the zone file is built from a single word rname, single word rtype and any length args.
rname, rtype, args_ = zone_file_line.split(None, 2)
# convert args into a tuple fo args
if args_.startswith('['):
args = tuple(json.loads(args_))
else:
args = (args_,)
record = Record(rname, rtype, args)
except Exception as e:
traceback.print_exc()
raise RuntimeError('Error processing line due to ({e.__class__.__name__}: {e}) "{line}"'
.format(e=e, line=zone_file_line))
log.info("Parsing info: rname={0}, rtype={1}, args_={2}".format(rname, rtype, args_))
return record
def records_from_zone_file(zone_file):
'''
@summary:
Read a zone file and return lines that include DNS zone records.
@param zone_file:
The path to the zone file we want to use.
@example:
Zone files are nothing but simple text files, that can be easily modified by using text editors such as VIM,
EMACS etc.
This file contains the complete details of all resource records for that domain. In other words it will
contains the entire ip to domain mapping of the domain.
Zone files are made in such a way that it can be made portable for any DNS server.
The following is an example of what a zone file might look like for those who are editing it directly in a
text editor.
Note that the center column (second item on each line) includes a record type from those listed above.
When you see an "@" in the left column, it means that the record applies in all cases not otherwise
specified:
@ NS auth-ns1.howstuffworks.com
@ NS auth-ns2.howstuffworks.com
@ MX 10 mail
mail A 209.170.137.42
vip1 A 216.183.103.150
www CNAME vip1
@yield:
A generator that returns Record objects using the infromation from each zone file line.
'''
assert os.path.exists(zone_file), 'zone files "{0}" does not exist'.format(zone_file)
current_line = ''
with open(zone_file) as zone_obj:
lines = zone_obj.readlines()
for line in lines:
if line.startswith('#'):
continue
line = line.rstrip('\r\n\t ')
if not line.startswith(' ') and current_line:
yield parse_record_from_zone_line(current_line)
current_line = ''
current_line += line.lstrip('\r\n\t ')
if current_line:
yield parse_record_from_zone_line(current_line)
def records_list_from_zone_file(zone_file):
'''
@summary:
Create a list of Record object from a zone file
@param zone_file:
The path to a zone file we want to use.
@return:
A list of Record objects using the infromation from each zone file line.
'''
log.info('loading zone file "{0}":'.format(zone_file))
zones = [record for record in records_from_zone_file(zone_file)]
for index, record in enumerate(zones):
log.info(' {0:2}: {1}'.format(index, record))
log.info('{} zone resource records generated from zone file'.format(len(zones)))
return zones
def init_servers_with_zone_file(port, upstream, zone_file):
'''
@summary:
Setup a DNS server for TCP + UDP on the given port and with the given upstram server, and with the given local
records list.
@param upstream:
The IP or hostname of the upstream DNS server we want to use, i.e. the site's DNS server.
@param zone_file:
The path to a zone file we want to use.
@return:
A tuple of two DNSServer objects - one UDP and one TCP.
'''
resolver = LocalResolver(upstream, records_list_from_zone_file(zone_file))
return DNSServer(resolver, port=port), DNSServer(resolver, port=port, tcp=True)
# #############################
# Server Process Functions
# #############################
def handle_sig(signum, _):
'''
@summary:
Unix signal handler - so that the server can be stopped via a signal.
@note:
The signature is in accordance with the definition in signal.signal;
"A signal handler function is called with two arguments:
the first is the signal number, the second is the interrupted stack frame."
@param signum:
the number of the signal we received.
'''
log.info('pid={0}, got signal: {1}, stopping...'.format(os.getpid(), signum))
raise
def parse_args():
'''
@summary:
Handle parsing the command line arguments using argparse. Documented in the code.
'''
usage_str = "./dns_server_simulation.py [-p **port_number**] [-u **upstream address*] [-f **zone_file_path**]"
epilog_str = ('How to run the script:\n{0}'.format(usage_str))
cmd_line_parser = argparse.ArgumentParser(usage=usage_str, epilog=epilog_str,
formatter_class=argparse.RawDescriptionHelpFormatter)
cmd_line_parser.add_argument('-p', '--port',
help='The port for the DNS server', required=False,
dest="port", type=int, default=53)
cmd_line_parser.add_argument('-u', '--upstream',
help=('The upstream address for the DNS server. '
'Upstream server refers to a server that provides service to another server.'),
required=False,
dest="upstream", default="8.8.8.8")
cmd_line_parser.add_argument('-f', '--file_path',
help='The path to the zone file for the DNS server', required=False,
dest="zone_file_path",
default=(DEFAULT_ZONE_FILE))
return cmd_line_parser.parse_args()
def main(parsed_args):
'''
@summary:
Used parsed arguments to run the DNS servers
@param parsed_args:
argsparse object, the results of 'parse_args'.
'''
signal.signal(signal.SIGTERM, handle_sig)
servers = init_servers_with_zone_file(parsed_args.port, parsed_args.upstream, parsed_args.zone_file_path)
log.info('starting DNS server on port {port}, upstream DNS server "{upstream}"'
.format(port=parsed_args.port, upstream=parsed_args.upstream))
for server in servers:
server.start_thread()
try:
while all([server.isAlive() for server in servers]):
sleep(1)
except KeyboardInterrupt:
pass
finally:
for server in servers:
try:
server.stop()
except:
pass
# #############################
# Main
# #############################
if __name__ == '__main__':
main(parse_args())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment