Git init
This commit is contained in:
commit
d877bae398
4
.formatter.exs
Normal file
4
.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
||||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
21
README.md
Normal 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
11
config/config.exs
Normal 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
1
config/dev.exs
Normal file
@ -0,0 +1 @@
|
||||
import Config
|
||||
1
config/prod.exs
Normal file
1
config/prod.exs
Normal file
@ -0,0 +1 @@
|
||||
import Config
|
||||
11
config/runtime.exs
Normal file
11
config/runtime.exs
Normal 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
9
config/test.exs
Normal 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
29
lib/exdns/application.ex
Normal 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
104
lib/exdns/dns/packet.ex
Normal 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
136
lib/exdns/dns/responder.ex
Normal 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
|
||||
32
lib/exdns/dns_packet/binary.ex
Normal file
32
lib/exdns/dns_packet/binary.ex
Normal 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
|
||||
77
lib/exdns/dns_packet/header.ex
Normal file
77
lib/exdns/dns_packet/header.ex
Normal 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
|
||||
62
lib/exdns/dns_packet/name.ex
Normal file
62
lib/exdns/dns_packet/name.ex
Normal 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
|
||||
39
lib/exdns/dns_packet/opt.ex
Normal file
39
lib/exdns/dns_packet/opt.ex
Normal 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
|
||||
32
lib/exdns/dns_packet/option.ex
Normal file
32
lib/exdns/dns_packet/option.ex
Normal 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
|
||||
36
lib/exdns/dns_packet/question.ex
Normal file
36
lib/exdns/dns_packet/question.ex
Normal 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
|
||||
20
lib/exdns/dns_packet/rcode.ex
Normal file
20
lib/exdns/dns_packet/rcode.ex
Normal 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
|
||||
72
lib/exdns/dns_packet/resource_record.ex
Normal file
72
lib/exdns/dns_packet/resource_record.ex
Normal 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
|
||||
8
lib/exdns/dns_packet/rr_data.ex
Normal file
8
lib/exdns/dns_packet/rr_data.ex
Normal 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
|
||||
128
lib/exdns/dns_packet/rr_data/decode.ex
Normal file
128
lib/exdns/dns_packet/rr_data/decode.ex
Normal 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
|
||||
78
lib/exdns/dns_packet/rr_data/encode.ex
Normal file
78
lib/exdns/dns_packet/rr_data/encode.ex
Normal 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
|
||||
54
lib/exdns/dns_packet/rr_data/types.ex
Normal file
54
lib/exdns/dns_packet/rr_data/types.ex
Normal 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
|
||||
23
lib/exdns/dns_packet/rr_type.ex
Normal file
23
lib/exdns/dns_packet/rr_type.ex
Normal 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
31
lib/exdns/http/codec.ex
Normal 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
|
||||
204
lib/exdns/http/codec/record.ex
Normal file
204
lib/exdns/http/codec/record.ex
Normal 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
133
lib/exdns/http/router.ex
Normal 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
|
||||
70
lib/exdns/transport/tcp_listener.ex
Normal file
70
lib/exdns/transport/tcp_listener.ex
Normal 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
|
||||
49
lib/exdns/transport/udp_listener.ex
Normal file
49
lib/exdns/transport/udp_listener.ex
Normal 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
147
lib/exdns/zone/rules.ex
Normal 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
159
lib/exdns/zone/server.ex
Normal 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
|
||||
47
lib/exdns/zone/server/loader.ex
Normal file
47
lib/exdns/zone/server/loader.ex
Normal 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
89
lib/exdns/zone/storage.ex
Normal 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
41
mix.exs
Normal 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
16
mix.lock
Normal 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
578
openapi.json
Normal 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
65
test/dns/packet_test.exs
Normal 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
|
||||
53
test/dns/real_requests_test.exs
Normal file
53
test/dns/real_requests_test.exs
Normal 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
9
test/exdns_app_test.exs
Normal 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
53
test/http/codec_test.exs
Normal 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
95
test/http/router_test.exs
Normal 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
1
test/test_helper.exs
Normal file
@ -0,0 +1 @@
|
||||
ExUnit.start()
|
||||
12
test/udp/udp_listener_test.exs
Normal file
12
test/udp/udp_listener_test.exs
Normal 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
|
||||
39
test/zone/zone_rules_test.exs
Normal file
39
test/zone/zone_rules_test.exs
Normal 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
|
||||
94
test/zone/zone_server_test.exs
Normal file
94
test/zone/zone_server_test.exs
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user