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 """
|
||||
Enables DNSSEC key generation for a zone (KSK + ZSK, ECDSA P-256).
|
||||
|
||||
Stores encrypted private keys and zone flags only — does not change the
|
||||
authoritative answer path (see PR 10b for signing/serve).
|
||||
When the global `DNS_DNSSEC_ENABLED` serve flag is on, the authority cache is
|
||||
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}
|
||||
|
||||
@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."
|
||||
def zone_dnssec_status(%Zone{} = zone), do: Dnssec.status(zone)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ defmodule Elektrine.DNS.Dnssec do
|
|||
@moduledoc """
|
||||
DNSSEC key generation and DS export for managed zones.
|
||||
|
||||
PR 10a: generates and stores keys, exports DS for the parent registrar.
|
||||
Does **not** sign RRSets or change the authoritative answer path.
|
||||
Key material and DS export live here. RRSet signing / RRSIG serve is in
|
||||
`Elektrine.DNS.Dnssec.Signer`, gated by the global `DNS_DNSSEC_ENABLED` flag.
|
||||
"""
|
||||
|
||||
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,
|
||||
aaaa: 28,
|
||||
srv: 33,
|
||||
sshfp: 44,
|
||||
# DS before SSHFP so the map documents IANA order; both are supported.
|
||||
ds: 43,
|
||||
sshfp: 44,
|
||||
rrsig: 46,
|
||||
dnskey: 48,
|
||||
tlsa: 52,
|
||||
svcb: 64,
|
||||
|
|
@ -267,11 +269,24 @@ defmodule Elektrine.DNS.Packet do
|
|||
|
||||
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
|
||||
name = encode_owner(record_name(record), qname_norm)
|
||||
type = encode_type(record.type)
|
||||
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>>
|
||||
end
|
||||
|
|
@ -300,11 +315,16 @@ defmodule Elektrine.DNS.Packet do
|
|||
tuple |> Tuple.to_list() |> Enum.map_join(&<<&1::16>>)
|
||||
end
|
||||
|
||||
defp encode_rdata(%{type: :ns, value: value}), do: encode_name(value)
|
||||
defp encode_rdata(%{type: :cname, content: content}), do: encode_name(content)
|
||||
defp encode_rdata(%{type: :ns, value: value}) when is_binary(value),
|
||||
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}),
|
||||
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, value: value}), do: encode_txt(value)
|
||||
|
|
@ -319,7 +339,7 @@ defmodule Elektrine.DNS.Packet do
|
|||
weight: weight,
|
||||
port: port
|
||||
}) 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
|
||||
|
||||
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>>
|
||||
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})
|
||||
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
|
||||
{:error, reason} -> raise ArgumentError, "invalid service binding record: #{reason}"
|
||||
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(%{
|
||||
type: :soa,
|
||||
mname: mname,
|
||||
|
|
@ -387,8 +419,41 @@ defmodule Elektrine.DNS.Packet do
|
|||
expire: expire,
|
||||
minimum: minimum
|
||||
}) do
|
||||
<<encode_name(mname)::binary, encode_name(rname)::binary, serial::32, refresh::32, retry::32,
|
||||
expire::32, minimum::32>>
|
||||
<<encode_name_canonical(mname)::binary, encode_name_canonical(rname)::binary, serial::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
|
||||
|
||||
defp encode_txt(content) do
|
||||
|
|
@ -419,19 +484,6 @@ defmodule Elektrine.DNS.Packet do
|
|||
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(%{name: name}), do: name
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ defmodule Elektrine.DNS.Query do
|
|||
@moduledoc false
|
||||
|
||||
import Bitwise
|
||||
require Logger
|
||||
|
||||
alias Elektrine.DNS
|
||||
alias Elektrine.DNS.Dnssec.Signer
|
||||
alias Elektrine.DNS.Packet
|
||||
|
||||
@type_names %{
|
||||
|
|
@ -17,6 +19,7 @@ defmodule Elektrine.DNS.Query do
|
|||
"https" => :https,
|
||||
"mx" => :mx,
|
||||
"ns" => :ns,
|
||||
"rrsig" => :rrsig,
|
||||
"soa" => :soa,
|
||||
"srv" => :srv,
|
||||
"sshfp" => :sshfp,
|
||||
|
|
@ -163,6 +166,9 @@ defmodule Elektrine.DNS.Query do
|
|||
authority = [Elektrine.DNS.Zone.soa_record(zone)]
|
||||
additional = additional_records(zone, records, opts)
|
||||
|
||||
{records, authority, additional} =
|
||||
maybe_attach_dnssec(zone, query, records, authority, additional)
|
||||
|
||||
cond do
|
||||
records != [] ->
|
||||
response =
|
||||
|
|
@ -196,6 +202,99 @@ defmodule Elektrine.DNS.Query do
|
|||
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
|
||||
%{
|
||||
response: response,
|
||||
|
|
@ -249,9 +348,22 @@ defmodule Elektrine.DNS.Query do
|
|||
defp do_records_for_query(zone, qname, qtype, opts) do
|
||||
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)]
|
||||
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)
|
||||
|
||||
if exact_records != [] do
|
||||
|
|
@ -280,7 +392,6 @@ defmodule Elektrine.DNS.Query do
|
|||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp exact_records_for_query(zone, fqdn, qtype, opts) do
|
||||
zone_records(zone, opts)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ field :axfr_enabled, :boolean, default: false
|
|||
field :axfr_allow_cidrs, {:array, :string}, default: []
|
||||
field :axfr_require_tsig, :boolean, default: false
|
||||
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 :axfr_tsig_key, Elektrine.DNS.TsigKey
|
||||
|
|
@ -43,7 +45,7 @@ has_many :tunnels, Elektrine.DNS.Tunnel, foreign_key: :zone_id
|
|||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@dnssec_statuses ~w(disabled keys_generated)
|
||||
@dnssec_statuses ~w(disabled keys_generated signed)
|
||||
|
||||
def changeset(zone, attrs) do
|
||||
zone
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
defmodule Elektrine.DNS.ZoneCache do
|
||||
@moduledoc """
|
||||
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
|
||||
|
|
@ -8,6 +12,8 @@ defmodule Elektrine.DNS.ZoneCache do
|
|||
|
||||
require Logger
|
||||
|
||||
alias Elektrine.DNS
|
||||
alias Elektrine.DNS.Dnssec.Signer
|
||||
alias Elektrine.DNS.Zone
|
||||
alias Elektrine.Repo
|
||||
|
||||
|
|
@ -97,17 +103,42 @@ defmodule Elektrine.DNS.ZoneCache do
|
|||
end
|
||||
|
||||
defp load_zones(repo_opts) do
|
||||
{:ok,
|
||||
sign_dnssec? = DNS.dnssec_enabled?()
|
||||
|
||||
zones =
|
||||
Zone
|
||||
|> where([z], z.status == "verified")
|
||||
|> preload(:records)
|
||||
|> preload_zone_assocs(sign_dnssec?)
|
||||
|> 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
|
||||
error in [Postgrex.Error, DBConnection.OwnershipError] ->
|
||||
{:error, error}
|
||||
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
|
||||
case Elektrine.DNS.repair_builtin_user_zone_records(zone) do
|
||||
{: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,
|
||||
recursive_enabled: false,
|
||||
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,
|
||||
udp_port: 5300,
|
||||
tcp_port: 5300,
|
||||
|
|
|
|||
|
|
@ -187,6 +187,11 @@ config :elektrine, :dns,
|
|||
"DNS_RECURSIVE_ENABLED",
|
||||
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:
|
||||
parse_bool_env.(
|
||||
"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=
|
||||
# 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_ENDPOINT_HOST=vpn.example.com
|
||||
# VPN_SELFHOST_PRIVATE_KEY=
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue