Packages

Elixir DNS codec, concurrent UDP client/server, and pluggable resolvers (RFC 1035).

Current section

Files

Jump to
adns README.md
Raw

README.md

# Adns
[![Elixir](https://img.shields.io/badge/Elixir-~%3E%201.18-4B275F?logo=elixir)](https://elixir-lang.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](mix.exs)
Elixir DNS **codec + concurrent UDP client/server** with pluggable resolvers.
Implements the classic [RFC 1035](https://www.rfc-editor.org/rfc/rfc1035.html) message format (§4.1) — header, questions, resource records, and name compression on decode.
Built as a network service stack: shared-socket UDP server, GenServer request correlation, `:telemetry` phase timings, and a load harness that reports RPS and latency percentiles.
## Features
- **Concurrent UDP server** — shared socket; async replies on a `Task.Supervisor`, or `sync: true` for inline handle/reply
- **Request/response correlation** — GenServer client matches in-flight queries by 16-bit DNS ID under concurrency
- **Clear service boundaries**`Adns.Resolver` behaviour; transport stays separate from lookup logic
- **Observability**`:telemetry` events with decode / handle / encode (and client encode / send / receive / decode) timings
- **Measured performance**`mix benchmark` and `mix dnsperf` report RPS / QPS and latency
- **Protocol-correct failures** — valid header + broken body → `FORMERR` reply with the same ID
- **Extensible RR codecs** — ETS registry (`read_concurrency: true`); unknown types kept as opaque binaries
## Quick start
```elixir
# mix.exs
def deps do
[{:adns, "~> 0.1.0"}]
end
```
```elixir
Adns.RR.Registry.init(Adns.RR.Registry.default_codecs())
msg = %Adns.Message{
id: 1,
qr: :question,
opcode: :QUERY,
aa: false,
tc: false,
rd: true,
ra: false,
rcode: :ok,
questions: [%Adns.Question{qname: "example.com", qtype: :A, qclass: :IN}],
answers: [],
authority: [],
additional: []
}
bin = Adns.Message.encode(msg)
{:ok, ^msg} = Adns.Message.decode(bin)
```
## Build a DNS server
Implement the resolver behaviour, then plug it into the UDP server:
```elixir
defmodule MyResolver do
use Adns.Resolver
@impl true
def resolve(%Adns.Resolver.Request{questions: questions}, _config) do
answers =
for %Question{qname: name, qtype: :A} <- questions do
%RR.Known{
name: name,
class: :IN,
ttl: 60,
rdata: %RR.A{address: 0x7F000001}
}
end
%Adns.Resolver.Response{
answers: answers,
authority: [],
additional: [],
aa: true,
ra: false,
rcode: :ok
}
end
end
# Boot RR codecs, then the server (port 8053 for non-root)
{:ok, _} =
Adns.Supervisor.start_link(
[{Adns.Server.UDP, port: 8053, resolver: MyResolver, config: nil}],
strategy: :one_for_one
)
```
Or use the built-in ETS cache resolver:
```elixir
cache = Adns.Resolver.Cache.config()
question = %Adns.Question{qname: "example.com", qtype: :A, qclass: :IN}
Adns.Resolver.Cache.register(cache, question, {
[%Adns.RR.Known{name: "example.com", class: :IN, ttl: 300, rdata: %Adns.RR.A{address: 0x08080808}}],
[],
[]
})
{:ok, _} =
Adns.Supervisor.start_link(
[{Adns.Server.UDP, port: 8053, resolver: Adns.Resolver.Cache, config: cache}],
strategy: :one_for_one
)
```
## Client query
```elixir
{:ok, _} = Adns.Client.start_link([])
{:ok, response} =
Adns.Client.request(%Adns.Client.Request{
address: {127, 0, 0, 1},
port: 8053,
opcode: :QUERY,
rd: true,
questions: [%Adns.Question{qname: "example.com", qtype: :A, qclass: :IN}]
})
```
One-shot helpers: `Adns.Client.request_once/1` and `Adns.Client.request_client/2` (reuse a socket).
## Architecture
```
UDP packet
Server.UDP (shared socket; async Tasks or sync loop)
Message.decode
├── {:ok, msg} → Resolver.resolve/2 → Message.encode → reply
└── {:partial, h, _} → FORMERR (same ID) → reply
```
The server does not embed lookup logic. Resolvers return answers, authority, additional, and flags (`aa`, `ra`, `rcode`); the server assembles the wire response.
## Benchmarks
### Built-in Elixir client
```bash
mix benchmark
mix benchmark --concurrency 100 --seconds 8 --warmup 3000 --client stateful
mix benchmark --concurrency 100 --seconds 5 --sync
```
Clients: `stateful` (shared GenServer), `once` (new socket per query), `sustained` (reused socket).
`--sync` runs the UDP server recv/handle/reply on one process (no per-query Task).
**Sample run** (stateful client, concurrency 100, 8s, warmup 3s):
| Metric | Value |
|--------|-------|
| RPS | ~65k |
| p50 | 274 µs |
| p95 | 407 µs |
| p99 | 456 µs |
| p999 | 500 µs |
| under 1ms | 100% |
### dnsperf (industry-standard)
Requires [`dnsperf`](https://github.com/DNS-OARC/dnsperf) on `PATH` (`apt install dnsperf`).
```bash
mix dnsperf
mix dnsperf --clients 50 --threads 4 --seconds 10 --outstanding 100
mix dnsperf --sync
```
Starts the cached UDP server, then drives it with dnsperf using [`bench/dnsperf.query`](bench/dnsperf.query).
**Sample run** (`mix dnsperf --clients 10 --threads 2 --seconds 5 --outstanding 100`):
| Metric | Value |
|--------|-------|
| Queries / s | ~178k |
| Completed | 99.99% |
| Avg latency | ~91 µs |
| Response codes | NOERROR 100% |
Hardware: 13th Gen Intel Core i7-13620H (16 threads), Linux. Numbers are localhost client↔server; treat them as a relative baseline.
Profile hotspots with `mix eprof --concurrency 1000 --warmup 5000`.
## Wire format (RFC 1035)
Header layout matches [§4.1.1](https://www.rfc-editor.org/rfc/rfc1035.html#section-4.1.1):
```
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ID |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR| Opcode |AA|TC|RD|RA| Z | RCODE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QDCOUNT |
| ANCOUNT |
| NSCOUNT |
| ARCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
```
Mapped to `Adns.Header` / `Adns.Message` fields (`id`, `qr`, `opcode`, `aa`, `tc`, `rd`, `ra`, `rcode`, plus question and RR lists). Reserved `Z` bits are encoded as `0` and required to be `0` on decode.
**Name compression (§4.1.4):** decode follows pointers (`11` + 14-bit offset) into the full message buffer. Encode currently writes labels in full (no pointer emission yet).
**Supported RR types (classic set):** A, NS, MD, MF, CNAME, SOA, MB, MG, MR, NULL, WKS, PTR, HINFO, MINFO, MX, TXT.
## API overview
| Module | Role |
|--------|------|
| `Adns.Message` | Encode/decode full messages; `{:partial, header, reason}` on body errors |
| `Adns.Header` / `Adns.Question` / `Adns.Label` | Section and name codecs |
| `Adns.RR` | Known (typed RDATA) vs Unhandled (raw type + binary) |
| `Adns.RR.Registry` / `Adns.RR.Codec` | Pluggable RDATA codecs |
| `Adns.Resolver` | Behaviour: `resolve/2``Response` |
| `Adns.Resolver.Cache` | ETS-backed resolver |
| `Adns.Server` | Decode → resolve → encode; FORMERR on partial |
| `Adns.Server.UDP` | Shared-socket UDP listener (`sync:` for inline replies) |
| `Adns.Client` | Stateful GenServer client + one-shot helpers |
| `Adns.Supervisor` | Init RR registry, then supervise children |
### Codec
```elixir
Adns.Message.encode(t()) :: binary()
Adns.Message.decode(binary()) ::
{:ok, t()} | {:error, reason} | {:partial, Adns.Header.t(), reason}
Adns.RR.encode(t()) :: binary()
Adns.RR.decode(binary(), message :: binary()) :: {:ok, {t(), rest}} | {:error, reason}
```
### Resolver
```elixir
@callback resolve(Adns.Resolver.Request.t(), config :: term()) ::
Adns.Resolver.Response.t()
```
### Server / client
```elixir
Adns.Server.handle_message_stream(binary(), resolver, config) :: {:ok, binary()} | :no_message
Adns.Server.UDP.start_link(port:, resolver:, config:, sync: false)
Adns.Client.start_link(opts)
Adns.Client.request(request, timeout \\ 3000) :: {:ok, Response.t()} | {:error, term()}
```
Custom RR types: implement `Adns.RR.Codec`, then `Adns.RR.Registry.register/1` (or pass codecs into `Adns.Supervisor`).
## Status
**In scope today:** RFC 1035 classic RRs, UDP client/server, cache resolver, telemetry, benchmarks.
**Not yet:** AAAA / EDNS0 / DNSSEC, encode-side name compression, production-ready TCP (length-prefixed path is incomplete).
## Development
```bash
mix deps.get
mix test
mix format
mix benchmark
mix dnsperf
mix eprof
```
## License
MIT — see [LICENSE](LICENSE).