merge: pr-10b-featdns-dnssec-sign-on-publish-serve-rrsigdnskey
This commit is contained in:
commit
2651f20d3a
12 changed files with 1508 additions and 64 deletions
|
|
@ -361,13 +361,34 @@ defmodule Elektrine.DNS do
|
||||||
@doc """
|
@doc """
|
||||||
Enables DNSSEC key generation for a zone (KSK + ZSK, ECDSA P-256).
|
Enables DNSSEC key generation for a zone (KSK + ZSK, ECDSA P-256).
|
||||||
|
|
||||||
Stores encrypted private keys and zone flags only — does not change the
|
When the global `DNS_DNSSEC_ENABLED` serve flag is on, the authority cache is
|
||||||
authoritative answer path (see PR 10b for signing/serve).
|
refreshed so the zone is pre-signed (DNSKEY/RRSIG) for publish.
|
||||||
"""
|
"""
|
||||||
def enable_zone_dnssec(%Zone{} = zone), do: Dnssec.enable_zone(zone)
|
def enable_zone_dnssec(%Zone{} = zone) do
|
||||||
|
case Dnssec.enable_zone(zone) do
|
||||||
|
{:ok, enabled} ->
|
||||||
|
refresh_authority_cache()
|
||||||
|
{:ok, enabled}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def enable_zone_dnssec(_), do: {:error, :invalid_zone}
|
def enable_zone_dnssec(_), do: {:error, :invalid_zone}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Global DNSSEC serve-path switch (`DNS_DNSSEC_ENABLED`).
|
||||||
|
|
||||||
|
When false (default), no RRSIGs or managed DNSKEY answers are served even if
|
||||||
|
a zone has keys. When true, zones with `dnssec_enabled` and prepared keys
|
||||||
|
serve DNSKEY/RRSIG for signed types.
|
||||||
|
"""
|
||||||
|
def dnssec_enabled? do
|
||||||
|
Application.get_env(:elektrine, :dns, [])
|
||||||
|
|> Keyword.get(:dnssec_enabled, false) == true
|
||||||
|
end
|
||||||
|
|
||||||
@doc "DNSSEC status, public key metadata, and DS records for registrar export."
|
@doc "DNSSEC status, public key metadata, and DS records for registrar export."
|
||||||
def zone_dnssec_status(%Zone{} = zone), do: Dnssec.status(zone)
|
def zone_dnssec_status(%Zone{} = zone), do: Dnssec.status(zone)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ defmodule Elektrine.DNS.Dnssec do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
DNSSEC key generation and DS export for managed zones.
|
DNSSEC key generation and DS export for managed zones.
|
||||||
|
|
||||||
PR 10a: generates and stores keys, exports DS for the parent registrar.
|
Key material and DS export live here. RRSet signing / RRSIG serve is in
|
||||||
Does **not** sign RRSets or change the authoritative answer path.
|
`Elektrine.DNS.Dnssec.Signer`, gated by the global `DNS_DNSSEC_ENABLED` flag.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
|
||||||
654
apps/elektrine_dns/lib/elektrine/dns/dnssec/signer.ex
Normal file
654
apps/elektrine_dns/lib/elektrine/dns/dnssec/signer.ex
Normal file
|
|
@ -0,0 +1,654 @@
|
||||||
|
defmodule Elektrine.DNS.Dnssec.Signer do
|
||||||
|
@moduledoc """
|
||||||
|
Pre-signs zone RRSets (ECDSA P-256 / algorithm 13) for authoritative serve.
|
||||||
|
|
||||||
|
Called when zones are published into `ZoneCache`. Signatures are attached as a
|
||||||
|
virtual `dnssec_bundle` on the zone so the query path never performs crypto
|
||||||
|
on the hot path.
|
||||||
|
|
||||||
|
## Matching answers to signatures
|
||||||
|
|
||||||
|
Each signed RRset stores a digest of its canonical RDATA. Query only attaches
|
||||||
|
an RRSIG when the answer RRset digests equal the signed digest — so health
|
||||||
|
failover (or any other answer mutator) that shrinks an A/AAAA set omits the
|
||||||
|
RRSIG instead of serving a non-covering signature (BOGUS for validators).
|
||||||
|
|
||||||
|
## ALIAS limitations
|
||||||
|
|
||||||
|
Proxied ALIAS/CNAME are signed as the edge A/AAAA answers Query returns.
|
||||||
|
Non-proxied ALIAS that flattens to **in-zone** A/AAAA is signed at publish
|
||||||
|
time against those local targets. ALIAS that resolves **outside** the zone
|
||||||
|
(runtime external lookup) is left unsigned — document that combination as
|
||||||
|
unsupported until a later phase.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Elektrine.DNS
|
||||||
|
alias Elektrine.DNS.Dnssec
|
||||||
|
alias Elektrine.DNS.DnssecKey
|
||||||
|
alias Elektrine.DNS.Packet
|
||||||
|
alias Elektrine.DNS.Record
|
||||||
|
alias Elektrine.DNS.Zone
|
||||||
|
|
||||||
|
# Common types signed in PR 10b. Unsupported / dynamic types are left unsigned.
|
||||||
|
@signed_types ~w(a aaaa ns soa mx txt cname caa srv sshfp tlsa ds dnskey https svcb)a
|
||||||
|
@class_in 1
|
||||||
|
@signature_validity_seconds 30 * 24 * 60 * 60
|
||||||
|
@signature_inception_skew_seconds 3600
|
||||||
|
|
||||||
|
@type rrsig_record :: map()
|
||||||
|
@type dnssec_bundle :: %{
|
||||||
|
dnskeys: [map()],
|
||||||
|
rrsigs: %{{String.t(), atom()} => [rrsig_record()]},
|
||||||
|
rrset_digests: %{{String.t(), atom()} => binary()},
|
||||||
|
signed_at: DateTime.t()
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Prepares a zone for DNSSEC serve by building DNSKEY answers and RRSIGs.
|
||||||
|
|
||||||
|
Returns the zone unchanged when keys are missing or signing fails for the
|
||||||
|
entire zone. Individual unsignable RRSets are skipped without raising.
|
||||||
|
|
||||||
|
On success, `dnssec_keys` is cleared on the returned zone so decrypted
|
||||||
|
private material is not retained in ZoneCache/ETS (only `dnssec_bundle`).
|
||||||
|
"""
|
||||||
|
@spec prepare_zone(Zone.t()) :: Zone.t()
|
||||||
|
def prepare_zone(%Zone{} = zone) do
|
||||||
|
keys = active_keys(zone)
|
||||||
|
|
||||||
|
if keys == [] do
|
||||||
|
drop_private_keys(zone)
|
||||||
|
else
|
||||||
|
case build_bundle(zone, keys) do
|
||||||
|
{:ok, bundle} ->
|
||||||
|
zone
|
||||||
|
|> Map.put(:dnssec_bundle, bundle)
|
||||||
|
|> drop_private_keys()
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("DNSSEC prepare skipped for zone #{zone.domain}: #{inspect(reason)}")
|
||||||
|
|
||||||
|
drop_private_keys(zone)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare_zone(zone), do: zone
|
||||||
|
|
||||||
|
@doc "Whether the zone has a prepared signing bundle ready to serve."
|
||||||
|
@spec prepared?(Zone.t()) :: boolean()
|
||||||
|
def prepared?(%Zone{dnssec_bundle: %{dnskeys: keys, rrsigs: sigs}})
|
||||||
|
when is_list(keys) and map_size(sigs) >= 0 and keys != [],
|
||||||
|
do: true
|
||||||
|
|
||||||
|
def prepared?(_), do: false
|
||||||
|
|
||||||
|
@doc "Synthesized apex DNSKEY records from the prepared bundle (or empty)."
|
||||||
|
@spec dnskey_records(Zone.t()) :: [map()]
|
||||||
|
def dnskey_records(%Zone{dnssec_bundle: %{dnskeys: dnskeys}}) when is_list(dnskeys), do: dnskeys
|
||||||
|
def dnskey_records(_), do: []
|
||||||
|
|
||||||
|
@doc "RRSIG records covering `{owner, type}` from the prepared bundle."
|
||||||
|
@spec rrsigs_for(Zone.t(), String.t(), atom() | String.t()) :: [rrsig_record()]
|
||||||
|
def rrsigs_for(%Zone{dnssec_bundle: %{rrsigs: rrsigs}}, owner, type)
|
||||||
|
when is_map(rrsigs) and is_binary(owner) do
|
||||||
|
key = {normalize_name(owner), normalize_type(type)}
|
||||||
|
Map.get(rrsigs, key, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def rrsigs_for(_, _, _), do: []
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
RRSIGs for `{owner, type}` only when `records` match the signed RRset digest.
|
||||||
|
|
||||||
|
Prefer this on the query path so mutators (e.g. health failover) never attach
|
||||||
|
a signature that no longer covers the answer.
|
||||||
|
"""
|
||||||
|
@spec rrsigs_for_matching(Zone.t(), String.t(), atom() | String.t(), [map()]) :: [
|
||||||
|
rrsig_record()
|
||||||
|
]
|
||||||
|
def rrsigs_for_matching(zone, owner, type, records)
|
||||||
|
when is_binary(owner) and is_list(records) do
|
||||||
|
if covers_rrset?(zone, owner, type, records) do
|
||||||
|
rrsigs_for(zone, owner, type)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
True when the answer RRset's canonical RDATA digest matches what was signed.
|
||||||
|
"""
|
||||||
|
@spec covers_rrset?(Zone.t(), String.t(), atom() | String.t(), [map()]) :: boolean()
|
||||||
|
def covers_rrset?(%Zone{dnssec_bundle: %{rrset_digests: digests}}, owner, type, records)
|
||||||
|
when is_map(digests) and is_binary(owner) and is_list(records) do
|
||||||
|
key = {normalize_name(owner), normalize_type(type)}
|
||||||
|
|
||||||
|
case Map.fetch(digests, key) do
|
||||||
|
{:ok, expected} ->
|
||||||
|
case rrset_digest(normalize_type(type), records) do
|
||||||
|
{:ok, ^expected} -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def covers_rrset?(_, _, _, _), do: false
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec sign_rrset(String.t(), atom(), [map()], DnssecKey.t() | map(), keyword()) ::
|
||||||
|
{:ok, rrsig_record()} | {:error, term()}
|
||||||
|
def sign_rrset(owner, type, records, key, opts \\ [])
|
||||||
|
when is_binary(owner) and is_atom(type) and is_list(records) do
|
||||||
|
with {:ok, private_key} <- load_private_key(key),
|
||||||
|
{:ok, rdata_list} <- encode_rrset_rdatas(type, records) do
|
||||||
|
now = Keyword.get(opts, :now, DateTime.utc_now() |> DateTime.truncate(:second))
|
||||||
|
original_ttl = Keyword.get(opts, :original_ttl) || max_ttl(records, 300)
|
||||||
|
inception = DateTime.to_unix(now) - @signature_inception_skew_seconds
|
||||||
|
expiration = DateTime.to_unix(now) + @signature_validity_seconds
|
||||||
|
signer = Keyword.get(opts, :signer) || owner_zone_apex(owner, opts)
|
||||||
|
labels = label_count(owner)
|
||||||
|
type_covered = Packet.type_number(type)
|
||||||
|
algorithm = Map.get(key, :algorithm) || Dnssec.default_algorithm()
|
||||||
|
key_tag = Map.fetch!(key, :key_tag)
|
||||||
|
|
||||||
|
signer_wire = Dnssec.encode_domain_name(signer)
|
||||||
|
|
||||||
|
rrsig_prefix =
|
||||||
|
<<type_covered::16, algorithm::8, labels::8, original_ttl::32, expiration::32,
|
||||||
|
inception::32, key_tag::16, signer_wire::binary>>
|
||||||
|
|
||||||
|
owner_wire = Dnssec.encode_domain_name(owner)
|
||||||
|
|
||||||
|
rrset_wire =
|
||||||
|
rdata_list
|
||||||
|
|> Enum.map(fn rdata ->
|
||||||
|
<<owner_wire::binary, type_covered::16, @class_in::16, original_ttl::32,
|
||||||
|
byte_size(rdata)::16, rdata::binary>>
|
||||||
|
end)
|
||||||
|
|> Enum.sort()
|
||||||
|
|> IO.iodata_to_binary()
|
||||||
|
|
||||||
|
to_sign = rrsig_prefix <> rrset_wire
|
||||||
|
der_sig = :public_key.sign(to_sign, :sha256, private_key)
|
||||||
|
|
||||||
|
case der_ecdsa_to_raw(der_sig) do
|
||||||
|
{:ok, raw_sig} ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
host: normalize_name(owner),
|
||||||
|
type: :rrsig,
|
||||||
|
ttl: original_ttl,
|
||||||
|
type_covered: type,
|
||||||
|
algorithm: algorithm,
|
||||||
|
labels: labels,
|
||||||
|
original_ttl: original_ttl,
|
||||||
|
expiration: expiration,
|
||||||
|
inception: inception,
|
||||||
|
key_tag: key_tag,
|
||||||
|
signer: normalize_name(signer),
|
||||||
|
signature: raw_sig,
|
||||||
|
content: Base.encode64(raw_sig)
|
||||||
|
}}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec verify_rrsig(rrsig_record(), [map()], binary() | String.t()) :: boolean()
|
||||||
|
def verify_rrsig(rrsig, records, public_key_wire)
|
||||||
|
when is_map(rrsig) and is_list(records) and is_binary(public_key_wire) do
|
||||||
|
with {:ok, rdata_list} <- encode_rrset_rdatas(rrsig.type_covered, records),
|
||||||
|
{:ok, ec_point, curve} <- public_key_from_wire(public_key_wire) do
|
||||||
|
type_covered = Packet.type_number(rrsig.type_covered)
|
||||||
|
owner = rrsig.host
|
||||||
|
owner_wire = Dnssec.encode_domain_name(owner)
|
||||||
|
signer_wire = Dnssec.encode_domain_name(rrsig.signer)
|
||||||
|
|
||||||
|
rrsig_prefix =
|
||||||
|
<<type_covered::16, rrsig.algorithm::8, rrsig.labels::8, rrsig.original_ttl::32,
|
||||||
|
rrsig.expiration::32, rrsig.inception::32, rrsig.key_tag::16, signer_wire::binary>>
|
||||||
|
|
||||||
|
rrset_wire =
|
||||||
|
rdata_list
|
||||||
|
|> Enum.map(fn rdata ->
|
||||||
|
<<owner_wire::binary, type_covered::16, @class_in::16, rrsig.original_ttl::32,
|
||||||
|
byte_size(rdata)::16, rdata::binary>>
|
||||||
|
end)
|
||||||
|
|> Enum.sort()
|
||||||
|
|> IO.iodata_to_binary()
|
||||||
|
|
||||||
|
to_verify = rrsig_prefix <> rrset_wire
|
||||||
|
signature = rrsig_signature_bytes(rrsig)
|
||||||
|
der = raw_ecdsa_to_der(signature)
|
||||||
|
# OTP accepts `{ECPoint, Curve}` or a single-element key list for ECDSA.
|
||||||
|
:public_key.verify(to_verify, :sha256, der, {ec_point, curve})
|
||||||
|
else
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_bundle(%Zone{} = zone, keys) do
|
||||||
|
zsk = Enum.find(keys, &(&1.key_role == "zsk")) || List.first(keys)
|
||||||
|
ksk = Enum.find(keys, &(&1.key_role == "ksk")) || zsk
|
||||||
|
apex = normalize_name(zone.domain)
|
||||||
|
ttl = zone.default_ttl || 300
|
||||||
|
|
||||||
|
dnskeys = Enum.map(keys, &dnskey_answer(apex, &1, ttl))
|
||||||
|
|
||||||
|
rrsets =
|
||||||
|
zone
|
||||||
|
|> collect_public_rrsets()
|
||||||
|
|> Map.put({apex, :dnskey}, dnskeys)
|
||||||
|
|> Map.put({apex, :soa}, [Zone.soa_record(zone)])
|
||||||
|
|
||||||
|
{rrsigs, digests} =
|
||||||
|
Enum.reduce(rrsets, {%{}, %{}}, fn {{owner, type}, records}, {sig_acc, dig_acc} ->
|
||||||
|
if type in @signed_types and records != [] do
|
||||||
|
signing_key = if type == :dnskey, do: ksk, else: zsk
|
||||||
|
|
||||||
|
with {:ok, rrsig} <-
|
||||||
|
sign_rrset(owner, type, records, signing_key,
|
||||||
|
signer: apex,
|
||||||
|
original_ttl: max_ttl(records, ttl)
|
||||||
|
),
|
||||||
|
{:ok, digest} <- rrset_digest(type, records) do
|
||||||
|
{
|
||||||
|
Map.update(sig_acc, {owner, type}, [rrsig], &[rrsig | &1]),
|
||||||
|
Map.put(dig_acc, {owner, type}, digest)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.debug("DNSSEC skip RRSet #{owner}/#{type} for #{apex}: #{inspect(reason)}")
|
||||||
|
|
||||||
|
{sig_acc, dig_acc}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{sig_acc, dig_acc}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
dnskeys: dnskeys,
|
||||||
|
rrsigs: rrsigs,
|
||||||
|
rrset_digests: digests,
|
||||||
|
signed_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
}}
|
||||||
|
rescue
|
||||||
|
error ->
|
||||||
|
{:error, error}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_public_rrsets(%Zone{} = zone) do
|
||||||
|
apex = normalize_name(zone.domain)
|
||||||
|
|
||||||
|
records =
|
||||||
|
(DNS.zone_onboarding_records(zone) ++ (zone.records || []))
|
||||||
|
|> Enum.reject(&Record.private?/1)
|
||||||
|
|
||||||
|
Enum.reduce(records, %{}, fn record, acc ->
|
||||||
|
type = normalize_type(Map.get(record, :type))
|
||||||
|
|
||||||
|
cond do
|
||||||
|
type == :alias and Record.proxied?(record) ->
|
||||||
|
merge_proxied_rrsets(acc, zone, record, apex)
|
||||||
|
|
||||||
|
type == :alias ->
|
||||||
|
# Non-proxied ALIAS: sign in-zone A/AAAA expansions only (external
|
||||||
|
# flatten targets stay unsigned — runtime lookup is not stable).
|
||||||
|
merge_local_alias_rrsets(acc, zone, record, apex)
|
||||||
|
|
||||||
|
type not in @signed_types ->
|
||||||
|
acc
|
||||||
|
|
||||||
|
Record.proxied?(record) and type in [:a, :aaaa, :cname] ->
|
||||||
|
merge_proxied_rrsets(acc, zone, record, apex)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
owner = record_owner(zone, record, apex)
|
||||||
|
answers = [public_answer(zone, record, owner)]
|
||||||
|
merge_rrset(acc, owner, type, answers)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp merge_local_alias_rrsets(acc, zone, record, apex) do
|
||||||
|
owner = record_owner(zone, record, apex)
|
||||||
|
target = normalize_name(Map.get(record, :content) || "")
|
||||||
|
ttl = Map.get(record, :ttl) || zone.default_ttl || 300
|
||||||
|
|
||||||
|
if target in ["", owner] do
|
||||||
|
acc
|
||||||
|
else
|
||||||
|
zone_apex = apex
|
||||||
|
|
||||||
|
Enum.reduce([:a, :aaaa], acc, fn qtype, inner ->
|
||||||
|
answers =
|
||||||
|
(zone.records || [])
|
||||||
|
|> Enum.filter(fn r ->
|
||||||
|
record_owner(zone, r, zone_apex) == target and
|
||||||
|
normalize_type(Map.get(r, :type)) == qtype and
|
||||||
|
not Record.private?(r) and
|
||||||
|
not Record.proxied?(r)
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn r ->
|
||||||
|
%{
|
||||||
|
host: owner,
|
||||||
|
type: qtype,
|
||||||
|
content: Map.get(r, :content),
|
||||||
|
ttl: ttl
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
if answers == [], do: inner, else: merge_rrset(inner, owner, qtype, answers)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp merge_proxied_rrsets(acc, zone, record, apex) do
|
||||||
|
owner = record_owner(zone, record, apex)
|
||||||
|
type = normalize_type(Map.get(record, :type))
|
||||||
|
ttl = Map.get(record, :ttl) || zone.default_ttl || 300
|
||||||
|
|
||||||
|
case type do
|
||||||
|
t when t in [:a, :aaaa] ->
|
||||||
|
contents =
|
||||||
|
if t == :a,
|
||||||
|
do: DNS.edge_proxy_ipv4_addresses(),
|
||||||
|
else: DNS.edge_proxy_ipv6_addresses()
|
||||||
|
|
||||||
|
answers =
|
||||||
|
Enum.map(contents, fn content ->
|
||||||
|
%{host: owner, type: t, content: content, ttl: ttl}
|
||||||
|
end)
|
||||||
|
|
||||||
|
if answers == [], do: acc, else: merge_rrset(acc, owner, t, answers)
|
||||||
|
|
||||||
|
t when t in [:cname, :alias] ->
|
||||||
|
# Proxied CNAME/ALIAS are answered as edge A/AAAA, so sign those forms.
|
||||||
|
a_answers =
|
||||||
|
Enum.map(DNS.edge_proxy_ipv4_addresses(), fn content ->
|
||||||
|
%{host: owner, type: :a, content: content, ttl: ttl}
|
||||||
|
end)
|
||||||
|
|
||||||
|
aaaa_answers =
|
||||||
|
Enum.map(DNS.edge_proxy_ipv6_addresses(), fn content ->
|
||||||
|
%{host: owner, type: :aaaa, content: content, ttl: ttl}
|
||||||
|
end)
|
||||||
|
|
||||||
|
acc
|
||||||
|
|> then(fn a -> if a_answers == [], do: a, else: merge_rrset(a, owner, :a, a_answers) end)
|
||||||
|
|> then(fn a ->
|
||||||
|
if aaaa_answers == [], do: a, else: merge_rrset(a, owner, :aaaa, aaaa_answers)
|
||||||
|
end)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp merge_rrset(acc, owner, type, answers) do
|
||||||
|
Map.update(acc, {owner, type}, answers, &(&1 ++ answers))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp public_answer(zone, record, owner) do
|
||||||
|
type = normalize_type(Map.get(record, :type))
|
||||||
|
|
||||||
|
base = %{
|
||||||
|
host: owner,
|
||||||
|
type: type,
|
||||||
|
ttl: Map.get(record, :ttl) || zone.default_ttl || 300,
|
||||||
|
content: Map.get(record, :content),
|
||||||
|
value: Map.get(record, :value) || Map.get(record, :content),
|
||||||
|
priority: Map.get(record, :priority),
|
||||||
|
weight: Map.get(record, :weight),
|
||||||
|
port: Map.get(record, :port),
|
||||||
|
flags: Map.get(record, :flags),
|
||||||
|
tag: Map.get(record, :tag),
|
||||||
|
protocol: Map.get(record, :protocol),
|
||||||
|
algorithm: Map.get(record, :algorithm),
|
||||||
|
key_tag: Map.get(record, :key_tag),
|
||||||
|
digest_type: Map.get(record, :digest_type),
|
||||||
|
usage: Map.get(record, :usage),
|
||||||
|
selector: Map.get(record, :selector),
|
||||||
|
matching_type: Map.get(record, :matching_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
case type do
|
||||||
|
:ns ->
|
||||||
|
Map.put(base, :value, Map.get(record, :value) || Map.get(record, :content))
|
||||||
|
|
||||||
|
:soa ->
|
||||||
|
Map.merge(base, %{
|
||||||
|
mname: Map.get(record, :mname),
|
||||||
|
rname: Map.get(record, :rname),
|
||||||
|
serial: Map.get(record, :serial),
|
||||||
|
refresh: Map.get(record, :refresh),
|
||||||
|
retry: Map.get(record, :retry),
|
||||||
|
expire: Map.get(record, :expire),
|
||||||
|
minimum: Map.get(record, :minimum)
|
||||||
|
})
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
base
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp record_owner(zone, record, apex) do
|
||||||
|
cond do
|
||||||
|
host = Map.get(record, :host) ->
|
||||||
|
normalize_name(host)
|
||||||
|
|
||||||
|
name = Map.get(record, :name) ->
|
||||||
|
normalized = normalize_name(name)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
normalized in ["", "@"] -> apex
|
||||||
|
normalized == apex -> apex
|
||||||
|
String.ends_with?(normalized, "." <> apex) -> normalized
|
||||||
|
true -> normalize_name(normalized <> "." <> zone.domain)
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
apex
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp dnskey_answer(apex, key, ttl) do
|
||||||
|
%{
|
||||||
|
host: apex,
|
||||||
|
type: :dnskey,
|
||||||
|
ttl: ttl,
|
||||||
|
flags: key.flags,
|
||||||
|
protocol: key.protocol || 3,
|
||||||
|
algorithm: key.algorithm,
|
||||||
|
content: key.public_key
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp active_keys(%Zone{dnssec_keys: keys}) when is_list(keys) do
|
||||||
|
Enum.filter(keys, &(&1.active == true))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp active_keys(%Zone{id: zone_id}) when is_integer(zone_id) do
|
||||||
|
Dnssec.list_active_keys(zone_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp active_keys(_), do: []
|
||||||
|
|
||||||
|
defp encode_rrset_rdatas(type, records) do
|
||||||
|
rdatas =
|
||||||
|
Enum.map(records, fn record ->
|
||||||
|
Packet.rr_rdata(Map.put(record, :type, normalize_type(type)))
|
||||||
|
end)
|
||||||
|
|
||||||
|
if Enum.any?(rdatas, &is_nil/1) do
|
||||||
|
{:error, :unencodable_rdata}
|
||||||
|
else
|
||||||
|
{:ok, rdatas}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
error -> {:error, error}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Digest of sorted canonical RDATA bytes — independent of answer order and
|
||||||
|
# of owner/TTL framing so Query can cheaply detect RRset mutations.
|
||||||
|
defp rrset_digest(type, records) do
|
||||||
|
with {:ok, rdatas} <- encode_rrset_rdatas(type, records) do
|
||||||
|
material =
|
||||||
|
rdatas
|
||||||
|
|> Enum.sort()
|
||||||
|
|> IO.iodata_to_binary()
|
||||||
|
|
||||||
|
{:ok, :crypto.hash(:sha256, material)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp drop_private_keys(%Zone{} = zone) do
|
||||||
|
# Serving only needs dnssec_bundle. Never keep decrypted PEMs in ETS.
|
||||||
|
%{zone | dnssec_keys: []}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_private_key(%{private_key: pem}) when is_binary(pem) do
|
||||||
|
case :public_key.pem_decode(pem) do
|
||||||
|
[entry | _] ->
|
||||||
|
key = :public_key.pem_entry_decode(entry)
|
||||||
|
{:ok, key}
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
{:error, :invalid_private_key}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_ -> {:error, :invalid_private_key}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_private_key(_), do: {:error, :invalid_private_key}
|
||||||
|
|
||||||
|
defp public_key_from_wire(<<_::binary-64>> = wire) do
|
||||||
|
{:ok, {:ECPoint, <<0x04, wire::binary>>}, {:namedCurve, :secp256r1}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp public_key_from_wire(b64) when is_binary(b64) do
|
||||||
|
case Base.decode64(b64) do
|
||||||
|
{:ok, <<_::binary-64>> = wire} ->
|
||||||
|
public_key_from_wire(wire)
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
{:error, :invalid_public_key}
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:error, :invalid_public_key}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp public_key_from_wire(_), do: {:error, :invalid_public_key}
|
||||||
|
|
||||||
|
defp der_ecdsa_to_raw(der) when is_binary(der) do
|
||||||
|
{:"ECDSA-Sig-Value", r, s} = :public_key.der_decode(:"ECDSA-Sig-Value", der)
|
||||||
|
{:ok, <<r::unsigned-big-integer-size(256), s::unsigned-big-integer-size(256)>>}
|
||||||
|
rescue
|
||||||
|
_ ->
|
||||||
|
# Manual ASN.1 SEQUENCE { INTEGER r, INTEGER s } parse as fallback.
|
||||||
|
case der do
|
||||||
|
<<0x30, _len, 0x02, r_len, r::binary-size(r_len), 0x02, s_len, s::binary-size(s_len)>> ->
|
||||||
|
{:ok, int_bytes_to_32(r) <> int_bytes_to_32(s)}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, :invalid_signature_encoding}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp raw_ecdsa_to_der(<<r::unsigned-big-integer-size(256), s::unsigned-big-integer-size(256)>>) do
|
||||||
|
:public_key.der_encode(:"ECDSA-Sig-Value", {:"ECDSA-Sig-Value", r, s})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp raw_ecdsa_to_der(other) when is_binary(other) and byte_size(other) == 64 do
|
||||||
|
<<r::binary-32, s::binary-32>> = other
|
||||||
|
r_int = :binary.decode_unsigned(r)
|
||||||
|
s_int = :binary.decode_unsigned(s)
|
||||||
|
:public_key.der_encode(:"ECDSA-Sig-Value", {:"ECDSA-Sig-Value", r_int, s_int})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp int_bytes_to_32(bin) do
|
||||||
|
bin = String.trim_leading(bin, <<0>>)
|
||||||
|
bin = if bin == <<>>, do: <<0>>, else: bin
|
||||||
|
pad = 32 - byte_size(bin)
|
||||||
|
|
||||||
|
if pad < 0 do
|
||||||
|
binary_part(bin, byte_size(bin) - 32, 32)
|
||||||
|
else
|
||||||
|
:binary.copy(<<0>>, pad) <> bin
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp rrsig_signature_bytes(%{signature: sig}) when is_binary(sig) and byte_size(sig) == 64,
|
||||||
|
do: sig
|
||||||
|
|
||||||
|
defp rrsig_signature_bytes(%{content: content}) when is_binary(content) do
|
||||||
|
case Base.decode64(content) do
|
||||||
|
{:ok, sig} -> sig
|
||||||
|
:error -> Base.decode64!(content, padding: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp max_ttl(records, default) do
|
||||||
|
records
|
||||||
|
|> Enum.map(&(Map.get(&1, :ttl) || default))
|
||||||
|
|> Enum.max(fn -> default end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp label_count(name) do
|
||||||
|
name
|
||||||
|
|> normalize_name()
|
||||||
|
|> String.split(".", trim: true)
|
||||||
|
|> length()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp owner_zone_apex(owner, opts) do
|
||||||
|
Keyword.get(opts, :signer) || normalize_name(owner)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_name(name) when is_binary(name) do
|
||||||
|
name |> String.trim() |> String.trim_trailing(".") |> String.downcase()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_name(name), do: name |> to_string() |> normalize_name()
|
||||||
|
|
||||||
|
defp normalize_type(type) when is_atom(type), do: type
|
||||||
|
|
||||||
|
defp normalize_type(type) when is_binary(type) do
|
||||||
|
case String.downcase(type) do
|
||||||
|
"a" -> :a
|
||||||
|
"aaaa" -> :aaaa
|
||||||
|
"ns" -> :ns
|
||||||
|
"soa" -> :soa
|
||||||
|
"mx" -> :mx
|
||||||
|
"txt" -> :txt
|
||||||
|
"cname" -> :cname
|
||||||
|
"caa" -> :caa
|
||||||
|
"srv" -> :srv
|
||||||
|
"sshfp" -> :sshfp
|
||||||
|
"tlsa" -> :tlsa
|
||||||
|
"ds" -> :ds
|
||||||
|
"dnskey" -> :dnskey
|
||||||
|
"https" -> :https
|
||||||
|
"svcb" -> :svcb
|
||||||
|
"rrsig" -> :rrsig
|
||||||
|
"alias" -> :alias
|
||||||
|
_ -> :unsupported
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_type(type), do: type
|
||||||
|
end
|
||||||
|
|
@ -16,8 +16,10 @@ defmodule Elektrine.DNS.Packet do
|
||||||
txt: 16,
|
txt: 16,
|
||||||
aaaa: 28,
|
aaaa: 28,
|
||||||
srv: 33,
|
srv: 33,
|
||||||
sshfp: 44,
|
# DS before SSHFP so the map documents IANA order; both are supported.
|
||||||
ds: 43,
|
ds: 43,
|
||||||
|
sshfp: 44,
|
||||||
|
rrsig: 46,
|
||||||
dnskey: 48,
|
dnskey: 48,
|
||||||
tlsa: 52,
|
tlsa: 52,
|
||||||
svcb: 64,
|
svcb: 64,
|
||||||
|
|
@ -267,11 +269,24 @@ defmodule Elektrine.DNS.Packet do
|
||||||
|
|
||||||
defp decode_name(_, _, _, _), do: {:error, :bad_name}
|
defp decode_name(_, _, _, _), do: {:error, :bad_name}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Wire RDATA for a single resource record (no owner/type/class/ttl framing).
|
||||||
|
|
||||||
|
Used by the DNSSEC signer for canonical RRSet hashing and by response
|
||||||
|
encoding. Raises `ArgumentError` when the record cannot be encoded.
|
||||||
|
"""
|
||||||
|
def rr_rdata(record) when is_map(record) do
|
||||||
|
encode_rdata(Map.put(record, :type, normalize_type(Map.get(record, :type))))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "IANA RR type number for a known type atom or string."
|
||||||
|
def type_number(type), do: encode_type(type)
|
||||||
|
|
||||||
defp encode_record(record, qname_norm) do
|
defp encode_record(record, qname_norm) do
|
||||||
name = encode_owner(record_name(record), qname_norm)
|
name = encode_owner(record_name(record), qname_norm)
|
||||||
type = encode_type(record.type)
|
type = encode_type(record.type)
|
||||||
ttl = Map.get(record, :ttl, 300)
|
ttl = Map.get(record, :ttl, 300)
|
||||||
rdata = encode_rdata(Map.put(record, :type, normalize_type(record.type)))
|
rdata = rr_rdata(record)
|
||||||
|
|
||||||
<<name::binary, type::16, 1::16, ttl::32, byte_size(rdata)::16, rdata::binary>>
|
<<name::binary, type::16, 1::16, ttl::32, byte_size(rdata)::16, rdata::binary>>
|
||||||
end
|
end
|
||||||
|
|
@ -300,11 +315,16 @@ defmodule Elektrine.DNS.Packet do
|
||||||
tuple |> Tuple.to_list() |> Enum.map_join(&<<&1::16>>)
|
tuple |> Tuple.to_list() |> Enum.map_join(&<<&1::16>>)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp encode_rdata(%{type: :ns, value: value}), do: encode_name(value)
|
defp encode_rdata(%{type: :ns, value: value}) when is_binary(value),
|
||||||
defp encode_rdata(%{type: :cname, content: content}), do: encode_name(content)
|
do: encode_name_canonical(value)
|
||||||
|
|
||||||
|
defp encode_rdata(%{type: :ns, content: content}) when is_binary(content),
|
||||||
|
do: encode_name_canonical(content)
|
||||||
|
|
||||||
|
defp encode_rdata(%{type: :cname, content: content}), do: encode_name_canonical(content)
|
||||||
|
|
||||||
defp encode_rdata(%{type: :mx, content: content, priority: priority}),
|
defp encode_rdata(%{type: :mx, content: content, priority: priority}),
|
||||||
do: <<priority || 10::16, encode_name(content)::binary>>
|
do: <<priority || 10::16, encode_name_canonical(content)::binary>>
|
||||||
|
|
||||||
defp encode_rdata(%{type: :txt, content: content}), do: encode_txt(content)
|
defp encode_rdata(%{type: :txt, content: content}), do: encode_txt(content)
|
||||||
defp encode_rdata(%{type: :txt, value: value}), do: encode_txt(value)
|
defp encode_rdata(%{type: :txt, value: value}), do: encode_txt(value)
|
||||||
|
|
@ -319,7 +339,7 @@ defmodule Elektrine.DNS.Packet do
|
||||||
weight: weight,
|
weight: weight,
|
||||||
port: port
|
port: port
|
||||||
}) do
|
}) do
|
||||||
<<priority || 0::16, weight || 0::16, port || 0::16, encode_name(content)::binary>>
|
<<priority || 0::16, weight || 0::16, port || 0::16, encode_name_canonical(content)::binary>>
|
||||||
end
|
end
|
||||||
|
|
||||||
defp encode_rdata(%{type: :caa, flags: flags, tag: tag, content: content}) do
|
defp encode_rdata(%{type: :caa, flags: flags, tag: tag, content: content}) do
|
||||||
|
|
@ -366,17 +386,29 @@ defmodule Elektrine.DNS.Packet do
|
||||||
<<algorithm || 0::8, digest_type || 0::8, decode_hex_data(content)::binary>>
|
<<algorithm || 0::8, digest_type || 0::8, decode_hex_data(content)::binary>>
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp encode_rdata(%{type: :rrsig} = record) do
|
||||||
|
type_covered = encode_type(Map.get(record, :type_covered))
|
||||||
|
algorithm = Map.get(record, :algorithm) || 0
|
||||||
|
labels = Map.get(record, :labels) || 0
|
||||||
|
original_ttl = Map.get(record, :original_ttl) || Map.get(record, :ttl) || 0
|
||||||
|
expiration = Map.get(record, :expiration) || 0
|
||||||
|
inception = Map.get(record, :inception) || 0
|
||||||
|
key_tag = Map.get(record, :key_tag) || 0
|
||||||
|
signer = Map.get(record, :signer) || Map.get(record, :signers_name) || ""
|
||||||
|
signature = rrsig_signature_binary(record)
|
||||||
|
|
||||||
|
<<type_covered::16, algorithm::8, labels::8, original_ttl::32, expiration::32, inception::32,
|
||||||
|
key_tag::16, encode_name_canonical(signer)::binary, signature::binary>>
|
||||||
|
end
|
||||||
|
|
||||||
defp encode_rdata(%{type: type, priority: priority, content: content})
|
defp encode_rdata(%{type: type, priority: priority, content: content})
|
||||||
when type in [:svcb, :https] do
|
when type in [:svcb, :https] do
|
||||||
case ServiceBinding.encode_rdata(priority || 0, content, &encode_name/1) do
|
case ServiceBinding.encode_rdata(priority || 0, content, &encode_name_canonical/1) do
|
||||||
{:ok, rdata} -> rdata
|
{:ok, rdata} -> rdata
|
||||||
{:error, reason} -> raise ArgumentError, "invalid service binding record: #{reason}"
|
{:error, reason} -> raise ArgumentError, "invalid service binding record: #{reason}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp encode_rdata(%{content: content}) when is_binary(content), do: content
|
|
||||||
defp encode_rdata(%{value: value}) when is_binary(value), do: value
|
|
||||||
|
|
||||||
defp encode_rdata(%{
|
defp encode_rdata(%{
|
||||||
type: :soa,
|
type: :soa,
|
||||||
mname: mname,
|
mname: mname,
|
||||||
|
|
@ -387,8 +419,41 @@ defmodule Elektrine.DNS.Packet do
|
||||||
expire: expire,
|
expire: expire,
|
||||||
minimum: minimum
|
minimum: minimum
|
||||||
}) do
|
}) do
|
||||||
<<encode_name(mname)::binary, encode_name(rname)::binary, serial::32, refresh::32, retry::32,
|
<<encode_name_canonical(mname)::binary, encode_name_canonical(rname)::binary, serial::32,
|
||||||
expire::32, minimum::32>>
|
refresh::32, retry::32, expire::32, minimum::32>>
|
||||||
|
end
|
||||||
|
|
||||||
|
defp encode_rdata(%{content: content}) when is_binary(content), do: content
|
||||||
|
defp encode_rdata(%{value: value}) when is_binary(value), do: value
|
||||||
|
|
||||||
|
defp rrsig_signature_binary(%{signature: sig}) when is_binary(sig) and byte_size(sig) > 0,
|
||||||
|
do: sig
|
||||||
|
|
||||||
|
defp rrsig_signature_binary(%{content: content}) when is_binary(content),
|
||||||
|
do: decode_base64_data(content)
|
||||||
|
|
||||||
|
defp rrsig_signature_binary(_), do: raise(ArgumentError, "RRSIG missing signature bytes")
|
||||||
|
|
||||||
|
# Lower-case owner labels for DNSSEC canonical form (RFC 4034 §6.2). Safe for
|
||||||
|
# non-DNSSEC answers too — DNS names are case-insensitive on the wire.
|
||||||
|
defp encode_name_canonical(name), do: encode_name(name)
|
||||||
|
|
||||||
|
defp encode_name(name) do
|
||||||
|
normalized =
|
||||||
|
name
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim_trailing(".")
|
||||||
|
|> String.downcase()
|
||||||
|
|
||||||
|
labels = String.split(normalized, ".", trim: true)
|
||||||
|
|
||||||
|
if byte_size(normalized) > 253 or Enum.any?(labels, &(byte_size(&1) > 63)) do
|
||||||
|
raise ArgumentError, "DNS name is too long"
|
||||||
|
end
|
||||||
|
|
||||||
|
labels
|
||||||
|
|> Enum.map_join(fn label -> <<byte_size(label)>> <> label end)
|
||||||
|
|> Kernel.<>(<<0>>)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp encode_txt(content) do
|
defp encode_txt(content) do
|
||||||
|
|
@ -419,19 +484,6 @@ defmodule Elektrine.DNS.Packet do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp encode_name(name) do
|
|
||||||
normalized = name |> to_string() |> String.trim_trailing(".")
|
|
||||||
labels = String.split(normalized, ".", trim: true)
|
|
||||||
|
|
||||||
if byte_size(normalized) > 253 or Enum.any?(labels, &(byte_size(&1) > 63)) do
|
|
||||||
raise ArgumentError, "DNS name is too long"
|
|
||||||
end
|
|
||||||
|
|
||||||
labels
|
|
||||||
|> Enum.map_join(fn label -> <<byte_size(label)>> <> label end)
|
|
||||||
|> Kernel.<>(<<0>>)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp record_name(%{host: host}), do: host
|
defp record_name(%{host: host}), do: host
|
||||||
defp record_name(%{name: name}), do: name
|
defp record_name(%{name: name}), do: name
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ defmodule Elektrine.DNS.Query do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
import Bitwise
|
import Bitwise
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias Elektrine.DNS
|
alias Elektrine.DNS
|
||||||
|
alias Elektrine.DNS.Dnssec.Signer
|
||||||
alias Elektrine.DNS.Packet
|
alias Elektrine.DNS.Packet
|
||||||
|
|
||||||
@type_names %{
|
@type_names %{
|
||||||
|
|
@ -17,6 +19,7 @@ defmodule Elektrine.DNS.Query do
|
||||||
"https" => :https,
|
"https" => :https,
|
||||||
"mx" => :mx,
|
"mx" => :mx,
|
||||||
"ns" => :ns,
|
"ns" => :ns,
|
||||||
|
"rrsig" => :rrsig,
|
||||||
"soa" => :soa,
|
"soa" => :soa,
|
||||||
"srv" => :srv,
|
"srv" => :srv,
|
||||||
"sshfp" => :sshfp,
|
"sshfp" => :sshfp,
|
||||||
|
|
@ -163,6 +166,9 @@ defmodule Elektrine.DNS.Query do
|
||||||
authority = [Elektrine.DNS.Zone.soa_record(zone)]
|
authority = [Elektrine.DNS.Zone.soa_record(zone)]
|
||||||
additional = additional_records(zone, records, opts)
|
additional = additional_records(zone, records, opts)
|
||||||
|
|
||||||
|
{records, authority, additional} =
|
||||||
|
maybe_attach_dnssec(zone, query, records, authority, additional)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
records != [] ->
|
records != [] ->
|
||||||
response =
|
response =
|
||||||
|
|
@ -196,6 +202,99 @@ defmodule Elektrine.DNS.Query do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# DNSSEC serve path (PR 10b). Flag off / unprepared zones are a pure no-op so
|
||||||
|
# unsigned authority behavior is unchanged. Failures never raise — unsigned
|
||||||
|
# types simply omit RRSIGs.
|
||||||
|
defp maybe_attach_dnssec(zone, query, records, authority, additional) do
|
||||||
|
if serve_dnssec?(zone) do
|
||||||
|
records = attach_answer_dnssec(zone, query, records)
|
||||||
|
authority = attach_authority_dnssec(zone, query, records, authority)
|
||||||
|
{records, authority, additional}
|
||||||
|
else
|
||||||
|
{records, authority, additional}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
error ->
|
||||||
|
Logger.warning("DNSSEC attach failed for #{query.qname}: #{Exception.message(error)}")
|
||||||
|
{records, authority, additional}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp serve_dnssec?(zone) do
|
||||||
|
DNS.dnssec_enabled?() and zone.dnssec_enabled == true and Signer.prepared?(zone)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp attach_answer_dnssec(zone, query, records) do
|
||||||
|
cond do
|
||||||
|
records == [] ->
|
||||||
|
records
|
||||||
|
|
||||||
|
# DNSKEY at the apex is synthesized from key material, not user records.
|
||||||
|
query.qtype == :dnskey and apex_query?(zone, query.qname) ->
|
||||||
|
dnskeys = Signer.dnskey_records(zone)
|
||||||
|
|
||||||
|
if dnskeys == [] do
|
||||||
|
records
|
||||||
|
else
|
||||||
|
maybe_append_rrsigs(zone, query, dnskeys, normalize_name(zone.domain), :dnskey)
|
||||||
|
end
|
||||||
|
|
||||||
|
query.qtype == :rrsig ->
|
||||||
|
# Direct RRSIG queries: return covering signatures for the owner when known.
|
||||||
|
owner = normalize_name(query.qname)
|
||||||
|
|
||||||
|
zone
|
||||||
|
|> Map.get(:dnssec_bundle, %{})
|
||||||
|
|> Map.get(:rrsigs, %{})
|
||||||
|
|> Enum.filter(fn {{name, _type}, _} -> name == owner end)
|
||||||
|
|> Enum.flat_map(fn {_, sigs} -> sigs end)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
type = answer_rrset_type(records, query.qtype)
|
||||||
|
owner = answer_rrset_owner(records, query.qname)
|
||||||
|
maybe_append_rrsigs(zone, query, records, owner, type)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp attach_authority_dnssec(zone, query, records, authority) do
|
||||||
|
# Attach SOA RRSIG on NODATA / NXDOMAIN when the client set DO.
|
||||||
|
if records == [] and dnssec_ok?(query) do
|
||||||
|
soa_owner = normalize_name(zone.domain)
|
||||||
|
maybe_append_rrsigs(zone, query, authority, soa_owner, :soa)
|
||||||
|
else
|
||||||
|
authority
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_append_rrsigs(zone, query, records, owner, type) do
|
||||||
|
# RRSIGs accompany data only when the client set DO (RFC 4035), or when
|
||||||
|
# RRSIG itself is the QTYPE. Only attach when the answer RRset still matches
|
||||||
|
# what was signed (e.g. health failover must not keep a full-set RRSIG).
|
||||||
|
if dnssec_ok?(query) or query.qtype == :rrsig do
|
||||||
|
case Signer.rrsigs_for_matching(zone, owner, type, records) do
|
||||||
|
[] -> records
|
||||||
|
sigs -> records ++ sigs
|
||||||
|
end
|
||||||
|
else
|
||||||
|
records
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp dnssec_ok?(query), do: Map.get(query, :dnssec_ok, false) == true
|
||||||
|
|
||||||
|
defp apex_query?(zone, qname), do: normalize_name(qname) == normalize_name(zone.domain)
|
||||||
|
|
||||||
|
defp answer_rrset_type([first | _], _qtype) do
|
||||||
|
normalize_type(Map.get(first, :type))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp answer_rrset_type(_, qtype), do: qtype
|
||||||
|
|
||||||
|
defp answer_rrset_owner([first | _], qname) do
|
||||||
|
Map.get(first, :host) || Map.get(first, :name) || qname
|
||||||
|
end
|
||||||
|
|
||||||
|
defp answer_rrset_owner(_, qname), do: qname
|
||||||
|
|
||||||
defp response_meta(query, response, zone, rcode, authoritative) do
|
defp response_meta(query, response, zone, rcode, authoritative) do
|
||||||
%{
|
%{
|
||||||
response: response,
|
response: response,
|
||||||
|
|
@ -249,9 +348,22 @@ defmodule Elektrine.DNS.Query do
|
||||||
defp do_records_for_query(zone, qname, qtype, opts) do
|
defp do_records_for_query(zone, qname, qtype, opts) do
|
||||||
fqdn = normalize_name(qname)
|
fqdn = normalize_name(qname)
|
||||||
|
|
||||||
if qtype == :soa and fqdn == normalize_name(zone.domain) do
|
cond do
|
||||||
|
qtype == :soa and fqdn == normalize_name(zone.domain) ->
|
||||||
[Elektrine.DNS.Zone.soa_record(zone)]
|
[Elektrine.DNS.Zone.soa_record(zone)]
|
||||||
else
|
|
||||||
|
qtype == :dnskey and fqdn == normalize_name(zone.domain) and serve_dnssec?(zone) ->
|
||||||
|
case Signer.dnskey_records(zone) do
|
||||||
|
[] -> exact_or_fallthrough(zone, fqdn, qtype, opts)
|
||||||
|
dnskeys -> dnskeys
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
exact_or_fallthrough(zone, fqdn, qtype, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp exact_or_fallthrough(zone, fqdn, qtype, opts) do
|
||||||
exact_records = exact_records_for_query(zone, fqdn, qtype, opts)
|
exact_records = exact_records_for_query(zone, fqdn, qtype, opts)
|
||||||
|
|
||||||
if exact_records != [] do
|
if exact_records != [] do
|
||||||
|
|
@ -280,7 +392,6 @@ defmodule Elektrine.DNS.Query do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
defp exact_records_for_query(zone, fqdn, qtype, opts) do
|
defp exact_records_for_query(zone, fqdn, qtype, opts) do
|
||||||
zone_records(zone, opts)
|
zone_records(zone, opts)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ field :axfr_enabled, :boolean, default: false
|
||||||
field :axfr_allow_cidrs, {:array, :string}, default: []
|
field :axfr_allow_cidrs, {:array, :string}, default: []
|
||||||
field :axfr_require_tsig, :boolean, default: false
|
field :axfr_require_tsig, :boolean, default: false
|
||||||
field :axfr_allow_cidrs_text, :string, virtual: true
|
field :axfr_allow_cidrs_text, :string, virtual: true
|
||||||
|
# Populated by ZoneCache / Dnssec.Signer when DNS_DNSSEC_ENABLED and zone keys exist.
|
||||||
|
field :dnssec_bundle, :map, virtual: true, default: nil
|
||||||
|
|
||||||
belongs_to :user, Elektrine.Accounts.User
|
belongs_to :user, Elektrine.Accounts.User
|
||||||
belongs_to :axfr_tsig_key, Elektrine.DNS.TsigKey
|
belongs_to :axfr_tsig_key, Elektrine.DNS.TsigKey
|
||||||
|
|
@ -43,7 +45,7 @@ has_many :tunnels, Elektrine.DNS.Tunnel, foreign_key: :zone_id
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
||||||
@dnssec_statuses ~w(disabled keys_generated)
|
@dnssec_statuses ~w(disabled keys_generated signed)
|
||||||
|
|
||||||
def changeset(zone, attrs) do
|
def changeset(zone, attrs) do
|
||||||
zone
|
zone
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
defmodule Elektrine.DNS.ZoneCache do
|
defmodule Elektrine.DNS.ZoneCache do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
ETS-backed cache for authoritative zone data.
|
ETS-backed cache for authoritative zone data.
|
||||||
|
|
||||||
|
When the global `DNS_DNSSEC_ENABLED` flag is on, verified zones with
|
||||||
|
`dnssec_enabled` are pre-signed on load/refresh (sign-on-publish) so the
|
||||||
|
query path can serve DNSKEY/RRSIG without crypto on the hot path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use GenServer
|
use GenServer
|
||||||
|
|
@ -8,6 +12,8 @@ defmodule Elektrine.DNS.ZoneCache do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Elektrine.DNS
|
||||||
|
alias Elektrine.DNS.Dnssec.Signer
|
||||||
alias Elektrine.DNS.Zone
|
alias Elektrine.DNS.Zone
|
||||||
alias Elektrine.Repo
|
alias Elektrine.Repo
|
||||||
|
|
||||||
|
|
@ -97,17 +103,42 @@ defmodule Elektrine.DNS.ZoneCache do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_zones(repo_opts) do
|
defp load_zones(repo_opts) do
|
||||||
{:ok,
|
sign_dnssec? = DNS.dnssec_enabled?()
|
||||||
|
|
||||||
|
zones =
|
||||||
Zone
|
Zone
|
||||||
|> where([z], z.status == "verified")
|
|> where([z], z.status == "verified")
|
||||||
|> preload(:records)
|
|> preload_zone_assocs(sign_dnssec?)
|
||||||
|> Repo.all(repo_opts)
|
|> Repo.all(repo_opts)
|
||||||
|> Enum.map(&repair_builtin_zone_records/1)}
|
|> Enum.map(&repair_builtin_zone_records/1)
|
||||||
|
|> Enum.map(&maybe_prepare_dnssec(&1, sign_dnssec?))
|
||||||
|
|
||||||
|
{:ok, zones}
|
||||||
rescue
|
rescue
|
||||||
error in [Postgrex.Error, DBConnection.OwnershipError] ->
|
error in [Postgrex.Error, DBConnection.OwnershipError] ->
|
||||||
{:error, error}
|
{:error, error}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp preload_zone_assocs(query, true) do
|
||||||
|
preload(query, [:records, :dnssec_keys])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preload_zone_assocs(query, false) do
|
||||||
|
preload(query, :records)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_prepare_dnssec(%Zone{} = zone, true) do
|
||||||
|
if zone.dnssec_enabled == true do
|
||||||
|
# prepare_zone strips dnssec_keys so PEMs never land in ETS.
|
||||||
|
Signer.prepare_zone(zone)
|
||||||
|
else
|
||||||
|
# Do not retain preloaded key rows when the zone is not DNSSEC-enabled.
|
||||||
|
%{zone | dnssec_keys: []}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_prepare_dnssec(zone, _), do: zone
|
||||||
|
|
||||||
defp repair_builtin_zone_records(%Zone{} = zone) do
|
defp repair_builtin_zone_records(%Zone{} = zone) do
|
||||||
case Elektrine.DNS.repair_builtin_user_zone_records(zone) do
|
case Elektrine.DNS.repair_builtin_user_zone_records(zone) do
|
||||||
{:ok, zone} -> zone
|
{:ok, zone} -> zone
|
||||||
|
|
|
||||||
372
apps/elektrine_dns/test/elektrine/dns/dnssec_query_test.exs
Normal file
372
apps/elektrine_dns/test/elektrine/dns/dnssec_query_test.exs
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
defmodule Elektrine.DNS.DnssecQueryTest do
|
||||||
|
use Elektrine.DataCase, async: false
|
||||||
|
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
|
alias Elektrine.AccountsFixtures
|
||||||
|
alias Elektrine.DNS
|
||||||
|
alias Elektrine.DNS.Dnssec
|
||||||
|
alias Elektrine.DNS.Dnssec.Signer
|
||||||
|
alias Elektrine.DNS.Packet
|
||||||
|
alias Elektrine.DNS.Query
|
||||||
|
alias Elektrine.DNS.Record
|
||||||
|
|
||||||
|
setup do
|
||||||
|
old_master = Application.get_env(:elektrine, :encryption_master_secret)
|
||||||
|
old_salt = Application.get_env(:elektrine, :encryption_key_salt)
|
||||||
|
old_dns = Application.get_env(:elektrine, :dns, [])
|
||||||
|
|
||||||
|
Application.put_env(:elektrine, :encryption_master_secret, "test-master-secret-dnssec-012345")
|
||||||
|
Application.put_env(:elektrine, :encryption_key_salt, "test-key-salt-dnssec-0123456789")
|
||||||
|
|
||||||
|
Application.put_env(
|
||||||
|
:elektrine,
|
||||||
|
:dns,
|
||||||
|
Keyword.merge(old_dns,
|
||||||
|
dnssec_enabled: false,
|
||||||
|
nameservers: ["ns1.elektrine.com", "ns2.elektrine.com"],
|
||||||
|
edge_proxy_ipv4_addresses: ["198.51.100.200"],
|
||||||
|
edge_proxy_ipv6_addresses: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
restore_env(:encryption_master_secret, old_master)
|
||||||
|
restore_env(:encryption_key_salt, old_salt)
|
||||||
|
Application.put_env(:elektrine, :dns, old_dns)
|
||||||
|
end)
|
||||||
|
|
||||||
|
table = :ets.whereis(Elektrine.DNS.ZoneCache)
|
||||||
|
|
||||||
|
if table == :undefined do
|
||||||
|
:ets.new(Elektrine.DNS.ZoneCache, [:named_table, :public])
|
||||||
|
else
|
||||||
|
:ets.delete_all_objects(Elektrine.DNS.ZoneCache)
|
||||||
|
end
|
||||||
|
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
{:ok, zone} = DNS.create_zone(user, %{"domain" => "dnssec-q.example.com"})
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
DNS.create_record(zone, %{
|
||||||
|
"name" => "@",
|
||||||
|
"type" => "A",
|
||||||
|
"content" => "203.0.113.77",
|
||||||
|
"ttl" => 300
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
DNS.create_record(zone, %{
|
||||||
|
"name" => "www",
|
||||||
|
"type" => "A",
|
||||||
|
"content" => "203.0.113.78",
|
||||||
|
"ttl" => 300
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
DNS.create_record(zone, %{
|
||||||
|
"name" => "txt",
|
||||||
|
"type" => "TXT",
|
||||||
|
"content" => "v=hello",
|
||||||
|
"ttl" => 300
|
||||||
|
})
|
||||||
|
|
||||||
|
zone = DNS.get_zone(zone.id, user.id)
|
||||||
|
%{user: user, zone: zone}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flag off: no RRSIGs even when zone has keys", %{zone: zone} do
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
keys = Dnssec.list_active_keys(enabled)
|
||||||
|
prepared = Signer.prepare_zone(%{enabled | dnssec_keys: keys, status: "verified"})
|
||||||
|
|
||||||
|
# Global flag remains false (setup default).
|
||||||
|
refute DNS.dnssec_enabled?()
|
||||||
|
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
|
||||||
|
|
||||||
|
response = Query.answer(build_query("dnssec-q.example.com", 1, dnssec_ok: true))
|
||||||
|
assert header(response).ancount == 1
|
||||||
|
assert header(response).rcode == 0
|
||||||
|
assert response =~ <<203, 0, 113, 77>>
|
||||||
|
refute has_rrsig?(response)
|
||||||
|
|
||||||
|
dnskey = Query.answer(build_query("dnssec-q.example.com", 48, dnssec_ok: true))
|
||||||
|
# Without the serve flag, user/managed DNSKEY path is empty for this zone.
|
||||||
|
assert header(dnskey).ancount == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flag on + enabled zone: serves DNSKEY and RRSIG for signed types", %{zone: zone} do
|
||||||
|
put_dnssec_flag(true)
|
||||||
|
assert DNS.dnssec_enabled?()
|
||||||
|
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
keys = Dnssec.list_active_keys(enabled)
|
||||||
|
|
||||||
|
prepared =
|
||||||
|
Signer.prepare_zone(%{
|
||||||
|
enabled
|
||||||
|
| dnssec_keys: keys,
|
||||||
|
status: "verified",
|
||||||
|
records: zone.records
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Signer.prepared?(prepared)
|
||||||
|
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
|
||||||
|
|
||||||
|
# A + DO → answer + RRSIG
|
||||||
|
a_response = Query.answer(build_query("www.dnssec-q.example.com", 1, dnssec_ok: true))
|
||||||
|
assert header(a_response).rcode == 0
|
||||||
|
assert header(a_response).ancount == 2
|
||||||
|
assert a_response =~ <<203, 0, 113, 78>>
|
||||||
|
assert has_rrsig?(a_response)
|
||||||
|
|
||||||
|
# A without DO → no RRSIG
|
||||||
|
plain = Query.answer(build_query("www.dnssec-q.example.com", 1, dnssec_ok: false))
|
||||||
|
assert header(plain).ancount == 1
|
||||||
|
refute has_rrsig?(plain)
|
||||||
|
|
||||||
|
# Apex DNSKEY → both keys (+ RRSIG when DO)
|
||||||
|
dnskey_resp = Query.answer(build_query("dnssec-q.example.com", 48, dnssec_ok: true))
|
||||||
|
assert header(dnskey_resp).rcode == 0
|
||||||
|
# 2 DNSKEY + 1 RRSIG
|
||||||
|
assert header(dnskey_resp).ancount == 3
|
||||||
|
assert has_rrsig?(dnskey_resp)
|
||||||
|
|
||||||
|
dnskey_plain = Query.answer(build_query("dnssec-q.example.com", 48, dnssec_ok: false))
|
||||||
|
assert header(dnskey_plain).ancount == 2
|
||||||
|
refute has_rrsig?(dnskey_plain)
|
||||||
|
|
||||||
|
# SOA + DO includes RRSIG
|
||||||
|
soa = Query.answer(build_query("dnssec-q.example.com", 6, dnssec_ok: true))
|
||||||
|
assert header(soa).ancount == 2
|
||||||
|
assert has_rrsig?(soa)
|
||||||
|
|
||||||
|
# TXT signed
|
||||||
|
txt = Query.answer(build_query("txt.dnssec-q.example.com", 16, dnssec_ok: true))
|
||||||
|
assert header(txt).ancount == 2
|
||||||
|
assert has_rrsig?(txt)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unsigned types / missing signatures do not crash", %{zone: zone} do
|
||||||
|
put_dnssec_flag(true)
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
keys = Dnssec.list_active_keys(enabled)
|
||||||
|
|
||||||
|
# Bundle with DNSKEY only — no A RRSIGs — still answers A without crash.
|
||||||
|
prepared = %{
|
||||||
|
enabled
|
||||||
|
| dnssec_keys: [],
|
||||||
|
status: "verified",
|
||||||
|
records: zone.records,
|
||||||
|
dnssec_bundle: %{
|
||||||
|
dnskeys: Signer.dnskey_records(Signer.prepare_zone(%{enabled | dnssec_keys: keys})),
|
||||||
|
rrsigs: %{},
|
||||||
|
rrset_digests: %{},
|
||||||
|
signed_at: DateTime.utc_now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
|
||||||
|
|
||||||
|
response = Query.answer(build_query("www.dnssec-q.example.com", 1, dnssec_ok: true))
|
||||||
|
assert header(response).rcode == 0
|
||||||
|
assert header(response).ancount == 1
|
||||||
|
assert response =~ <<203, 0, 113, 78>>
|
||||||
|
refute has_rrsig?(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "zone without dnssec_enabled never serves signatures even with flag on", %{zone: zone} do
|
||||||
|
put_dnssec_flag(true)
|
||||||
|
|
||||||
|
unsigned = %{zone | dnssec_enabled: false, status: "verified"}
|
||||||
|
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", unsigned})
|
||||||
|
|
||||||
|
response = Query.answer(build_query("www.dnssec-q.example.com", 1, dnssec_ok: true))
|
||||||
|
assert header(response).ancount == 1
|
||||||
|
refute has_rrsig?(response)
|
||||||
|
|
||||||
|
dnskey = Query.answer(build_query("dnssec-q.example.com", 48, dnssec_ok: true))
|
||||||
|
assert header(dnskey).ancount == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "NODATA authority can carry SOA RRSIG when DO is set", %{zone: zone} do
|
||||||
|
put_dnssec_flag(true)
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
keys = Dnssec.list_active_keys(enabled)
|
||||||
|
|
||||||
|
prepared =
|
||||||
|
Signer.prepare_zone(%{
|
||||||
|
enabled
|
||||||
|
| dnssec_keys: keys,
|
||||||
|
status: "verified",
|
||||||
|
records: zone.records
|
||||||
|
})
|
||||||
|
|
||||||
|
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
|
||||||
|
|
||||||
|
# www has A but no TXT → NODATA with SOA + SOA RRSIG in authority
|
||||||
|
response = Query.answer(build_query("www.dnssec-q.example.com", 16, dnssec_ok: true))
|
||||||
|
assert header(response).rcode == 0
|
||||||
|
assert header(response).ancount == 0
|
||||||
|
assert header(response).nscount == 2
|
||||||
|
assert has_rrsig?(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "health failover omits RRSIG when answer RRset no longer matches signed set", %{
|
||||||
|
zone: zone
|
||||||
|
} do
|
||||||
|
put_dnssec_flag(true)
|
||||||
|
|
||||||
|
if :ets.whereis(:elektrine_dns_health_status) == :undefined do
|
||||||
|
:ets.new(:elektrine_dns_health_status, [:named_table, :set, :public])
|
||||||
|
end
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
if :ets.whereis(:elektrine_dns_health_status) != :undefined do
|
||||||
|
:ets.delete(:elektrine_dns_health_status, {"203.0.113.30", 443})
|
||||||
|
:ets.delete(:elektrine_dns_health_status, {"203.0.113.31", 443})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
keys = Dnssec.list_active_keys(enabled)
|
||||||
|
|
||||||
|
records = [
|
||||||
|
%Record{
|
||||||
|
name: "@",
|
||||||
|
type: "A",
|
||||||
|
content: "203.0.113.30",
|
||||||
|
ttl: 300,
|
||||||
|
metadata: %{"health_check" => %{"enabled" => true}}
|
||||||
|
},
|
||||||
|
%Record{
|
||||||
|
name: "@",
|
||||||
|
type: "A",
|
||||||
|
content: "203.0.113.31",
|
||||||
|
ttl: 300,
|
||||||
|
metadata: %{"health_check" => %{"enabled" => true}}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
prepared =
|
||||||
|
Signer.prepare_zone(%{
|
||||||
|
enabled
|
||||||
|
| dnssec_keys: keys,
|
||||||
|
status: "verified",
|
||||||
|
records: records
|
||||||
|
})
|
||||||
|
|
||||||
|
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", prepared})
|
||||||
|
|
||||||
|
# Full set healthy → both A + covering RRSIG
|
||||||
|
full = Query.answer(build_query("dnssec-q.example.com", 1, dnssec_ok: true))
|
||||||
|
assert header(full).ancount == 3
|
||||||
|
assert has_rrsig?(full)
|
||||||
|
assert full =~ <<203, 0, 113, 30>>
|
||||||
|
assert full =~ <<203, 0, 113, 31>>
|
||||||
|
|
||||||
|
# One target down → single A without RRSIG (never full-set RRSIG on subset)
|
||||||
|
:ets.insert(:elektrine_dns_health_status, {{"203.0.113.31", 443}, :down, 3})
|
||||||
|
filtered = Query.answer(build_query("dnssec-q.example.com", 1, dnssec_ok: true))
|
||||||
|
assert header(filtered).ancount == 1
|
||||||
|
assert filtered =~ <<203, 0, 113, 30>>
|
||||||
|
refute filtered =~ <<203, 0, 113, 31>>
|
||||||
|
refute has_rrsig?(filtered)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Packet response with RRSIG survives encode_response" do
|
||||||
|
query = %{
|
||||||
|
id: 42,
|
||||||
|
rd: 1,
|
||||||
|
qname: "example.com",
|
||||||
|
qtype: :a,
|
||||||
|
udp_size: 1232,
|
||||||
|
dnssec_ok: true,
|
||||||
|
edns: true
|
||||||
|
}
|
||||||
|
|
||||||
|
answers = [
|
||||||
|
%{host: "example.com", type: :a, content: "192.0.2.1", ttl: 300},
|
||||||
|
%{
|
||||||
|
host: "example.com",
|
||||||
|
type: :rrsig,
|
||||||
|
ttl: 300,
|
||||||
|
type_covered: :a,
|
||||||
|
algorithm: 13,
|
||||||
|
labels: 2,
|
||||||
|
original_ttl: 300,
|
||||||
|
expiration: 1_800_000_000,
|
||||||
|
inception: 1_700_000_000,
|
||||||
|
key_tag: 1001,
|
||||||
|
signer: "example.com",
|
||||||
|
signature: :binary.copy(<<1>>, 64)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = Packet.encode_response(query, answers, :noerror, authoritative: true)
|
||||||
|
assert header(response).ancount == 2
|
||||||
|
assert has_rrsig?(response)
|
||||||
|
assert header(response).aa == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "regression: ordinary authoritative answers still work with flag on", %{zone: zone} do
|
||||||
|
put_dnssec_flag(true)
|
||||||
|
:ets.insert(Elektrine.DNS.ZoneCache, {"dnssec-q.example.com", %{zone | status: "verified"}})
|
||||||
|
|
||||||
|
response = Query.answer(build_query("www.dnssec-q.example.com", 1))
|
||||||
|
assert header(response).rcode == 0
|
||||||
|
assert header(response).ancount == 1
|
||||||
|
assert response =~ <<203, 0, 113, 78>>
|
||||||
|
refute has_rrsig?(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_dnssec_flag(enabled) do
|
||||||
|
dns = Application.get_env(:elektrine, :dns, [])
|
||||||
|
Application.put_env(:elektrine, :dns, Keyword.put(dns, :dnssec_enabled, enabled))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_query(name, type, opts \\ []) do
|
||||||
|
Packet.encode_query(%{
|
||||||
|
id: 0x1234,
|
||||||
|
rd: 1,
|
||||||
|
qname: name,
|
||||||
|
qtype: decode_qtype(type),
|
||||||
|
udp_size: 1232,
|
||||||
|
dnssec_ok: Keyword.get(opts, :dnssec_ok, false)
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_qtype(type) when is_integer(type) do
|
||||||
|
%{
|
||||||
|
1 => :a,
|
||||||
|
6 => :soa,
|
||||||
|
16 => :txt,
|
||||||
|
28 => :aaaa,
|
||||||
|
46 => :rrsig,
|
||||||
|
48 => :dnskey
|
||||||
|
}
|
||||||
|
|> Map.get(type, type)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_qtype(type), do: type
|
||||||
|
|
||||||
|
defp has_rrsig?(packet) when is_binary(packet) do
|
||||||
|
# RRSIG type number is 46 (0x002E). Look for TYPE=46 CLASS=IN in answers.
|
||||||
|
:binary.match(packet, <<46::16, 1::16>>) != :nomatch
|
||||||
|
end
|
||||||
|
|
||||||
|
defp header(
|
||||||
|
<<_id::16, flags::16, _qd::16, ancount::16, nscount::16, arcount::16, _rest::binary>>
|
||||||
|
) do
|
||||||
|
%{
|
||||||
|
rcode: flags &&& 0x000F,
|
||||||
|
aa: flags >>> 10 &&& 1,
|
||||||
|
ancount: ancount,
|
||||||
|
nscount: nscount,
|
||||||
|
arcount: arcount
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp restore_env(key, nil), do: Application.delete_env(:elektrine, key)
|
||||||
|
defp restore_env(key, value), do: Application.put_env(:elektrine, key, value)
|
||||||
|
end
|
||||||
192
apps/elektrine_dns/test/elektrine/dns/dnssec_signer_test.exs
Normal file
192
apps/elektrine_dns/test/elektrine/dns/dnssec_signer_test.exs
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
defmodule Elektrine.DNS.Dnssec.SignerTest do
|
||||||
|
use Elektrine.DataCase, async: false
|
||||||
|
|
||||||
|
alias Elektrine.AccountsFixtures
|
||||||
|
alias Elektrine.DNS
|
||||||
|
alias Elektrine.DNS.Dnssec
|
||||||
|
alias Elektrine.DNS.Dnssec.Signer
|
||||||
|
alias Elektrine.DNS.Packet
|
||||||
|
alias Elektrine.DNS.Record
|
||||||
|
|
||||||
|
setup do
|
||||||
|
old_master = Application.get_env(:elektrine, :encryption_master_secret)
|
||||||
|
old_salt = Application.get_env(:elektrine, :encryption_key_salt)
|
||||||
|
old_dns = Application.get_env(:elektrine, :dns, [])
|
||||||
|
|
||||||
|
Application.put_env(:elektrine, :encryption_master_secret, "test-master-secret-dnssec-012345")
|
||||||
|
Application.put_env(:elektrine, :encryption_key_salt, "test-key-salt-dnssec-0123456789")
|
||||||
|
|
||||||
|
Application.put_env(
|
||||||
|
:elektrine,
|
||||||
|
:dns,
|
||||||
|
Keyword.merge(old_dns,
|
||||||
|
dnssec_enabled: true,
|
||||||
|
nameservers: ["ns1.elektrine.com", "ns2.elektrine.com"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
restore_env(:encryption_master_secret, old_master)
|
||||||
|
restore_env(:encryption_key_salt, old_salt)
|
||||||
|
Application.put_env(:elektrine, :dns, old_dns)
|
||||||
|
end)
|
||||||
|
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
{:ok, zone} = DNS.create_zone(user, %{"domain" => "sign-example.com"})
|
||||||
|
|
||||||
|
{:ok, _record} =
|
||||||
|
DNS.create_record(zone, %{"name" => "@", "type" => "A", "content" => "203.0.113.9"})
|
||||||
|
|
||||||
|
zone = DNS.get_zone(zone.id, user.id)
|
||||||
|
%{user: user, zone: zone}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prepare_zone builds DNSKEY answers and verifiable RRSIGs", %{zone: zone} do
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
keys = Dnssec.list_active_keys(enabled)
|
||||||
|
prepared = Signer.prepare_zone(%{enabled | dnssec_keys: keys})
|
||||||
|
|
||||||
|
assert Signer.prepared?(prepared)
|
||||||
|
dnskeys = Signer.dnskey_records(prepared)
|
||||||
|
assert length(dnskeys) == 2
|
||||||
|
assert Enum.all?(dnskeys, &(&1.type == :dnskey and &1.host == "sign-example.com"))
|
||||||
|
|
||||||
|
a_records = [
|
||||||
|
%{host: "sign-example.com", type: :a, content: "203.0.113.9", ttl: 300}
|
||||||
|
]
|
||||||
|
|
||||||
|
[rrsig] = Signer.rrsigs_for(prepared, "sign-example.com", :a)
|
||||||
|
assert rrsig.type == :rrsig
|
||||||
|
assert rrsig.type_covered == :a
|
||||||
|
assert rrsig.algorithm == 13
|
||||||
|
assert rrsig.labels == 2
|
||||||
|
assert byte_size(rrsig.signature) == 64
|
||||||
|
|
||||||
|
zsk = Enum.find(keys, &(&1.key_role == "zsk"))
|
||||||
|
assert Signer.verify_rrsig(rrsig, a_records, zsk.public_key)
|
||||||
|
|
||||||
|
[dnskey_rrsig] = Signer.rrsigs_for(prepared, "sign-example.com", :dnskey)
|
||||||
|
ksk = Enum.find(keys, &(&1.key_role == "ksk"))
|
||||||
|
assert Signer.verify_rrsig(dnskey_rrsig, dnskeys, ksk.public_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sign_rrset produces verifiable ECDSA P-256 signatures", %{zone: zone} do
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
[zsk | _] = Enum.filter(Dnssec.list_active_keys(enabled), &(&1.key_role == "zsk"))
|
||||||
|
now = ~U[2026-01-15 12:00:00Z]
|
||||||
|
|
||||||
|
records = [%{host: "sign-example.com", type: :a, content: "192.0.2.1", ttl: 300}]
|
||||||
|
|
||||||
|
assert {:ok, sig1} =
|
||||||
|
Signer.sign_rrset("sign-example.com", :a, records, zsk,
|
||||||
|
now: now,
|
||||||
|
signer: "sign-example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:ok, sig2} =
|
||||||
|
Signer.sign_rrset("sign-example.com", :a, records, zsk,
|
||||||
|
now: now,
|
||||||
|
signer: "sign-example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ECDSA nonces are random; both signatures must verify the same RRset.
|
||||||
|
assert sig1.inception == sig2.inception
|
||||||
|
assert sig1.expiration == sig2.expiration
|
||||||
|
assert byte_size(sig1.signature) == 64
|
||||||
|
assert byte_size(sig2.signature) == 64
|
||||||
|
assert Signer.verify_rrsig(sig1, records, zsk.public_key)
|
||||||
|
assert Signer.verify_rrsig(sig2, records, zsk.public_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prepare_zone is a no-op without keys", %{zone: zone} do
|
||||||
|
prepared = Signer.prepare_zone(zone)
|
||||||
|
refute Signer.prepared?(prepared)
|
||||||
|
assert prepared.dnssec_bundle == nil
|
||||||
|
assert prepared.dnssec_keys == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prepare_zone drops private keys from the returned zone", %{zone: zone} do
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
keys = Dnssec.list_active_keys(enabled)
|
||||||
|
assert Enum.any?(keys, &is_binary(&1.private_key))
|
||||||
|
|
||||||
|
prepared = Signer.prepare_zone(%{enabled | dnssec_keys: keys})
|
||||||
|
assert Signer.prepared?(prepared)
|
||||||
|
assert prepared.dnssec_keys == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "covers_rrset? rejects reduced answer sets (health-failover shape)", %{zone: zone} do
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
keys = Dnssec.list_active_keys(enabled)
|
||||||
|
|
||||||
|
multi = %{
|
||||||
|
enabled
|
||||||
|
| dnssec_keys: keys,
|
||||||
|
records: [
|
||||||
|
%Record{name: "@", type: "A", content: "203.0.113.30", ttl: 300},
|
||||||
|
%Record{name: "@", type: "A", content: "203.0.113.31", ttl: 300}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared = Signer.prepare_zone(multi)
|
||||||
|
|
||||||
|
full = [
|
||||||
|
%{host: "sign-example.com", type: :a, content: "203.0.113.30", ttl: 300},
|
||||||
|
%{host: "sign-example.com", type: :a, content: "203.0.113.31", ttl: 300}
|
||||||
|
]
|
||||||
|
|
||||||
|
one = [%{host: "sign-example.com", type: :a, content: "203.0.113.30", ttl: 300}]
|
||||||
|
|
||||||
|
assert Signer.covers_rrset?(prepared, "sign-example.com", :a, full)
|
||||||
|
refute Signer.covers_rrset?(prepared, "sign-example.com", :a, one)
|
||||||
|
assert Signer.rrsigs_for_matching(prepared, "sign-example.com", :a, full) != []
|
||||||
|
assert Signer.rrsigs_for_matching(prepared, "sign-example.com", :a, one) == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unsigned / unencodable types do not crash prepare", %{zone: zone} do
|
||||||
|
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||||
|
keys = Dnssec.list_active_keys(enabled)
|
||||||
|
|
||||||
|
weird = %{
|
||||||
|
enabled
|
||||||
|
| dnssec_keys: keys,
|
||||||
|
records: [
|
||||||
|
%Record{name: "@", type: "A", content: "203.0.113.1", ttl: 300},
|
||||||
|
%Record{name: "bad", type: "A", content: "not-an-ip", ttl: 300}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared = Signer.prepare_zone(weird)
|
||||||
|
assert Signer.prepared?(prepared)
|
||||||
|
# Good A RRset still signed; bad A skipped without raising.
|
||||||
|
assert Signer.rrsigs_for(prepared, "sign-example.com", :a) != []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Packet encodes RRSIG rdata for algorithm 13" do
|
||||||
|
rrsig = %{
|
||||||
|
host: "example.com",
|
||||||
|
type: :rrsig,
|
||||||
|
ttl: 300,
|
||||||
|
type_covered: :a,
|
||||||
|
algorithm: 13,
|
||||||
|
labels: 2,
|
||||||
|
original_ttl: 300,
|
||||||
|
expiration: 1_800_000_000,
|
||||||
|
inception: 1_700_000_000,
|
||||||
|
key_tag: 12_345,
|
||||||
|
signer: "example.com",
|
||||||
|
signature: :binary.copy(<<0xAB>>, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
rdata = Packet.rr_rdata(rrsig)
|
||||||
|
# type covered A (1), alg 13, labels 2, original ttl 300
|
||||||
|
assert <<1::16, 13, 2, 300::32, 1_800_000_000::32, 1_700_000_000::32, 12_345::16,
|
||||||
|
rest::binary>> = rdata
|
||||||
|
|
||||||
|
assert rest =~ <<7, "example", 3, "com", 0>>
|
||||||
|
assert String.ends_with?(rest, :binary.copy(<<0xAB>>, 64))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp restore_env(key, nil), do: Application.delete_env(:elektrine, key)
|
||||||
|
defp restore_env(key, value), do: Application.put_env(:elektrine, key, value)
|
||||||
|
end
|
||||||
|
|
@ -304,6 +304,8 @@ config :elektrine, :dns,
|
||||||
authority_enabled: false,
|
authority_enabled: false,
|
||||||
recursive_enabled: false,
|
recursive_enabled: false,
|
||||||
public_bind_ip: nil,
|
public_bind_ip: nil,
|
||||||
|
# Serve DNSKEY/RRSIG for zones with DNSSEC keys (sign-on-publish). Default off.
|
||||||
|
dnssec_enabled: false,
|
||||||
zone_cache_refresh_interval_ms: 300_000,
|
zone_cache_refresh_interval_ms: 300_000,
|
||||||
udp_port: 5300,
|
udp_port: 5300,
|
||||||
tcp_port: 5300,
|
tcp_port: 5300,
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,11 @@ config :elektrine, :dns,
|
||||||
"DNS_RECURSIVE_ENABLED",
|
"DNS_RECURSIVE_ENABLED",
|
||||||
Keyword.get(dns_config, :recursive_enabled, false)
|
Keyword.get(dns_config, :recursive_enabled, false)
|
||||||
),
|
),
|
||||||
|
dnssec_enabled:
|
||||||
|
parse_bool_env.(
|
||||||
|
"DNS_DNSSEC_ENABLED",
|
||||||
|
Keyword.get(dns_config, :dnssec_enabled, false)
|
||||||
|
),
|
||||||
edge_proxy_enabled:
|
edge_proxy_enabled:
|
||||||
parse_bool_env.(
|
parse_bool_env.(
|
||||||
"DNS_EDGE_PROXY_ENABLED",
|
"DNS_EDGE_PROXY_ENABLED",
|
||||||
|
|
|
||||||
2
env/core.env.example
vendored
2
env/core.env.example
vendored
|
|
@ -123,6 +123,8 @@ MESSAGING_FEDERATION_PEERS_JSON='[]'
|
||||||
|
|
||||||
# INTERNAL_API_KEY=
|
# INTERNAL_API_KEY=
|
||||||
# DNS_RECURSIVE_ENABLED=true
|
# DNS_RECURSIVE_ENABLED=true
|
||||||
|
# Serve DNSKEY/RRSIG for zones with DNSSEC keys (default off).
|
||||||
|
# DNS_DNSSEC_ENABLED=false
|
||||||
# VPN_SELFHOST_PUBLIC_IP=203.0.113.10
|
# VPN_SELFHOST_PUBLIC_IP=203.0.113.10
|
||||||
# VPN_SELFHOST_ENDPOINT_HOST=vpn.example.com
|
# VPN_SELFHOST_ENDPOINT_HOST=vpn.example.com
|
||||||
# VPN_SELFHOST_PRIVATE_KEY=
|
# VPN_SELFHOST_PRIVATE_KEY=
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue