├── .gitignore ├── LICENSE ├── README.org ├── dnslib.py ├── pyproject.toml ├── redis.py └── server.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | /.DS_Store 131 | /poetry.lock 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | #+TITLE: μDNS is oversimpistic dns server with direct answers from Redis 2 | 3 | Main purpose for this solution is to have very fast-rewritable DNS names with virtually no caching. 4 | Typical usecase: fine-graining requests sharding in microservice's requests with complex rules. For example I use it to route requests from one microserver to hundred of anothers with sharding by user's ID without single API Gateway and flooding of Consul or CoreDNS. Just prefill redis with pairs like `user1923923923: 192.168.3.31` and so on. 5 | 6 | μDNS have no dependencies except of standard library and shows 10k+ RPS on single core of my macbook pro (more than 40k rps on modern production server). You can increase rps rate by installing uvloop but it's normally there's no need for that. 7 | 8 | * Query's working cycle 9 | When μDNS get a new request it will be processed with this steps: 10 | 1. check if exact this fqdn is avaliable in Redis database. If record is found it immediately returned as DNS answer. 11 | 2. If DNS request contain `.` symbol (i.e. looks like a full domain request) it will forward to real nameserver and answer is forwarded back 12 | 3. in any other case - NXDOMAIN (no name found) is the answer 13 | 14 | * Usage 15 | Just start it with python3 16 | #+begin_src shell 17 | python3 server.py 18 | #+end_src 19 | * Configuration 20 | Configuration is available trough environment variables. 21 | - *REDIS*: host of redis database /default: 127.0.0.1/ 22 | - *REDIS_DB*: database number on redis server /default: 0/ 23 | - *DNS_RELAY*: address of live dns server who will serve real dns requests if no record in Redis available /default: reading nameserver from /etc/resolv.conf/ 24 | - *BIND*: ip address to bind dns server for /default: 0.0.0.0/ 25 | -------------------------------------------------------------------------------- /dnslib.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple, List 2 | import struct 3 | import asyncio 4 | import pathlib 5 | 6 | 7 | def parse_query(data: bytes) -> Tuple[int, List[bytes]]: 8 | header = data[:12] 9 | payload = data[12:] 10 | 11 | ( 12 | transaction_id, 13 | flags, 14 | num_queries, 15 | num_answers, 16 | num_authority, 17 | num_additional, 18 | ) = struct.unpack(">6H", header) 19 | 20 | queries: List[bytes] = [] 21 | for i in range(num_queries): 22 | res = payload.index(0) + 5 23 | queries.append(payload[:res]) 24 | payload = payload[res:] 25 | 26 | return transaction_id, queries 27 | 28 | 29 | def get_domain(query: bytes) -> str: 30 | parts = [] 31 | while True: 32 | length = query[0] 33 | query = query[1:] 34 | if length == 0: 35 | break 36 | parts.append(query[:length]) 37 | query = query[length:] 38 | return ".".join(x.decode("ascii") for x in parts) 39 | 40 | 41 | def build_answer( 42 | trans_id: int, 43 | queries: List[bytes], 44 | answer: Optional[bytes] = None, 45 | ttl: int = 128, 46 | ) -> bytes: 47 | flags = 0 48 | flags |= 0x8000 49 | flags |= 0x0400 50 | 51 | if not answer: 52 | flags |= 0x0003 # NXDOMAIN 53 | 54 | header = struct.pack(">6H", trans_id, flags, len(queries), 1 if answer else 0, 0, 0) 55 | payload = b"".join(queries) 56 | 57 | if answer: 58 | payload += ( 59 | b"\xc0\x0c" 60 | + struct.pack(">1H1H1L1H", 1, 1, ttl, 4) # IN # A # TTL # payload length 61 | + answer 62 | ) 63 | return header + payload 64 | 65 | 66 | def get_default_resolver(resolv_conf: str = "/etc/resolv.conf") -> str: 67 | rc = pathlib.Path(resolv_conf) 68 | if rc.is_file(): 69 | with rc.open() as file: 70 | while True: 71 | line = file.readline() 72 | if not line: 73 | break 74 | parsed = line.strip().split("#", 1)[0].split() 75 | if len(parsed) == 2 and parsed[0] == "nameserver": 76 | return parsed[1] 77 | return "8.8.8.8" 78 | 79 | 80 | class DNSForward(asyncio.DatagramProtocol): 81 | def __init__(self, message: bytes, on_exit: asyncio.Future): 82 | self.message = message 83 | self.on_exit = on_exit 84 | self.result: Optional[bytes] = None 85 | self.transport: asyncio.DatagramTransport = asyncio.DatagramTransport() 86 | 87 | def datagram_received(self, data: Optional[bytes], addr: Tuple[str, int]): 88 | self.result = data 89 | if self.transport: 90 | self.transport.close() 91 | 92 | def connection_made(self, transport: asyncio.DatagramTransport): # type: ignore[override] 93 | self.transport = transport 94 | self.transport.sendto(self.message) 95 | 96 | def error_received(self, exc): 97 | pass 98 | 99 | def connection_lost(self, exc): 100 | self.on_exit.set_result(self.result) 101 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "udns" 3 | version = "0.1.0" 4 | description = "μDNS is oversimpistic dns server with direct answers from Redis" 5 | authors = ["Grigory Bakunov "] 6 | license = "Unlicense" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.7" 10 | uvloop = {version = "^0.14.0", optional = true} 11 | 12 | [tool.poetry.dev-dependencies] 13 | mypy = "^0.800" 14 | black = "^20.8b1" 15 | pylint = "^2.6.0" 16 | 17 | [build-system] 18 | requires = ["poetry-core>=1.0.0"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /redis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from typing import Optional, Tuple 4 | import asyncio 5 | from collections import deque 6 | 7 | 8 | class RedisException(Exception): 9 | """Any exception from Redis""" 10 | 11 | 12 | class Redis: 13 | def __init__(self, host: str = "127.0.0.1", port: int = 6379, db: int = 0): 14 | self.host = host 15 | self.port = port 16 | self.db = db 17 | 18 | self.connection: Optional[ 19 | Tuple[asyncio.StreamReader, asyncio.StreamWriter] 20 | ] = None 21 | 22 | async def execute(self, cmd: str = "") -> Optional[bytes]: 23 | if not self.connection: 24 | self.connection = await asyncio.open_connection(self.host, self.port) 25 | if len(self.connection) != 2: 26 | raise RedisException(f"can't connect to {self.host}:{self.port}") 27 | if self.db != 0: 28 | await self.execute("select " + str(self.db)) 29 | command = (cmd + "\r\n").encode() 30 | self.connection[1].write(command) 31 | res = (await self.connection[0].read(128)).rstrip() 32 | if res == b"$-1": 33 | return None 34 | if res[0] in (ord("+"), ord("-")): 35 | return None 36 | length, value = res.split(b"\r\n") 37 | return value 38 | 39 | async def close(self): 40 | if self.connection: 41 | self.connection[1].close() 42 | 43 | 44 | class RedisPool: 45 | def __init__( 46 | self, 47 | host: str = "127.0.0.1", 48 | port: int = 6379, 49 | db: int = 0, 50 | pool_size: int = 20, 51 | ): 52 | self.host = host 53 | self.port = port 54 | self.db = db 55 | self.size = pool_size - 1 56 | self.queue: deque = deque() 57 | self.sem = asyncio.Semaphore(self.size) 58 | for i in range(self.size): 59 | self.queue.append(Redis(host, port, db)) 60 | 61 | async def execute(self, cmd: str) -> Optional[str]: 62 | async with self.sem: 63 | conn = self.queue.popleft() 64 | try: 65 | res = await conn.execute(cmd) 66 | finally: 67 | self.queue.append(conn) 68 | return res.decode() if res else None 69 | 70 | async def get(self, key: str) -> Optional[str]: 71 | return await self.execute("GET " + key) 72 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import socket 5 | import ipaddress 6 | from os import environ as env 7 | from collections import deque 8 | from typing import Optional, Tuple 9 | from dnslib import * 10 | from redis import RedisPool 11 | 12 | try: 13 | import uvloop # type: ignore 14 | 15 | uvloop.install() 16 | except: 17 | pass 18 | 19 | DB = RedisPool(env.get("REDIS", "127.0.0.1"), db=int(env.get("REDIS_DB", 0))) 20 | DNS_RELAY = (env.get("DNS_RELAY", get_default_resolver()), 53) 21 | HOST = env.get("BIND", "0.0.0.0") 22 | 23 | 24 | class DNSServer: 25 | def __init__(self, loop=asyncio.get_event_loop()): 26 | self.loop = loop 27 | 28 | self.sock = None 29 | self.event = asyncio.Event() 30 | self.queue = deque() 31 | 32 | async def on_data_received(self, data: bytes, addr: Tuple[str, int]): 33 | trans_id, queries = parse_query(data) 34 | for q in queries: 35 | domain = get_domain(q) 36 | # print(trans_id, domain) 37 | res = await DB.get(domain) 38 | if not res and "." in domain: 39 | on_exit = self.loop.create_future() 40 | transport, _ = await self.loop.create_datagram_endpoint( 41 | lambda: DNSForward(data, on_exit), remote_addr=DNS_RELAY 42 | ) 43 | try: 44 | self.send(await on_exit, addr) 45 | finally: 46 | transport.close() 47 | return 48 | ip = ipaddress.IPv4Address(res).packed if res else None 49 | self.send(build_answer(trans_id, queries, answer=ip), addr) 50 | 51 | def run(self, host: str = "0.0.0.0", port: int = 53): 52 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0) 53 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 54 | self.sock.setblocking(False) 55 | self.sock.bind((host, port)) 56 | asyncio.ensure_future(self.recv_periodically(), loop=self.loop) 57 | asyncio.ensure_future(self.send_periodically(), loop=self.loop) 58 | 59 | def sock_recv( 60 | self, fut: Optional[asyncio.Future] = None, registered: bool = False 61 | ) -> Optional[asyncio.Future]: 62 | fd = self.sock.fileno() 63 | 64 | if not fut: 65 | fut = self.loop.create_future() 66 | 67 | if fut: 68 | if registered: 69 | self.loop.remove_reader(fd) 70 | try: 71 | data, addr = self.sock.recvfrom(2048) 72 | except (BlockingIOError, InterruptedError): 73 | self.loop.add_reader(fd, self.sock_recv, fut, True) 74 | except Exception as ex: 75 | print(ex) 76 | fut.set_result(0) 77 | else: 78 | fut.set_result((data, addr)) 79 | return fut 80 | 81 | async def recv_periodically(self): 82 | while True: 83 | data, addr = await self.sock_recv() 84 | asyncio.ensure_future(self.on_data_received(data, addr), loop=self.loop) 85 | 86 | def send(self, data: bytes, addr: Tuple[str, int]): 87 | self.queue.append((data, addr)) 88 | self.event.set() 89 | 90 | def sock_send( 91 | self, 92 | data: bytes, 93 | addr: Tuple[str, int], 94 | fut: Optional[asyncio.Future] = None, 95 | registered: bool = False, 96 | ) -> Optional[asyncio.Future]: 97 | fd = self.sock.fileno() 98 | if not fut: 99 | fut = self.loop.create_future() 100 | if fut: 101 | if registered: 102 | self.loop.remove_writer(fd) 103 | 104 | try: 105 | sent = self.sock.sendto(data, addr) 106 | except (BlockingIOError, InterruptedError): 107 | self.loop.add_writer(fd, self.sock_send, data, addr, fut, True) 108 | except Exception as ex: 109 | print(ex) 110 | fut.set_result(0) 111 | else: 112 | fut.set_result(sent) 113 | return fut 114 | 115 | async def send_periodically(self): 116 | while True: 117 | await self.event.wait() 118 | try: 119 | while self.queue: 120 | data, addr = self.queue.popleft() 121 | _ = await self.sock_send(data, addr) 122 | finally: 123 | self.event.clear() 124 | 125 | 126 | async def main(loop): 127 | dns = DNSServer(loop) 128 | dns.run(host=HOST) 129 | 130 | 131 | if __name__ == "__main__": 132 | loop = asyncio.get_event_loop() 133 | loop.run_until_complete(main(loop)) 134 | loop.run_forever() 135 | --------------------------------------------------------------------------------