From d877bae39865803c9651e1cc5b4ad4516e9f1fa2 Mon Sep 17 00:00:00 2001 From: Ivan Zinchenko Date: Tue, 13 Jan 2026 18:26:26 +0300 Subject: [PATCH] Git init --- .formatter.exs | 4 + .gitignore | 25 + README.md | 21 + config/config.exs | 11 + config/dev.exs | 1 + config/prod.exs | 1 + config/runtime.exs | 11 + config/test.exs | 9 + lib/exdns/application.ex | 29 ++ lib/exdns/dns/packet.ex | 104 +++++ lib/exdns/dns/responder.ex | 136 ++++++ lib/exdns/dns_packet/binary.ex | 32 ++ lib/exdns/dns_packet/header.ex | 77 ++++ lib/exdns/dns_packet/name.ex | 62 +++ lib/exdns/dns_packet/opt.ex | 39 ++ lib/exdns/dns_packet/option.ex | 32 ++ lib/exdns/dns_packet/question.ex | 36 ++ lib/exdns/dns_packet/rcode.ex | 20 + lib/exdns/dns_packet/resource_record.ex | 72 +++ lib/exdns/dns_packet/rr_data.ex | 8 + lib/exdns/dns_packet/rr_data/decode.ex | 128 ++++++ lib/exdns/dns_packet/rr_data/encode.ex | 78 ++++ lib/exdns/dns_packet/rr_data/types.ex | 54 +++ lib/exdns/dns_packet/rr_type.ex | 23 + lib/exdns/http/codec.ex | 31 ++ lib/exdns/http/codec/record.ex | 204 +++++++++ lib/exdns/http/router.ex | 133 ++++++ lib/exdns/transport/tcp_listener.ex | 70 +++ lib/exdns/transport/udp_listener.ex | 49 ++ lib/exdns/zone/rules.ex | 147 ++++++ lib/exdns/zone/server.ex | 159 +++++++ lib/exdns/zone/server/loader.ex | 47 ++ lib/exdns/zone/storage.ex | 89 ++++ mix.exs | 41 ++ mix.lock | 16 + openapi.json | 578 ++++++++++++++++++++++++ test/dns/packet_test.exs | 65 +++ test/dns/real_requests_test.exs | 53 +++ test/exdns_app_test.exs | 9 + test/http/codec_test.exs | 53 +++ test/http/router_test.exs | 95 ++++ test/test_helper.exs | 1 + test/udp/udp_listener_test.exs | 12 + test/zone/zone_rules_test.exs | 39 ++ test/zone/zone_server_test.exs | 94 ++++ 45 files changed, 2998 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/runtime.exs create mode 100644 config/test.exs create mode 100644 lib/exdns/application.ex create mode 100644 lib/exdns/dns/packet.ex create mode 100644 lib/exdns/dns/responder.ex create mode 100644 lib/exdns/dns_packet/binary.ex create mode 100644 lib/exdns/dns_packet/header.ex create mode 100644 lib/exdns/dns_packet/name.ex create mode 100644 lib/exdns/dns_packet/opt.ex create mode 100644 lib/exdns/dns_packet/option.ex create mode 100644 lib/exdns/dns_packet/question.ex create mode 100644 lib/exdns/dns_packet/rcode.ex create mode 100644 lib/exdns/dns_packet/resource_record.ex create mode 100644 lib/exdns/dns_packet/rr_data.ex create mode 100644 lib/exdns/dns_packet/rr_data/decode.ex create mode 100644 lib/exdns/dns_packet/rr_data/encode.ex create mode 100644 lib/exdns/dns_packet/rr_data/types.ex create mode 100644 lib/exdns/dns_packet/rr_type.ex create mode 100644 lib/exdns/http/codec.ex create mode 100644 lib/exdns/http/codec/record.ex create mode 100644 lib/exdns/http/router.ex create mode 100644 lib/exdns/transport/tcp_listener.ex create mode 100644 lib/exdns/transport/udp_listener.ex create mode 100644 lib/exdns/zone/rules.ex create mode 100644 lib/exdns/zone/server.ex create mode 100644 lib/exdns/zone/server/loader.ex create mode 100644 lib/exdns/zone/storage.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 openapi.json create mode 100644 test/dns/packet_test.exs create mode 100644 test/dns/real_requests_test.exs create mode 100644 test/exdns_app_test.exs create mode 100644 test/http/codec_test.exs create mode 100644 test/http/router_test.exs create mode 100644 test/test_helper.exs create mode 100644 test/udp/udp_listener_test.exs create mode 100644 test/zone/zone_rules_test.exs create mode 100644 test/zone/zone_server_test.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dfa10d --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c634273 --- /dev/null +++ b/README.md @@ -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 . + diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..dd1a126 --- /dev/null +++ b/config/config.exs @@ -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" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..becde76 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1 @@ +import Config diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..becde76 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1 @@ +import Config diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..e078189 --- /dev/null +++ b/config/runtime.exs @@ -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 diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..61511c1 --- /dev/null +++ b/config/test.exs @@ -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" diff --git a/lib/exdns/application.ex b/lib/exdns/application.ex new file mode 100644 index 0000000..48bb945 --- /dev/null +++ b/lib/exdns/application.ex @@ -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 diff --git a/lib/exdns/dns/packet.ex b/lib/exdns/dns/packet.ex new file mode 100644 index 0000000..7a9af94 --- /dev/null +++ b/lib/exdns/dns/packet.ex @@ -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 diff --git a/lib/exdns/dns/responder.ex b/lib/exdns/dns/responder.ex new file mode 100644 index 0000000..1e33fff --- /dev/null +++ b/lib/exdns/dns/responder.ex @@ -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: <>} = 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 diff --git a/lib/exdns/dns_packet/binary.ex b/lib/exdns/dns_packet/binary.ex new file mode 100644 index 0000000..ff9f9b6 --- /dev/null +++ b/lib/exdns/dns_packet/binary.ex @@ -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 diff --git a/lib/exdns/dns_packet/header.ex b/lib/exdns/dns_packet/header.ex new file mode 100644 index 0000000..898e9de --- /dev/null +++ b/lib/exdns/dns_packet/header.ex @@ -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>> -> + <> = + <> + + 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 diff --git a/lib/exdns/dns_packet/name.ex b/lib/exdns/dns_packet/name.ex new file mode 100644 index 0000000..8d06cf9 --- /dev/null +++ b/lib/exdns/dns_packet/name.ex @@ -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) + <> + 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>> = <> + + 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 diff --git a/lib/exdns/dns_packet/opt.ex b/lib/exdns/dns_packet/opt.ex new file mode 100644 index 0000000..d4065ef --- /dev/null +++ b/lib/exdns/dns_packet/opt.ex @@ -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 + end + + def decode(udp_payload_size, ttl, rdata_bin) do + <> = <> + + 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 diff --git a/lib/exdns/dns_packet/option.ex b/lib/exdns/dns_packet/option.ex new file mode 100644 index 0000000..a50ad23 --- /dev/null +++ b/lib/exdns/dns_packet/option.ex @@ -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 + <> + end + + def encode(%{code: code, data: data}) do + <> + end + + def decode_many(bin) do + decode_many(bin, []) + end + + defp decode_many(<<>>, acc), do: {:ok, Enum.reverse(acc)} + + defp decode_many(<>, acc) do + if byte_size(rest) < length do + {:error, :invalid_option} + else + <> = rest + option = %__MODULE__{code: code, data: data} + decode_many(tail, [option | acc]) + end + end +end diff --git a/lib/exdns/dns_packet/question.ex b/lib/exdns/dns_packet/question.ex new file mode 100644 index 0000000..bd62f88 --- /dev/null +++ b/lib/exdns/dns_packet/question.ex @@ -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), + <> + ] + 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 diff --git a/lib/exdns/dns_packet/rcode.ex b/lib/exdns/dns_packet/rcode.ex new file mode 100644 index 0000000..15f82b3 --- /dev/null +++ b/lib/exdns/dns_packet/rcode.ex @@ -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 diff --git a/lib/exdns/dns_packet/resource_record.ex b/lib/exdns/dns_packet/resource_record.ex new file mode 100644 index 0000000..f9f2432 --- /dev/null +++ b/lib/exdns/dns_packet/resource_record.ex @@ -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), + <>, + 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 diff --git a/lib/exdns/dns_packet/rr_data.ex b/lib/exdns/dns_packet/rr_data.ex new file mode 100644 index 0000000..44c0f6f --- /dev/null +++ b/lib/exdns/dns_packet/rr_data.ex @@ -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 diff --git a/lib/exdns/dns_packet/rr_data/decode.ex b/lib/exdns/dns_packet/rr_data/decode.ex new file mode 100644 index 0000000..d317e35 --- /dev/null +++ b/lib/exdns/dns_packet/rr_data/decode.ex @@ -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, {<>, _}} <- 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), + <> <- 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 diff --git a/lib/exdns/dns_packet/rr_data/encode.ex b/lib/exdns/dns_packet/rr_data/encode.ex new file mode 100644 index 0000000..2c07f4a --- /dev/null +++ b/lib/exdns/dns_packet/rr_data/encode.ex @@ -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, <>}} + + def encode(%AAAA{address: {a, b, c, d, e, f, g, h}}), + do: {:ok, {:aaaa, <>}} + + 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, <>}} + end + + def encode(%SRV{priority: prio, weight: weight, port: port, target: target}) do + {:ok, {:srv, <>}} + 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, <>}} + 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, [<> | acc]} + end + end) + |> case do + {:error, _} = error -> error + list -> {:ok, list |> Enum.reverse() |> IO.iodata_to_binary()} + end + end +end diff --git a/lib/exdns/dns_packet/rr_data/types.ex b/lib/exdns/dns_packet/rr_data/types.ex new file mode 100644 index 0000000..24c0724 --- /dev/null +++ b/lib/exdns/dns_packet/rr_data/types.ex @@ -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 diff --git a/lib/exdns/dns_packet/rr_type.ex b/lib/exdns/dns_packet/rr_type.ex new file mode 100644 index 0000000..00c1788 --- /dev/null +++ b/lib/exdns/dns_packet/rr_type.ex @@ -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 diff --git a/lib/exdns/http/codec.ex b/lib/exdns/http/codec.ex new file mode 100644 index 0000000..90baf3b --- /dev/null +++ b/lib/exdns/http/codec.ex @@ -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 diff --git a/lib/exdns/http/codec/record.ex b/lib/exdns/http/codec/record.ex new file mode 100644 index 0000000..2c6a11a --- /dev/null +++ b/lib/exdns/http/codec/record.ex @@ -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 diff --git a/lib/exdns/http/router.ex b/lib/exdns/http/router.ex new file mode 100644 index 0000000..0b71a7d --- /dev/null +++ b/lib/exdns/http/router.ex @@ -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 diff --git a/lib/exdns/transport/tcp_listener.ex b/lib/exdns/transport/tcp_listener.ex new file mode 100644 index 0000000..5aecf03 --- /dev/null +++ b/lib/exdns/transport/tcp_listener.ex @@ -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, <>} -> :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, <>) + else + {:error, _} -> :ok + end + end +end diff --git a/lib/exdns/transport/udp_listener.ex b/lib/exdns/transport/udp_listener.ex new file mode 100644 index 0000000..b83b8da --- /dev/null +++ b/lib/exdns/transport/udp_listener.ex @@ -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 diff --git a/lib/exdns/zone/rules.ex b/lib/exdns/zone/rules.ex new file mode 100644 index 0000000..01d14be --- /dev/null +++ b/lib/exdns/zone/rules.ex @@ -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 diff --git a/lib/exdns/zone/server.ex b/lib/exdns/zone/server.ex new file mode 100644 index 0000000..68a788d --- /dev/null +++ b/lib/exdns/zone/server.ex @@ -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 diff --git a/lib/exdns/zone/server/loader.ex b/lib/exdns/zone/server/loader.ex new file mode 100644 index 0000000..805bd5c --- /dev/null +++ b/lib/exdns/zone/server/loader.ex @@ -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 diff --git a/lib/exdns/zone/storage.ex b/lib/exdns/zone/storage.ex new file mode 100644 index 0000000..f206209 --- /dev/null +++ b/lib/exdns/zone/storage.ex @@ -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 diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..b108948 --- /dev/null +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..a02d66f --- /dev/null +++ b/mix.lock @@ -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"}, +} diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..f09fd92 --- /dev/null +++ b/openapi.json @@ -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" + } + } + } + } + } + } + } + } +} diff --git a/test/dns/packet_test.exs b/test/dns/packet_test.exs new file mode 100644 index 0000000..1cadb91 --- /dev/null +++ b/test/dns/packet_test.exs @@ -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 diff --git a/test/dns/real_requests_test.exs b/test/dns/real_requests_test.exs new file mode 100644 index 0000000..489c5a7 --- /dev/null +++ b/test/dns/real_requests_test.exs @@ -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 diff --git a/test/exdns_app_test.exs b/test/exdns_app_test.exs new file mode 100644 index 0000000..9d16e7d --- /dev/null +++ b/test/exdns_app_test.exs @@ -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 diff --git a/test/http/codec_test.exs b/test/http/codec_test.exs new file mode 100644 index 0000000..dcbd67d --- /dev/null +++ b/test/http/codec_test.exs @@ -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 diff --git a/test/http/router_test.exs b/test/http/router_test.exs new file mode 100644 index 0000000..ee55491 --- /dev/null +++ b/test/http/router_test.exs @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/test/udp/udp_listener_test.exs b/test/udp/udp_listener_test.exs new file mode 100644 index 0000000..34e2f73 --- /dev/null +++ b/test/udp/udp_listener_test.exs @@ -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 diff --git a/test/zone/zone_rules_test.exs b/test/zone/zone_rules_test.exs new file mode 100644 index 0000000..e44e729 --- /dev/null +++ b/test/zone/zone_rules_test.exs @@ -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 diff --git a/test/zone/zone_server_test.exs b/test/zone/zone_server_test.exs new file mode 100644 index 0000000..76c4af7 --- /dev/null +++ b/test/zone/zone_server_test.exs @@ -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