From 512eab903f0bb1462d0abfb0e2b038830e81e64f Mon Sep 17 00:00:00 2001 From: James Mills Date: Fri, 8 Jan 2016 23:55:13 -0800 Subject: [PATCH 1/2] Fixed logging of forwarded requests --- udns/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/udns/server.py b/udns/server.py index 5939a2f..ca578f9 100755 --- a/udns/server.py +++ b/udns/server.py @@ -142,7 +142,7 @@ def request(self, peer, request): if not records: self.logger.info( - "Request ({0:s}): {1:s} {2:s} {3:s}:{4:s} -> {4:d}".format( + "Request ({0:s}): {1:s} {2:s} {3:s} -> {4:s}:{5:d}".format( "{0:s}:{1:d}".format(*peer), CLASS.get(qclass), QTYPE.get(qtype), qname, self.forward, 53 From 230cb5422e77de46d6011c334c69823dc369ee19 Mon Sep 17 00:00:00 2001 From: James Mills Date: Sat, 9 Jan 2016 18:28:46 -0800 Subject: [PATCH 2/2] Added a Test suite --- .coveragerc | 9 +++ .gitignore | 3 + .travis.yml | 14 ++++ README.md | 10 ++- README.rst | 16 +++- bin/flushdb.py | 9 --- bin/test_models.py | 15 ---- requirements.txt | 4 +- tests/__init__.py | 6 ++ tests/client.py | 48 ++++++++++++ tests/conftest.py | 174 +++++++++++++++++++++++++++++++++++++++++ tests/main.py | 31 ++++++++ tests/requirements.txt | 4 + tests/test_core.py | 30 +++++++ tests/usage.t | 46 +++++++++++ udns/server.py | 36 +++++---- 16 files changed, 411 insertions(+), 44 deletions(-) create mode 100644 .coveragerc create mode 100644 .travis.yml delete mode 100755 bin/flushdb.py delete mode 100755 bin/test_models.py create mode 100644 tests/__init__.py create mode 100644 tests/client.py create mode 100644 tests/conftest.py create mode 100755 tests/main.py create mode 100644 tests/requirements.txt create mode 100644 tests/test_core.py create mode 100644 tests/usage.t diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4edfe55 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,9 @@ +[run] +omit = udns/__init__.py + tests/* + +[html] +directory = coverage + +[xml] +output = coverage.xml diff --git a/.gitignore b/.gitignore index 2953ceb..7994c53 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ dist build *.pyc zones +.cache +coverage +.coverage *.egg-info diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..01b4509 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: python +services: + - redis-server +python: + - "2.7" +install: + - pip install -r requirements.txt + - pip install -r tests/requirements.txt + - pip install -e . +script: + - cram tests + - python setup.py test +after_success: + - coveralls diff --git a/README.md b/README.md index 4473c70..3adb8ed 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ -udns -==== +udns - a micro (µ) DNS Server +============================= [!['Stories in Ready'](https://badge.waffle.io/prologic/udns.png?label=ready&title=Ready)](https://waffle.io/prologic/udns) +[![Build Status](https://travis-ci.org/prologic/udns.svg)](https://travis-ci.org/prologic/udns) + +[![Coverage](https://coveralls.io/repos/prologic/udns/badge.svg)](https://coveralls.io/r/prologic/udns) + +[![Quality](https://landscape.io/github/prologic/udns/master/landscape.png)](https://landscape.io/github/prologic/udns/master) + udns is an authoritative, caching DNS server for development and small deployments written in [Python](http://python.org/) using the [circuits](http://circuitsframework.org/) Application Framework and the [dnslib](https://pypi.python.org/pypi/dnslib) DNS library. udns can be run standalone, via [Docker](http://docker.com/) or using the [Docker Compose](https://docs.docker.com/compose/) tool. udns is designed to be small, lightweight, fast and flexible. udns fully supports forwarding, caching as well as honoring TTL(s). udns will also read your `/etc/hosts` file at startup and use this to populate an internal hosts cache so that entries in your local `/etc/hosts` file are fully resolvable with tools such as `host`, `dig` and resolver client libraries. Installation and Usage diff --git a/README.rst b/README.rst index 5b81d1b..4045df5 100644 --- a/README.rst +++ b/README.rst @@ -5,13 +5,25 @@ .. _Docker Compose: https://docs.docker.com/compose/ -udns -==== +udns - a micro (µ) DNS Server +============================= .. image:: https://badge.waffle.io/prologic/udns.png?label=ready&title=Ready :target: https://waffle.io/prologic/udns :alt: 'Stories in Ready' +.. image:: https://travis-ci.org/prologic/udns.svg + :target: https://travis-ci.org/prologic/udns + :alt: Build Status + +.. image:: https://coveralls.io/repos/prologic/udns/badge.svg + :target: https://coveralls.io/r/prologic/udns + :alt: Coverage + +.. image:: https://landscape.io/github/prologic/udns/master/landscape.png + :target: https://landscape.io/github/prologic/udns/master + :alt: Quality + udns is an authoritative, caching DNS server for development and small deployments written in `Python`_ using the `circuits`_ Application Framework and the `dnslib`_ DNS library. udns can be run standalone, via `Docker`_ diff --git a/bin/flushdb.py b/bin/flushdb.py deleted file mode 100755 index 383b923..0000000 --- a/bin/flushdb.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env python - - -from redisco import connection_setup, get_client - - -connection_setup() -db = get_client() -db.flushall() diff --git a/bin/test_models.py b/bin/test_models.py deleted file mode 100755 index 8a86ac1..0000000 --- a/bin/test_models.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python - - -from __future__ import print_function - - -from dnslib import CLASS, QTYPE # noqa -from redisco import connection_setup, get_client - - -from udns.server import Zone, Record # noqa - - -connection_setup() -db = get_client() diff --git a/requirements.txt b/requirements.txt index 9ba0c1f..01e6f37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ dnslib -circuits cachetools +# Development version of circuits +-e git+https://github.com/circuits/circuits.git#egg=circuits + # Development version of redisco -e git+https://github.com/kiddouk/redisco.git#egg=redisco-0.2.4 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3a3bc64 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +# Package: tests +# Date: 16th August 2014 +# Author: James Mills, prologic at shortcircuit dot net dot au + + +"""Test Suite""" diff --git a/tests/client.py b/tests/client.py new file mode 100644 index 0000000..e3d5701 --- /dev/null +++ b/tests/client.py @@ -0,0 +1,48 @@ +"""Test Client""" + + +from dnslib import DNSQuestion, DNSRecord, CLASS, QTYPE + +from circuits import Event, Component +from circuits.net.events import write +from circuits.net.sockets import UDPClient + + +class query(Event): + """query Event""" + + +class reply(Event): + """reply Event""" + + +class DNS(Component): + """DNS Protocol Handling""" + + def read(self, peer, data): + self.fire(reply(DNSRecord.parse(data))) + + +class Client(Component): + + channel = "client" + + def init(self, server, port): + self.server = server + self.port = int(port) + + self.transport = UDPClient( + ("127.0.0.1", 0), channel=self.channel + ).register(self) + self.protocol = DNS(channel=self.channel).register(self) + + def reply(self, a): + self.a = a + + def query(self, qname, qtype="A", qclass="IN"): + qtype = QTYPE.reverse[qtype] + qclass = CLASS.reverse[qclass] + + q = DNSRecord(q=DNSQuestion(qname, qtype, qclass)) + + self.fire(write((self.server, self.port), q.pack())) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2e2e92b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,174 @@ +"""Test Configuration and Fixtures""" + + +import threading +from time import sleep +from collections import deque + + +from pytest import fixture + +from circuits.core.manager import TIMEOUT +from circuits import handler, BaseComponent, Debugger, Manager + + +from udns.server import ( + parse_args, parse_hosts, setup_database, setup_logging, Server +) + +from .client import Client + + +class Watcher(BaseComponent): + + def init(self): + self._lock = threading.Lock() + self.events = deque() + + @handler(channel="*", priority=999.9) + def _on_event(self, event, *args, **kwargs): + with self._lock: + self.events.append(event) + + def clear(self): + self.events.clear() + + def wait(self, name, channel=None, timeout=6.0): + try: + for i in range(int(timeout / TIMEOUT)): + if channel is None: + with self._lock: + for event in self.events: + if event.name == name: + return True + else: + with self._lock: + for event in self.events: + if event.name == name and \ + channel in event.channels: + return True + + sleep(TIMEOUT) + finally: + self.events.clear() + + +class Flag(object): + status = False + + +class WaitEvent(object): + + def __init__(self, manager, name, channel=None, timeout=6.0): + if channel is None: + channel = getattr(manager, "channel", None) + + self.timeout = timeout + self.manager = manager + + flag = Flag() + + @handler(name, channel=channel) + def on_event(self, *args, **kwargs): + flag.status = True + + self.handler = self.manager.addHandler(on_event) + self.flag = flag + + def wait(self): + try: + for i in range(int(self.timeout / TIMEOUT)): + if self.flag.status: + return True + sleep(TIMEOUT) + finally: + self.manager.removeHandler(self.handler) + + +def pytest_addoption(parser): + parser.addoption( + "--dbhost", action="store", default="localhost", + help="Redis host to use for testing" + ) + + +@fixture(scope="session") +def dbhost(request): + return request.config.getoption("--dbhost") + + +@fixture(scope="session") +def manager(request): + manager = Manager() + + def finalizer(): + manager.stop() + + request.addfinalizer(finalizer) + + waiter = WaitEvent(manager, "started") + manager.start() + assert waiter.wait() + + if request.config.option.verbose: + verbose = True + else: + verbose = False + + Debugger(events=verbose).register(manager) + + return manager + + +@fixture(scope="session") +def watcher(request, manager): + watcher = Watcher().register(manager) + + def finalizer(): + waiter = WaitEvent(manager, "unregistered") + watcher.unregister() + waiter.wait() + + request.addfinalizer(finalizer) + + return watcher + + +@fixture(scope="session") +def server(request, manager, watcher, dbhost): + argv = ["-b", "0.0.0.0:5300", "--debug", "--dbhost", dbhost] + + args = parse_args(argv) + + logger = setup_logging(args) + + db = setup_database(args, logger) + + hosts = parse_hosts("/etc/hosts") + + server = Server(args, db, hosts, logger) + + server.register(manager) + watcher.wait("ready") + + def finalizer(): + server.unregister() + + request.addfinalizer(finalizer) + + return server + + +@fixture(scope="session") +def client(request, manager, watcher, server): + client = Client("127.0.0.1", 5300) + + client.register(manager) + watcher.wait("ready") + + def finalizer(): + client.unregister() + + request.addfinalizer(finalizer) + + return client diff --git a/tests/main.py b/tests/main.py new file mode 100755 index 0000000..4374760 --- /dev/null +++ b/tests/main.py @@ -0,0 +1,31 @@ +"""Tests Main""" + + +import sys +from types import ModuleType +from os.path import abspath, dirname +from subprocess import Popen, STDOUT + + +def importable(module): + try: + m = __import__(module, globals(), locals()) + return type(m) is ModuleType + except ImportError: + return False + + +def main(): + cmd = ["py.test", "-r", "fsxX", "--ignore=tmp"] + + if importable("pytest_cov"): + cmd.append("--cov=udns") + cmd.append("--cov-report=html") + + cmd.append(dirname(abspath(__file__))) + + raise SystemExit(Popen(cmd, stdout=sys.stdout, stderr=STDOUT).wait()) + + +if __name__ == "__main__": + main() diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..f14b9d6 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,4 @@ +cram +pytest +coveralls +pytest-cov diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..18a5fb9 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,30 @@ +"""Test Core""" + + +from __future__ import print_function + +from operator import attrgetter + + +from dnslib import A, AAAA, CLASS, QTYPE + + +from .client import query + + +def test_basic(client, watcher): + client.fire(query("google-public-dns-a.google.com")) + assert watcher.wait("reply", "client") + assert client.a.get_a().rdata == A("8.8.8.8") + + +def test_hosts(client, watcher): + client.fire(query("localhost")) + assert watcher.wait("reply", "client") + assert ( + set(map(attrgetter("rclass", "rtype", "rdata.data"), client.a.rr)) == + set([ + (CLASS.IN, QTYPE.A, A("127.0.0.1").data), + (CLASS.IN, QTYPE.AAAA, AAAA("::1").data) + ]) + ) diff --git a/tests/usage.t b/tests/usage.t new file mode 100644 index 0000000..17617f9 --- /dev/null +++ b/tests/usage.t @@ -0,0 +1,46 @@ +udnsd Usage: + + $ udnsd --help + usage: udnsd [-h] [-v] [--debug] [--verbose] [--logfile FILE] [--pidfile FILE] + [--dbhost HOST] [--dbport PORT] [--cachesize SIZEe] [-b BIND] + [-d] [-f FORWARD] + + optional arguments: + -h, --help show this help message and exit + -v, --version show program's version number and exit + --debug enable debugging mode (default: False) + --verbose enable verbose logging (default: False) + --logfile FILE write logs to FILE (default: /dev/stdout) + --pidfile FILE write process id to FILE (default: udns.pid) + --dbhost HOST set database host to HOST (Redis) (default: localhost) + --dbport PORT set database port to PORT (Redis) (default: 6379) + --cachesize SIZEe set cache size to SIZE (default: 1024) + -b BIND, --bind BIND Bind to address:[port] (default: 0.0.0.0:53) + -d, --daemon run as a background process (default: False) + -f FORWARD, --forward FORWARD + DNS server to forward to (default: 8.8.8.8) + +udnsc Usage: + + $ udnsc --help + usage: udnsc [-h] [-v] [--dbhost HOST] [--dbport PORT] + {create,add,delete,list,show,export,dbshell} ... + + optional arguments: + -h, --help show this help message and exit + -v, --version show program's version number and exit + --dbhost HOST set database host to HOST (Redis) (default: localhost) + --dbport PORT set database port to PORT (Redis) (default: 6379) + + Commands: + Available Commands + + {create,add,delete,list,show,export,dbshell} + Description + create Create a new Zone + add Add a Zone or Record entry + delete Delete a Zone or Record + list List Zones + show Display records of a zone + export Export a Zone + dbshell Interactive DB Shell diff --git a/udns/server.py b/udns/server.py index ca578f9..6e3890e 100755 --- a/udns/server.py +++ b/udns/server.py @@ -1,8 +1,3 @@ -# Module: server -# Date: 30th August 2014 -# Author: James Mills, prologic at shortcircuit dot net dot au - - """Server""" @@ -13,14 +8,15 @@ from time import sleep from os import environ, path from logging import getLogger +from collections import defaultdict from socket import AF_INET, SOCK_STREAM, socket from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter, FileType from cachetools import LRUCache -from dnslib import A, CLASS, QR, QTYPE, RR from dnslib import DNSQuestion, DNSRecord +from dnslib import A, AAAA, CLASS, QR, QTYPE, RR from circuits.app import Daemon from circuits.net.events import write @@ -55,6 +51,8 @@ def read(self, peer, data): class Server(Component): + channel = "server" + def init(self, args, db, hosts, logger): self.args = args self.db = db @@ -75,15 +73,17 @@ def init(self, args, db, hosts, logger): if args.debug: Debugger(events=args.verbose, logger=logger).register(self) - self.transport = UDPServer(self.bind).register(self) - self.protocol = DNS().register(self) + self.transport = UDPServer( + self.bind, channel=self.channel + ).register(self) + self.protocol = DNS(channel=self.channel).register(self) def ready(self, server, bind): self.logger.info( "DNS Server Ready! Listening on {0:s}:{1:d}".format(*bind) ) - Timer(1, Event.create("ttl"), persist=True).register(self) + # Timer(1, Event.create("ttl"), persist=True, channel=self.channel).register(self) def ttl(self): for k, rrs in self.cache.items()[:]: @@ -128,9 +128,15 @@ def request(self, peer, request): ) ) - rr = [RR(qname, rdata=A(self.hosts[key]))] reply = request.reply() - reply.add_answer(*rr) + for rdata in self.hosts[key]: + rr = RR( + qname, + rclass=CLASS.IN, + rtype=QTYPE.AAAA if ":" in rdata else QTYPE.A, + rdata=AAAA(rdata) if ":" in rdata else A(rdata) + ) + reply.add_answer(rr) self.cache[key] = rr @@ -261,7 +267,7 @@ def setup_logging(args): return getLogger(__name__) -def parse_args(): +def parse_args(args=None): parser = ArgumentParser( formatter_class=ArgumentDefaultsHelpFormatter, version=__version__, @@ -334,11 +340,11 @@ def parse_args(): help="DNS server to forward to" ) - return parser.parse_args() + return parser.parse_args(args) def parse_hosts(filename): - hosts = {} + hosts = defaultdict(list) if path.exists(filename): with open(filename, "r") as f: @@ -352,7 +358,7 @@ def parse_hosts(filename): if not label.endswith("."): label = "{0:s}.".format(label) key = (label, QTYPE.A, CLASS.IN) - hosts[key] = rdata + hosts[key].append(rdata) return hosts