Spf.DNS (Spfcheck v0.10.0) View Source

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"]}

Link to this section Summary

Types

An opaque datastructure as returned by :inet_res.resolve/3 as part of its response.

A DNS result in the form of an ok/error-tuple.

A domain is a simply an ascii binary.

A dns result as returned by :inet_res.resolve/3.

An rrtype denoted by an atom.

Functions

Finds a domain name's start of authority and contact.

Checks validity of a domain name and returns {:ok, name} or {:error, reason}

Filters the dns_result/0, keeps only the rrdata's for which fun returns a truthy value.

Returns an updated context, a normalized name and a cache result in a 3-tuple

Populates the dns cache of given context, with dns's zonedata.

Normalize a domain name by trimming, downcasing and removing any trailing dot.

Resolves a query, updates the cache and returns a {ctx, dns_result/0}-tuple.

Return all acquired DNS RR's in a flat list of printable lines.

Link to this section Types

Specs

dns_msg() :: any()

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.

Specs

dns_result() :: {:ok, [any()]} | {:error, atom()}

A DNS result in the form of an ok/error-tuple.

In case of succes, this is a list of 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.

Specs

domain() :: binary()

A domain is a simply an ascii binary.

Specs

res_result() :: {:ok, dns_msg()} | {:error, any()}

A dns result as returned by :inet_res.resolve/3.

Specs

rrtype() :: atom()

An rrtype denoted by an atom.

See also inet_res.rr_type.

Link to this section Functions

Specs

authority(Spf.Context.t(), binary()) ::
  {:ok, domain(), domain(), binary()} | {:error, atom()}

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"}

Specs

check_domain(binary()) :: {:ok, domain()} | {:error, binary()}

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"}

Specs

filter(dns_result(), function()) :: dns_result()

Filters the 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}
Link to this function

from_cache(context, name, type)

View Source

Specs

from_cache(Spf.Context.t(), domain(), rrtype()) ::
  {Spf.Context.t(), dns_result()}

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 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"]}

Specs

load(Spf.Context.t(), nil | binary() | [binary()]) :: Spf.Context.t()

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: ["a", "aaaa", "cname", "mx", "ns", "ptr", "soa", "spf", "txt"]
  • error is one of ["formerr", "nxdomain", "servfail", "timeout", "zero_answers"]
  • 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"]

Specs

normalize(domain() | charlist()) :: binary()

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"
Link to this function

resolve(ctx, name, opts \\ [])

View Source

Specs

Resolves a query, updates the cache and returns a {ctx, 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.

Link to this function

to_list(ctx, opts \\ [])

View Source

Specs

to_list(Spf.Context.t(), Keyword.t()) :: [binary()]

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"
]