Skip to main content

Pure asyncio Python DNS resolver

Project description

aiodnsresolver CircleCI Test Coverage

Asyncio Python DNS resolver. Pure Python, with no dependencies other than the standard library, threads are not used, no additional tasks are created, and all code is in a single module. The nameservers to query are taken from /etc/resolve.conf, and treats hosts in /etc/hosts as A or AAAA records with a TTL of 0.

Designed for highly concurrent/HA situations. Based on https://github.com/gera2ld/async_dns.

Installation

pip install aiodnsresolver

Usage

from aiodnsresolver import Resolver, TYPES

resolve, _ = Resolver()
ip_addresses = await resolve('www.google.com', TYPES.A)

Returned are tuples of subclasses of IPv4Address or IPv6Address. Both support conversion to their usual string form by passing them to str.

Cache

A cache is part of each Resolver(), expiring records automatically according to their TTL.

import asyncio
from aiodnsresolver import Resolver, TYPES

resolve, clear_cache = Resolver()

# Will make a request to the nameserver(s)
ip_addresses = await resolve('www.google.com', TYPES.A)

# Will only make another request to the nameserver(s) if the ip_addresses have expired
ip_addresses = await resolve('www.google.com', TYPES.A)

clear_cache()
# Will make another request to the nameserver(s)
ip_addresses = await resolve('www.google.com', TYPES.A)

The cache for each record starts on the start of each request, so duplicate concurrent requests for the same record are not made.

TTL / Record expiry

The address objects each have an extra property, expires_at, that returns the expiry time of the address, according to the loop.time() clock, and the TTL of the records involved to find that address.

import asyncio
from aiodnsresolver import Resolver, TYPES

resolve, _ = Resolver()
ip_addresses = await resolve('www.google.com', TYPES.A)

loop = asyncio.get_event_loop()
for ip_address in ip_address:
    print('TTL',  max(0.0, ip_address.expires_at - loop.time())

This can be used in HA situations to assist failovers. The timer for expires_at starts just before the request to the nameserver is made.

CNAMEs

CNAME records are followed transparently. The expires_at of IP addresses found via intermediate CNAME(s) is determined by using the minimum expires_at of all the records involved in determining those IP addresses.

Custom nameservers and timeouts

It is possible to query nameservers other than those in /etc/resolve.conf, and for each to specify a timeout in seconds to wait for a reply before querying the next.

async def get_nameservers(_):
    yield (0.5, ('8.8.8.8', 53))
    yield (0.5, ('1.1.1.1', 53))
    yield (1.0, ('8.8.8.8', 53))
    yield (1.0, ('1.1.1.1', 53))

resolve, _ = Resolver(get_nameservers=get_nameservers)
ip_addresses = await resolve('www.google.com', TYPES.A)

Parallel requests to multiple nameservers are also possible, where the first response from each set of requests is used.

async def get_nameservers(_):
    # For any record request, udp packets are sent to both 8.8.8.8 and 1.1.1.1, waiting 0.5 seconds
    # for the first response...
    yield (0.5, ('8.8.8.8', 53), ('1.1.1.1', 53))
    # ... if no response, make another set of requests, waiting 1.0 seconds before timing out
    yield (1.0, ('8.8.8.8', 53), ('1.1.1.1', 53))

resolve, _ = Resolver(get_nameservers=get_nameservers)
ip_addresses = await resolve('www.google.com', TYPES.A)

This can be used as part of a HA system: if a nameserver isn't contactable, this pattern avoids waiting for its timeout before querying another nameserver.

Security considerations

To migitate spoofing, several techniques are used.

  • Each query is given a random ID, which is checked against any response.

  • Each domain name is encoded with 0x20-bit encoding, which is checked against any response.

  • A new socket, and so a new random local port, is used for each query.

  • Requests made for a domain while there is an in-flight query for that domain, wait for the the in-flight query to finish, and use its result.

Also, to migitate the risk of evil responses/configuration

  • Pointer loops are detected.

  • CNAME chains have a maximum length.

Scope

The scope of this project is deliberately restricted to operations that are used to resolve A or AAAA records: to resolve a domain name to its IP addresses, and have similar responsibilities to gethostbyname. Some limited extra behaviour is present/may be added, but great care is taken to prevent scope creep, especially to not add complexity that isn't required to resolve A or AAAA records.

  • UDP queries are made, but not TCP. DNS servers must support UDP, and it's impossible for a single A and AAAA record to not fit into the maximum size of a UDP DNS response, 512 bytes. There may be other data that the DNS server would return in TCP connections, but this isn't required to resolve a domain name to a single IP address.

    It is technically possible that in the case of extremely high numbers of A or AAAA records for a domain, they would not fit in a single UDP message. However, this is extremely unlikely, and in this unlikely case, extremely unlikely to affect applications in any meaningful way.

  • The resolver is a stub resolver: it delegates the responsibility of recursion to the nameserver(s) it queries. In the vast majority of envisioned use cases this is acceptable, since the nameservers in /etc/resolve.conf will be recursive.

Example: aiohttp

import asyncio
import socket

from aiodnsresolver import (
    TYPES,
    ResolverError,
    DoesNotExist,
    Resolver,
)
import aiohttp


class AioHttpDnsResolver(aiohttp.abc.AbstractResolver):
    def __init__(self):
        super().__init__()
        self.resolver, self.clear_cache = Resolver()

    async def resolve(self, host, port=0, family=socket.AF_INET):
        # Use ipv4 unless requested otherwise
        # This is consistent with the default aiohttp + aiodns AsyncResolver
        record_type = \
            TYPES.AAAA if family == socket.AF_INET6 else \
            TYPES.A

        try:
            ip_addresses = await self.resolver(host, record_type)
        except DoesNotExist as does_not_exist:
            raise OSError(0, '{} does not exist'.format(host)) from does_not_exist
        except ResolverError as resolver_error:
            raise OSError(0, '{} failed to resolve'.format(host)) from resolver_error

        return [{
            'hostname': host,
            'host': str(ip_address),
            'port': port,
            'family': family,
            'proto': socket.IPPROTO_TCP,
            'flags': socket.AI_NUMERICHOST,
        } for ip_address in ip_addresses]

    async def close(self):
        self.clear_cache()


async def main():
    async with aiohttp.ClientSession(
        connector=aiohttp.TCPConnector(use_dns_cache=False, resolver=AioHttpDnsResolver()),
    ) as session:
        async with await session.get('https://www.google.com/') as result:
            print(result)

loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

Example: tornado

import asyncio
import socket

from aiodnsresolver import (
    TYPES,
    ResolverError,
    DoesNotExist,
    Resolver,
)

import tornado.httpclient
import tornado.netutil

class AioHttpDnsResolver(tornado.netutil.Resolver):
    def initialize(self):
        self.resolver, self.clear_cache = Resolver()

    async def resolve(self, host, port=0, family=socket.AF_UNSPEC):
        # Use ipv4 unless ipv6 requested
        record_type, family_conn = \
            (TYPES.AAAA, socket.AF_INET6) if family == socket.AF_INET6 else \
            (TYPES.A, socket.AF_INET)

        try:
            ip_addresses = await self.resolver(host, record_type)
        except DoesNotExist as does_not_exist:
            raise IOError('{} does not exist'.format(host)) from does_not_exist
        except ResolverError as resolver_error:
            raise IOError('{} failed to resolve'.format(host)) from resolver_error

        return [
            (family_conn, (str(ip_address), port))
            for ip_address in ip_addresses
        ]

    async def close(self):
        self.clear_cache()

async def main():
    tornado.netutil.Resolver.configure(AioHttpDnsResolver)
    http_client = tornado.httpclient.AsyncHTTPClient()
    response = await http_client.fetch("http://www.google.com")
    print(response.body)

loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

Project details


Release history Release notifications | RSS feed

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

aiodnsresolver-0.0.100.tar.gz (9.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

aiodnsresolver-0.0.100-py3-none-any.whl (9.8 kB view details)

Uploaded Python 3

File details

Details for the file aiodnsresolver-0.0.100.tar.gz.

File metadata

  • Download URL: aiodnsresolver-0.0.100.tar.gz
  • Upload date:
  • Size: 9.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/1.13.0 pkginfo/1.5.0.1 requests/2.21.0 setuptools/39.0.1 requests-toolbelt/0.9.1 tqdm/4.31.1 CPython/3.6.6

File hashes

Hashes for aiodnsresolver-0.0.100.tar.gz
Algorithm Hash digest
SHA256 ecaa27aebb01d0e1f1c55b02db285c0a8c8c516afea0bf62678b55b2bc16109e
MD5 4789290edec83c1ba91c6d8de556ad32
BLAKE2b-256 425ecd912bb4cf5d69637c7140d1e8e84610131ba4d780732f2a36eb025a847f

See more details on using hashes here.

File details

Details for the file aiodnsresolver-0.0.100-py3-none-any.whl.

File metadata

  • Download URL: aiodnsresolver-0.0.100-py3-none-any.whl
  • Upload date:
  • Size: 9.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/1.13.0 pkginfo/1.5.0.1 requests/2.21.0 setuptools/39.0.1 requests-toolbelt/0.9.1 tqdm/4.31.1 CPython/3.6.6

File hashes

Hashes for aiodnsresolver-0.0.100-py3-none-any.whl
Algorithm Hash digest
SHA256 410082f00c507d434e17b3f13a7a5d7c510cb3a1e2c85c5175b3c5510603ffb7
MD5 8c514d44b7cf77d0700249980d367db6
BLAKE2b-256 fa06c619b7930b31d26c9cb8d1c7b2c6a428eaf2aded0f1e927c73df13c84393

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page