defmodule Spf.DNS do @moduledoc ~S""" A simple DNS caching resolver for SPF evaluations. During an SPF evaluation all DNS responses are cached. Since the cache lasts only for the duration of the evaluation, TTL values are ignored. The cache allows for reporting on DNS data acquired during the evaluation. By preloading the cache, using `Spf.DNS.load/2`, new records can be tested. The caching resolver also tracks the number of DNS queries made and the number of void queries seen. ## Example iex> zonedata = " ...> example.com TXT v=spf1 +all ...> " iex> ctx = Spf.Context.new("example.com", dns: zonedata) iex> {_ctx, result} = Spf.DNS.resolve(ctx, "example.com", type: :txt) iex> result {:ok, ["v=spf1 +all"]} """ import Spf.Context @ldh Enum.concat([?a..?z, [?-], ?0..?9, ?A..?Z]) @rrtypes %{ "a" => :a, "aaaa" => :aaaa, "cname" => :cname, "mx" => :mx, "ns" => :ns, "ptr" => :ptr, "soa" => :soa, "spf" => :spf, "txt" => :txt } @rgxtypes ~r/\s+(A|AAAA|CNAME|MX|NS|PTR|SOA|SPF|TXT)\s+/i @rrerrors %{ "formerr" => :formerr, "nxdomain" => :nxdomain, "servfail" => :servfail, "timeout" => :timeout, "zero_answers" => :zero_answers } @typedoc """ A DNS result in the form of an ok/error-tuple. In case of succes, this is a list of [`dns_data()`](https://www.erlang.org/doc/man/inet_res.html#type-dns_data) that is normalized (e.g. charlists are converted to strings as are ip address tuples). Interpretation by caller depends on the `rrtype` used. """ @type dns_result :: {:ok, [any]} | {:error, atom} @typedoc """ An opaque datastructure as returned by `:inet_res.resolve/3` as part of its response. Interpretation is done using the (erlang internal) `:inet_dns` functions. """ @type dns_msg :: any @typedoc """ An `rrtype` denoted by an atom. See also [`inet_res.rr_type`](https://www.erlang.org/doc/man/inet_res.html#type-rr_type). """ @type rrtype :: atom @typedoc """ A `domain` is a simply an ascii binary. """ @type domain :: binary @typedoc """ A dns result as returned by `:inet_res.resolve/3`. """ @type res_result :: {:ok, dns_msg} | {:error, atom} # see also: # - https://www.rfc-editor.org/rfc/rfc6895.html # - https://erlang.org/doc/man/inet_res.html # # local cache is map: {domain, type} -> dns_result() # API @doc """ Finds a domain `name`'s start of authority and contact. SPF evaluation might require evaluating multiple records of different domains. This function allows for reporting the owner and contact for each SPF record encountered. Returns - `{:ok, domain, authority, contact}`, or - `{:error, reason}` The given `name` does not need to actually exist, the aim is to find the owner of the zone the `name` belongs to. Note that CNAME's are ignored. This function should be used *after* evaluation has completed, since it may cause void DNS responses. The soa-record is searched by querying for the soa-record and dropping the front-label (possibly mulitple times) while trying again. ## Examples iex> Spf.Context.new("example.com") ...> |> Spf.DNS.authority("non-existing.example.com") {:ok, "non-existing.example.com", "example.com", "noc@dns.icann.org"} iex> zonedata = " ...> www.example.com CNAME example.org ...> " iex> Spf.Context.new("some.tld", dns: zonedata) ...> |> Spf.DNS.authority("www.example.com") {:ok, "www.example.com", "example.com", "noc@dns.icann.org"} """ @spec authority(Spf.Context.t(), binary) :: {:ok, domain, domain, binary} | {:error, atom} def authority(ctx, name) do labels = normalize(name) |> String.split(".", trim: true) # note: since a zone might be delegated, search needs to start with the # full domain and drops labels as search continues for the soa record for d <- 0..(length(labels) - 2) do Enum.drop(labels, d) |> Enum.join(".") end |> authorityp(ctx) |> case do {:ok, domain, contact} -> {:ok, name, domain, contact} {:error, reason} -> {:error, reason} end end @doc """ Checks validity of a domain name and returns `{:ok, name}` or `{:error, reason}` Given `domain` can be a binary or a charlist. It is normalized (downcase'd, trailing dot removed and, if applicable, charlist is converted to a binary) and checked that it: - is an ascii string - is less than 254 chars long - has labels that are 1..63 chars long - has no empty labels - has at least 2 labels - has a valid ldh-toplabel ## Examples iex> check_domain("com") {:error, "not multi-label"} iex> check_domain(".example.com") {:error, "empty label"} iex> check_domain("example..com") {:error, "empty label"} iex> check_domain(<<128>> <> ".com") {:error, "contains non-ascii characters"} iex> check_domain("example.-com") {:error, "tld starts with hyphen"} iex> check_domain("example.com-") {:error, "tld ends with hyphen"} iex> check_domain("example.c%m") {:error, "tld not ldh"} # trailing dot is dropped iex> check_domain("example.c0m.") {:ok, "example.c0m"} # returned as lowercase binary without the trailing dot iex> check_domain('example.COM.') {:ok, "example.com"} """ @spec check_domain(binary) :: {:ok, domain} | {:error, binary} def check_domain(domain) do domain = normalize(domain) lbs = String.split(domain, ".") tld = List.last(lbs) cond do String.length(domain) > 253 -> {:error, "domain name too long"} length(lbs) < 2 -> {:error, "not multi-label"} Enum.any?(lbs, fn l -> String.length(l) > 63 end) -> {:error, "label too long"} Enum.any?(lbs, fn l -> String.length(l) < 1 end) -> {:error, "empty label"} domain != for(<>, c < 128, into: "", do: <>) -> {:error, "contains non-ascii characters"} String.starts_with?(tld, "-") -> {:error, "tld starts with hyphen"} String.ends_with?(tld, "-") -> {:error, "tld ends with hyphen"} tld == for(<>, c in ?0..?9, into: "", do: <>) -> {:error, "tld all numeric"} tld != for(<>, c in @ldh, into: "", do: <>) -> {:error, "tld not ldh"} true -> {:ok, domain} end end @doc """ Returns an updated context, a normalized name and a cache result in a 3-tuple The cache result can be one of: - `{:error, reason}` - `{:ok, rrs}`, for a cache hit (where `rrs` is a list of rrdata's). Where `reason` includes: - `:cache_miss`, nothing found in the cache - `:nxdomain`, a previously cached result - `:servfail`, a previously cached result or a cname loop was found - `:timeout`, a previously cached result - `:zero_answers`, a previously cached result - `:illegal_name`, name was not a proper domain name Note that this function does not make any real DNS requests and does not update any dns counters. The only time the context is updated is when there was an error in either `domain` or the `t:rrtype/0` given. ## Example iex> zonedata = " ...> example.net CNAME example.com ...> EXAMPLE.COM. A 1.2.3.4 ...> " iex> {_ctx, result} = Spf.Context.new("some.domain.tld", dns: zonedata) ...> |> Spf.DNS.from_cache("example.net", :a) iex> result {:ok, ["1.2.3.4"]} """ @spec from_cache(Spf.Context.t(), domain, rrtype) :: {Spf.Context.t(), dns_result()} def from_cache(context, name, type) do with {:ok, name} <- check_domain(name), {context, {:ok, name}} <- cname(context, name, type) do case context.dns[{name, type}] do nil -> {context, {:error, :cache_miss}} [{:error, reason}] -> {context, {:error, reason}} result -> {context, {:ok, result}} end else {:error, reason} -> {log(context, :dns, :error, "#{reason}"), {:error, :illegal_name}} {context, {:error, reason}} -> {context, {:error, reason}} end end @doc """ Filters the `t:dns_result/0`, keeps only the rrdata's for which `fun` returns a truthy value. If the `dns_result` is actually an error, it is returned untouched. ## Examples iex> zonedata = " ...> example.com TXT v=spf1 -all ...> example.com TXT another txt record ...> " iex> ctx = Spf.Context.new("example.com", dns: zonedata) iex> {_ctx, dns_result} = resolve(ctx, "example.com", type: :txt) iex> iex> dns_result {:ok, ["another txt record", "v=spf1 -all"]} iex> iex> filter(dns_result, &Spf.Eval.spf?/1) {:ok, ["v=spf1 -all"]} iex> dns_result = {:error, :nxdomain} iex> Spf.DNS.filter(dns_result, &Spf.Eval.spf?/1) {:error, :nxdomain} """ @spec filter(dns_result(), function()) :: dns_result() def filter(dns_result, fun) def filter({:ok, rrdatas}, fun), do: filter(rrdatas, fun) def filter({:error, {reason, _dns_msg}}, _fun), # just in case the DNS result was directly supplied by inet_res.resolve # rather than Spf.DNS.resolve ... do: {:error, reason} def filter({:error, reason}, _fun), do: {:error, reason} def filter(rrdatas, fun) when is_function(fun, 1), do: {:ok, Enum.filter(rrdatas, fn rrdata -> fun.(rrdata) end)} @doc """ Populates the dns cache of given `context`, with `dns`'s zonedata. `dns` can be a path to an existing file, a multi-line binary containing individual RR-records per line or a list thereof. The cache is held in the SPF evaluation `context` under the `:dns` key and is a simple map: `{name, rrtype}` -> `[rdata]`. Lines should be formatted as - `name rrtype rdata`, or - `name rrtype error` where - `rrtype` is one of: #{inspect(Map.keys(@rrtypes))} - `error` is one of #{inspect(Map.keys(@rrerrors))} - `rdata` text representation of data suitable for given `rrtype` Unknown rr-types or otherwise malformed RR's are ignored and logged as a warning during preloading. It is possible to load zonedata multiple times, each one adds to the cache. Note that when setting errors, they always override other similar RR's regardless of ordering. ## Examples iex> zonedata = " ...> example.com TXT v=spf1 +all ...> example.com A timeout ...> EXAMPLE.NET AAAA servfail ...> " iex> ctx = Spf.Context.new("some.domain.tld") ...> |> Spf.DNS.load(zonedata) iex> iex> {_ctx, result} = Spf.DNS.resolve(ctx, "example.com", type: :txt) iex> result {:ok, ["v=spf1 +all"]} iex> iex> Spf.DNS.resolve(ctx, "example.com", type: :a) |> elem(1) {:error, :timeout} iex> iex> Spf.DNS.resolve(ctx, "example.net", type: :aaaa) |> elem(1) {:error, :servfail} iex> iex> ctx.dns %{{"example.com", :a} => [error: :timeout], {"example.com", :txt} => ["v=spf1 +all"], {"example.net", :aaaa} => [error: :servfail] } iex> zonedata1 = " ...> example.com A 1.2.3.4 ...> example.com A timeout ...> example.net A 9.10.11.12 ...> " iex> zonedata2 = " ...> example.com AAAA servfail ...> example.com AAAA acdc:1976::1 ...> example.net A 5.6.7.8 ...> example.net A 9.10.11.12 ...> " iex> ctx = Spf.Context.new("some.tld") ...> |> Spf.DNS.load(zonedata1) ...> |> Spf.DNS.load(zonedata2) iex> ctx.dns[{"example.com", :a}] [{:error, :timeout}] iex> ctx.dns[{"example.com", :aaaa}] [{:error, :servfail}] iex> ctx.dns[{"example.net", :a}] ["5.6.7.8", "9.10.11.12"] """ @spec load(Spf.Context.t(), nil | binary | [binary]) :: Spf.Context.t() def load(context, dns) def load(ctx, nil), do: ctx def load(ctx, dns) do case File.exists?(dns) do true -> load_file(ctx, dns) false -> load_zonedata(ctx, dns) end end @doc """ Normalize a domain name by trimming, downcasing and removing any trailing dot. The validity of the domain name is *not* checked. ## Examples iex> normalize("Example.COM.") "example.com" iex> normalize("EXAMPLE.C%M") "example.c%m" """ @spec normalize(domain | charlist) :: binary def normalize(domain) when is_binary(domain) do domain |> String.trim() |> String.replace(~r/\.$/, "") |> String.downcase() end def normalize(domain) when is_list(domain), do: List.to_string(domain) |> normalize() @doc """ Resolves a query, updates the cache and returns a {`ctx`, `t:dns_result/0`}-tuple. Returns one of: - `{ctx, {:error, reason}}` if a DNS error occurred or was cached earlier - `{ctx, {:ok, [rrs]}}` where rrs is a list of rrdata's Although a result with ZERO answers is technically not a DNS error, it will be reported as an error. Error reasons include: - `:zero_answers` - `:illegal_name` - `:timeout` - `:nxdomain` - `:servfail` - other Options include: - `type:`, which defaults to `:a` - `stats`, which defaults to `true` When `stats` is `false`, void DNS responses (`:nxdomain` or `:zero_answers`) are not counted. """ @spec resolve(Spf.Context.t(), domain, Keyword.t()) :: {Spf.Context.t(), dns_result} def resolve(ctx, name, opts \\ []) do stats = Keyword.get(opts, :stats, true) type = Keyword.get(opts, :type, Map.get(ctx, :atype, :a)) case from_cache(ctx, name, type) do {ctx, {:error, :cache_miss}} -> tick(ctx, :num_dnsq) |> query(name, type, stats) {ctx, result} -> tick(ctx, :num_dnsq) |> do_stats(name, type, result, stats, cached: true) end end @doc ~S""" Return all acquired DNS RR's in a flat list of printable lines. Note that RR's with multiple entries in their rrdata are listed individually, so the output can be copy/paste'd into a local dns.txt pre-cache to facilitate experimentation with RR records. The lines are sorted such that domains and subdomains are kept together as much as possible. ## Example iex> zonedata = " ...> example.com TXT v=spf1 -all ...> a.example.com A 1.2.3.4 ...> b.example.com AaAa timeout ...> " iex> ctx = Spf.Context.new("example.com", dns: zonedata) iex> Spf.DNS.to_list(ctx) ...> |> Enum.map(fn x -> String.replace(x, ~r/\s+/, " ") end) [ "example.com TXT \"v=spf1 -all\"", "a.example.com A 1.2.3.4", "b.example.com AAAA TIMEOUT" ] iex> to_list(ctx, valid: :true) ...> |> Enum.map(fn x -> String.replace(x, ~r/\s+/, " ") end) [ "example.com TXT \"v=spf1 -all\"", "a.example.com A 1.2.3.4" ] iex> to_list(ctx, valid: false) ...> |> Enum.map(fn x -> String.replace(x, ~r/\s+/, " ") end) [ "b.example.com AAAA TIMEOUT" ] """ @spec to_list(Spf.Context.t(), Keyword.t()) :: [binary] def to_list(ctx, opts \\ []) do keep = case Keyword.get(opts, :valid, :both) do false -> fn x -> rr_is_error(x) end true -> fn x -> not rr_is_error(x) end _ -> fn _ -> true end end Map.get(ctx, :dns, %{}) |> Enum.map(fn entry -> rr_flatten(entry) end) |> List.flatten() |> rrs_sort() |> Enum.filter(fn {_domain, _type, data} -> keep.(data) end) |> Enum.map(fn {domain, type, data} -> rr_encode(domain, type, data) end) end # Helpers @spec authorityp([binary], Spf.Context.t()) :: {:error, atom} | {:ok, domain, binary} defp authorityp([], _ctx), do: {:error, :nxdomain} defp authorityp([head | tail], ctx) do # note: checks ctx.dns-cache directly for {`head`, :soa} skipping CNAME's {ctx, _} = resolve(ctx, head, type: :soa) case ctx.dns[{head, :soa}] do [{_, contact, _, _, _, _, _}] -> {:ok, head, String.replace(contact, ".", "@", global: false)} _ -> authorityp(tail, ctx) end end @spec do_stats(Spf.Context.t(), domain, rrtype, dns_result, boolean, Keyword.t()) :: {Spf.Context.t(), dns_result} defp do_stats(ctx, name, type, result, stats, opts \\ []) do # log any warnings, possibly update void stats & return {ctx, result} # note: num_dnsq is updated in resolve, not here qry = case Keyword.get(opts, :cached, false) do true -> "DNS QUERY (#{ctx.num_dnsq}) [cache] #{type} #{name}" false -> "DNS QUERY (#{ctx.num_dnsq}) #{type} #{name}" end delta = if stats, do: 1, else: 0 case result do {:error, :cache_miss} -> # result didn't include an answer for given `type` result = {:error, :zero_answers} {update(ctx, {name, type, result}) |> tick(:num_dnsv, delta) |> log(:dns, :warn, "#{qry} - ZERO answers"), result} {:error, :zero_answers} -> # a previously cache_miss, cached as zero answers {tick(ctx, :num_dnsv, delta) |> log(:dns, :warn, "#{qry} - ZERO answers"), result} {:error, :nxdomain} -> # nxdomain is a void query {tick(ctx, :num_dnsv, delta) |> log(:dns, :warn, "#{qry} - NXDOMAIN"), result} {:error, reason} -> # any other error, like :servfail or :illegal_name err = String.upcase("#{inspect(reason)}") {log(ctx, :dns, :warn, "#{qry} - #{err}"), result} {:ok, res} -> {log(ctx, :dns, :info, "#{qry} - #{inspect(res)}"), result} end end @spec query(Spf.Context.t(), binary, atom, boolean) :: {Spf.Context.t(), dns_result} defp query(ctx, name, type, stats) do # query DNS for name, type opts = [] timeout = Map.get(ctx, :dns_timeout, 2000) opts = Keyword.put(opts, :timeout, timeout) opts = case Map.get(ctx, :nameservers) do nil -> opts list -> Keyword.put(opts, :nameservers, list) end # resolve and update the cache with dns_msg received ctx = name |> String.to_charlist() |> :inet_res.resolve(:in, type, opts) |> to_dns_results() |> cache(ctx, name, type) # get result (or not) from cache {ctx, result} = case from_cache(ctx, name, type) do {ctx, {:error, :cache_miss}} -> {ctx, {:error, :zero_answers}} {ctx, result} -> {ctx, result} end do_stats(ctx, name, type, result, stats) rescue x in CaseClauseError -> error = {:error, :unknown_rr_type} ctx = update(ctx, {name, type, error}) |> log(:dns, :error, "DNS error: #{name} #{type}: #{inspect(x)}") {ctx, error} x in FunctionClauseError -> error = {:error, :illegal_name} ctx = update(ctx, {name, type, error}) |> log(:dns, :error, "DNS illegal name: #{name} #{Exception.message(x)}") {ctx, error} end # DNS->CACHE @spec to_dns_results(dns_msg) :: dns_result defp to_dns_results(msg) do # given a dns_msg {:dns_rec, ...} or error-tuple # -> return either: {:ok, [{domain, type, value}, ...]} | {:error, reason} # notes: # - in an `anlist`, each rrdata in the set has its own rrtype # - this happens e.g. when resolving for :A and you get :CNAME + :A back with {:ok, record} <- msg, answers <- :inet_dns.msg(record, :anlist) do rrdatas = for answer <- answers do domain = :inet_dns.rr(answer, :domain) |> to_string() type = :inet_dns.rr(answer, :type) data = :inet_dns.rr(answer, :data) |> charlists_tostr(type) {domain, type, data} end {:ok, rrdatas} end end @spec cname(Spf.Context.t(), domain, rrtype, map) :: {Spf.Context.t(), {:ok | :error, any}} defp cname(ctx, name, type, seen \\ %{}) defp cname(ctx, name, :cname, _), do: {ctx, {:ok, name}} defp cname(ctx, name, type, seen) do # return canonical name if present, name otherwise, must follow CNAME's if seen[name] do ctx = log(ctx, :dns, :error, "DNS SERVFAIL - circular CNAMEs: #{inspect(Map.keys(seen))}") {ctx, {:error, :servfail}} else case ctx.dns[{name, :cname}] do nil -> {ctx, {:ok, name}} [{:error, reason}] -> {ctx, {:error, reason}} [realname] -> cname(ctx, realname, type, Map.put(seen, name, realname)) end end end # from: https://www.erlang.org/doc/man/inet_res.html # inet_res.resolve results are one of: # a) {:ok, dns_msg()} # b) {:error, Reason}, or # c) {:error, {Reason, dns_msg()}} # # where Reason = inet:posix() | res_error() # - inet:posix() = an atom named from the POSIX error codes used in Unix # - res_error() = formerr, qfmterror, servfail, nxdomain, notimp, refused, badvers, timeout # # cache stores either # - {name, type} -> {:error, :err_code}, or # - {name, type} -> [rrdata] # @spec cache(dns_result, Spf.Context, binary, rrtype) :: Spf.Context.t() # defp cache({:error, {reason, _dns_msg}} = _result, ctx, name, type), # do: update(ctx, {name, type, {:error, reason}}) defp cache({:error, reason}, ctx, name, type), do: update(ctx, {name, type, {:error, reason}}) defp cache({:ok, []}, ctx, name, type), do: update(ctx, {name, type, {:error, :zero_answers}}) defp cache({:ok, entries}, ctx, _name, _type), do: Enum.reduce(entries, ctx, fn entry, acc -> update(acc, entry) end) @spec update(Spf.Context.t(), {binary, rrtype, any}) :: Spf.Context.t() defp update(ctx, {domain, type, {:error, _} = error}) do Map.put(ctx, :dns, Map.put(ctx.dns, {domain, type}, [error])) |> log(:dns, :debug, "added {#{domain}, #{type} -> #{inspect(error)}") end defp update(ctx, {domain, type, data}) do # update the cache for a single entry # - donot use from_cache since that unrolls cnames cache = Map.get(ctx, :dns, %{}) domain = normalize(domain) cached = cache[{domain, type}] || [] data = charlists_tostr(data, type) case data in cached do true -> ctx false -> Map.put(ctx, :dns, Map.put(cache, {domain, type}, [data | cached])) |> log(:dns, :debug, "added {#{domain}, #{type}} -> #{inspect(data)}") end end # charlists_tostr/2 # note: # - given a single rdata & type, turn its charlists into binaries (if any) # - e.g. charlists_tostr('some text record', :txt) # - relies on the fact that query is used for SPF related DNS queries # and authority (:soa) queries only. # (so the number of rrtypes to support is limited) # keep error-tuples as is defp charlists_tostr({:error, reason}, _), do: {:error, reason} # :a, :aaaa rdata, preserve error-tuple (if any) defp charlists_tostr(ip, rrtype) when rrtype in [:a, :aaaa] do "#{Pfx.new(ip)}" rescue _ -> ip end # :mta name to string defp charlists_tostr({pref, domain}, :mx), do: {pref, to_string(domain)} # soa rdata defp charlists_tostr({mname, rname, serial, refresh, retry, expiry, ttl}, :soa), do: {to_string(mname), to_string(rname), serial, refresh, retry, expiry, ttl} # other rdata, including :txt, :spf, :ptr, :cname, :ns defp charlists_tostr(val, type) when type in [:txt, :spf, :ptr, :cname, :ns], do: to_string(val) # LINES->CACHE @spec load_file(Spf.Context.t(), binary) :: Spf.Context.t() defp load_file(ctx, fpath) when is_binary(fpath) do ctx = case File.read(fpath) do {:ok, binary} -> load_zonedata(ctx, binary) {:error, reason} -> log(ctx, :dns, :error, "failed to read #{fpath}: #{inspect(reason)}") end log(ctx, :dns, :debug, "DNS cache has #{map_size(ctx.dns)} entries") rescue err -> log(ctx, :dns, :error, "failed to read #{fpath}: #{Exception.message(err)}") end @spec load_zonedata(Spf.Context.t(), binary | [binary]) :: Spf.Context.t() defp load_zonedata(ctx, binary) when is_binary(binary), do: String.split(binary, "\n", trim: true) |> Enum.map(&String.trim/1) |> Enum.filter(fn line -> not String.match?(line, ~r/\s*#/) end) |> then(fn lines -> load_zonedata(ctx, lines) end) defp load_zonedata(ctx, lines) when is_list(lines) do {malformed, good} = lines |> Enum.map(&rr_decode/1) |> List.flatten() |> Enum.split_with(fn {k, _, _} -> k == :error end) {errors, normal} = Enum.split_with(good, fn {_, _, v} -> is_tuple(v) and elem(v, 0) == :error end) ctx = Enum.reduce(malformed, ctx, fn {_, reason, line}, ctx -> log(ctx, :dns, :warn, "RR ignored: #{reason} - #{line}") end) ctx = Enum.reduce(normal, ctx, fn entry, ctx -> update(ctx, entry) end) Enum.reduce(errors, ctx, fn error, ctx -> update(ctx, error) end) end defp rr_decode(line) do with [name, type, rdata] <- String.split(line, @rgxtypes, parts: 2, include_captures: true), {:ok, name} <- check_domain(name), {:ok, type} <- rrtype_decode(type), {:ok, rdata} <- rrdata_decode(type, rdata) do {name, type, rdata} else {:error, reason} -> {:error, reason, line} _ -> {:error, "malformed RR", line} end end defp rrtype_decode(type) do case @rrtypes[String.trim(type) |> String.downcase()] do nil -> {:error, "unsupported RR type"} type -> {:ok, type} end end defp rrdata_decode(type, rdata) do error = String.downcase(rdata) case @rrerrors[error] do nil -> rrdata_type_decode(type, rdata) atom -> {:ok, {:error, atom}} end end defp rrdata_type_decode(type, rdata) when type in [:txt, :spf], do: {:ok, no_quotes(rdata)} defp rrdata_type_decode(type, rdata) when type in [:a, :aaaa] do pfx = Pfx.new(rdata) case {type, pfx.maxlen} do {:a, 32} -> {:ok, rdata} {:aaaa, 128} -> {:ok, String.downcase(rdata)} _ -> {:error, "illegal address"} end rescue _ -> {:error, "illegal address"} end defp rrdata_type_decode(:mx, rdata) do with [pref, name] <- String.split(rdata, ~r/\s+/, parts: 2), {:ok, domain} <- check_domain(name), {pref, ""} <- Integer.parse(pref) do {:ok, {pref, domain}} else :error -> {:error, "illegal pref"} error -> error end end defp rrdata_type_decode(type, rdata) when type in [:ptr, :cname, :ns], do: check_domain(rdata) defp rrdata_type_decode(:soa, rdata) do # ns responsible-name serial refresh retry expire nxdomain-ttl with [ns, rn, serial, refresh, retry, expire, ttl] <- String.split(rdata, ~r/\s+/, parts: 7), {:ok, ns} <- check_domain(ns), {:ok, rn} <- check_domain(rn), {:serial, {serial, ""}} <- {:serial, Integer.parse(serial)}, {:refresh, {refresh, ""}} <- {:refresh, Integer.parse(refresh)}, {:retry, {retry, ""}} <- {:retry, Integer.parse(retry)}, {:expire, {expire, ""}} <- {:expire, Integer.parse(expire)}, {:ttl, {ttl, ""}} <- {:ttl, Integer.parse(ttl)} do {:ok, {ns, rn, serial, refresh, retry, expire, ttl}} else {number, :error} -> {:error, "illegal #{number}"} error -> error end end defp rrdata_type_decode(type, _rdata), do: {:error, :rrtype, "#{type}"} # CACHE->LINES @spec rr_encode(binary, atom, any) :: binary defp rr_encode(domain, type, data) do rrtype = String.upcase("#{type}") rrdata = rrdata_encode(type, data) "#{domain} #{rrtype} #{rrdata}" end @spec rrdata_encode(rrtype, any) :: binary defp rrdata_encode(_, {:error, reason}) do "#{inspect(reason)}" |> String.upcase() |> String.trim_leading(":") end defp rrdata_encode(type, ip) when type in [:a, :aaaa] and is_tuple(ip) do "#{Pfx.new(ip)}" rescue # just in case.. _ -> ip end defp rrdata_encode(:mx, {pref, domain}), do: "#{pref} #{domain}" defp rrdata_encode(:txt, txt), do: inspect(txt) defp rrdata_encode(:soa, {ns, rn, serial, refresh, retry, expiry, ttl}), do: "#{ns} #{rn} #{serial} #{refresh} #{retry} #{expiry} #{ttl}" defp rrdata_encode(_type, data), do: "#{inspect(data)}" |> no_quotes() defp rr_flatten({{domain, type}, rrdatas}), do: for(rrdata <- rrdatas, do: {domain, type, rrdata}) defp rrs_sort(rrs) do # keeps related records close to each other in report output Enum.sort(rrs, fn {domain1, _type, _data}, {domain2, _type1, _data2} -> String.reverse(domain1) <= String.reverse(domain2) end) end defp rr_is_error(data) do case data do {:error, _} -> true _ -> false end end @spec no_quotes(binary) :: binary defp no_quotes(str) do str |> String.replace(~r/^\"/, "") |> String.replace(~r/\"$/, "") end end