├── .gitignore ├── Makefile ├── README.md ├── boot.py ├── main.py └── scrub.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.o 2 | trusted_networks.py 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CC=/home/yuri/repo/webrepl/webrepl_cli.py 2 | #DST=185.49.140.179 3 | DST=10.0.0.138 4 | SOURCES=main.py boot.py trusted_networks.py 5 | OBJECTS=$(SOURCES:.py=.o) 6 | 7 | .NOTPARALLEL: %.o #list wipe 8 | .PHONY: test clean #list wipe 9 | 10 | all: $(OBJECTS) 11 | %.o: %.py 12 | cat $< | python3 scrub.py > _tmp 13 | $(CC) _tmp $(DST):/$< 14 | rm _tmp 15 | touch $@ 16 | 17 | clean: 18 | rm -f $(OBJECTS) 19 | 20 | test: 21 | dig @$(DST) hello 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | I Can't Believe It's Not DNS! 2 | === 3 | "I Can't Believe It's Not DNS!" (ICBIND) is an authoritative DNS server for the 4 | ESP8266 written in MicroPython. 5 | 6 | Anti-features 7 | --- 8 | * No storage of zone files, populated by AXFR. 9 | * DNSSEC filtering. 10 | * TSIG-less AXFR support! 11 | * Notify handling. 12 | * Highly optimized: no sanity checks. 13 | 14 | Preamble 15 | --- 16 | When I first received my ESP8266 I flashed the NodeMCU firmware on it. To make 17 | myself familiar with the ESP and Lua tried writing an authoritative DNS server 18 | for it. A crappy server only echoing the queries over TCP (UDP was broken on 19 | NodeMCU) could do _almost_ 2 qps. That project quickly stopped. 20 | 21 | I was excited when MicroPython came out and decided to start a similar project. 22 | MicroPython was much more capable than Lua and I got 150 to 160 qps! Soon I hit 23 | limitations on memory consumption and code size. To get it running I needed to 24 | ~~cut some corners~~ highly optimize the code. 25 | 26 | Introduction 27 | --- 28 | The code is contained in three files: boot.py establishes Wifi connection. 29 | main.py contains the DNS server code and, trusted_networks.py has just a 30 | dictionary of ESSID and password combinations. The latter is not included in 31 | the git repository and you should create one yourself (see boot.py). 32 | 33 | The other thing you need to edit before uploading the files is the first few 34 | lines of main.py. There the zone as well as the master server to get it from are 35 | defined. 36 | 37 | For convenience a Makefile is included. Though in order to use it you probably 38 | need to fiddle with it. 39 | 40 | Modus Operandi 41 | --- 42 | At boot ICBIND will select the strongest Wifi access point it has credentials 43 | for and connect to it. It does _not_ start webrepl as that would prevent 44 | main.py to load due to memory shortage. 45 | 46 | Then the daemon will start. First it will transfer the zone via AXFR. When that 47 | is done it starts to answer queries from its database. Finally when a notify is 48 | received it will retransfer the zone. 49 | 50 | Implementation Details 51 | --- 52 | My personal domain is rather small. Less than 20 records or thereabouts. While 53 | my TLD does not offer upload of DS records I _did_ sign my zone. As a result a 54 | AXFR reply is about 13 KiB. After receiving the AXFR I tried to do something 55 | with that data: ENOMEM. Crud. 56 | 57 | Python to the rescue! I can make an iterator that I would feed a socket which 58 | would read from said socket and spit out parsed Resource records! Easy does it. 59 | Except... DNS uses compression pointers. Compression pointers greatly reduce 60 | the size of DNS packets by eliminating repeating of owner names of resource 61 | records. BUT we don't have enough memory to buffer the entire AXFR. We need a 62 | plan. 63 | 64 | Plan A 65 | --- 66 | So a compression pointer is just an octet pointing back relative to its current 67 | position right? (HINT: No it isn't you up mucking asshole codemonkey). So I 68 | just need to keep a sliding window of the last 256 bytes! In reality a 69 | compression pointer is 14 bits wide and absolute from the start of the message. 70 | Most names will point to one of the very first resource records in the message. 71 | So let us also keep a copy of the first 256 bytes. That surely must catch 99 72 | percent of all cases, we just drop any record we can't resolve the pointer for. 73 | Who cares! Well, that is mostly true. But I wasn't satisfied with the amount 74 | of records dropped in my small zone. So nothing else to do but store the AXFR 75 | on flash you say? Oh you don't know me! It's personal now, I have a plan B. 76 | 77 | Plan B 78 | --- 79 | What if we don't resolve the compression pointers during the AXFR? That's right, 80 | just let them sit unresolved for a bit. In the mean time drop all those pesky 81 | DNSSEC records we are offered. Those are to big anyway and I really don't want to 82 | deal with NSEC lookups on this tiny device. Also, while we are at it drop 83 | anything other than class IN, that does not exist in my world. We end up with 84 | just a small set of records. But how do we resolve the owner names, we don't 85 | have this data any more? 86 | 87 | I know somebody who has this data... the master! You know what? With that set 88 | of records in hand do _another_ AXFR a couple of bytes at the time and resolve 89 | those pointers on the fly without the need to buffer anything longer than a 90 | label (max 63 bytes). Of course compression pointers can be nested so we need 91 | to repeat this process in a loop until every pointer is resolved! 92 | 93 | Serving Queries 94 | --- 95 | This is the easy part. Lets do as little as possible. When a query comes in we 96 | chop of anything beyond the question section. BAM! We have most of our reply 97 | done. Fiddle a bit with the flags and section counts, assume query name is 98 | uncompressed and append our resource record. Our database only contains TYPE and 99 | RDATA. Query name? Always a pointer to byte 12 in the packet. Class? always 100 | IN. TLL? always 15 minutes, deal with it. 101 | 102 | SOA Serial Management 103 | --- 104 | Finally we need a mechanism to update our little DNS server if the zone has 105 | changed. Serious software would keep track of the version of the zone via the 106 | SOA serial number. Poll for a new version on set times, listen to notifies 107 | from the master and make an intelligible decision when and how to update the zone. 108 | 109 | We don't have the memory available to be intelligible. But we can listen for 110 | notify queries. If we receive a notify, any notify - we optimized out any ACL or 111 | checking of the zone name, we simply reboot(). The ESP8266 will powercycle and 112 | the new version of the zone will be transferred and served. SOA serial 113 | management made easy! 114 | 115 | Epilogue 116 | --- 117 | This software is shit. It sort of mimics DNS but really it isn't. You should 118 | not use this, I should not use this (but you know I will because DNS hosting 119 | on my ESP8266 is freaking awesome!) 120 | 121 | -------------------------------------------------------------------------------- /boot.py: -------------------------------------------------------------------------------- 1 | # This file is executed on every boot (including wake-boot from deepsleep) 2 | import gc 3 | 4 | from trusted_networks import NETWORKS 5 | 6 | #NETWORKS = { 7 | # "essid":"password", 8 | # "essid":"password", 9 | # "essid":"password" 10 | #} 11 | 12 | ## This creates a wifi connection with 13 | ## any of our known access points 14 | def connect_to_ap(essids, tries=3): 15 | from network import WLAN, STA_IF 16 | from time import sleep 17 | wlan = WLAN(STA_IF) 18 | wlan.active(True) 19 | ## Select only known networks 20 | ap_list = list(filter(lambda ap: ap[0].decode('UTF-8') in 21 | essids.keys(), wlan.scan())) 22 | ## sort by signal strength 23 | ap_list.sort(key=lambda ap: ap[3], reverse=True) 24 | for ap in ap_list: 25 | essid = ap[0].decode('UTF-8') 26 | wlan.connect(essid, essids[essid]) 27 | for i in range(5): 28 | ## this is somewhat crude, we actually have a 29 | ## wlan.status() we can inspect. oh well... 30 | if wlan.isconnected(): 31 | return True 32 | sleep(1) 33 | return False 34 | 35 | connect_to_ap(NETWORKS) 36 | 37 | gc.collect() 38 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from time import sleep 3 | from machine import reset, Pin 4 | 5 | MASTER = "10.0.0.10" 6 | ZONE = "schaeffer.tk" 7 | 8 | def decode_bigendian(m): 9 | ## input byte array. Output uint. 10 | r = 0 11 | for b in m: 12 | r = r<<8 | b 13 | return r 14 | 15 | def encode_bigendian(r, l): 16 | ## r: integer 17 | ## l: length of bytearray 18 | ## return bytearray bigendian 19 | m = [] 20 | for i in range(l): 21 | m.append(r&0xff) 22 | r = r>>8 23 | m.reverse() 24 | return bytes(m) 25 | 26 | def name_to_wire(name): 27 | wire = b'' 28 | for token in name.split('.'): 29 | wire += bytes(chr(len(token)) + token, 'utf8') 30 | return wire + b'\x00' 31 | 32 | ## contract: exactly 0 bytes must be read from name 33 | class RRiter: 34 | def __init__(self, sock): 35 | self.sock = sock 36 | 37 | def __iter__(self): 38 | ## seek to answer section 39 | qu_count = decode_bigendian(self.sock.read(2)) 40 | an_count = decode_bigendian(self.sock.read(2)) 41 | au_count = decode_bigendian(self.sock.read(2)) 42 | ad_count = decode_bigendian(self.sock.read(2)) 43 | self.counts = [qu_count, an_count, au_count, ad_count] 44 | return self 45 | 46 | def __next__(self): 47 | for i, c in enumerate(self.counts): 48 | if c != 0: break 49 | else: 50 | self.sock.close() 51 | raise StopIteration 52 | self.counts[i] -= 1 53 | name = b'' 54 | while True: #loop per label 55 | b = self.sock.read(1) 56 | name += b 57 | if (ord(b)&0xC0) == 0xC0: 58 | name += self.sock.read(1) 59 | break 60 | if ord(b) == 0x00: 61 | break 62 | name += self.sock.read(ord(b)&0x3F) 63 | qtype = self.sock.read(2) 64 | qclass = self.sock.read(2) 65 | ttl = None 66 | payload = None 67 | if i != 0: 68 | ttl = self.sock.read(4) 69 | payload = self.sock.read(2) 70 | datalen = decode_bigendian(payload) 71 | payload += self.sock.read(datalen) 72 | return i, name, qtype, qclass, ttl, payload 73 | 74 | def open_axfr(master, zone): 75 | print("Requesting AXFR for", zone, "from", master) 76 | axfr_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 77 | axfr_sock.connect((master, 53)) 78 | m = encode_bigendian(7, 2) # id 79 | m += encode_bigendian((1<<5), 2) # flags 80 | m += encode_bigendian(1, 2) # 1 query 81 | m += encode_bigendian(0, 6) # empty other sections 82 | m += name_to_wire(zone) + b'\x00\xFC\x00\x01' # AXFR IN 83 | axfr_sock.sendall(encode_bigendian(len(m), 2) + m) 84 | axfr_sock.read(2) #aint nobody got mem for that. 85 | return axfr_sock 86 | 87 | def axfr(master, zone): 88 | ## an AXFR might be very big. We can just return all 89 | ## we must process on the fly. 90 | axfr_sock = open_axfr(master, zone) 91 | qid = decode_bigendian(axfr_sock.read(2)) 92 | flags = decode_bigendian(axfr_sock.read(2)) 93 | return RRiter(axfr_sock) 94 | 95 | def axfr_reslv_ptrs(master, zone, ptrs, ptrs_to_reslv): 96 | axfr_sock = open_axfr(master, zone) 97 | i = 0 98 | while ptrs_to_reslv: 99 | p = ptrs_to_reslv.pop(0) 100 | if p < i: 101 | #we passed it :( unlikely, but possible 102 | continue 103 | axfr_sock.read(p-i) 104 | i = p 105 | #read labels until 0 or ptr 106 | name = b'' 107 | while 1: 108 | b = axfr_sock.read(1) 109 | i += 1 110 | if ord(b)&0xC0 == 0xC0: 111 | ptrs[p] = name + b + axfr_sock.read(1) 112 | i += 1 113 | break 114 | elif ord(b) == 0x00: 115 | ptrs[p] = name + b 116 | break 117 | else: 118 | name += b + axfr_sock.read(ord(b)) 119 | i += ord(b) 120 | axfr_sock.close() 121 | 122 | def weedwacker(rr): 123 | if rr[0] != 1: #from ans section only 124 | return False 125 | if decode_bigendian(rr[3]) != 1: #only IN allowed 126 | return False 127 | qtype = decode_bigendian(rr[2]) 128 | return not (qtype in [46, 47, 48, 50, 51]) #no dnssec 129 | 130 | def uncompress(qowner, ptrs, ptrs_reslv): 131 | # tries to uncompress name. return name. else return null and add to ptrs 132 | name = b'' 133 | while 1: 134 | b = qowner[0] 135 | if b&0xC0 == 0xC0: 136 | jmp = ((b&0x3F)<<8) | qowner[1] 137 | if jmp in ptrs: 138 | qowner = ptrs[jmp] + qowner[2:] 139 | else: 140 | ptrs_reslv.add(jmp) 141 | return name + qowner, False 142 | elif b == 0x00: 143 | return name + chr(b), True 144 | else: 145 | name += qowner[0: b+1] 146 | qowner = qowner[b+1:] 147 | 148 | def find_ptr(name): 149 | ## returns value of pointer in name. Or -1 if no pointer. 150 | i = 0 151 | while name[i] != 0x00: 152 | if name[i]&0xC0 == 0xC0: 153 | return (name[i]&0x3F)<<8 | name[i+1] 154 | i += name[i] + 1 155 | return -1 156 | 157 | def populate_db(host, zone): 158 | try: 159 | axfr_iter = axfr(host, zone) 160 | except OSError as e: 161 | print("OSError: {0}, rebooting".format(e)) 162 | sleep(5) 163 | reset() 164 | 165 | records = [] 166 | for rr in filter(weedwacker, axfr_iter): 167 | _, qname, qtype, _, _, rdata = rr 168 | records.append([qname, qtype, rdata, 0]) 169 | #last one is second SOA. Don't need it. 170 | _ = records.pop() 171 | 172 | resolved = False 173 | ptrs = {} 174 | reslv = set() 175 | while not resolved: 176 | for qname, qtype, rdata, _ in records: 177 | p = find_ptr(qname) 178 | if p != -1: 179 | reslv.add(p) 180 | if qtype == b'\x00\x05': 181 | p = find_ptr(rdata[2:]) 182 | if p != -1: #CNAME 183 | reslv.add(p) 184 | axfr_reslv_ptrs(host, zone, ptrs, sorted(list(reslv))) 185 | resolved = True 186 | for rr in records: 187 | if rr[3]: continue 188 | n, rdy = uncompress(rr[0], ptrs, reslv) 189 | rr[0] = n 190 | rr[3] = rdy 191 | resolved &= rdy 192 | if rr[1] in [b'\x00\x02', b'\x00\x05']: 193 | n, rdy = uncompress(rr[2][2:], ptrs, reslv) 194 | rr[2] = encode_bigendian(len(n), 2) + n 195 | rr[3] &= rdy 196 | resolved &= rdy 197 | db = {} 198 | for qname, qtype, rdata, _ in records: 199 | db.setdefault((qname, qtype), []).append(rdata) 200 | return db 201 | 202 | db = populate_db(MASTER, ZONE) 203 | for rr in db: 204 | print(rr[0], decode_bigendian(rr[1])) 205 | 206 | #Open UDP socket. 207 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 208 | #bind to first IF. Fine I guess. 209 | s.bind(socket.getaddrinfo('0.0.0.0', 53)[0][-1]) 210 | 211 | led = Pin(2, Pin.OUT) 212 | led.value(0) 213 | ledstate = 1 214 | 215 | while 1: #while not recv packet of death ;) 216 | try: 217 | m, addr = s.recvfrom(256) 218 | 219 | # Toggle a LED 220 | led.value(ledstate) 221 | ledstate ^= 1 222 | 223 | # Packet of death: 224 | # query, notify 225 | # We don't want to do any SOA serial management. Nor ACLs or 226 | # TSIG. Just reboot and retransfer zone. 227 | if (m[2]&0xF8) == 0x20: 228 | reset() 229 | 230 | # find qname. Falsely assume this cannot be compressed. 231 | end = 12 232 | while m[end] != 0x00: 233 | end += m[end] + 1 234 | qname = m[12:end+1] 235 | 236 | # find qtype 237 | qtype = m[end+1:end+3] 238 | 239 | # now steal first 12 + (end-12) + 4 bytes of msg 240 | # set response code and append RR 241 | resp = bytearray(m[:end+5]) # 242 | resp[2] = (resp[2]&0x01)|0x84 # is reply 243 | resp[3] &= 0x10 # AD 244 | for i in range(8, 12): # no auth or additional 245 | resp[i] = 0 246 | 247 | rdatas = db.get((qname, qtype)) 248 | if not rdatas: #look for cname then 249 | rdatas = db.get((qname, b'\x00\x05')) 250 | qtype = b'\x00\x05' 251 | if not rdatas: #NXD 252 | resp[3] |= 0x03 # NXD 253 | else: 254 | resp[7] = len(rdatas) 255 | for rdata in rdatas: 256 | resp += b'\xC0\x0C' #always point to question for qname 257 | resp += qtype 258 | resp += b'\x00\x01' # IN 259 | resp += b'\x00\x00\x03\x84' #900S TTL 260 | resp += rdata 261 | s.sendto(resp, addr) 262 | except KeyboardInterrupt as e: 263 | from webrepl import start 264 | start() 265 | break 266 | except Exception as e: 267 | print("Exception: {0}".format(e), type(e)) 268 | sleep(2) 269 | reset() 270 | -------------------------------------------------------------------------------- /scrub.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | for line in sys.stdin.readlines(): 6 | ## remove # and beyond 7 | ## don't cleanup empty lines! 8 | print(line.split("#")[0].rstrip()) 9 | --------------------------------------------------------------------------------