This commit is contained in:
Ivan Zinchenko 2026-01-13 18:26:26 +03:00
commit d877bae398
Signed by: zinch
GPG Key ID: 6D45AA2C8FD6A37E
45 changed files with 2998 additions and 0 deletions

4
.formatter.exs Normal file
View File

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
exdns-*.tar
# Temporary files, for example, from tests.
/tmp/
AGENTS.md

21
README.md Normal file
View File

@ -0,0 +1,21 @@
# Exdns
**TODO: Add description**
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `exdns` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:exdns, "~> 0.1.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/exdns>.

11
config/config.exs Normal file
View File

@ -0,0 +1,11 @@
import Config
config :exdns,
auth_token: "change-me",
http_port: 8080,
dns_port: 5354,
dns_tcp_port: 5354,
zones_dir: "zones",
cookie_seed: "exdns-cookie"
import_config "#{config_env()}.exs"

1
config/dev.exs Normal file
View File

@ -0,0 +1 @@
import Config

1
config/prod.exs Normal file
View File

@ -0,0 +1 @@
import Config

11
config/runtime.exs Normal file
View File

@ -0,0 +1,11 @@
import Config
if config_env() == :prod do
config :exdns,
auth_token: System.get_env("EXDNS_AUTH_TOKEN", "change-me"),
http_port: String.to_integer(System.get_env("EXDNS_HTTP_PORT", "8080")),
dns_port: String.to_integer(System.get_env("EXDNS_DNS_PORT", "5354")),
dns_tcp_port: String.to_integer(System.get_env("EXDNS_DNS_TCP_PORT", "5354")),
zones_dir: System.get_env("EXDNS_ZONES_DIR", "zones"),
cookie_seed: System.get_env("EXDNS_COOKIE_SEED", "exdns-cookie")
end

9
config/test.exs Normal file
View File

@ -0,0 +1,9 @@
import Config
import Config
config :exdns,
auth_token: "test-token",
http_port: 0,
dns_port: 0,
dns_tcp_port: 0,
zones_dir: "tmp/zones_test"

29
lib/exdns/application.ex Normal file
View File

@ -0,0 +1,29 @@
defmodule Exdns.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
token = Application.fetch_env!(:exdns, :auth_token)
http_port = Application.fetch_env!(:exdns, :http_port)
dns_port = Application.fetch_env!(:exdns, :dns_port)
tcp_port = Application.fetch_env!(:exdns, :dns_tcp_port)
children = [
# Starts a worker by calling: Exdns.Worker.start_link(arg)
# {Exdns.Worker, arg}
{Exdns.ZoneServer, []},
{Exdns.UdpListener, port: dns_port},
{Exdns.TcpListener, port: tcp_port},
{Bandit, plug: {Exdns.HttpRouter, auth_token: token}, scheme: :http, port: http_port}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Exdns.Supervisor]
Supervisor.start_link(children, opts)
end
end

104
lib/exdns/dns/packet.ex Normal file
View File

@ -0,0 +1,104 @@
defmodule Exdns.DnsPacket do
@moduledoc """
DNS packet structures with binary serialization and deserialization.
"""
alias Exdns.DnsPacket.{Header, Question, ResourceRecord}
defstruct header: %Header{},
questions: [],
answers: [],
authorities: [],
additionals: []
def to_binary(%__MODULE__{} = packet) do
header = with_counts(packet)
header_bin = Header.encode(header)
question_bin =
packet.questions
|> Enum.map(&Question.encode/1)
|> IO.iodata_to_binary()
answer_bin =
packet.answers
|> Enum.map(&ResourceRecord.encode/1)
|> IO.iodata_to_binary()
authority_bin =
packet.authorities
|> Enum.map(&ResourceRecord.encode/1)
|> IO.iodata_to_binary()
additional_bin =
packet.additionals
|> Enum.map(&ResourceRecord.encode/1)
|> IO.iodata_to_binary()
IO.iodata_to_binary([
header_bin,
question_bin,
answer_bin,
authority_bin,
additional_bin
])
end
def from_binary(bin) when is_binary(bin) do
with {:ok, {header, offset}} <- Header.decode(bin, 0),
{:ok, {questions, offset}} <- decode_questions(bin, offset, header.qdcount),
{:ok, {answers, offset}} <- decode_rrs(bin, offset, header.ancount),
{:ok, {authorities, offset}} <- decode_rrs(bin, offset, header.nscount),
{:ok, {additionals, _offset}} <- decode_rrs(bin, offset, header.arcount) do
{:ok,
%__MODULE__{
header: header,
questions: questions,
answers: answers,
authorities: authorities,
additionals: additionals
}}
end
end
defp with_counts(%__MODULE__{} = packet) do
%Header{
packet.header
| qdcount: length(packet.questions),
ancount: length(packet.answers),
nscount: length(packet.authorities),
arcount: length(packet.additionals)
}
end
defp decode_questions(bin, offset, count) do
decode_many(bin, offset, count, fn bin, offset ->
Question.decode(bin, offset)
end)
end
defp decode_rrs(bin, offset, count) do
decode_many(bin, offset, count, fn bin, offset ->
ResourceRecord.decode(bin, offset)
end)
end
defp decode_many(_bin, offset, 0, _fun), do: {:ok, {[], offset}}
defp decode_many(bin, offset, count, fun) do
decode_many_loop(bin, offset, count, fun)
end
defp decode_many_loop(bin, offset, count, fun) do
Enum.reduce_while(1..count, {:ok, {[], offset}}, fn _, {:ok, {acc, offset}} ->
case fun.(bin, offset) do
{:ok, {item, offset}} -> {:cont, {:ok, {[item | acc], offset}}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, {items, offset}} -> {:ok, {Enum.reverse(items), offset}}
{:error, _} = error -> error
end
end
end

136
lib/exdns/dns/responder.ex Normal file
View File

@ -0,0 +1,136 @@
defmodule Exdns.DnsResponder do
@moduledoc false
alias Exdns.{DnsPacket, ZoneServer}
alias Exdns.DnsPacket.{Header, Opt, Option, RCode, ResourceRecord, RRType}
alias Exdns.DnsPacket.RRData.Types
def build_response(%DnsPacket{questions: []} = query, ip, port) do
{:ok, response_packet(query, [], :form_err, ip, port)}
end
def build_response(%DnsPacket{questions: [question | _]} = query, ip, port) do
case ZoneServer.get(question.qname) do
{:ok, records} ->
answers = filter_answers(records, question.qtype, question.qclass)
{:ok, response_packet(query, answers, :no_error, ip, port)}
:error ->
{:ok, response_packet(query, [], :nxdomain, ip, port)}
end
end
defp response_packet(%DnsPacket{} = query, answers, rcode, ip, port) do
header = response_header(query.header, rcode)
authorities = response_authorities(query, rcode, answers)
additionals = response_additionals(query.additionals, ip, port)
%DnsPacket{
header: header,
questions: query.questions,
answers: answers,
authorities: authorities,
additionals: additionals
}
end
defp response_header(%Header{} = header, rcode) do
rcode_value = RCode.to_code(rcode) || header.rcode
%Header{
header
| qr: 1,
aa: 1,
tc: 0,
ra: 0,
rcode: rcode_value
}
end
defp response_additionals(additionals, ip, port) do
Enum.map(additionals, fn
%ResourceRecord{rdata: %Opt{} = opt} = rr ->
%ResourceRecord{rr | rdata: update_opt(opt, ip, port)}
rr ->
rr
end)
end
defp update_opt(%Opt{} = opt, ip, port) do
options =
Enum.map(opt.options, fn
%Option{code: 10, data: <<client::binary-size(8), _::binary>>} = option ->
%Option{option | data: client <> server_cookie(ip, port)}
option ->
option
end)
%Opt{opt | options: options}
end
defp server_cookie(nil, nil), do: <<0, 0, 0, 0, 0, 0, 0, 0>>
defp server_cookie(ip, port) do
seed = :erlang.term_to_binary({ip, port, cookie_seed()})
:crypto.hash(:sha256, seed) |> binary_part(0, 8)
end
defp cookie_seed do
Application.fetch_env!(:exdns, :cookie_seed)
end
defp response_authorities(%DnsPacket{questions: []}, _rcode, _answers), do: []
defp response_authorities(%DnsPacket{questions: [question | _]}, rcode, answers) do
case ZoneServer.authority(question.qname) do
{:ok, %{soa: soa, ns: ns}} -> pick_authorities(rcode, soa, ns, answers)
:error -> []
end
end
defp pick_authorities(:nxdomain, soa, _ns, _answers) do
if soa != [], do: soa, else: []
end
defp pick_authorities(:no_error, soa, ns, answers) do
if answers == [] do
if soa != [], do: soa, else: ns
else
ns
end
end
defp pick_authorities(_rcode, _soa, ns, _answers), do: ns
defp filter_answers(records, qtype, qclass) do
Enum.filter(records, fn rr ->
rr.class == qclass and match_type?(rr, qtype)
end)
end
defp match_type?(_rr, 255), do: true
defp match_type?(%ResourceRecord{type: type}, qtype) when is_integer(type), do: type == qtype
defp match_type?(%ResourceRecord{type: type}, qtype) when is_atom(type),
do: RRType.to_code(type) == qtype
defp match_type?(%ResourceRecord{rdata: rdata}, qtype) do
case rdata_type_code(rdata) do
nil -> false
code -> qtype == code
end
end
defp rdata_type_code(%Types.A{}), do: 1
defp rdata_type_code(%Types.NS{}), do: 2
defp rdata_type_code(%Types.CNAME{}), do: 5
defp rdata_type_code(%Types.SOA{}), do: 6
defp rdata_type_code(%Types.MX{}), do: 15
defp rdata_type_code(%Types.TXT{}), do: 16
defp rdata_type_code(%Types.AAAA{}), do: 28
defp rdata_type_code(%Types.SRV{}), do: 33
defp rdata_type_code(%Types.CAA{}), do: 257
defp rdata_type_code(_), do: nil
end

View File

@ -0,0 +1,32 @@
defmodule Exdns.DnsPacket.Binary do
@moduledoc false
def take_binary(bin, offset, len) do
if byte_size(bin) < offset + len do
{:error, :truncated}
else
<<_::binary-size(offset), chunk::binary-size(len), _::binary>> = bin
{:ok, {chunk, offset + len}}
end
end
def take_u16(bin, offset) do
case bin do
<<_::binary-size(offset), value::16, _::binary>> ->
{:ok, {value, offset + 2}}
_ ->
{:error, :truncated}
end
end
def take_u32(bin, offset) do
case bin do
<<_::binary-size(offset), value::32, _::binary>> ->
{:ok, {value, offset + 4}}
_ ->
{:error, :truncated}
end
end
end

View File

@ -0,0 +1,77 @@
defmodule Exdns.DnsPacket.Header do
@moduledoc """
DNS header structure.
"""
import Bitwise
defstruct id: 0,
qr: 0,
opcode: 0,
aa: 0,
tc: 0,
rd: 1,
ra: 0,
z: 0,
ad: 0,
cd: 0,
rcode: 0,
qdcount: 0,
ancount: 0,
nscount: 0,
arcount: 0
def encode(%__MODULE__{} = header) do
flags =
header.qr <<< 15 |||
header.opcode <<< 11 |||
header.aa <<< 10 |||
header.tc <<< 9 |||
header.rd <<< 8 |||
header.ra <<< 7 |||
header.z <<< 6 |||
header.ad <<< 5 |||
header.cd <<< 4 |||
header.rcode <<< 0
<<
header.id::16,
flags::16,
header.qdcount::16,
header.ancount::16,
header.nscount::16,
header.arcount::16
>>
end
def decode(bin, offset) do
case bin do
<<_::binary-size(offset), id::16, flags::16, qd::16, an::16, ns::16, ar::16, _::binary>> ->
<<qr::1, opcode::4, aa::1, tc::1, rd::1, ra::1, z::1, ad::1, cd::1, rcode::4>> =
<<flags::16>>
header = %__MODULE__{
id: id,
qr: qr,
opcode: opcode,
aa: aa,
tc: tc,
rd: rd,
ra: ra,
z: z,
ad: ad,
cd: cd,
rcode: rcode,
qdcount: qd,
ancount: an,
nscount: ns,
arcount: ar
}
{:ok, {header, offset + 12}}
_ ->
{:error, :invalid_header}
end
end
end

View File

@ -0,0 +1,62 @@
defmodule Exdns.DnsPacket.Name do
@moduledoc """
DNS name encoding and decoding with compression pointers.
"""
import Bitwise
alias Exdns.DnsPacket.Binary
def encode(labels) when is_list(labels) do
labels
|> Enum.map(fn label ->
size = byte_size(label)
<<size::8, label::binary>>
end)
|> IO.iodata_to_binary()
|> Kernel.<>(<<0>>)
end
def encode(name) when is_binary(name) do
name
|> String.trim(".")
|> String.split(".", trim: true)
|> encode()
end
def decode(bin, offset) do
decode(bin, offset, [], MapSet.new())
end
defp decode(bin, offset, labels, visited) do
if MapSet.member?(visited, offset) do
{:error, :name_pointer_loop}
else
bin
|> decode_at(offset, labels, MapSet.put(visited, offset))
end
end
defp decode_at(bin, offset, labels, visited) do
case bin do
<<_::binary-size(offset), 0, _::binary>> ->
{:ok, {Enum.reverse(labels), offset + 1}}
<<_::binary-size(offset), len::8, _::binary>> when (len &&& 0xC0) == 0xC0 ->
<<_::binary-size(offset), ptr::16, _::binary>> = bin
<<_::2, pointer::14>> = <<ptr::16>>
case decode(bin, pointer, [], visited) do
{:ok, {suffix, _}} -> {:ok, {Enum.reverse(labels) ++ suffix, offset + 2}}
{:error, _} = error -> error
end
<<_::binary-size(offset), len::8, _::binary>> ->
with {:ok, {label, next_offset}} <- Binary.take_binary(bin, offset + 1, len) do
decode(bin, next_offset, [label | labels], visited)
end
_ ->
{:error, :invalid_name}
end
end
end

View File

@ -0,0 +1,39 @@
defmodule Exdns.DnsPacket.Opt do
@moduledoc """
EDNS(0) OPT pseudo-record data.
"""
alias Exdns.DnsPacket.Option
defstruct udp_payload_size: 1232,
ext_rcode: 0,
version: 0,
flags: 0,
options: []
def encode_ttl(%__MODULE__{} = opt) do
<<ttl::32>> = <<opt.ext_rcode::8, opt.version::8, opt.flags::16>>
ttl
end
def decode(udp_payload_size, ttl, rdata_bin) do
<<ext_rcode::8, version::8, flags::16>> = <<ttl::32>>
with {:ok, options} <- Option.decode_many(rdata_bin) do
{:ok,
%__MODULE__{
udp_payload_size: udp_payload_size,
ext_rcode: ext_rcode,
version: version,
flags: flags,
options: options
}}
end
end
def encode_options(options) do
options
|> Enum.map(&Option.encode/1)
|> IO.iodata_to_binary()
end
end

View File

@ -0,0 +1,32 @@
defmodule Exdns.DnsPacket.Option do
@moduledoc """
EDNS(0) option.
"""
defstruct code: 0,
data: <<>>
def encode(%__MODULE__{code: code, data: data}) do
<<code::16, byte_size(data)::16, data::binary>>
end
def encode(%{code: code, data: data}) do
<<code::16, byte_size(data)::16, data::binary>>
end
def decode_many(bin) do
decode_many(bin, [])
end
defp decode_many(<<>>, acc), do: {:ok, Enum.reverse(acc)}
defp decode_many(<<code::16, length::16, rest::binary>>, acc) do
if byte_size(rest) < length do
{:error, :invalid_option}
else
<<data::binary-size(length), tail::binary>> = rest
option = %__MODULE__{code: code, data: data}
decode_many(tail, [option | acc])
end
end
end

View File

@ -0,0 +1,36 @@
defmodule Exdns.DnsPacket.Question do
@moduledoc """
DNS question structure.
"""
alias Exdns.DnsPacket.Name
defstruct qname: [],
qtype: 1,
qclass: 1
def encode(%__MODULE__{} = q) do
[
Name.encode(q.qname),
<<q.qtype::16, q.qclass::16>>
]
end
def decode(bin, offset) do
with {:ok, {name, offset}} <- Name.decode(bin, offset),
{:ok, {qtype, qclass, offset}} <- decode_qtype_class(bin, offset) do
question = %__MODULE__{qname: name, qtype: qtype, qclass: qclass}
{:ok, {question, offset}}
end
end
defp decode_qtype_class(bin, offset) do
case bin do
<<_::binary-size(offset), qtype::16, qclass::16, _::binary>> ->
{:ok, {qtype, qclass, offset + 4}}
_ ->
{:error, :invalid_question}
end
end
end

View File

@ -0,0 +1,20 @@
defmodule Exdns.DnsPacket.RCode do
@moduledoc false
@code_map %{
no_error: 0,
form_err: 1,
serv_fail: 2,
nxdomain: 3,
not_imp: 4,
refused: 5
}
def to_code(rcode) when is_atom(rcode), do: Map.get(@code_map, rcode)
def from_code(code) when is_integer(code) do
Enum.find_value(@code_map, fn {rcode, value} ->
if value == code, do: rcode, else: nil
end)
end
end

View File

@ -0,0 +1,72 @@
defmodule Exdns.DnsPacket.ResourceRecord do
@moduledoc """
DNS resource record structure.
"""
alias Exdns.DnsPacket.{Binary, Name, Opt, RRData, RRType}
defstruct name: [],
type: 1,
class: 1,
ttl: 0,
rdata: <<>>
def encode(%__MODULE__{} = rr) do
{type, class, ttl, rdata_bin} =
case rr.rdata do
%Opt{} = opt ->
{41, opt.udp_payload_size, Opt.encode_ttl(opt), Opt.encode_options(opt.options)}
_ ->
case RRData.encode(rr.rdata) do
{:ok, {type, rdata_bin}} ->
{RRType.to_code(type) || rr.type, rr.class, rr.ttl, rdata_bin}
:error ->
{rr.type, rr.class, rr.ttl, rr.rdata}
{:error, _} ->
{rr.type, rr.class, rr.ttl, rr.rdata}
end
end
[
Name.encode(rr.name),
<<type::16, class::16, ttl::32, byte_size(rdata_bin)::16>>,
rdata_bin
]
end
def decode(bin, offset) do
with {:ok, {name, offset}} <- Name.decode(bin, offset),
{:ok, {type, class, ttl, rdlength, offset}} <- decode_header(bin, offset),
{:ok, {rdata, offset}} <- decode_rdata(bin, offset, type, class, ttl, rdlength) do
rr = %__MODULE__{name: name, type: type, class: class, ttl: ttl, rdata: rdata}
{:ok, {rr, offset}}
end
end
defp decode_header(bin, offset) do
case bin do
<<_::binary-size(offset), type::16, class::16, ttl::32, rdlength::16, _::binary>> ->
{:ok, {type, class, ttl, rdlength, offset + 10}}
_ ->
{:error, :invalid_rr}
end
end
defp decode_rdata(bin, offset, 41, class, ttl, rdlength) do
with {:ok, {rdata_bin, offset}} <- Binary.take_binary(bin, offset, rdlength),
{:ok, opt} <- Opt.decode(class, ttl, rdata_bin) do
{:ok, {opt, offset}}
end
end
defp decode_rdata(bin, offset, type, _class, _ttl, rdlength) do
case RRType.from_code(type) do
nil -> Binary.take_binary(bin, offset, rdlength)
mapped -> RRData.decode(bin, offset, rdlength, mapped)
end
end
end

View File

@ -0,0 +1,8 @@
defmodule Exdns.DnsPacket.RRData do
@moduledoc false
alias Exdns.DnsPacket.RRData.{Decode, Encode}
defdelegate encode(rdata), to: Encode
defdelegate decode(bin, offset, rdlength, type), to: Decode
end

View File

@ -0,0 +1,128 @@
defmodule Exdns.DnsPacket.RRData.Decode do
@moduledoc false
alias Exdns.DnsPacket.{Binary, Name}
alias Exdns.DnsPacket.RRData.Types.{A, AAAA, CAA, CNAME, MX, NS, SOA, SRV, TXT}
def decode(bin, offset, rdlength, :a), do: decode_a(bin, offset, rdlength)
def decode(bin, offset, rdlength, :aaaa), do: decode_aaaa(bin, offset, rdlength)
def decode(bin, offset, rdlength, :cname), do: decode_name(bin, offset, rdlength, CNAME)
def decode(bin, offset, rdlength, :ns), do: decode_name(bin, offset, rdlength, NS)
def decode(bin, offset, rdlength, :mx), do: decode_mx(bin, offset, rdlength)
def decode(bin, offset, rdlength, :txt), do: decode_txt(bin, offset, rdlength)
def decode(bin, offset, rdlength, :srv), do: decode_srv(bin, offset, rdlength)
def decode(bin, offset, rdlength, :caa), do: decode_caa(bin, offset, rdlength)
def decode(bin, offset, rdlength, :soa), do: decode_soa(bin, offset, rdlength)
def decode(bin, offset, rdlength, _type), do: Binary.take_binary(bin, offset, rdlength)
defp decode_a(bin, offset, 4) do
with {:ok, {<<a::8, b::8, c::8, d::8>>, _}} <- Binary.take_binary(bin, offset, 4) do
{:ok, {%A{address: {a, b, c, d}}, offset + 4}}
end
end
defp decode_a(_bin, _offset, _len), do: {:error, :invalid_rdata_length}
defp decode_aaaa(bin, offset, 16) do
with {:ok, {chunk, _}} <- Binary.take_binary(bin, offset, 16),
<<a::16, b::16, c::16, d::16, e::16, f::16, g::16, h::16>> <- chunk do
{:ok, {%AAAA{address: {a, b, c, d, e, f, g, h}}, offset + 16}}
else
_ -> {:error, :invalid_rdata}
end
end
defp decode_aaaa(_bin, _offset, _len), do: {:error, :invalid_rdata_length}
defp decode_name(bin, offset, rdlength, module) do
end_offset = offset + rdlength
with {:ok, {name, _}} <- Name.decode(bin, offset) do
{:ok, {struct(module, name: name), end_offset}}
end
end
defp decode_mx(bin, offset, rdlength) do
end_offset = offset + rdlength
with {:ok, {pref, next_offset}} <- Binary.take_u16(bin, offset),
{:ok, {exchange, _}} <- Name.decode(bin, next_offset) do
{:ok, {%MX{preference: pref, exchange: exchange}, end_offset}}
end
end
defp decode_srv(bin, offset, rdlength) do
end_offset = offset + rdlength
with {:ok, {prio, offset}} <- Binary.take_u16(bin, offset),
{:ok, {weight, offset}} <- Binary.take_u16(bin, offset),
{:ok, {port, offset}} <- Binary.take_u16(bin, offset),
{:ok, {target, _}} <- Name.decode(bin, offset) do
{:ok, {%SRV{priority: prio, weight: weight, port: port, target: target}, end_offset}}
end
end
defp decode_caa(bin, offset, rdlength) when rdlength >= 2 do
end_offset = offset + rdlength
with <<_::binary-size(offset), flags::8, tag_len::8, _::binary>> <- bin,
tag_offset <- offset + 2,
true <- tag_offset + tag_len <= end_offset,
{:ok, {tag, value_offset}} <- Binary.take_binary(bin, tag_offset, tag_len),
{:ok, {value, _}} <- Binary.take_binary(bin, value_offset, end_offset - value_offset) do
{:ok, {%CAA{flags: flags, tag: tag, value: value}, end_offset}}
else
false -> {:error, :invalid_rdata_length}
_ -> {:error, :invalid_rdata}
end
end
defp decode_caa(_bin, _offset, _len), do: {:error, :invalid_rdata_length}
defp decode_soa(bin, offset, rdlength) do
end_offset = offset + rdlength
with {:ok, {mname, offset}} <- Name.decode(bin, offset),
{:ok, {rname, offset}} <- Name.decode(bin, offset),
{:ok, {serial, offset}} <- Binary.take_u32(bin, offset),
{:ok, {refresh, offset}} <- Binary.take_u32(bin, offset),
{:ok, {retry, offset}} <- Binary.take_u32(bin, offset),
{:ok, {expire, offset}} <- Binary.take_u32(bin, offset),
{:ok, {minimum, _}} <- Binary.take_u32(bin, offset) do
soa = %SOA{
mname: mname,
rname: rname,
serial: serial,
refresh: refresh,
retry: retry,
expire: expire,
minimum: minimum
}
{:ok, {soa, end_offset}}
end
end
defp decode_txt(bin, offset, rdlength) do
end_offset = offset + rdlength
decode_txt_chunks(bin, offset, end_offset, [])
end
defp decode_txt_chunks(_bin, offset, offset, acc) do
{:ok, {%TXT{strings: Enum.reverse(acc)}, offset}}
end
defp decode_txt_chunks(bin, offset, end_offset, acc) when offset < end_offset do
with <<_::binary-size(offset), len::8, _::binary>> <- bin,
next <- offset + 1 + len,
true <- next <= end_offset,
{:ok, {chunk, _}} <- Binary.take_binary(bin, offset + 1, len) do
decode_txt_chunks(bin, next, end_offset, [chunk | acc])
else
false -> {:error, :invalid_rdata_length}
_ -> {:error, :invalid_rdata}
end
end
defp decode_txt_chunks(_bin, _offset, _end_offset, _acc), do: {:error, :invalid_rdata}
end

View File

@ -0,0 +1,78 @@
defmodule Exdns.DnsPacket.RRData.Encode do
@moduledoc false
alias Exdns.DnsPacket.Name
alias Exdns.DnsPacket.RRData.Types.{A, AAAA, CAA, CNAME, MX, NS, SOA, SRV, TXT}
def encode(%A{address: {a, b, c, d}}),
do: {:ok, {:a, <<a::8, b::8, c::8, d::8>>}}
def encode(%AAAA{address: {a, b, c, d, e, f, g, h}}),
do: {:ok, {:aaaa, <<a::16, b::16, c::16, d::16, e::16, f::16, g::16, h::16>>}}
def encode(%CNAME{name: name}), do: {:ok, {:cname, Name.encode(name)}}
def encode(%NS{name: name}), do: {:ok, {:ns, Name.encode(name)}}
def encode(%TXT{strings: strings}) do
with {:ok, bin} <- encode_txt(strings) do
{:ok, {:txt, bin}}
end
end
def encode(%MX{preference: pref, exchange: exchange}) do
{:ok, {:mx, <<pref::16, Name.encode(exchange)::binary>>}}
end
def encode(%SRV{priority: prio, weight: weight, port: port, target: target}) do
{:ok, {:srv, <<prio::16, weight::16, port::16, Name.encode(target)::binary>>}}
end
def encode(%CAA{flags: flags, tag: tag, value: value}) do
tag = to_string(tag)
value = to_string(value)
tag_len = byte_size(tag)
if tag_len > 255 do
{:error, :invalid_caa_tag_length}
else
{:ok, {:caa, <<flags::8, tag_len::8, tag::binary, value::binary>>}}
end
end
def encode(%SOA{} = soa) do
rdata =
<<
Name.encode(soa.mname)::binary,
Name.encode(soa.rname)::binary,
soa.serial::32,
soa.refresh::32,
soa.retry::32,
soa.expire::32,
soa.minimum::32
>>
{:ok, {:soa, rdata}}
end
def encode(_), do: :error
defp encode_txt(strings) when is_binary(strings), do: encode_txt([strings])
defp encode_txt(strings) when is_list(strings) do
strings
|> Enum.reduce_while([], fn item, acc ->
item = to_string(item)
size = byte_size(item)
if size > 255 do
{:halt, {:error, :invalid_txt_chunk_length}}
else
{:cont, [<<size::8, item::binary>> | acc]}
end
end)
|> case do
{:error, _} = error -> error
list -> {:ok, list |> Enum.reverse() |> IO.iodata_to_binary()}
end
end
end

View File

@ -0,0 +1,54 @@
defmodule Exdns.DnsPacket.RRData.Types do
@moduledoc false
defmodule A do
@moduledoc false
defstruct address: {0, 0, 0, 0}
end
defmodule AAAA do
@moduledoc false
defstruct address: {0, 0, 0, 0, 0, 0, 0, 0}
end
defmodule CNAME do
@moduledoc false
defstruct name: []
end
defmodule NS do
@moduledoc false
defstruct name: []
end
defmodule TXT do
@moduledoc false
defstruct strings: []
end
defmodule MX do
@moduledoc false
defstruct preference: 0, exchange: []
end
defmodule SRV do
@moduledoc false
defstruct priority: 0, weight: 0, port: 0, target: []
end
defmodule CAA do
@moduledoc false
defstruct flags: 0, tag: "", value: ""
end
defmodule SOA do
@moduledoc false
defstruct mname: [],
rname: [],
serial: 0,
refresh: 0,
retry: 0,
expire: 0,
minimum: 0
end
end

View File

@ -0,0 +1,23 @@
defmodule Exdns.DnsPacket.RRType do
@moduledoc false
@type_map %{
a: 1,
ns: 2,
cname: 5,
soa: 6,
mx: 15,
txt: 16,
aaaa: 28,
srv: 33,
caa: 257
}
def to_code(type) when is_atom(type), do: Map.get(@type_map, type)
def from_code(code) when is_integer(code) do
Enum.find_value(@type_map, fn {type, value} ->
if value == code, do: type, else: nil
end)
end
end

31
lib/exdns/http/codec.ex Normal file
View File

@ -0,0 +1,31 @@
defmodule Exdns.HttpCodec do
@moduledoc false
alias Exdns.HttpCodec.Record
def name_from_string(name) do
name
|> String.trim(".")
|> String.split(".", trim: true)
end
def name_to_string(labels) when is_list(labels), do: Enum.join(labels, ".")
def decode_records(%{"records" => records}, zone_name) when is_list(records) do
records
|> Enum.reduce_while([], fn record, acc ->
case Record.decode(record, zone_name) do
{:ok, rr} -> {:cont, [rr | acc]}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:error, _} = error -> error
list -> {:ok, Enum.reverse(list)}
end
end
def decode_records(_, _zone_name), do: {:error, "invalid_body"}
def encode_records(records), do: Enum.map(records, &Record.encode/1)
end

View File

@ -0,0 +1,204 @@
defmodule Exdns.HttpCodec.Record do
@moduledoc false
alias Exdns.DnsPacket.ResourceRecord
alias Exdns.DnsPacket.RRData.Types
alias Exdns.DnsPacket.RRType
alias Exdns.HttpCodec
@type_map %{
"a" => :a,
"aaaa" => :aaaa,
"cname" => :cname,
"txt" => :txt,
"mx" => :mx,
"srv" => :srv,
"caa" => :caa,
"soa" => :soa,
"ns" => :ns
}
def decode(%{} = record, zone_name) do
with {:ok, type} <- decode_type(record),
{:ok, name} <- decode_name(Map.get(record, "name"), zone_name),
{:ok, rdata} <- decode_rdata(type, Map.get(record, "rdata", %{})) do
{:ok,
%ResourceRecord{
name: name,
type: type,
class: Map.get(record, "class", 1),
ttl: Map.get(record, "ttl", 0),
rdata: rdata
}}
end
end
def decode(_, _zone_name), do: {:error, "invalid_record"}
def encode(%ResourceRecord{} = rr) do
%{
"name" => HttpCodec.name_to_string(rr.name),
"type" => encode_type(rr),
"class" => rr.class,
"ttl" => rr.ttl,
"rdata" => encode_rdata(rr.rdata)
}
end
defp decode_type(%{"type" => type}), do: decode_type_value(type)
defp decode_type(%{type: type}), do: decode_type_value(type)
defp decode_type(_), do: {:error, "missing_type"}
defp decode_type_value(type) when is_binary(type) do
case Map.fetch(@type_map, String.downcase(type)) do
{:ok, mapped} -> {:ok, mapped}
:error -> {:error, "unsupported_type"}
end
end
defp decode_type_value(type) when is_atom(type), do: {:ok, type}
defp decode_type_value(type) when is_integer(type), do: {:ok, RRType.from_code(type)}
defp decode_type_value(_), do: {:error, "invalid_type"}
defp decode_name(nil, zone_name), do: {:ok, zone_name}
defp decode_name("@", zone_name), do: {:ok, zone_name}
defp decode_name("*", zone_name), do: {:ok, ["*" | zone_name]}
defp decode_name(name, _zone_name) when is_binary(name) do
{:ok, HttpCodec.name_from_string(name)}
end
defp decode_name(["@"], zone_name), do: {:ok, zone_name}
defp decode_name(["*"], zone_name), do: {:ok, ["*" | zone_name]}
defp decode_name(name, _zone_name) when is_list(name), do: {:ok, name}
defp decode_name(_, _zone_name), do: {:error, "invalid_name"}
defp decode_rdata(:a, %{"address" => address}), do: decode_a(address)
defp decode_rdata(:aaaa, %{"address" => address}), do: decode_aaaa(address)
defp decode_rdata(:cname, %{"name" => name}),
do: {:ok, %Types.CNAME{name: HttpCodec.name_from_string(name)}}
defp decode_rdata(:ns, %{"name" => name}),
do: {:ok, %Types.NS{name: HttpCodec.name_from_string(name)}}
defp decode_rdata(:txt, %{"strings" => strings}),
do: {:ok, %Types.TXT{strings: wrap_list(strings)}}
defp decode_rdata(:mx, %{"preference" => pref, "exchange" => exch}),
do: {:ok, %Types.MX{preference: pref, exchange: HttpCodec.name_from_string(exch)}}
defp decode_rdata(:srv, %{
"priority" => prio,
"weight" => weight,
"port" => port,
"target" => target
}),
do:
{:ok,
%Types.SRV{
priority: prio,
weight: weight,
port: port,
target: HttpCodec.name_from_string(target)
}}
defp decode_rdata(:caa, %{"flags" => flags, "tag" => tag, "value" => value}),
do: {:ok, %Types.CAA{flags: flags, tag: tag, value: value}}
defp decode_rdata(
:soa,
%{
"mname" => mname,
"rname" => rname,
"serial" => serial,
"refresh" => refresh,
"retry" => retry,
"expire" => expire,
"minimum" => minimum
}
) do
{:ok,
%Types.SOA{
mname: HttpCodec.name_from_string(mname),
rname: HttpCodec.name_from_string(rname),
serial: serial,
refresh: refresh,
retry: retry,
expire: expire,
minimum: minimum
}}
end
defp decode_rdata(_, _), do: {:error, "invalid_rdata"}
defp decode_a(address) when is_binary(address) do
case :inet.parse_address(to_charlist(address)) do
{:ok, {_, _, _, _} = tuple} -> {:ok, %Types.A{address: tuple}}
_ -> {:error, "invalid_ipv4"}
end
end
defp decode_a([a, b, c, d]), do: {:ok, %Types.A{address: {a, b, c, d}}}
defp decode_a(_), do: {:error, "invalid_ipv4"}
defp decode_aaaa(address) when is_binary(address) do
case :inet.parse_address(to_charlist(address)) do
{:ok, {_, _, _, _, _, _, _, _} = tuple} -> {:ok, %Types.AAAA{address: tuple}}
_ -> {:error, "invalid_ipv6"}
end
end
defp decode_aaaa([a, b, c, d, e, f, g, h]),
do: {:ok, %Types.AAAA{address: {a, b, c, d, e, f, g, h}}}
defp decode_aaaa(_), do: {:error, "invalid_ipv6"}
defp encode_type(%ResourceRecord{type: type}) when is_atom(type), do: Atom.to_string(type)
defp encode_type(%ResourceRecord{type: type}) when is_integer(type), do: type
defp encode_rdata(%Types.A{address: {a, b, c, d}}), do: %{"address" => "#{a}.#{b}.#{c}.#{d}"}
defp encode_rdata(%Types.AAAA{address: addr}), do: %{"address" => ip_to_string(addr)}
defp encode_rdata(%Types.CNAME{name: name}), do: %{"name" => HttpCodec.name_to_string(name)}
defp encode_rdata(%Types.NS{name: name}), do: %{"name" => HttpCodec.name_to_string(name)}
defp encode_rdata(%Types.TXT{strings: strings}), do: %{"strings" => strings}
defp encode_rdata(%Types.MX{preference: pref, exchange: exchange}),
do: %{"preference" => pref, "exchange" => HttpCodec.name_to_string(exchange)}
defp encode_rdata(%Types.SRV{priority: prio, weight: weight, port: port, target: target}) do
%{
"priority" => prio,
"weight" => weight,
"port" => port,
"target" => HttpCodec.name_to_string(target)
}
end
defp encode_rdata(%Types.CAA{flags: flags, tag: tag, value: value}),
do: %{"flags" => flags, "tag" => tag, "value" => value}
defp encode_rdata(%Types.SOA{} = soa) do
%{
"mname" => HttpCodec.name_to_string(soa.mname),
"rname" => HttpCodec.name_to_string(soa.rname),
"serial" => soa.serial,
"refresh" => soa.refresh,
"retry" => soa.retry,
"expire" => soa.expire,
"minimum" => soa.minimum
}
end
defp encode_rdata(bin) when is_binary(bin), do: %{"raw" => Base.encode64(bin)}
defp encode_rdata(_), do: %{}
defp wrap_list(list) when is_list(list), do: list
defp wrap_list(item), do: [item]
defp ip_to_string(tuple) do
tuple
|> Tuple.to_list()
|> Enum.map_join(":", &Integer.to_string/1)
end
end

133
lib/exdns/http/router.ex Normal file
View File

@ -0,0 +1,133 @@
defmodule Exdns.HttpRouter do
@moduledoc false
use Plug.Router
import Plug.Conn
alias Exdns.{HttpCodec, ZoneServer}
plug(:assign_auth)
plug(:match)
plug(Plug.Parsers, parsers: [:json], json_decoder: Jason)
plug(:authorize)
plug(:dispatch)
def init(opts), do: opts
def call(conn, opts) do
conn
|> assign(:auth_token, Keyword.fetch!(opts, :auth_token))
|> super(opts)
end
get "/zones" do
zones =
ZoneServer.list()
|> Enum.map(&HttpCodec.name_to_string/1)
send_json(conn, 200, %{"zones" => zones})
end
get "/zones/:name" do
name = HttpCodec.name_from_string(name)
case ZoneServer.get(name) do
{:ok, records} ->
send_json(conn, 200, %{"name" => name, "records" => HttpCodec.encode_records(records)})
:error ->
send_json(conn, 404, %{"error" => "not_found"})
end
end
post "/zones/:name" do
name = HttpCodec.name_from_string(name)
case HttpCodec.decode_records(conn.body_params, name) do
{:ok, records} ->
case ZoneServer.create_zone(name, records) do
:ok -> send_json(conn, 201, %{"status" => "created"})
{:error, :already_exists} -> send_json(conn, 409, %{"error" => "already_exists"})
:error -> send_json(conn, 500, %{"error" => "storage_error"})
end
{:error, reason} ->
send_json(conn, 400, %{"error" => reason})
end
end
put "/zones/:name" do
name = HttpCodec.name_from_string(name)
case HttpCodec.decode_records(conn.body_params, name) do
{:ok, records} ->
case ZoneServer.update_records(name, records) do
:ok -> send_json(conn, 200, %{"status" => "updated"})
{:error, :not_found} -> send_json(conn, 404, %{"error" => "not_found"})
:error -> send_json(conn, 500, %{"error" => "storage_error"})
end
{:error, reason} ->
send_json(conn, 400, %{"error" => reason})
end
end
delete "/zones/:name/records" do
name = HttpCodec.name_from_string(name)
case HttpCodec.decode_records(conn.body_params, name) do
{:ok, records} ->
case ZoneServer.delete_records(name, records) do
:ok -> send_json(conn, 200, %{"status" => "deleted"})
{:error, :not_found} -> send_json(conn, 404, %{"error" => "not_found"})
:error -> send_json(conn, 500, %{"error" => "storage_error"})
end
{:error, reason} ->
send_json(conn, 400, %{"error" => reason})
end
end
delete "/zones/:name" do
name = HttpCodec.name_from_string(name)
case ZoneServer.delete(name) do
:ok -> send_json(conn, 200, %{"status" => "deleted"})
:error -> send_json(conn, 500, %{"error" => "storage_error"})
end
end
match _ do
send_json(conn, 404, %{"error" => "not_found"})
end
defp assign_auth(conn, _opts), do: conn
defp authorize(conn, _opts) do
token = conn.assigns[:auth_token] || ""
header = get_req_header(conn, "authorization")
if authorized?(header, token) do
conn
else
conn
|> send_json(401, %{"error" => "unauthorized"})
|> halt()
end
end
defp authorized?([value], token) do
value == token or value == "Bearer " <> token
end
defp authorized?(_, _token), do: false
defp send_json(conn, status, body) do
json = Jason.encode!(body)
conn
|> put_resp_content_type("application/json")
|> send_resp(status, json)
end
end

View File

@ -0,0 +1,70 @@
defmodule Exdns.TcpListener do
@moduledoc """
TCP listener that receives DNS messages with 2-byte length prefix.
"""
use GenServer
require Logger
alias Exdns.{DnsPacket, DnsResponder}
@default_port 53
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(opts) do
port = Keyword.get(opts, :port, @default_port)
case :gen_tcp.listen(port, [:binary, active: false, reuseaddr: true]) do
{:ok, listen} ->
Logger.info("TCP listener started on port #{port}")
Task.start_link(fn -> accept_loop(listen) end)
{:ok, %{listen: listen, port: port}}
{:error, reason} ->
{:stop, reason}
end
end
defp accept_loop(listen) do
case :gen_tcp.accept(listen) do
{:ok, socket} ->
Task.start(fn -> handle_conn(socket) end)
accept_loop(listen)
{:error, _} ->
:ok
end
end
defp handle_conn(socket) do
case recv_message(socket) do
{:ok, data} ->
reply(socket, data)
handle_conn(socket)
{:error, _} ->
:gen_tcp.close(socket)
end
end
defp recv_message(socket) do
case :gen_tcp.recv(socket, 2) do
{:ok, <<len::16>>} -> :gen_tcp.recv(socket, len)
{:error, _} = error -> error
end
end
defp reply(socket, data) do
with {:ok, query} <- DnsPacket.from_binary(data),
{:ok, response} <- DnsResponder.build_response(query, nil, nil),
response_bin <- DnsPacket.to_binary(response) do
:gen_tcp.send(socket, <<byte_size(response_bin)::16, response_bin::binary>>)
else
{:error, _} -> :ok
end
end
end

View File

@ -0,0 +1,49 @@
defmodule Exdns.UdpListener do
@moduledoc """
UDP listener that receives raw DNS packets.
"""
use GenServer
require Logger
alias Exdns.{DnsPacket, DnsResponder}
@default_port 53
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(opts) do
port = Keyword.get(opts, :port, @default_port)
case :gen_udp.open(port, [:binary, active: true, reuseaddr: true]) do
{:ok, socket} ->
Logger.info("UDP listener started on port #{port}")
{:ok, %{socket: socket, port: port}}
{:error, reason} ->
{:stop, reason}
end
end
@impl true
def handle_info({:udp, socket, ip, port, data}, state) do
Logger.debug("UDP packet from #{format_ip(ip)}:#{port}, #{byte_size(data)} bytes")
with {:ok, query} <- DnsPacket.from_binary(data),
{:ok, response} <- DnsResponder.build_response(query, ip, port),
response_bin <- DnsPacket.to_binary(response) do
:gen_udp.send(socket, ip, port, response_bin)
else
{:error, reason} ->
Logger.debug("Failed to process DNS packet: #{inspect(reason)}")
end
{:noreply, state}
end
defp format_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}"
defp format_ip(ip), do: :inet.ntoa(ip) |> to_string()
end

147
lib/exdns/zone/rules.ex Normal file
View File

@ -0,0 +1,147 @@
defmodule Exdns.ZoneRules do
@moduledoc false
alias Exdns.DnsPacket.{ResourceRecord, RRType}
alias Exdns.DnsPacket.RRData.Types
def normalize_records(name, records, existing_records) do
serial = next_serial(existing_records)
records
|> ensure_soa(name, serial)
|> ensure_ns(name)
end
def find_authority(zone, labels) do
labels
|> suffixes()
|> Enum.reduce_while(:error, fn name, _ ->
records = Map.get(zone, name, [])
soa = filter_records(records, :soa)
ns = filter_records(records, :ns)
if soa != [] or ns != [] do
{:halt, {:ok, %{name: name, soa: soa, ns: ns}}}
else
{:cont, :error}
end
end)
end
def lookup_records(zone, name) do
name
|> suffixes()
|> Enum.reduce_while(:error, fn zone_name, _ -> lookup_in_zone(zone, name, zone_name) end)
end
def remove_records(records, to_remove) when is_list(records) and is_list(to_remove) do
remove_set = MapSet.new(to_remove)
Enum.reject(records, &MapSet.member?(remove_set, &1))
end
defp next_serial(records) do
case find_soa(records) do
%Types.SOA{serial: serial} when is_integer(serial) -> serial + 1
_ -> :os.system_time(:second)
end
end
defp ensure_soa(records, zone_name, serial) do
if Enum.any?(records, &soa_record?/1) do
Enum.map(records, fn
%ResourceRecord{rdata: %Types.SOA{} = soa} = rr ->
%ResourceRecord{rr | rdata: %Types.SOA{soa | serial: serial}}
rr ->
rr
end)
else
[default_soa(zone_name, serial) | records]
end
end
defp ensure_ns(records, zone_name) do
if Enum.any?(records, &ns_record?/1) do
records
else
[default_ns(zone_name) | records]
end
end
defp default_soa(zone_name, serial) do
%ResourceRecord{
name: zone_name,
type: :soa,
class: 1,
ttl: 3600,
rdata: %Types.SOA{
mname: default_ns_name(zone_name),
rname: default_rname(zone_name),
serial: serial,
refresh: 3600,
retry: 600,
expire: 1_209_600,
minimum: 300
}
}
end
defp default_ns(zone_name) do
%ResourceRecord{
name: zone_name,
type: :ns,
class: 1,
ttl: 3600,
rdata: %Types.NS{name: default_ns_name(zone_name)}
}
end
defp default_ns_name(zone_name), do: ["ns1" | zone_name]
defp default_rname(zone_name), do: ["hostmaster" | zone_name]
defp soa_record?(%ResourceRecord{rdata: %Types.SOA{}}), do: true
defp soa_record?(%ResourceRecord{} = rr), do: rr_type_atom(rr) == :soa
defp soa_record?(_), do: false
defp ns_record?(%ResourceRecord{rdata: %Types.NS{}}), do: true
defp ns_record?(%ResourceRecord{} = rr), do: rr_type_atom(rr) == :ns
defp ns_record?(_), do: false
defp find_soa(records) do
records
|> Enum.find(&soa_record?/1)
|> case do
%ResourceRecord{rdata: %Types.SOA{} = soa} -> soa
_ -> nil
end
end
defp filter_records(records, type) do
Enum.filter(records, fn rr -> rr_type_atom(rr) == type end)
end
defp pick_records(records, name, zone_name) do
direct = Enum.filter(records, fn rr -> rr.name == name end)
if direct != [] do
{:ok, direct}
else
wildcard = Enum.filter(records, fn rr -> rr.name == ["*" | zone_name] end)
if wildcard != [], do: {:ok, wildcard}, else: :error
end
end
defp lookup_in_zone(zone, name, zone_name) do
case Map.fetch(zone, zone_name) do
{:ok, records} ->
case pick_records(records, name, zone_name) do
{:ok, _} = ok -> {:halt, ok}
:error -> {:halt, :error}
end
:error ->
{:cont, :error}
end
end
defp rr_type_atom(%ResourceRecord{type: type}) when is_atom(type), do: type
defp rr_type_atom(%ResourceRecord{type: type}) when is_integer(type), do: RRType.from_code(type)
defp rr_type_atom(%ResourceRecord{rdata: %Types.A{}}), do: :a
defp rr_type_atom(%ResourceRecord{rdata: %Types.AAAA{}}), do: :aaaa
defp rr_type_atom(%ResourceRecord{rdata: %Types.CNAME{}}), do: :cname
defp rr_type_atom(%ResourceRecord{rdata: %Types.NS{}}), do: :ns
defp rr_type_atom(%ResourceRecord{rdata: %Types.TXT{}}), do: :txt
defp rr_type_atom(%ResourceRecord{rdata: %Types.MX{}}), do: :mx
defp rr_type_atom(%ResourceRecord{rdata: %Types.SRV{}}), do: :srv
defp rr_type_atom(%ResourceRecord{rdata: %Types.CAA{}}), do: :caa
defp rr_type_atom(%ResourceRecord{rdata: %Types.SOA{}}), do: :soa
defp rr_type_atom(_), do: nil
defp suffixes(labels) when is_list(labels) do
Enum.with_index(labels) |> Enum.map(fn {_item, index} -> Enum.drop(labels, index) end)
end
end

159
lib/exdns/zone/server.ex Normal file
View File

@ -0,0 +1,159 @@
defmodule Exdns.ZoneServer do
@moduledoc false
use GenServer
alias Exdns.{ZoneRules, ZoneStorage}
alias Exdns.ZoneServer.Loader
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
def get(name, server \\ __MODULE__) do
GenServer.call(server, {:get, normalize_name(name)})
end
def put(name, records, server \\ __MODULE__) do
GenServer.call(server, {:put, normalize_name(name), records})
end
def create_zone(name, records, server \\ __MODULE__) do
GenServer.call(server, {:create, normalize_name(name), records})
end
def update_records(name, records, server \\ __MODULE__) do
GenServer.call(server, {:update, normalize_name(name), records})
end
def delete_records(name, records, server \\ __MODULE__) do
GenServer.call(server, {:delete_records, normalize_name(name), records})
end
def delete(name, server \\ __MODULE__) do
GenServer.call(server, {:delete, normalize_name(name)})
end
def list(server \\ __MODULE__) do
GenServer.call(server, :list)
end
def authority(name, server \\ __MODULE__) do
GenServer.call(server, {:authority, normalize_name(name)})
end
@impl true
def init(opts) do
zone = Keyword.get(opts, :zone, %{})
{:ok, %{zone: zone}}
end
@impl true
def handle_call({:get, name}, _from, state) do
case ZoneRules.lookup_records(state.zone, name) do
{:ok, records} ->
{:reply, {:ok, records}, state}
:error ->
case ZoneStorage.read(name) do
{:ok, records} ->
zone = Map.put(state.zone, name, records)
{:reply, {:ok, records}, %{state | zone: zone}}
{:error, _} ->
{:reply, :error, state}
end
end
end
@impl true
def handle_call({:put, name, records}, _from, state) when is_list(records) do
updated = ZoneRules.normalize_records(name, records, Map.get(state.zone, name, []))
case ZoneStorage.write(name, updated) do
:ok -> {:reply, :ok, %{state | zone: Map.put(state.zone, name, updated)}}
{:error, _} -> {:reply, :error, state}
end
end
def handle_call({:create, name, records}, _from, state) when is_list(records) do
if ZoneStorage.exists?(name) do
{:reply, {:error, :already_exists}, state}
else
updated = ZoneRules.normalize_records(name, records, [])
case ZoneStorage.write(name, updated) do
:ok -> {:reply, :ok, %{state | zone: Map.put(state.zone, name, updated)}}
{:error, _} -> {:reply, :error, state}
end
end
end
def handle_call({:update, name, records}, _from, state) when is_list(records) do
case Loader.load_zone(state, name) do
{:ok, existing, next_state} ->
updated = ZoneRules.normalize_records(name, records, existing)
case ZoneStorage.write(name, updated) do
:ok -> {:reply, :ok, %{next_state | zone: Map.put(next_state.zone, name, updated)}}
{:error, _} -> {:reply, :error, next_state}
end
:error ->
{:reply, {:error, :not_found}, state}
end
end
def handle_call({:delete_records, name, records}, _from, state) when is_list(records) do
case Loader.load_zone(state, name) do
{:ok, existing, next_state} ->
removed = ZoneRules.remove_records(existing, records)
updated = ZoneRules.normalize_records(name, removed, existing)
case ZoneStorage.write(name, updated) do
:ok -> {:reply, :ok, %{next_state | zone: Map.put(next_state.zone, name, updated)}}
{:error, _} -> {:reply, :error, next_state}
end
:error ->
{:reply, {:error, :not_found}, state}
end
end
def handle_call({:delete, name}, _from, state) do
case ZoneStorage.delete(name) do
:ok -> {:reply, :ok, %{state | zone: Map.delete(state.zone, name)}}
{:error, _} -> {:reply, :error, state}
end
end
def handle_call(:list, _from, state) do
names = ZoneStorage.list_names()
{:reply, Enum.uniq(names ++ Map.keys(state.zone)), state}
end
def handle_call({:authority, name}, _from, state) do
case ZoneRules.find_authority(state.zone, name) do
{:ok, _} = ok ->
{:reply, ok, state}
:error ->
{zone, loaded} = Loader.load_suffixes(state.zone, name)
if loaded do
{:reply, ZoneRules.find_authority(zone, name), %{state | zone: zone}}
else
{:reply, :error, state}
end
end
end
defp normalize_name(name) when is_binary(name) do
name
|> String.trim(".")
|> String.split(".", trim: true)
end
defp normalize_name(name) when is_list(name), do: name
end

View File

@ -0,0 +1,47 @@
defmodule Exdns.ZoneServer.Loader do
@moduledoc false
alias Exdns.ZoneStorage
def load_zone(state, name) do
case Map.fetch(state.zone, name) do
{:ok, records} ->
{:ok, records, state}
:error ->
case ZoneStorage.read(name) do
{:ok, records} ->
zone = Map.put(state.zone, name, records)
{:ok, records, %{state | zone: zone}}
_ ->
:error
end
end
end
def load_suffixes(zone, labels) do
labels
|> suffixes()
|> Enum.reduce({zone, false}, fn name, {acc, loaded} ->
maybe_load_zone(acc, loaded, name)
end)
end
defp maybe_load_zone(zone, loaded, name) do
if Map.has_key?(zone, name) do
{zone, loaded}
else
case ZoneStorage.read(name) do
{:ok, records} -> {Map.put(zone, name, records), true}
_ -> {zone, loaded}
end
end
end
defp suffixes(labels) when is_list(labels) do
labels
|> Enum.with_index()
|> Enum.map(fn {_item, index} -> Enum.drop(labels, index) end)
end
end

89
lib/exdns/zone/storage.ex Normal file
View File

@ -0,0 +1,89 @@
defmodule Exdns.ZoneStorage do
@moduledoc false
alias Exdns.HttpCodec
def read(name) do
with {:ok, path} <- zone_path(name),
{:ok, content} <- File.read(path),
{:ok, data} <- Jason.decode(content),
{:ok, records} <- HttpCodec.decode_records(data, name) do
{:ok, records}
else
{:error, _} = error -> error
end
end
def write(name, records) when is_list(records) do
payload = %{"records" => HttpCodec.encode_records(records)}
with {:ok, path} <- zone_path(name),
:ok <- File.mkdir_p(Path.dirname(path)),
{:ok, json} <- Jason.encode(payload),
:ok <- File.write(path, json) do
:ok
else
{:error, _} = error -> error
end
end
def delete(name) do
with {:ok, path} <- zone_path(name) do
case File.rm(path) do
:ok -> :ok
{:error, :enoent} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
def list_names do
zones_dir()
|> File.ls()
|> case do
{:ok, entries} -> Enum.flat_map(entries, &name_from_filename/1)
{:error, _} -> []
end
end
def exists?(name) do
case zone_path(name) do
{:ok, path} -> File.exists?(path)
_ -> false
end
end
defp zone_path(name) when is_list(name) do
filename = filename_for_name(name)
{:ok, Path.join(zones_dir(), filename)}
end
defp zone_path(_), do: {:error, :invalid_name}
defp zones_dir do
:exdns
|> Application.fetch_env!(:zones_dir)
|> Path.expand(File.cwd!())
end
defp filename_for_name(labels) do
Enum.join(labels, ".") <> ".json"
end
defp name_from_filename(filename) do
if String.ends_with?(filename, ".json") do
labels =
filename
|> String.trim_trailing(".json")
|> String.split(".", trim: true)
if labels == [] do
[]
else
[labels]
end
else
[]
end
end
end

41
mix.exs Normal file
View File

@ -0,0 +1,41 @@
defmodule Exdns.MixProject do
use Mix.Project
def project do
[
app: :exdns,
version: "0.1.0",
elixir: "~> 1.18",
start_permanent: Mix.env() == :prod,
deps: deps(),
releases: releases()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger],
mod: {Exdns.Application, []}
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:jason, "~> 1.4"},
{:plug, "~> 1.15"},
{:bandit, "~> 1.5"}
]
end
defp releases do
[
exdns: [
include_executables_for: [:windows]
]
]
end
end

16
mix.lock Normal file
View File

@ -0,0 +1,16 @@
%{
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
}

578
openapi.json Normal file
View File

@ -0,0 +1,578 @@
{
"openapi": "3.0.3",
"info": {
"title": "Exdns Zone API",
"version": "0.1.0"
},
"servers": [
{
"url": "http://localhost:8080"
}
],
"components": {
"securitySchemes": {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "token"
}
},
"schemas": {
"ZoneList": {
"type": "object",
"properties": {
"zones": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["zones"]
},
"Zone": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"records": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Record"
}
}
},
"required": ["name", "records"]
},
"ZoneWrite": {
"type": "object",
"properties": {
"records": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Record"
}
}
},
"required": ["records"]
},
"Record": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Fully qualified name; defaults to zone name when omitted. Use @ for zone apex, * for wildcard."
},
"type": {
"type": "string",
"enum": ["a", "aaaa", "cname", "txt", "mx", "srv", "caa", "soa", "ns"]
},
"class": {
"type": "integer",
"default": 1
},
"ttl": {
"type": "integer",
"default": 0
},
"rdata": {
"$ref": "#/components/schemas/RData"
}
},
"required": ["type", "rdata"]
},
"RData": {
"oneOf": [
{
"title": "A",
"type": "object",
"properties": {
"address": {
"type": "string",
"example": "1.2.3.4"
}
},
"required": ["address"]
},
{
"title": "AAAA",
"type": "object",
"properties": {
"address": {
"type": "string",
"example": "2001:db8::1"
}
},
"required": ["address"]
},
{
"title": "CNAME",
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
},
{
"title": "NS",
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
},
{
"title": "TXT",
"type": "object",
"properties": {
"strings": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["strings"]
},
{
"title": "MX",
"type": "object",
"properties": {
"preference": {
"type": "integer"
},
"exchange": {
"type": "string"
}
},
"required": ["preference", "exchange"]
},
{
"title": "SRV",
"type": "object",
"properties": {
"priority": {
"type": "integer"
},
"weight": {
"type": "integer"
},
"port": {
"type": "integer"
},
"target": {
"type": "string"
}
},
"required": ["priority", "weight", "port", "target"]
},
{
"title": "CAA",
"type": "object",
"properties": {
"flags": {
"type": "integer"
},
"tag": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": ["flags", "tag", "value"]
},
{
"title": "SOA",
"type": "object",
"properties": {
"mname": {
"type": "string"
},
"rname": {
"type": "string"
},
"serial": {
"type": "integer"
},
"refresh": {
"type": "integer"
},
"retry": {
"type": "integer"
},
"expire": {
"type": "integer"
},
"minimum": {
"type": "integer"
}
},
"required": ["mname", "rname", "serial", "refresh", "retry", "expire", "minimum"]
}
]
},
"Error": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
},
"required": ["error"]
}
}
},
"security": [
{
"BearerAuth": []
}
],
"paths": {
"/zones": {
"get": {
"summary": "List zones",
"responses": {
"200": {
"description": "List of zones",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ZoneList"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/zones/{name}": {
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"summary": "Get zone",
"responses": {
"200": {
"description": "Zone details",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Zone"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"post": {
"summary": "Create zone",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ZoneWrite"
}
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
},
"required": ["status"]
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Already exists",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Storage error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"put": {
"summary": "Update zone",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ZoneWrite"
}
}
}
},
"responses": {
"200": {
"description": "Updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
},
"required": ["status"]
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Storage error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"delete": {
"summary": "Delete zone",
"responses": {
"200": {
"description": "Deleted",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
},
"required": ["status"]
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Storage error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/zones/{name}/records": {
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"delete": {
"summary": "Delete records",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ZoneWrite"
}
}
}
},
"responses": {
"200": {
"description": "Deleted",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
},
"required": ["status"]
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Storage error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}

65
test/dns/packet_test.exs Normal file
View File

@ -0,0 +1,65 @@
defmodule Exdns.DnsPacketTest do
use ExUnit.Case, async: true
alias Exdns.DnsPacket.Header
alias Exdns.DnsPacket.Name
alias Exdns.DnsPacket.RCode
alias Exdns.DnsPacket.ResourceRecord
alias Exdns.DnsPacket.RRData
alias Exdns.DnsPacket.RRData.Types
alias Exdns.DnsPacket.RRType
test "header encode/decode round trip" do
header = %Header{
id: 0x1234,
qr: 1,
opcode: 0,
aa: 1,
tc: 0,
rd: 1,
ra: 0,
z: 0,
ad: 0,
cd: 0,
rcode: 3,
qdcount: 1,
ancount: 2,
nscount: 0,
arcount: 1
}
bin = Header.encode(header)
assert {:ok, {decoded, 12}} = Header.decode(bin, 0)
assert decoded == header
end
test "name encode/decode" do
bin = Name.encode("example.com")
assert {:ok, {labels, _}} = Name.decode(bin, 0)
assert labels == ["example", "com"]
end
test "resource record A round trip" do
rr = %ResourceRecord{
name: ["example", "com"],
type: 1,
class: 1,
ttl: 60,
rdata: %Types.A{address: {1, 2, 3, 4}}
}
bin = IO.iodata_to_binary(ResourceRecord.encode(rr))
assert {:ok, {decoded, _}} = ResourceRecord.decode(bin, 0)
assert decoded.name == rr.name
assert decoded.type == 1
assert decoded.rdata == rr.rdata
end
test "rcode and rrtype mappings" do
assert RRType.to_code(:a) == 1
assert RRType.from_code(1) == :a
assert RCode.to_code(:nxdomain) == 3
assert RCode.from_code(3) == :nxdomain
assert {:ok, {:ns, _}} = RRData.encode(%Types.NS{name: ["ns1", "example", "com"]})
end
end

View File

@ -0,0 +1,53 @@
defmodule Exdns.DnsPacket.RealRequestsTest do
use ExUnit.Case, async: true
alias Exdns.DnsPacket
alias Exdns.DnsPacket.{Opt, Option, ResourceRecord}
@requests [
%{
hex:
"C94E012000010000000000010568656C6C6F036E65740000010001000029100000000000000C000A00081A609B453CE69B6B",
id: 0xC94E,
qname: ["hello", "net"],
cookie: <<0x1A, 0x60, 0x9B, 0x45, 0x3C, 0xE6, 0x9B, 0x6B>>
},
%{
hex:
"D6C8012000010000000000010568656C6C6F036E65740000010001000029100000000000000C000A0008694CEB430739333F",
id: 0xD6C8,
qname: ["hello", "net"],
cookie: <<0x69, 0x4C, 0xEB, 0x43, 0x07, 0x39, 0x33, 0x3F>>
},
%{
hex:
"2EA101200001000000000001047465737403636F6D0000010001000029100000000000000C000A0008ED25CEE19C6C55AE",
id: 0x2EA1,
qname: ["test", "com"],
cookie: <<0xED, 0x25, 0xCE, 0xE1, 0x9C, 0x6C, 0x55, 0xAE>>
}
]
test "real DNS queries deserialize correctly" do
Enum.each(@requests, fn %{hex: hex, id: id, qname: qname, cookie: cookie} ->
binary = Base.decode16!(hex, case: :mixed)
assert {:ok, packet} = DnsPacket.from_binary(binary)
assert packet.header.id == id
assert packet.header.qdcount == 1
assert packet.header.ancount == 0
assert packet.header.nscount == 0
assert packet.header.arcount == 1
assert [%{qname: ^qname, qtype: 1, qclass: 1}] = packet.questions
assert [%ResourceRecord{rdata: %Opt{} = opt}] = packet.additionals
assert opt.udp_payload_size == 4096
assert opt.ext_rcode == 0
assert opt.version == 0
assert opt.flags == 0
assert [%Option{code: 10, data: ^cookie}] = opt.options
end)
end
end

9
test/exdns_app_test.exs Normal file
View File

@ -0,0 +1,9 @@
defmodule Exdns.ApplicationTest do
use ExUnit.Case, async: false
test "application starts supervisor tree" do
pid = Process.whereis(Exdns.Supervisor)
assert is_pid(pid)
assert Process.alive?(pid)
end
end

53
test/http/codec_test.exs Normal file
View File

@ -0,0 +1,53 @@
defmodule Exdns.HttpCodecTest do
use ExUnit.Case, async: true
alias Exdns.DnsPacket.ResourceRecord
alias Exdns.DnsPacket.RRData.Types
alias Exdns.HttpCodec
test "decode_records uses zone name when record name is missing" do
zone = ["example", "com"]
payload = %{
"records" => [
%{"type" => "A", "rdata" => %{"address" => "1.2.3.4"}}
]
}
assert {:ok, [rr]} = HttpCodec.decode_records(payload, zone)
assert rr.name == zone
assert rr.rdata == %Types.A{address: {1, 2, 3, 4}}
end
test "decode_records handles @ and *" do
zone = ["example", "com"]
payload = %{
"records" => [
%{"name" => "@", "type" => "A", "rdata" => %{"address" => "1.2.3.4"}},
%{"name" => "*", "type" => "A", "rdata" => %{"address" => "5.6.7.8"}}
]
}
assert {:ok, records} = HttpCodec.decode_records(payload, zone)
assert Enum.at(records, 0).name == zone
assert Enum.at(records, 1).name == ["*" | zone]
end
test "encode_records returns string names" do
records = [
%ResourceRecord{
name: ["www", "example", "com"],
type: :txt,
class: 1,
ttl: 0,
rdata: %Types.TXT{strings: ["hello"]}
}
]
[encoded] = HttpCodec.encode_records(records)
assert encoded["name"] == "www.example.com"
assert encoded["type"] == "txt"
assert encoded["rdata"]["strings"] == ["hello"]
end
end

95
test/http/router_test.exs Normal file
View File

@ -0,0 +1,95 @@
defmodule Exdns.HttpRouterTest do
use ExUnit.Case, async: false
import Plug.Conn
import Plug.Test
alias Exdns.HttpRouter
alias Exdns.ZoneServer
setup do
unless Process.whereis(ZoneServer) do
start_supervised!({ZoneServer, []})
end
tmp_dir =
Path.join(System.tmp_dir!(), "exdns_http_zones_#{System.unique_integer([:positive])}")
Application.put_env(:exdns, :zones_dir, tmp_dir)
on_exit(fn -> File.rm_rf(tmp_dir) end)
:ok
end
test "rejects unauthorized requests" do
conn = conn(:get, "/zones")
conn = HttpRouter.call(conn, auth_token: "secret")
assert conn.status == 401
end
test "creates and fetches zone with valid token" do
body =
Jason.encode!(%{
"records" => [
%{"type" => "A", "rdata" => %{"address" => "1.2.3.4"}}
]
})
conn =
conn(:post, "/zones/example.com", body)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer secret")
conn = HttpRouter.call(conn, auth_token: "secret")
assert conn.status == 201
conn =
conn(:get, "/zones/example.com")
|> put_req_header("authorization", "Bearer secret")
conn = HttpRouter.call(conn, auth_token: "secret")
assert conn.status == 200
{:ok, data} = Jason.decode(conn.resp_body)
assert %{"records" => [_ | _]} = data
ZoneServer.delete(["example", "com"])
end
test "deletes records from zone" do
body =
Jason.encode!(%{
"records" => [
%{"type" => "A", "rdata" => %{"address" => "1.2.3.4"}}
]
})
conn =
conn(:post, "/zones/example.com", body)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer secret")
conn = HttpRouter.call(conn, auth_token: "secret")
assert conn.status == 201
conn =
conn(:delete, "/zones/example.com/records", body)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer secret")
conn = HttpRouter.call(conn, auth_token: "secret")
assert conn.status == 200
conn =
conn(:get, "/zones/example.com")
|> put_req_header("authorization", "Bearer secret")
conn = HttpRouter.call(conn, auth_token: "secret")
{:ok, data} = Jason.decode(conn.resp_body)
assert Enum.all?(data["records"], fn record ->
record["type"] != "a" or record["rdata"]["address"] != "1.2.3.4"
end)
ZoneServer.delete(["example", "com"])
end
end

1
test/test_helper.exs Normal file
View File

@ -0,0 +1 @@
ExUnit.start()

View File

@ -0,0 +1,12 @@
defmodule Exdns.UdpListenerTest do
use ExUnit.Case, async: true
alias Exdns.UdpListener
test "invalid packet does not crash listener" do
state = %{socket: :socket, port: 5353}
assert {:noreply, ^state} =
UdpListener.handle_info({:udp, :socket, {127, 0, 0, 1}, 5353, <<1, 2, 3>>}, state)
end
end

View File

@ -0,0 +1,39 @@
defmodule Exdns.ZoneRulesTest do
use ExUnit.Case, async: true
alias Exdns.DnsPacket.ResourceRecord
alias Exdns.DnsPacket.RRData.Types
alias Exdns.ZoneRules
test "find_authority returns NS or SOA from nearest suffix" do
zone_name = ["example", "com"]
ns = %ResourceRecord{
name: zone_name,
type: :ns,
class: 1,
ttl: 60,
rdata: %Types.NS{name: ["ns1" | zone_name]}
}
soa = %ResourceRecord{
name: zone_name,
type: :soa,
class: 1,
ttl: 60,
rdata: %Types.SOA{
mname: ["ns1" | zone_name],
rname: ["hostmaster" | zone_name],
serial: 1,
refresh: 1,
retry: 1,
expire: 1,
minimum: 1
}
}
zones = %{zone_name => [ns, soa]}
assert {:ok, %{ns: [_], soa: [_]}} = ZoneRules.find_authority(zones, ["www" | zone_name])
end
end

View File

@ -0,0 +1,94 @@
defmodule Exdns.ZoneServerTest do
use ExUnit.Case, async: false
alias Exdns.DnsPacket.{ResourceRecord, RRData.Types}
alias Exdns.ZoneServer
setup do
unless Process.whereis(ZoneServer) do
start_supervised!({ZoneServer, []})
end
tmp_dir = Path.join(System.tmp_dir!(), "exdns_zones_#{System.unique_integer([:positive])}")
Application.put_env(:exdns, :zones_dir, tmp_dir)
on_exit(fn -> File.rm_rf(tmp_dir) end)
:ok
end
test "put adds SOA and NS and increments serial" do
name = ["example", "com"]
assert :ok = ZoneServer.put(name, [])
assert {:ok, records} = ZoneServer.get(name)
soa = Enum.find(records, fn rr -> match?(%{rdata: %Types.SOA{}}, rr) end)
ns = Enum.find(records, fn rr -> match?(%{rdata: %Types.NS{}}, rr) end)
assert %Types.SOA{serial: serial1} = soa.rdata
assert %Types.NS{} = ns.rdata
assert :ok = ZoneServer.put(name, [])
assert {:ok, records} = ZoneServer.get(name)
soa2 = Enum.find(records, fn rr -> match?(%{rdata: %Types.SOA{}}, rr) end)
assert %Types.SOA{serial: serial2} = soa2.rdata
assert serial2 > serial1
ZoneServer.delete(name)
end
test "list and delete work" do
name = ["example", "com"]
assert :ok = ZoneServer.put(name, [])
assert name in ZoneServer.list()
assert :ok = ZoneServer.delete(name)
assert :error = ZoneServer.get(name)
end
test "create_zone and update_records enforce existence" do
name = ["example", "com"]
assert {:error, :not_found} = ZoneServer.update_records(name, [])
assert :ok = ZoneServer.create_zone(name, [])
assert {:error, :already_exists} = ZoneServer.create_zone(name, [])
assert :ok = ZoneServer.update_records(name, [])
ZoneServer.delete(name)
end
test "delete_records removes entries and keeps SOA and NS" do
name = ["example", "com"]
record = %ResourceRecord{
name: name,
type: :a,
class: 1,
ttl: 60,
rdata: %Types.A{address: {1, 2, 3, 4}}
}
assert :ok = ZoneServer.create_zone(name, [record])
assert :ok = ZoneServer.delete_records(name, [record])
assert {:ok, records} = ZoneServer.get(name)
refute Enum.any?(records, fn rr -> rr.rdata == record.rdata end)
assert Enum.any?(records, fn rr -> match?(%{rdata: %Types.SOA{}}, rr) end)
assert Enum.any?(records, fn rr -> match?(%{rdata: %Types.NS{}}, rr) end)
ZoneServer.delete(name)
end
test "wildcard lookup returns records for subdomain" do
name = ["example", "com"]
target = ["www", "example", "com"]
record = %ResourceRecord{
name: ["*" | name],
type: :a,
class: 1,
ttl: 0,
rdata: %Types.A{address: {10, 0, 0, 1}}
}
assert :ok = ZoneServer.create_zone(name, [record])
assert {:ok, _} = ZoneServer.get(target)
ZoneServer.delete(name)
end
end