├── .gitignore ├── whitelist.dns.conf ├── tests ├── Dockerfile └── test.py ├── docker-compose.yml ├── dns.conf.example ├── example.com.soa ├── LICENSE ├── README.md └── fakedns.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ 3 | 4 | dns.conf 5 | -------------------------------------------------------------------------------- /whitelist.dns.conf: -------------------------------------------------------------------------------- 1 | A ^(?!.*(cnn\.com|google\.com)).* 127.0.0.1 2 | AAAA ^(?!.*(cnn\.com|google\.com)).* ::1 3 | -------------------------------------------------------------------------------- /tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | WORKDIR /opt/FakeDns/tests 4 | COPY ./ /opt/FakeDns/tests 5 | RUN pip install dnspython 6 | CMD python3 -m unittest discover -v /opt/FakeDns/tests -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | FakeDns: 4 | container_name: FakeDns 5 | image: python:3.8 6 | working_dir: /opt/FakeDns/ 7 | command: /opt/FakeDns/fakedns.py -c /opt/FakeDns/dns.conf.example 8 | volumes: 9 | - ./:/opt/FakeDns 10 | ports: 11 | - "53:53/udp" 12 | FakeDns-test: 13 | container_name: FakeDns-test 14 | image: fakedns-test:latest 15 | build: ./tests 16 | depends_on: 17 | - FakeDns -------------------------------------------------------------------------------- /dns.conf.example: -------------------------------------------------------------------------------- 1 | CNAME woot.com asdf.com 2 | A .*reddit.* 8.8.8.8 3 | TXT .* HELLO 4 | AAAA lulz.com 2607:f8b0:4006:807::100e 5 | A .*rebind.* 1.1.1.1,2.2.2.2 6 | A testrule.test 1.1.1.1,2.2.2.2,3.3.3.3 7 | A roundrobin 1.1.1.1 10%2.2.2.2,3.3.3.3,4.4.4.4 8 | # comment 9 | # A sample PTR entry, note the backwards IP here is required. 10 | PTR 1.0.0.127 localhost 11 | SOA example.com example.com.soa # SOA rules have a special syntax: SOA [target domain] [config file path] 12 | # see the example.com.soa file for an example and additional comments 13 | -------------------------------------------------------------------------------- /example.com.soa: -------------------------------------------------------------------------------- 1 | [example.com.] ; extra dot required at end. 2 | ; example python ini file comment. 3 | mname=ns1 ; the domain name (.example.com) will be appended to this automatically 4 | rname=mx ; the domain name (.example.com) will be appended to this automatically 5 | serial=random ; this can be random (for a random 32-bit integer) or an integer number 6 | refresh=60 7 | retry=60 8 | expire=60 9 | minimum=60 10 | 11 | ;[ruledomain.tld.] ; the extra dot at the end IS REQUIRED 12 | ;mname -- name server that was original or primary source for this zone 13 | ;rname -- domain name which specified mailbox of person responsible for zone 14 | ;serial-- 32-bit long version number of the zone copy 15 | ;refresh- 32-bit time interval before zone refresh 16 | ;retry -- 32-bit time interval before retrying failed refresh 17 | ;expire-- 32-bit time interval after which the zone is not authoritative 18 | ;minimum- The unsigned 32 bit minimum TTL for any RR from this zone. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Bryan "Crypt0s" Halfpap 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 | FakeDns 2 | ======= 3 | Update 4/14/2020 - Python 2 support removed and code swapped to Python3 4 | 5 | Now with round-robin & improved options! 6 | 7 | Bugs: 8 | @crypt0s - Twitter 9 | 10 | bryanhalf@gmail.com - Email 11 | 12 | 13 | A python regular-expression based DNS server! 14 | 15 | USAGE: 16 | ./fakedns.py [-h] -c Config path [-i interface IP address] [--rebind] 17 | 18 | The dns.conf should be set the following way: 19 | 20 | [RECORD TYPE CODE] [python regular expression] [answer] [rebind answer] 21 | 22 | The answer could be a ip address or string `self`, 23 | the `self` syntax sugar will be translated to your current machine's local ip address, such as `192.168.1.100`. 24 | 25 | If a match is not made, the DNS server will attempt to resolve the request using whatever you have your DNS server set to on your local machine and will proxy the request to that server on behalf of the requesting user. 26 | 27 | 28 | Supported Request Types 29 | ======================= 30 | - A 31 | - TXT 32 | - AAAA 33 | - PTR 34 | - SOA 35 | 36 | In-Progress Request Types 37 | ========================= 38 | - MX 39 | - CNAME 40 | 41 | Misc 42 | ==== 43 | - Supports DNS Rebinding 44 | - Supports round-robin 45 | 46 | Round-Robin 47 | =========== 48 | Round-robin rules are implemented. Every time a client requests a matching rule, FakeDNS will serve out the next IP in the list of IP's provided in the rule. 49 | A list of IP's is comma-separated. 50 | 51 | 52 | For example: 53 | 54 | A robin.net 1.2.3.4,1.1.1.1,2.2.2.2 55 | 56 | Is a round-robin rule for robin.net which will serve out responses pointing to 1.2.3.4, 1.1.1.1, and 2.2.2.2, iterating through that order every time a request is made by any client for the robin.net entry. 57 | 58 | *NOTE* : These IP's aren't included as a list to the client in the response - they still only get just one IP in the response (could change that later) 59 | 60 | DNS Rebinding 61 | ============= 62 | FakeDNS supports rebinding rules, which basically means that the server accepts a certain number of requests from a client for a domain until a threshold (default 1 request) and then it changes the IP address to a different one. 63 | 64 | For example: 65 | 66 | A rebind.net 1.1.1.1 10%4.5.6.7 67 | 68 | Means that we have an A record for rebind.net which evaluates to 1.1.1.1 for the first 10 tries. On the 11th request from a client which has already made 10 requests, FakeDNS starts serving out the second ip, 4.5.6.7 69 | 70 | You can use a list of addresses here and FakeDNS will round-robin them for you, just like in the "regular" rule. 71 | 72 | 73 | Testing FakeDNS in Docker 74 | ====== 75 | _(localhost only without extra steps)_ 76 | 77 | I have had a lot of success testing/developing FakeDNS in Docker because it's easier than running it natively on modern Ubuntu installs which have their own DNS services running on port 53 already. 78 | 79 | If you want to try it out, you can do so without much heavy lifting by following these steps: 80 | 81 | Assuming you are **_inside the FakeDns directory_**: `sudo docker run --interactive --tty --volume \`pwd\`:/opt/FakeDns -p 5353:53/udp python:3.8 /opt/FakeDns/fakedns.py -c /opt/FakeDns/dns.conf.example`. And to test you can run `nslookup -port=5353 testrule.test 127.0.0.1` which should return `1.1.1.1` on your first request 82 | 83 | Or, if you'd like to use docker-compose, simply run `docker-compose up` and use the same test as above. -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """Test framework for FakeDns""" 4 | 5 | 6 | """Imported Libraries 7 | 8 | unittest - Unit Testing Python Framework 9 | socket - Do one cheap DNS Lookup variant 10 | dns - DNS Query library 11 | """ 12 | import unittest 13 | import socket 14 | import dns.resolver 15 | 16 | 17 | """Global Variables 18 | 19 | """ 20 | 21 | 22 | class DNSTestCase(unittest.TestCase): 23 | """Parent Class to give common function and setUp 24 | """ 25 | 26 | 27 | def _dns_lookup(self, q: str, record_type: str) -> str: 28 | """Does a DNS lookup for us, and returns the string 29 | 30 | :param q: query (ip or hostname) 31 | :type q: str 32 | :param record_type: DNS record type 33 | :type record_type: str 34 | :return: DNS response 35 | :rtype: str 36 | """ 37 | # Ask the DNS server 38 | answer = self.resolver.resolve(q, record_type) 39 | # Make sure we only got one response 40 | if len(answer.rrset) != 1: 41 | raise RuntimeError( 42 | "ERROR: More than one record set return in _dns_lookup(\"{}\", \"{}\")".format(q, record_type) 43 | ) 44 | # Return the value 45 | return answer.rrset[0].to_text() 46 | 47 | 48 | def setUp(self): 49 | """Creates the FakeDns Process 50 | """ 51 | # Determine FakeDns IP address to use that as the name server 52 | self.resolver = dns.resolver.Resolver() 53 | self.resolver.nameservers = [socket.gethostbyname('FakeDns')] # Can't lookup 'FakeDns' via dns.resolver 54 | 55 | 56 | class TestRecordTypes(DNSTestCase): 57 | """Checks the return of specific DNS Requests to ensure all record types are working as intended 58 | """ 59 | 60 | 61 | def tearDown(self): 62 | """Destroys the FakeDns process 63 | """ 64 | del self.resolver 65 | 66 | 67 | def test_ARecord(self): 68 | """Tests A Record 69 | """ 70 | dns_response = self._dns_lookup("test.reddit.com", "A") 71 | self.assertEqual(dns_response, "8.8.8.8") 72 | 73 | 74 | def test_TXTRecord(self): 75 | """Tests TXT Record 76 | """ 77 | dns_response = self._dns_lookup("anyvalue", "TXT") 78 | self.assertEqual(dns_response, "\"HELLO\"") 79 | 80 | 81 | def test_AAAARecord(self): 82 | """Tests AAAA Record 83 | """ 84 | dns_response = self._dns_lookup("lulz.com", "AAAA") 85 | self.assertEqual(dns_response, "2607:f8b0:4006:807::100e") 86 | 87 | 88 | def test_PTRRecord(self): 89 | """Tests PTR Record 90 | """ 91 | dns_response = self._dns_lookup("1.0.0.127", "PTR") 92 | self.assertEqual(dns_response, "localhost.") 93 | 94 | 95 | def test_SOARecord(self): 96 | """Tests SOA Record 97 | """ 98 | dns_response = self._dns_lookup("example.com", "SOA") 99 | self.assertTrue( 100 | dns_response.startswith("ns1.example.com. mx.example.com. ") and dns_response.endswith(" 60 60 60 60") 101 | ) 102 | 103 | 104 | class TestFeatures(DNSTestCase): 105 | """Tests various DNS features implemented in FakeDns 106 | """ 107 | 108 | 109 | def test_Rebinding(self): 110 | """Test DNS rebinding 111 | """ 112 | # We do two rounds because we want to make sure the "wrapping around" feature doesn't break 113 | answers = [self._dns_lookup("testrule.test", "A") for _ in range(6)] 114 | expected_answers = ["1.1.1.1", "2.2.2.2", "3.3.3.3", "1.1.1.1", "2.2.2.2", "3.3.3.3"] 115 | self.assertEqual(answers, expected_answers) 116 | 117 | 118 | def test_RoundRobin(self): 119 | """Test DNS roundrobin 120 | """ 121 | answers = [self._dns_lookup("roundrobin", "A") for _ in range(13)] 122 | expected_answers = ["1.1.1.1"] * 10 + ["2.2.2.2", "3.3.3.3", "4.4.4.4"] 123 | self.assertEqual(answers, expected_answers) 124 | 125 | 126 | if __name__ == "__main__": 127 | unittest.main() 128 | -------------------------------------------------------------------------------- /fakedns.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Fakedns.py: A regular-expression based DNS MITM Server by Crypt0s.""" 3 | 4 | # This isn't the most elegent way - i could possibly support both versions of python, 5 | # but people should really not use Python 2 anymore. 6 | import sys 7 | 8 | vnum = sys.version.split()[0] 9 | if int(vnum[0]) < 3: 10 | print("Python 2 support has been deprecated. Please run FakeDNS using Python3!") 11 | sys.exit(1) 12 | 13 | import binascii 14 | import configparser as ConfigParser 15 | import os 16 | import random 17 | import re 18 | import socket 19 | import socketserver as SocketServer 20 | import struct 21 | import sys 22 | import threading 23 | 24 | 25 | # inspired from DNSChef 26 | class ThreadedUDPServer(SocketServer.ThreadingMixIn, SocketServer.UDPServer): 27 | def __init__(self, server_address, request_handler): 28 | self.address_family = socket.AF_INET 29 | SocketServer.UDPServer.__init__(self, server_address, request_handler) 30 | 31 | 32 | class UDPHandler(SocketServer.BaseRequestHandler): 33 | def handle(self): 34 | (data, s) = self.request 35 | respond(data, self.client_address, s) 36 | 37 | 38 | class DNSQuery: 39 | def __init__(self, data): 40 | self.data = data 41 | self.domain = b"" 42 | tipo = (data[2] >> 3) & 15 # Opcode bits 43 | if tipo == 0: # Standard query 44 | ini = 12 45 | lon = data[ini] 46 | while lon != 0: 47 | self.domain += data[ini + 1 : ini + lon + 1] + b"." 48 | ini += lon + 1 # you can implement CNAME and PTR 49 | lon = data[ini] 50 | self.type = data[ini:][1:3] 51 | else: 52 | self.type = data[-4:-2] 53 | 54 | 55 | # Stolen: 56 | # https://github.com/learningequality/ka-lite/blob/master/python-packages/django/utils/ipv6.py#L209 57 | def _is_shorthand_ip(ip_str): 58 | """Determine if the address is shortened. 59 | Args: 60 | ip_str: A string, the IPv6 address. 61 | Returns: 62 | A boolean, True if the address is shortened. 63 | """ 64 | if ip_str.count("::") == 1: 65 | return True 66 | if any(len(x) < 4 for x in ip_str.split(":")): 67 | return True 68 | return False 69 | 70 | 71 | # Stolen: 72 | # https://github.com/learningequality/ka-lite/blob/master/python-packages/django/utils/ipv6.py#L209 73 | def _explode_shorthand_ip_string(ip_str): 74 | """ 75 | Expand a shortened IPv6 address. 76 | Args: 77 | ip_str: A string, the IPv6 address. 78 | Returns: 79 | A string, the expanded IPv6 address. 80 | """ 81 | if not _is_shorthand_ip(ip_str): 82 | # We've already got a longhand ip_str. 83 | return ip_str 84 | 85 | hextet = ip_str.split("::") 86 | 87 | # If there is a ::, we need to expand it with zeroes 88 | # to get to 8 hextets - unless there is a dot in the last hextet, 89 | # meaning we're doing v4-mapping 90 | if "." in ip_str.split(":")[-1]: 91 | fill_to = 7 92 | else: 93 | fill_to = 8 94 | 95 | if len(hextet) > 1: 96 | sep = len(hextet[0].split(":")) + len(hextet[1].split(":")) 97 | new_ip = hextet[0].split(":") 98 | 99 | for _ in range(fill_to - sep): 100 | new_ip.append("0000") 101 | new_ip += hextet[1].split(":") 102 | 103 | else: 104 | new_ip = ip_str.split(":") 105 | 106 | # Now need to make sure every hextet is 4 lower case characters. 107 | # If a hextet is < 4 characters, we've got missing leading 0's. 108 | ret_ip = [] 109 | for hextet in new_ip: 110 | ret_ip.append(("0" * (4 - len(hextet)) + hextet).lower()) 111 | return ":".join(ret_ip) 112 | 113 | 114 | def _get_question_section(query): 115 | # Query format is as follows: 12 byte header, question section (comprised 116 | # of arbitrary-length name, 2 byte type, 2 byte class), followed by an 117 | # additional section sometimes. (e.g. OPT record for DNSSEC) 118 | start_idx = 12 119 | end_idx = start_idx 120 | 121 | num_questions = (query.data[4] << 8) | query.data[5] 122 | 123 | while num_questions > 0: 124 | while query.data[end_idx] != 0: 125 | end_idx += query.data[end_idx] + 1 126 | # Include the null byte, type, and class 127 | end_idx += 5 128 | num_questions -= 1 129 | 130 | return query.data[start_idx:end_idx] 131 | 132 | 133 | class DNSFlag: 134 | # qr opcode aa tc rd ra z rcode 135 | # 1 0000 0 0 1 1 000 0000 136 | # accept a series of kwargs to build a proper flags segment. 137 | def __init__(self, 138 | qr=0b1, # query record, 1 if response 139 | opcode=0b0000, # 0 = query, 1 = inverse query, 2 = status request 3-15 unused 140 | aa=0b0, # authoritative answer = 1 141 | tc=0b0, # truncation - 1 if truncated 142 | rd=0b1, # recursion desired? 143 | ra=0b1, # recursion available 144 | z=0b000, # Reserved, must be zero in queries and responsed 145 | rcode=0b0000 # errcode, 0 none, 1 format, 2 server, 3 name, 4 not impl, 5 refused, 6-15 unused 146 | ): 147 | 148 | # pack the elements into an integer 149 | flag_field = qr 150 | flag_field = flag_field << 4 151 | flag_field ^= opcode 152 | flag_field = flag_field << 1 153 | flag_field ^= aa 154 | flag_field = flag_field << 1 155 | flag_field ^= tc 156 | flag_field = flag_field << 1 157 | flag_field ^= rd 158 | flag_field = flag_field << 1 159 | flag_field ^= ra 160 | flag_field = flag_field << 3 161 | flag_field ^= z 162 | flag_field = flag_field << 4 163 | flag_field ^= rcode 164 | 165 | self.flag_field = flag_field 166 | 167 | # return char rep. 168 | def pack(self): 169 | return struct.pack(">H", self.flag_field) 170 | 171 | 172 | class DNSResponse(object): 173 | def __init__(self, query): 174 | self.id = query.data[:2] # Use the ID from the request. 175 | self.flags = DNSFlag(aa=True).pack() # Authoritative = True 176 | self.questions = query.data[4:6] # Number of questions asked... 177 | # Answer RRs (Answer resource records contained in response) 1 for now. 178 | self.rranswers = b"\x00\x01" 179 | self.rrauthority = b"\x00\x00" # Same but for authority 180 | self.rradditional = b"\x00\x00" # Same but for additionals. 181 | # Include the question section 182 | self.query = _get_question_section(query) 183 | # The pointer to the resource record - seems to always be this value. 184 | self.pointer = b"\xc0\x0c" 185 | # This value is set by the subclass and is defined in TYPE dict. 186 | self.type = None 187 | self.dnsclass = b"\x00\x01" # "IN" class. 188 | # TODO: Make this adjustable - 1 is good for noobs/testers 189 | self.ttl = b"\x00\x00\x00\x01" 190 | # Set by subclass because is variable except in A/AAAA records. 191 | self.length = None 192 | self.data = None # Same as above. 193 | 194 | def make_packet(self): 195 | try: 196 | return self.id + self.flags + self.questions + self.rranswers + self.rrauthority + self.rradditional + self.query + self.pointer + self.type + self.dnsclass + self.ttl + self.length + self.data 197 | except Exception as e: # (TypeError, ValueError): 198 | if DEBUG: 199 | print("[!] - %s" % str(e)) 200 | 201 | 202 | # All classes need to set type, length, and data fields of the DNS Response 203 | # Finished 204 | class A(DNSResponse): 205 | def __init__(self, query, record): 206 | super(A, self).__init__(query) 207 | self.type = b"\x00\x01" 208 | self.length = b"\x00\x04" 209 | self.data = self.get_ip(record) 210 | 211 | @staticmethod 212 | def get_ip(dns_record): 213 | ip = dns_record 214 | # Convert to hex 215 | return b"".join(int(x).to_bytes(1, "little") for x in ip.split(".")) 216 | 217 | 218 | # Implemented 219 | class AAAA(DNSResponse): 220 | def __init__(self, query, address): 221 | super(AAAA, self).__init__(query) 222 | self.type = b"\x00\x1c" 223 | self.length = b"\x00\x10" 224 | # Address is already encoded properly for the response at rule-builder 225 | self.data = address 226 | 227 | # Thanks, stackexchange! 228 | # http://stackoverflow.com/questions/16276913/reliably-get-ipv6-address-in-python 229 | def get_ip_6(host, port=0): 230 | # search only for the wanted v6 addresses 231 | result = socket.getaddrinfo(host, port, socket.AF_INET6) 232 | # Will need something that looks like this: 233 | # just returns the first answer and only the address 234 | ip = result[0][4][0] 235 | 236 | 237 | # Implemented 238 | class CNAME(DNSResponse): 239 | def __init__(self, query, domain): 240 | super(CNAME, self).__init__(query) 241 | self.type = b"\x00\x05" 242 | 243 | self.data = b"" 244 | for label in domain.split("."): 245 | self.data += chr(len(label)).encode() + label.encode() 246 | self.data += b"\x00" 247 | 248 | self.length = chr(len(self.data)).encode() 249 | # Must be two bytes. 250 | if len(self.length) < 2: 251 | self.length = b"\x00" + self.length 252 | 253 | 254 | # Implemented 255 | class PTR(DNSResponse): 256 | def __init__(self, query, ptr_entry): 257 | super(PTR, self).__init__(query) 258 | if type(ptr_entry) != bytes: 259 | ptr_entry = ptr_entry.encode() 260 | 261 | self.type = b"\x00\x0c" 262 | self.ttl = b"\x00\x00\x00\x00" 263 | ptr_split = ptr_entry.split(b".") 264 | ptr_entry = b"\x07".join(ptr_split) 265 | 266 | self.data = b"\x09" + ptr_entry + b"\x00" 267 | self.length = chr(len(ptr_entry) + 2) 268 | # Again, must be 2-byte value. 269 | if self.length < "0xff": 270 | self.length = b"\x00" + self.length.encode() 271 | 272 | 273 | # Finished 274 | class TXT(DNSResponse): 275 | def __init__(self, query, txt_record): 276 | super(TXT, self).__init__(query) 277 | self.type = b"\x00\x10" 278 | self.data = txt_record.encode() 279 | self.length = chr(len(txt_record) + 1).encode() 280 | # Must be two bytes. This is the better, more python-3 way to calculate length. Swap to this later. 281 | if len(self.length) < 2: 282 | self.length = b"\x00" + self.length 283 | # Then, we have to add the TXT record length field! We utilize the 284 | # length field for this since it is already in the right spot 285 | self.length += chr(len(txt_record)).encode() 286 | 287 | 288 | class MX(DNSResponse): 289 | def __init__(self, query, txt_record): 290 | super(MX, self).__init__(query) 291 | self.type = b"\x00\x0f" 292 | self.data = b"\x00\x01" + self.get_domain(txt_record) + b"\x00" 293 | self.length = chr(len(txt_record) + 4) 294 | if self.length < "\xff": 295 | self.length = "\x00" + self.length 296 | 297 | @staticmethod 298 | def get_domain(dns_record): 299 | domain = dns_record 300 | ret_domain = [] 301 | for x in domain.split("."): 302 | st = "{:02x}".format(len(x)) 303 | ret_domain.append(st.decode("hex")) 304 | ret_domain.append(x) 305 | return "".join(ret_domain) 306 | 307 | 308 | class SOA(DNSResponse): 309 | def __init__(self, query, config_location): 310 | super(SOA, self).__init__(query) 311 | 312 | # TODO: pre-read and cache all the config files for the rules for speed. 313 | config = ConfigParser.ConfigParser(inline_comment_prefixes=";") 314 | config.read(config_location) 315 | 316 | # handle cases where we want the serial to be random 317 | serial = config.get(query.domain.decode(), "serial") 318 | if serial.lower() == "random": 319 | serial = int(random.getrandbits(32)) 320 | else: 321 | # serial is still a str, cast to int. 322 | serial = int(serial) 323 | 324 | self.type = b"\x00\x06" 325 | self.mname = config.get(query.domain.decode(), "mname") # name server that was original or primary source for this zone 326 | self.rname = config.get(query.domain.decode(), "rname") # domain name which specified mailbox of person responsible for zone 327 | self.serial = serial # 32-bit long version number of the zone copy 328 | self.refresh = config.getint(query.domain.decode(), "refresh") # 32-bit time interval before zone refresh 329 | self.retry = config.getint(query.domain.decode(), "retry") # 32-bit time interval before retrying failed refresh 330 | self.expire = config.getint(query.domain.decode(), "expire") # 32-bit time interval after which the zone is not authoritative 331 | self.minimum = config.getint(query.domain.decode(), "minimum") # The unsigned 32 bit minimum TTL for any RR from this zone. 332 | 333 | # convert the config entries into DNS format. Convenient conversion function will be moved up to module later. 334 | def convert(fqdn): 335 | tmp = b"" 336 | for domain in fqdn.split("."): 337 | tmp += chr(len(domain)).encode() + domain.encode() 338 | tmp += b"\xc0\x0c" 339 | return tmp 340 | 341 | self.data = b"" 342 | 343 | self.mname = convert(self.mname) 344 | self.data += self.mname 345 | 346 | self.rname = convert(self.rname) 347 | self.data += self.rname # already is a bytes object. 348 | 349 | # pack the rest of the structure 350 | self.data += struct.pack(">I", self.serial) 351 | self.data += struct.pack(">I", self.refresh) 352 | self.data += struct.pack(">I", self.retry) 353 | self.data += struct.pack(">I", self.refresh) 354 | self.data += struct.pack(">I", self.minimum) 355 | 356 | # get length of the answers area 357 | self.length = chr(len(self.data)) 358 | 359 | # length is always two bytes - add the extra blank byte if we're not large enough for two bytes. 360 | if self.length < "0xff": 361 | self.length = b"\x00" + self.length.encode() 362 | 363 | 364 | # Technically this is a subclass of A 365 | class NONEFOUND(DNSResponse): 366 | def __init__(self, query): 367 | super(NONEFOUND, self).__init__(query) 368 | self.type = query.type 369 | self.flags = b"\x81\x83" 370 | self.rranswers = b"\x00\x00" 371 | self.length = b"\x00\x00" 372 | self.data = b"\x00" 373 | if DEBUG: 374 | print(">> Built NONEFOUND response") 375 | 376 | 377 | class Rule(object): 378 | def __init__(self, rule_type, domain, ips, rebinds, threshold): 379 | self.type = rule_type 380 | self.domain = domain 381 | self.ips = ips 382 | self.rebinds = rebinds 383 | self.rebind_threshold = threshold 384 | 385 | # we need an additional object to track the rebind rules 386 | if self.rebinds is not None: 387 | self.match_history = {} 388 | self.rebinds = self._round_robin(rebinds) 389 | self.ips = self._round_robin(ips) 390 | 391 | def _round_robin(self, ip_list): 392 | """ 393 | Creates a generator over a list modulo list length to equally move between all elements in the list each request 394 | Since we have rules broken out into objects now, we can have this without much overhead. 395 | """ 396 | # check to make sure we don't try to modulo by zero 397 | # if we would, just add the same element to the list again. 398 | if len(ip_list) == 1: 399 | ip_list.append(ip_list[0]) 400 | 401 | # should be fine to continue now. 402 | index = 0 403 | while 1: # never stop iterating - it's OK since we dont always run 404 | yield ip_list[index] 405 | index += 1 406 | index = index % len(ip_list) 407 | 408 | def match(self, req_type, domain, addr): 409 | # assert that the query type and domain match 410 | try: 411 | req_type = TYPE[req_type] 412 | except KeyError: 413 | return None 414 | 415 | try: 416 | assert self.type == req_type 417 | except AssertionError: 418 | return None 419 | 420 | try: 421 | assert self.domain.match(domain.decode()) 422 | except AssertionError: 423 | return None 424 | 425 | # Check to see if we have a rebind rule and if we do, return that addr first 426 | if self.rebinds: 427 | if self.match_history.get(addr) is not None: 428 | 429 | # passed the threshold - start doing a rebind 430 | if self.match_history[addr] >= self.rebind_threshold: 431 | return next(self.rebinds) 432 | 433 | # plus one 434 | else: 435 | self.match_history[addr] += 1 436 | 437 | # add new client to this match history 438 | else: 439 | self.match_history[addr] = 1 440 | 441 | # We didn't trip on any rebind rules (or didnt have any) 442 | # but we're returning a rule-based entry based on the match 443 | return next(self.ips) 444 | 445 | 446 | # Error classes for handling rule issues 447 | class RuleError_BadRegularExpression(Exception): 448 | def __init__(self, lineno): 449 | if DEBUG: 450 | print("\n!! Malformed Regular Expression on rulefile line #%d\n\n" % lineno) 451 | 452 | 453 | class RuleError_BadRuleType(Exception): 454 | def __init__(self, lineno): 455 | if DEBUG: 456 | print("\n!! Rule type unsupported on rulefile line #%d\n\n" % lineno) 457 | 458 | 459 | class RuleError_BadFormat(Exception): 460 | def __init__(self, lineno): 461 | if DEBUG: 462 | print("\n!! Not Enough Parameters for rule on rulefile line #%d\n\n" % lineno) 463 | 464 | 465 | class RuleEngine2: 466 | # replaces the self keyword, but could be expanded to any keyword replacement 467 | def _replace_self(self, ips): 468 | # Deal with the user putting "self" in a rule (helpful if you don't know your IP) 469 | for ip in ips: 470 | if ip.lower() == "self": 471 | try: 472 | self_ip = socket.gethostbyname(socket.gethostname()) 473 | except socket.error: 474 | if DEBUG: 475 | print(">> Could not get your IP address from your DNS Server.") 476 | self_ip = "127.0.0.1" 477 | ips[ips.index(ip)] = self_ip 478 | return ips 479 | 480 | def __init__(self, rules): 481 | """ 482 | Parses the DNS Rulefile, validates the rules, replaces keywords 483 | 484 | """ 485 | 486 | # track DNS requests here 487 | self.match_history = {} 488 | self.rule_list = [] 489 | 490 | lineno = 0 # keep track of line number for errors 491 | 492 | for rule in rules: 493 | 494 | # ignore blank lines or lines starting with hashmark (comments) 495 | if len(rule.strip()) == 0 or rule.lstrip()[0] == "#" or rule == "\n": 496 | # thank you to github user cambid for the comments suggestion 497 | continue 498 | 499 | # remove any hashmarks (comments) at the end of a rule 500 | if "#" in rule: 501 | rule = rule.split("#", 1)[0] 502 | 503 | # Confirm that the rule has at least three columns to it 504 | if len(rule.split()) < 3: 505 | raise RuleError_BadFormat(lineno) 506 | 507 | # break the rule out into its components 508 | s_rule = rule.split() 509 | rule_type = s_rule[0].upper() 510 | domain = s_rule[1] 511 | ips = s_rule[2].split(",") # allow multiple ip's thru commas 512 | 513 | # only try this if the rule is long enough 514 | if len(s_rule) == 4: 515 | rebinds = s_rule[3] 516 | # handle old rule style (maybe someone updated) 517 | if "%" in rebinds: 518 | rebind_threshold, rebinds = rebinds.split("%") 519 | rebinds = rebinds.split(",") 520 | rebind_threshold = int(rebind_threshold) 521 | else: 522 | # in the old days we assumed a rebind thresh of 1 523 | rebind_threshold = 1 524 | else: 525 | rebinds = None 526 | rebind_threshold = None 527 | 528 | # Validate the rule 529 | # make sure we understand this type of response 530 | if rule_type not in TYPE.values(): 531 | raise RuleError_BadRuleType(lineno) 532 | # attempt to parse the regex (if any) in the domain field 533 | try: 534 | domain = re.compile(domain, flags=re.IGNORECASE) 535 | except: 536 | raise RuleError_BadRegularExpression(lineno) 537 | 538 | # replace self in the list of ips and list of rebinds (if any) 539 | ips = self._replace_self(ips) 540 | if rebinds is not None: 541 | rebinds = self._replace_self(rebinds) 542 | 543 | # Deal With Special IPv6 Nonsense 544 | if rule_type.upper() == "AAAA": 545 | tmp_ip_array = [] 546 | for ip in ips: 547 | if ip.lower() == "none": 548 | tmp_ip_array.append(ip) 549 | continue 550 | if _is_shorthand_ip(ip): 551 | ip = _explode_shorthand_ip_string(ip) 552 | ip = binascii.unhexlify(ip.replace(":", "")) # .decode('hex') 553 | tmp_ip_array.append(ip) 554 | ips = tmp_ip_array 555 | 556 | # add the validated and parsed rule into our list of rules 557 | self.rule_list.append(Rule(rule_type, domain, ips, rebinds, rebind_threshold)) 558 | 559 | # increment the line number 560 | lineno += 1 561 | if DEBUG: 562 | print(">> Parsed %d rules" % (len(self.rule_list))) 563 | 564 | def match(self, query, addr): 565 | """ 566 | See if the request matches any rules in the rule list by calling the 567 | match function of each rule in the list 568 | 569 | The rule checks two things before it continues so I imagine this is 570 | probably still fast 571 | 572 | """ 573 | if addr not in IGNORE: 574 | passthru = False 575 | for rule in self.rule_list: 576 | result = rule.match(query.type, query.domain, addr) 577 | if result is not None: 578 | response_data = result 579 | 580 | # Return Nonefound if the rule says "none" 581 | if response_data.lower() == "none": 582 | return NONEFOUND(query).make_packet() 583 | 584 | response = CASE[query.type](query, response_data) 585 | 586 | if DEBUG: 587 | print(f">> Matched Request - {query.domain.decode()} ({TYPE[query.type]})") 588 | return response.make_packet() 589 | else: 590 | passthru = True 591 | print(f">> Pass-thru for {addr}: {query.domain.decode()} ({TYPE[query.type]})") 592 | 593 | # if we got here, we didn't match. 594 | # Forward a request that we didnt have a rule for to someone else 595 | 596 | try: 597 | s = socket.socket(type=socket.SOCK_DGRAM) 598 | s.settimeout(3.0) 599 | addr = ("1.1.1.1", 53) 600 | s.sendto(query.data, addr) 601 | data = s.recv(1024) 602 | s.close() 603 | if DEBUG and not passthru: 604 | print(f"Unmatched Request {query.domain.decode()} ({TYPE[query.type]})") 605 | return data 606 | except socket.error as e: 607 | # We shouldn't wind up here but if we do, don't drop the request 608 | # send the client *something* 609 | if DEBUG: 610 | print(">> Error was handled by sending NONEFOUND") 611 | print(e) 612 | return NONEFOUND(query).make_packet() 613 | 614 | 615 | # Convenience method for threading. 616 | def respond(data, addr, s): 617 | p = DNSQuery(data) 618 | response = rules.match(p, addr[0]) 619 | s.sendto(response, addr) 620 | return response 621 | 622 | 623 | # Capture Control-C and handle here 624 | def signal_handler(signal, frame): 625 | print("Exiting...") 626 | sys.exit(0) 627 | 628 | 629 | def getch(): 630 | """MIT Licensed: https://github.com/joeyespo/py-getch""" 631 | import termios 632 | import tty 633 | 634 | fd = sys.stdin.fileno() 635 | old = termios.tcgetattr(fd) 636 | try: 637 | tty.setraw(fd) 638 | return sys.stdin.read(1) 639 | finally: 640 | termios.tcsetattr(fd, termios.TCSADRAIN, old) 641 | 642 | 643 | def closer(message): 644 | """Closing method""" 645 | print(message) 646 | if message != "\r>> Exiting... ": 647 | print("Press any key to exit...", end="") 648 | sys.stdout.flush() 649 | if os.name == "nt": 650 | from msvcrt import getch as w_getch 651 | 652 | w_getch() 653 | else: 654 | getch() 655 | print() 656 | sys.exit() 657 | 658 | 659 | def main(interface, port, rule_array, ignore, debug): 660 | global rule_list 661 | global rules 662 | global TYPE 663 | global CASE 664 | global DEBUG 665 | global IGNORE 666 | 667 | DEBUG = bool(debug) 668 | 669 | # Because python doesn't have native ENUM in 2.7: 670 | # https://en.wikipedia.org/wiki/List_of_DNS_record_types 671 | TYPE = { 672 | b"\x00\x01": "A", 673 | b"\x00\x1c": "AAAA", 674 | b"\x00\x05": "CNAME", 675 | b"\x00\x0c": "PTR", 676 | b"\x00\x10": "TXT", 677 | b"\x00\x0f": "MX", 678 | b"\x00\x06": "SOA" 679 | } 680 | 681 | # And this one is because Python doesn't have Case/Switch 682 | CASE = { 683 | b"\x00\x01": A, 684 | b"\x00\x1c": AAAA, 685 | b"\x00\x05": CNAME, 686 | b"\x00\x0c": PTR, 687 | b"\x00\x10": TXT, 688 | b"\x00\x0f": MX, 689 | b"\x00\x06": SOA, 690 | } 691 | 692 | IGNORE = ignore 693 | 694 | rules = RuleEngine2(rule_array) 695 | rule_list = rules.rule_list 696 | 697 | try: 698 | server = ThreadedUDPServer((interface, int(port)), UDPHandler) 699 | except socket.error: 700 | if os.geteuid() != 0 and int(port) < 1024: 701 | closer("Root privileges may be required to run on udp:{0}".format(port)) 702 | else: 703 | closer(">> Could not start server -- is another program on udp:{0}?".format(port)) 704 | exit(1) 705 | 706 | thread = threading.Thread(name="DNS_Server", 707 | target=server.serve_forever, 708 | args=(), 709 | daemon=True) 710 | thread.start() 711 | 712 | 713 | if __name__ == "__main__": 714 | pass 715 | --------------------------------------------------------------------------------