merge: pr-13-featdns-dnssec-rollover
This commit is contained in:
commit
d892ce951f
18 changed files with 1522 additions and 24 deletions
|
|
@ -368,8 +368,9 @@ alias Elektrine.DNS.SecondaryStore
|
|||
def enable_zone_dnssec(%Zone{} = zone) do
|
||||
case Dnssec.enable_zone(zone) do
|
||||
{:ok, enabled} ->
|
||||
refresh_authority_cache()
|
||||
{:ok, enabled}
|
||||
# Local ZoneCache refresh + pg_notify so the unclustered DNS authority
|
||||
# picks up keys without waiting for the periodic refresh interval.
|
||||
refresh_authority_cache_after_write({:ok, enabled})
|
||||
|
||||
error ->
|
||||
error
|
||||
|
|
@ -378,6 +379,93 @@ alias Elektrine.DNS.SecondaryStore
|
|||
|
||||
def enable_zone_dnssec(_), do: {:error, :invalid_zone}
|
||||
|
||||
@doc """
|
||||
Starts a pre-publication ZSK rollover (dual-active ZSKs + dual-sign).
|
||||
|
||||
Options:
|
||||
|
||||
* `:schedule_complete` (default `true`) — enqueue
|
||||
`Elektrine.DNS.Dnssec.ZskRolloverWorker` to complete after the hold-down.
|
||||
* `:hold_down_seconds` — override the recommended dual-sign window.
|
||||
"""
|
||||
def start_zone_zsk_rollover(zone, opts \\ [])
|
||||
|
||||
def start_zone_zsk_rollover(%Zone{} = zone, opts) when is_list(opts) do
|
||||
case Dnssec.start_zsk_rollover(zone) do
|
||||
{:ok, rolling} ->
|
||||
refresh_authority_cache_after_write({:ok, rolling})
|
||||
|
||||
if Keyword.get(opts, :schedule_complete, true) do
|
||||
hold_down =
|
||||
Keyword.get_lazy(opts, :hold_down_seconds, fn ->
|
||||
Dnssec.recommended_zsk_hold_down_seconds(rolling)
|
||||
end)
|
||||
|
||||
_ =
|
||||
Elektrine.DNS.Dnssec.ZskRolloverWorker.enqueue_complete(rolling,
|
||||
schedule_in: hold_down
|
||||
)
|
||||
end
|
||||
|
||||
{:ok, rolling}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def start_zone_zsk_rollover(_, _), do: {:error, :invalid_zone}
|
||||
|
||||
@doc """
|
||||
Completes a ZSK rollover by retiring all but the newest active ZSK.
|
||||
|
||||
Options:
|
||||
|
||||
* `:force` (default `false`) — allow complete before the recommended hold-down
|
||||
elapses. Without force, returns `{:error, :hold_down_active}` while dual-sign
|
||||
is still within the hold-down window.
|
||||
"""
|
||||
def complete_zone_zsk_rollover(zone_or_id, opts \\ [])
|
||||
|
||||
def complete_zone_zsk_rollover(%Zone{} = zone, opts) when is_list(opts) do
|
||||
case Dnssec.complete_zsk_rollover(zone, opts) do
|
||||
{:ok, completed} ->
|
||||
refresh_authority_cache_after_write({:ok, completed})
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def complete_zone_zsk_rollover(zone_id, opts) when is_integer(zone_id) and is_list(opts) do
|
||||
case Dnssec.complete_zsk_rollover(zone_id, opts) do
|
||||
{:ok, completed} ->
|
||||
refresh_authority_cache_after_write({:ok, completed})
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def complete_zone_zsk_rollover(_, _), do: {:error, :invalid_zone}
|
||||
|
||||
@doc """
|
||||
Emergency recovery: re-activate a previously revoked ZSK so dual-sign resumes.
|
||||
|
||||
See `docs/self-hosting/dnssec-zsk-rollover.md` recovery section.
|
||||
"""
|
||||
def reactivate_zone_zsk(%Zone{} = zone, key_id) when is_integer(key_id) do
|
||||
case Dnssec.reactivate_zsk(zone, key_id) do
|
||||
{:ok, rolling} ->
|
||||
refresh_authority_cache_after_write({:ok, rolling})
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def reactivate_zone_zsk(_, _), do: {:error, :invalid_zone}
|
||||
|
||||
@doc """
|
||||
Global DNSSEC serve-path switch (`DNS_DNSSEC_ENABLED`).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
defmodule Elektrine.DNS.Dnssec do
|
||||
@moduledoc """
|
||||
DNSSEC key generation and DS export for managed zones.
|
||||
DNSSEC key generation, DS export, and ZSK pre-publication rollover.
|
||||
|
||||
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.
|
||||
|
||||
## ZSK rollover (pre-publication dual-sign)
|
||||
|
||||
1. `start_zsk_rollover/1` mints a second active ZSK while keeping the old one.
|
||||
The signer dual-signs every data RRset with both ZSKs and publishes both
|
||||
DNSKEY records.
|
||||
2. Wait the recommended hold-down (`recommended_zsk_hold_down_seconds/1`) so
|
||||
caches and validators observe the dual DNSKEY set.
|
||||
3. `complete_zsk_rollover/2` retires every active ZSK except the newest once
|
||||
the hold-down has elapsed (or with `force: true`). The signer returns to
|
||||
single-ZSK signing.
|
||||
|
||||
Complete is **gated** server-side: without `force: true`, completing while
|
||||
`now < newest_zsk.activated_at + recommended_hold_down` returns
|
||||
`{:error, :hold_down_active}`. The default start path schedules the complete
|
||||
worker after the recommended hold-down so the automated path succeeds without
|
||||
force.
|
||||
|
||||
Use `Elektrine.DNS.Dnssec.ZskRolloverWorker` to schedule the complete step.
|
||||
Emergency recovery after early complete: `reactivate_zsk/2` (not "start again").
|
||||
KSK/DS rollover is out of scope for this module (registrar coordination).
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
|
|
@ -22,6 +43,10 @@ defmodule Elektrine.DNS.Dnssec do
|
|||
@digest_type_sha256 2
|
||||
@status_disabled "disabled"
|
||||
@status_keys_generated "keys_generated"
|
||||
@status_zsk_rollover "zsk_rollover"
|
||||
# Floor hold-down so short-TTL zones still allow cache propagation.
|
||||
@default_zsk_hold_down_seconds 86_400
|
||||
@max_zsk_key_tag_attempts 5
|
||||
|
||||
@type ds_record :: %{
|
||||
key_tag: integer(),
|
||||
|
|
@ -43,7 +68,6 @@ defmodule Elektrine.DNS.Dnssec do
|
|||
active: boolean(),
|
||||
activated_at: DateTime.t() | nil
|
||||
}
|
||||
|
||||
@doc "Default DNSSEC algorithm number (ECDSA P-256 / ECDSAP256SHA256)."
|
||||
def default_algorithm, do: @algorithm_ecdsa_p256
|
||||
|
||||
|
|
@ -195,6 +219,15 @@ defmodule Elektrine.DNS.Dnssec do
|
|||
enabled -> "keys_present"
|
||||
true -> "disabled"
|
||||
end
|
||||
zsks = Enum.filter(keys, &(&1.key_role == "zsk"))
|
||||
rolling? = zone.dnssec_status == @status_zsk_rollover or length(zsks) > 1
|
||||
hold_down = recommended_zsk_hold_down_seconds(zone)
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
newest = newest_zsk(zsks)
|
||||
started_at = newest && newest.activated_at
|
||||
|
||||
{earliest_complete_at, elapsed_seconds, remaining_seconds, can_complete} =
|
||||
hold_down_progress(rolling?, length(zsks), started_at, hold_down, now)
|
||||
|
||||
%{
|
||||
enabled: enabled,
|
||||
|
|
@ -204,7 +237,17 @@ defmodule Elektrine.DNS.Dnssec do
|
|||
algorithm: @algorithm_ecdsa_p256,
|
||||
algorithm_name: "ECDSAP256SHA256",
|
||||
keys: keys,
|
||||
ds: ds_records(zone)
|
||||
ds: ds_records(zone),
|
||||
zsk_rollover: %{
|
||||
active: rolling?,
|
||||
active_zsk_count: length(zsks),
|
||||
recommended_hold_down_seconds: hold_down,
|
||||
started_at: started_at,
|
||||
earliest_complete_at: earliest_complete_at,
|
||||
elapsed_seconds: elapsed_seconds,
|
||||
remaining_seconds: remaining_seconds,
|
||||
can_complete: can_complete
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -217,9 +260,169 @@ defmodule Elektrine.DNS.Dnssec do
|
|||
algorithm: @algorithm_ecdsa_p256,
|
||||
algorithm_name: "ECDSAP256SHA256",
|
||||
keys: [],
|
||||
ds: []
|
||||
ds: [],
|
||||
zsk_rollover: %{
|
||||
active: false,
|
||||
active_zsk_count: 0,
|
||||
recommended_hold_down_seconds: @default_zsk_hold_down_seconds,
|
||||
started_at: nil,
|
||||
earliest_complete_at: nil,
|
||||
elapsed_seconds: 0,
|
||||
remaining_seconds: 0,
|
||||
can_complete: true
|
||||
}
|
||||
}
|
||||
|
||||
@doc """
|
||||
Starts a pre-publication ZSK rollover: mint a new active ZSK, keep the old one.
|
||||
|
||||
Requires DNSSEC enabled with at least one active KSK and one active ZSK.
|
||||
Idempotent while already rolling (returns the zone unchanged).
|
||||
"""
|
||||
@spec start_zsk_rollover(Zone.t()) :: {:ok, Zone.t()} | {:error, term()}
|
||||
def start_zsk_rollover(%Zone{id: zone_id}) when is_integer(zone_id) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
material = generate_key_material(:zsk)
|
||||
|
||||
Multi.new()
|
||||
|> Multi.run(:locked_zone, fn repo, _ ->
|
||||
case from(z in Zone, where: z.id == ^zone_id, lock: "FOR UPDATE") |> repo.one() do
|
||||
%Zone{} = locked -> {:ok, locked}
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
|> Multi.run(:result, fn repo, %{locked_zone: locked} ->
|
||||
do_start_zsk_rollover(repo, locked, material, now, 0)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{result: zone}} ->
|
||||
{:ok, preload_zone(zone)}
|
||||
|
||||
{:error, _step, reason, _changes} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def start_zsk_rollover(_), do: {:error, :invalid_zone}
|
||||
|
||||
@doc """
|
||||
Completes a ZSK rollover by retiring every active ZSK except the newest.
|
||||
|
||||
Safe when only one ZSK is active (no-op beyond status normalize). Returns
|
||||
`{:error, :not_enabled}` when DNSSEC is off.
|
||||
|
||||
## Options
|
||||
|
||||
* `:force` (default `false`) — skip the hold-down eligibility check. Without
|
||||
force, retiring dual-active ZSKs before
|
||||
`newest_zsk.activated_at + recommended_hold_down` returns
|
||||
`{:error, :hold_down_active}`.
|
||||
"""
|
||||
@spec complete_zsk_rollover(Zone.t() | integer(), keyword()) ::
|
||||
{:ok, Zone.t()} | {:error, term()}
|
||||
def complete_zsk_rollover(zone_or_id, opts \\ [])
|
||||
|
||||
def complete_zsk_rollover(%Zone{id: zone_id}, opts) when is_list(opts),
|
||||
do: complete_zsk_rollover(zone_id, opts)
|
||||
|
||||
def complete_zsk_rollover(zone_id, opts) when is_integer(zone_id) and is_list(opts) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
force? = Keyword.get(opts, :force, false) == true
|
||||
|
||||
Multi.new()
|
||||
|> Multi.run(:locked_zone, fn repo, _ ->
|
||||
case from(z in Zone, where: z.id == ^zone_id, lock: "FOR UPDATE") |> repo.one() do
|
||||
%Zone{} = locked -> {:ok, locked}
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
|> Multi.run(:result, fn repo, %{locked_zone: locked} ->
|
||||
do_complete_zsk_rollover(repo, locked, now, force?)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{result: zone}} ->
|
||||
{:ok, preload_zone(zone)}
|
||||
|
||||
{:error, _step, reason, _changes} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def complete_zsk_rollover(_, _), do: {:error, :invalid_zone}
|
||||
|
||||
@doc """
|
||||
Re-activates a revoked ZSK for emergency dual-sign recovery.
|
||||
|
||||
After an early complete, validators may still hold RRSIGs from the retired
|
||||
ZSK. Starting a *new* rollover mints a fresh ZSK and does **not** re-publish
|
||||
the retired key. Call this with the retired key's id to put that ZSK back in
|
||||
the active set (status `zsk_rollover`) so dual-sign resumes.
|
||||
"""
|
||||
@spec reactivate_zsk(Zone.t() | integer(), integer()) :: {:ok, Zone.t()} | {:error, term()}
|
||||
def reactivate_zsk(%Zone{id: zone_id}, key_id) when is_integer(key_id),
|
||||
do: reactivate_zsk(zone_id, key_id)
|
||||
|
||||
def reactivate_zsk(zone_id, key_id) when is_integer(zone_id) and is_integer(key_id) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
|
||||
Multi.new()
|
||||
|> Multi.run(:locked_zone, fn repo, _ ->
|
||||
case from(z in Zone, where: z.id == ^zone_id, lock: "FOR UPDATE") |> repo.one() do
|
||||
%Zone{} = locked -> {:ok, locked}
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
|> Multi.run(:result, fn repo, %{locked_zone: locked} ->
|
||||
do_reactivate_zsk(repo, locked, key_id, now)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{result: zone}} ->
|
||||
{:ok, preload_zone(zone)}
|
||||
|
||||
{:error, _step, reason, _changes} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def reactivate_zsk(_, _), do: {:error, :invalid_zone}
|
||||
|
||||
@doc """
|
||||
Seconds remaining in the dual-sign hold-down (0 when complete is allowed).
|
||||
"""
|
||||
@spec hold_down_remaining_seconds(Zone.t()) :: non_neg_integer()
|
||||
def hold_down_remaining_seconds(%Zone{} = zone) do
|
||||
status(zone).zsk_rollover.remaining_seconds
|
||||
end
|
||||
|
||||
def hold_down_remaining_seconds(_), do: 0
|
||||
|
||||
@doc """
|
||||
Recommended seconds to wait between start and complete for a zone.
|
||||
|
||||
At least `2 * max(default_ttl, soa_minimum)` and never below the configured
|
||||
floor (`DNS_DNSSEC_ZSK_HOLD_DOWN_SECONDS`, default 86400).
|
||||
"""
|
||||
@spec recommended_zsk_hold_down_seconds(Zone.t() | nil) :: non_neg_integer()
|
||||
def recommended_zsk_hold_down_seconds(%Zone{} = zone) do
|
||||
ttl_basis = max(zone.default_ttl || 300, zone.soa_minimum || 300)
|
||||
max(2 * ttl_basis, zsk_hold_down_floor_seconds())
|
||||
end
|
||||
|
||||
def recommended_zsk_hold_down_seconds(_), do: zsk_hold_down_floor_seconds()
|
||||
|
||||
@doc false
|
||||
def zsk_rollover_status, do: @status_zsk_rollover
|
||||
|
||||
@doc false
|
||||
def zsk_hold_down_floor_seconds do
|
||||
Application.get_env(:elektrine, :dns, [])
|
||||
|> Keyword.get(:dnssec_zsk_hold_down_seconds, @default_zsk_hold_down_seconds)
|
||||
|> max(0)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def generate_key_material(role) when role in [:ksk, :zsk] do
|
||||
flags = if role == :ksk, do: @flags_ksk, else: @flags_zsk
|
||||
|
|
@ -325,6 +528,183 @@ defmodule Elektrine.DNS.Dnssec do
|
|||
end
|
||||
end
|
||||
|
||||
defp do_start_zsk_rollover(repo, %Zone{} = locked, material, now, attempt) do
|
||||
cond do
|
||||
locked.dnssec_enabled != true ->
|
||||
{:error, :not_enabled}
|
||||
|
||||
locked.dnssec_status == @status_zsk_rollover ->
|
||||
# Already dual-signing; do not mint another ZSK.
|
||||
{:ok, locked}
|
||||
|
||||
true ->
|
||||
active =
|
||||
DnssecKey
|
||||
|> where([k], k.zone_id == ^locked.id and k.active == true)
|
||||
|> repo.all()
|
||||
|
||||
zsks = Enum.filter(active, &(&1.key_role == "zsk"))
|
||||
ksks = Enum.filter(active, &(&1.key_role == "ksk"))
|
||||
|
||||
cond do
|
||||
ksks == [] or zsks == [] ->
|
||||
{:error, :missing_keys}
|
||||
|
||||
length(zsks) > 1 ->
|
||||
# Dual ZSKs present without status — normalize into rollover state.
|
||||
locked
|
||||
|> Zone.changeset(%{dnssec_status: @status_zsk_rollover})
|
||||
|> repo.update()
|
||||
|
||||
true ->
|
||||
insert_rollover_zsk(repo, locked, material, now, attempt)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_rollover_zsk(repo, locked, material, now, attempt) do
|
||||
case %DnssecKey{}
|
||||
|> DnssecKey.changeset(key_attrs(locked, material, now))
|
||||
|> repo.insert() do
|
||||
{:ok, _zsk} ->
|
||||
locked
|
||||
|> Zone.changeset(%{dnssec_status: @status_zsk_rollover})
|
||||
|> repo.update()
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
if key_tag_conflict?(changeset) and attempt < @max_zsk_key_tag_attempts do
|
||||
insert_rollover_zsk(repo, locked, generate_key_material(:zsk), now, attempt + 1)
|
||||
else
|
||||
{:error, changeset}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_complete_zsk_rollover(repo, %Zone{} = locked, now, force?) do
|
||||
if locked.dnssec_enabled != true do
|
||||
{:error, :not_enabled}
|
||||
else
|
||||
zsks =
|
||||
DnssecKey
|
||||
|> where([k], k.zone_id == ^locked.id and k.active == true and k.key_role == "zsk")
|
||||
|> order_by([k], desc: k.activated_at, desc: k.id)
|
||||
|> repo.all()
|
||||
|
||||
case zsks do
|
||||
[] ->
|
||||
{:error, :missing_keys}
|
||||
|
||||
[_keep] ->
|
||||
# Already single-ZSK: normalize status only (no hold-down gate).
|
||||
locked
|
||||
|> Zone.changeset(%{dnssec_status: @status_keys_generated})
|
||||
|> repo.update()
|
||||
|
||||
[keep | retire] ->
|
||||
with :ok <- assert_hold_down_elapsed(locked, keep, now, force?) do
|
||||
retire_ids = Enum.map(retire, & &1.id)
|
||||
|
||||
{_count, _} =
|
||||
from(k in DnssecKey, where: k.id in ^retire_ids)
|
||||
|> repo.update_all(set: [active: false, revoked_at: now, updated_at: now])
|
||||
|
||||
locked
|
||||
|> Zone.changeset(%{dnssec_status: @status_keys_generated})
|
||||
|> repo.update()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_reactivate_zsk(repo, %Zone{} = locked, key_id, now) do
|
||||
if locked.dnssec_enabled != true do
|
||||
{:error, :not_enabled}
|
||||
else
|
||||
case repo.get_by(DnssecKey, id: key_id, zone_id: locked.id) do
|
||||
%DnssecKey{key_role: "zsk", active: false} = key ->
|
||||
with {:ok, _key} <-
|
||||
key
|
||||
|> DnssecKey.changeset(%{
|
||||
active: true,
|
||||
revoked_at: nil,
|
||||
activated_at: key.activated_at || now
|
||||
})
|
||||
|> repo.update() do
|
||||
locked
|
||||
|> Zone.changeset(%{dnssec_status: @status_zsk_rollover})
|
||||
|> repo.update()
|
||||
end
|
||||
|
||||
%DnssecKey{key_role: "zsk", active: true} ->
|
||||
locked
|
||||
|> Zone.changeset(%{dnssec_status: @status_zsk_rollover})
|
||||
|> repo.update()
|
||||
|
||||
%DnssecKey{} ->
|
||||
{:error, :not_a_zsk}
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp assert_hold_down_elapsed(%Zone{} = zone, %DnssecKey{} = newest_zsk, now, force?) do
|
||||
if force? do
|
||||
:ok
|
||||
else
|
||||
hold_down = recommended_zsk_hold_down_seconds(zone)
|
||||
started = newest_zsk.activated_at || now
|
||||
earliest = DateTime.add(started, hold_down, :second)
|
||||
|
||||
if DateTime.compare(now, earliest) != :lt do
|
||||
:ok
|
||||
else
|
||||
{:error, :hold_down_active}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp newest_zsk([]), do: nil
|
||||
|
||||
defp newest_zsk(zsks) do
|
||||
Enum.max_by(zsks, fn zsk ->
|
||||
{zsk.activated_at || ~U[1970-01-01 00:00:00Z], zsk.id || 0}
|
||||
end)
|
||||
end
|
||||
|
||||
defp hold_down_progress(false, _zsk_count, _started_at, _hold_down, _now) do
|
||||
{nil, 0, 0, true}
|
||||
end
|
||||
|
||||
defp hold_down_progress(true, zsk_count, started_at, hold_down, _now)
|
||||
when zsk_count <= 1 do
|
||||
# Status says rolling but only one ZSK — complete is a safe normalize.
|
||||
{started_at && DateTime.add(started_at, hold_down, :second), 0, 0, true}
|
||||
end
|
||||
|
||||
defp hold_down_progress(true, _zsk_count, nil, hold_down, _now) do
|
||||
{nil, 0, hold_down, false}
|
||||
end
|
||||
|
||||
defp hold_down_progress(true, _zsk_count, started_at, hold_down, now) do
|
||||
earliest = DateTime.add(started_at, hold_down, :second)
|
||||
elapsed = max(DateTime.diff(now, started_at, :second), 0)
|
||||
remaining = max(DateTime.diff(earliest, now, :second), 0)
|
||||
{earliest, elapsed, remaining, remaining == 0}
|
||||
end
|
||||
|
||||
defp key_tag_conflict?(%Ecto.Changeset{} = changeset) do
|
||||
Enum.any?(changeset.errors, fn
|
||||
{:key_tag, _} -> true
|
||||
{_field, {_msg, opts}} -> opts[:constraint] == :unique
|
||||
_ -> false
|
||||
end)
|
||||
end
|
||||
|
||||
defp generate_ecdsa_p256_pair do
|
||||
private_key = :public_key.generate_key({:namedCurve, :secp256r1})
|
||||
|
||||
|
|
|
|||
|
|
@ -239,8 +239,14 @@ defmodule Elektrine.DNS.Dnssec.Signer do
|
|||
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
|
||||
# Dual-sign: every active ZSK signs data RRsets; every active KSK signs
|
||||
# DNSKEY. During ZSK rollover both old and new ZSKs stay active so validators
|
||||
# that still cache RRSIGs from the retiring key keep validating.
|
||||
zsks = Enum.filter(keys, &(&1.key_role == "zsk"))
|
||||
ksks = Enum.filter(keys, &(&1.key_role == "ksk"))
|
||||
zsks = if zsks == [], do: List.wrap(List.first(keys)), else: zsks
|
||||
ksks = if ksks == [], do: zsks, else: ksks
|
||||
|
||||
apex = normalize_name(zone.domain)
|
||||
ttl = zone.default_ttl || 300
|
||||
|
||||
|
|
@ -255,22 +261,35 @@ defmodule Elektrine.DNS.Dnssec.Signer do
|
|||
{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
|
||||
signing_keys = if type == :dnskey, do: ksks, else: zsks
|
||||
original_ttl = max_ttl(records, ttl)
|
||||
|
||||
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)}")
|
||||
sigs =
|
||||
Enum.flat_map(signing_keys, fn signing_key ->
|
||||
case sign_rrset(owner, type, records, signing_key,
|
||||
signer: apex,
|
||||
original_ttl: original_ttl
|
||||
) do
|
||||
{:ok, rrsig} ->
|
||||
[rrsig]
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug(
|
||||
"DNSSEC skip RRSet #{owner}/#{type} key_tag=#{signing_key.key_tag} for #{apex}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
[]
|
||||
end
|
||||
end)
|
||||
|
||||
case {sigs, rrset_digest(type, records)} do
|
||||
{[_ | _] = sigs, {:ok, digest}} ->
|
||||
{
|
||||
Map.put(sig_acc, {owner, type}, sigs),
|
||||
Map.put(dig_acc, {owner, type}, digest)
|
||||
}
|
||||
|
||||
_ ->
|
||||
{sig_acc, dig_acc}
|
||||
end
|
||||
else
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
defmodule Elektrine.DNS.Dnssec.ZskRolloverWorker do
|
||||
@moduledoc """
|
||||
Oban worker for DNSSEC ZSK pre-publication rollover phases.
|
||||
|
||||
## Actions
|
||||
|
||||
* `"start"` — mint a dual-active ZSK set via `DNS.start_zone_zsk_rollover/2`.
|
||||
By default this schedules a delayed `"complete"` job for the recommended
|
||||
hold-down (override with `schedule_complete: false` in args).
|
||||
* `"complete"` — retire retiring ZSKs after the dual-sign window via
|
||||
`DNS.complete_zone_zsk_rollover/1`.
|
||||
|
||||
Jobs are unique per zone+action while scheduled/executing so operators can
|
||||
safely re-enqueue without piling up completes.
|
||||
|
||||
See `docs/self-hosting/dnssec-zsk-rollover.md` for the operator runbook.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :default,
|
||||
max_attempts: 5,
|
||||
unique: [
|
||||
period: 60,
|
||||
fields: [:args, :worker],
|
||||
keys: [:zone_id, :action],
|
||||
states: [:available, :scheduled, :executing, :retryable]
|
||||
]
|
||||
|
||||
alias Elektrine.DNS
|
||||
alias Elektrine.DNS.Dnssec
|
||||
alias Elektrine.DNS.Zone
|
||||
alias Elektrine.JobQueue
|
||||
alias Elektrine.Repo
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"zone_id" => zone_id, "action" => "start"} = args}) do
|
||||
with {:ok, zone} <- fetch_zone(zone_id) do
|
||||
schedule? = Map.get(args, "schedule_complete", true) != false
|
||||
hold_down = Map.get(args, "hold_down_seconds")
|
||||
|
||||
opts =
|
||||
[schedule_complete: schedule?]
|
||||
|> maybe_put_hold_down(hold_down)
|
||||
|
||||
case DNS.start_zone_zsk_rollover(zone, opts) do
|
||||
{:ok, _zone} ->
|
||||
:ok
|
||||
|
||||
{:error, :not_enabled} ->
|
||||
Logger.info("ZSK rollover start skipped for zone #{zone_id}: DNSSEC not enabled")
|
||||
:ok
|
||||
|
||||
{:error, :missing_keys} ->
|
||||
Logger.warning("ZSK rollover start failed for zone #{zone_id}: missing keys")
|
||||
{:error, :missing_keys}
|
||||
|
||||
{:error, :not_found} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("ZSK rollover start failed for zone #{zone_id}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def perform(%Oban.Job{args: %{"zone_id" => zone_id, "action" => "complete"} = args}) do
|
||||
force? = Map.get(args, "force", false) == true
|
||||
|
||||
case DNS.complete_zone_zsk_rollover(zone_id, force: force?) do
|
||||
{:ok, _zone} ->
|
||||
:ok
|
||||
|
||||
{:error, :hold_down_active} ->
|
||||
# Scheduled slightly early or hold-down floor raised after enqueue — wait.
|
||||
remaining =
|
||||
case Repo.get(Zone, zone_id_int(zone_id)) do
|
||||
%Zone{} = zone -> max(Dnssec.hold_down_remaining_seconds(zone), 60)
|
||||
_ -> 60
|
||||
end
|
||||
|
||||
Logger.info(
|
||||
"ZSK rollover complete for zone #{zone_id} deferred: hold-down still active (snooze #{remaining}s)"
|
||||
)
|
||||
|
||||
{:snooze, remaining}
|
||||
|
||||
{:error, :not_enabled} ->
|
||||
:ok
|
||||
|
||||
{:error, :not_found} ->
|
||||
:ok
|
||||
|
||||
{:error, :missing_keys} ->
|
||||
Logger.warning("ZSK rollover complete failed for zone #{zone_id}: missing ZSKs")
|
||||
{:error, :missing_keys}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("ZSK rollover complete failed for zone #{zone_id}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def perform(%Oban.Job{args: args}) do
|
||||
Logger.warning("ZskRolloverWorker received unknown args: #{inspect(args)}")
|
||||
{:error, :invalid_args}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a scheduled job changeset for the given action.
|
||||
|
||||
## Options
|
||||
|
||||
* `:schedule_in` — seconds from now (Oban `schedule_in`)
|
||||
* `:hold_down_seconds` — forwarded into start args for complete scheduling
|
||||
* `:schedule_complete` — whether start should enqueue complete (default true)
|
||||
"""
|
||||
@spec new_job(integer(), String.t(), keyword()) :: Ecto.Changeset.t()
|
||||
def new_job(zone_id, action, opts \\ [])
|
||||
when is_integer(zone_id) and action in ["start", "complete"] do
|
||||
args =
|
||||
%{"zone_id" => zone_id, "action" => action}
|
||||
|> maybe_put_arg("hold_down_seconds", Keyword.get(opts, :hold_down_seconds))
|
||||
|> maybe_put_arg("schedule_complete", Keyword.get(opts, :schedule_complete))
|
||||
|
||||
job_opts =
|
||||
case Keyword.get(opts, :schedule_in) do
|
||||
nil -> []
|
||||
seconds when is_integer(seconds) and seconds >= 0 -> [schedule_in: seconds]
|
||||
end
|
||||
|
||||
new(args, job_opts)
|
||||
end
|
||||
|
||||
@doc "Enqueues a start job (optionally delayed)."
|
||||
@spec enqueue_start(integer() | Zone.t(), keyword()) ::
|
||||
{:ok, Oban.Job.t()} | {:error, term()}
|
||||
def enqueue_start(zone_or_id, opts \\ [])
|
||||
|
||||
def enqueue_start(%Zone{id: zone_id}, opts) when is_list(opts),
|
||||
do: enqueue_start(zone_id, opts)
|
||||
|
||||
def enqueue_start(zone_id, opts) when is_integer(zone_id) and is_list(opts) do
|
||||
zone_id
|
||||
|> new_job("start", opts)
|
||||
|> JobQueue.insert()
|
||||
end
|
||||
|
||||
@doc "Enqueues a complete job after `schedule_in` seconds (or immediately)."
|
||||
@spec enqueue_complete(integer() | Zone.t(), keyword()) ::
|
||||
{:ok, Oban.Job.t()} | {:error, term()}
|
||||
def enqueue_complete(zone_or_id, opts \\ [])
|
||||
|
||||
def enqueue_complete(%Zone{id: zone_id}, opts) when is_list(opts),
|
||||
do: enqueue_complete(zone_id, opts)
|
||||
|
||||
def enqueue_complete(zone_id, opts) when is_integer(zone_id) and is_list(opts) do
|
||||
zone_id
|
||||
|> new_job("complete", opts)
|
||||
|> JobQueue.insert()
|
||||
end
|
||||
|
||||
@doc "Default hold-down used when scheduling complete after start."
|
||||
@spec default_hold_down_seconds(Zone.t() | integer() | nil) :: non_neg_integer()
|
||||
def default_hold_down_seconds(%Zone{} = zone),
|
||||
do: Dnssec.recommended_zsk_hold_down_seconds(zone)
|
||||
|
||||
def default_hold_down_seconds(zone_id) when is_integer(zone_id) do
|
||||
case Repo.get(Zone, zone_id) do
|
||||
%Zone{} = zone -> Dnssec.recommended_zsk_hold_down_seconds(zone)
|
||||
_ -> Dnssec.zsk_hold_down_floor_seconds()
|
||||
end
|
||||
end
|
||||
|
||||
def default_hold_down_seconds(_), do: Dnssec.zsk_hold_down_floor_seconds()
|
||||
|
||||
defp fetch_zone(zone_id) when is_integer(zone_id) do
|
||||
case Repo.get(Zone, zone_id) do
|
||||
%Zone{} = zone -> {:ok, zone}
|
||||
nil -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_zone(zone_id) when is_binary(zone_id) do
|
||||
case Integer.parse(zone_id) do
|
||||
{id, ""} -> fetch_zone(id)
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_zone(_), do: {:error, :not_found}
|
||||
|
||||
defp zone_id_int(zone_id) when is_integer(zone_id), do: zone_id
|
||||
|
||||
defp zone_id_int(zone_id) do
|
||||
case zone_id do
|
||||
bin when is_binary(bin) ->
|
||||
case Integer.parse(bin) do
|
||||
{id, ""} -> id
|
||||
_ -> -1
|
||||
end
|
||||
|
||||
_ ->
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_hold_down(opts, nil), do: opts
|
||||
|
||||
defp maybe_put_hold_down(opts, seconds) when is_integer(seconds) and seconds >= 0 do
|
||||
Keyword.put(opts, :hold_down_seconds, seconds)
|
||||
end
|
||||
|
||||
defp maybe_put_hold_down(opts, _), do: opts
|
||||
|
||||
defp maybe_put_arg(args, _key, nil), do: args
|
||||
defp maybe_put_arg(args, key, value), do: Map.put(args, key, value)
|
||||
end
|
||||
|
|
@ -57,6 +57,7 @@ def kinds, do: @kinds
|
|||
|
||||
def secondary?(%__MODULE__{kind: "secondary"}), do: true
|
||||
def secondary?(_), do: false
|
||||
@dnssec_statuses ~w(disabled keys_generated signed zsk_rollover)
|
||||
|
||||
def changeset(zone, attrs) do
|
||||
zone
|
||||
|
|
|
|||
|
|
@ -463,7 +463,8 @@ status = DNS.zone_dnssec_status(zone)
|
|||
algorithm: status.algorithm,
|
||||
algorithm_name: status.algorithm_name,
|
||||
ds: status.ds,
|
||||
keys: status.keys
|
||||
keys: status.keys,
|
||||
zsk_rollover: status.zsk_rollover
|
||||
})
|
||||
else
|
||||
{:error, :bad_request} ->
|
||||
|
|
@ -600,6 +601,23 @@ status = DNS.zone_dnssec_status(zone)
|
|||
purged: Map.get(result, :purged, 0),
|
||||
single_node: true,
|
||||
zone_id: zone.id
|
||||
def start_zsk_rollover(conn, %{"id" => id} = params) do
|
||||
user = conn.assigns.current_user
|
||||
schedule_complete? = parse_bool_param(Map.get(params, "schedule_complete"), true)
|
||||
hold_down = parse_optional_non_neg_int(Map.get(params, "hold_down_seconds"))
|
||||
|
||||
opts =
|
||||
[schedule_complete: schedule_complete?]
|
||||
|> then(fn opts ->
|
||||
if is_integer(hold_down), do: Keyword.put(opts, :hold_down_seconds, hold_down), else: opts
|
||||
end)
|
||||
|
||||
with {:ok, zone_id} <- parse_id(id),
|
||||
%Zone{} = zone <- DNS.get_zone(zone_id, user.id),
|
||||
{:ok, zone} <- DNS.start_zone_zsk_rollover(zone, opts) do
|
||||
Response.ok(conn, %{
|
||||
zone: format_zone(zone, user: user),
|
||||
dnssec: DNS.zone_dnssec_status(zone)
|
||||
})
|
||||
else
|
||||
{:error, :bad_request} ->
|
||||
|
|
@ -638,6 +656,102 @@ status = DNS.zone_dnssec_status(zone)
|
|||
end
|
||||
|
||||
defp list_param(_, _), do: {:ok, []}
|
||||
nil ->
|
||||
Response.error(conn, :not_found, "not_found", "Zone not found")
|
||||
|
||||
{:error, :not_enabled} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"dnssec_not_enabled",
|
||||
"Enable DNSSEC before starting a ZSK rollover"
|
||||
)
|
||||
|
||||
{:error, :missing_keys} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"dnssec_missing_keys",
|
||||
"Zone is missing active KSK or ZSK material"
|
||||
)
|
||||
|
||||
{:error, _reason} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"dnssec_zsk_rollover_failed",
|
||||
"Could not start ZSK rollover"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def complete_zsk_rollover(conn, %{"id" => id} = params) do
|
||||
user = conn.assigns.current_user
|
||||
force? = parse_bool_param(Map.get(params, "force"), false)
|
||||
|
||||
with {:ok, zone_id} <- parse_id(id),
|
||||
%Zone{} = zone <- DNS.get_zone(zone_id, user.id),
|
||||
{:ok, zone} <- DNS.complete_zone_zsk_rollover(zone, force: force?) do
|
||||
Response.ok(conn, %{
|
||||
zone: format_zone(zone, user: user),
|
||||
dnssec: DNS.zone_dnssec_status(zone)
|
||||
})
|
||||
else
|
||||
{:error, :bad_request} ->
|
||||
Response.error(conn, :bad_request, "invalid_id", "Invalid zone id")
|
||||
|
||||
nil ->
|
||||
Response.error(conn, :not_found, "not_found", "Zone not found")
|
||||
|
||||
{:error, :not_enabled} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"dnssec_not_enabled",
|
||||
"Enable DNSSEC before completing a ZSK rollover"
|
||||
)
|
||||
|
||||
{:error, :hold_down_active} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"dnssec_hold_down_active",
|
||||
"ZSK dual-sign hold-down has not elapsed; wait or pass force=true to complete early"
|
||||
)
|
||||
|
||||
{:error, :missing_keys} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"dnssec_missing_keys",
|
||||
"Zone is missing active ZSK material"
|
||||
)
|
||||
|
||||
{:error, _reason} ->
|
||||
Response.error(
|
||||
conn,
|
||||
:unprocessable_entity,
|
||||
"dnssec_zsk_rollover_complete_failed",
|
||||
"Could not complete ZSK rollover"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_bool_param(value, _default) when value in [true, "true", "1", 1], do: true
|
||||
defp parse_bool_param(value, _default) when value in [false, "false", "0", 0], do: false
|
||||
defp parse_bool_param(_, default), do: default
|
||||
|
||||
defp parse_optional_non_neg_int(nil), do: nil
|
||||
defp parse_optional_non_neg_int(value) when is_integer(value) and value >= 0, do: value
|
||||
|
||||
defp parse_optional_non_neg_int(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, ""} when int >= 0 -> int
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_optional_non_neg_int(_), do: nil
|
||||
|
||||
defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
|
||||
|
||||
|
|
|
|||
|
|
@ -609,6 +609,65 @@ alias Elektrine.DNS.TsigKey
|
|||
|> push_patch(to: ~p"/dns?zone_id=#{zone.id}&tab=rules")}
|
||||
else
|
||||
_ -> {:noreply, put_flash(socket, :error, "Could not delete edge rule")}
|
||||
def handle_event("dnssec_zsk_rollover_start", _params, socket) do
|
||||
case socket.assigns.active_zone do
|
||||
%Zone{} = zone ->
|
||||
case DNS.start_zone_zsk_rollover(zone) do
|
||||
{:ok, updated} ->
|
||||
hold = DNS.zone_dnssec_status(updated).zsk_rollover.recommended_hold_down_seconds
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
"ZSK rollover started (dual-sign). Completion is scheduled in ~#{hold}s. See the DNSSEC ZSK rollover runbook."
|
||||
)
|
||||
|> push_patch(to: ~p"/dns?zone_id=#{updated.id}&tab=settings")}
|
||||
|
||||
{:error, :not_enabled} ->
|
||||
{:noreply, put_flash(socket, :error, "Enable DNSSEC before starting a ZSK rollover.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not start ZSK rollover.")}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("dnssec_zsk_rollover_complete", params, socket) do
|
||||
force? = Map.get(params, "force") in ["true", "1", true]
|
||||
|
||||
case socket.assigns.active_zone do
|
||||
%Zone{} = zone ->
|
||||
case DNS.complete_zone_zsk_rollover(zone, force: force?) do
|
||||
{:ok, updated} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "ZSK rollover completed. Only the newest ZSK remains active.")
|
||||
|> push_patch(to: ~p"/dns?zone_id=#{updated.id}&tab=settings")}
|
||||
|
||||
{:error, :hold_down_active} ->
|
||||
remaining = DNS.zone_dnssec_status(zone).zsk_rollover.remaining_seconds
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"Hold-down still active (~#{remaining}s remaining). Wait for dual-sign propagation, or force complete early (unsafe)."
|
||||
)}
|
||||
|
||||
{:error, :not_enabled} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, "Enable DNSSEC before completing a ZSK rollover.")}
|
||||
|
||||
{:error, _reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not complete ZSK rollover.")}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1987,6 +2046,9 @@ alias Elektrine.DNS.TsigKey
|
|||
<p class="text-xs text-base-content/55">
|
||||
Generate ECDSA P-256 keys and export DS for your registrar. Authority
|
||||
workers serve DNSKEY/RRSIG only when <span class="font-mono">DNS_DNSSEC_ENABLED=true</span>.
|
||||
Generate ECDSA P-256 keys and export DS for your registrar. When
|
||||
<code class="text-2xs">DNS_DNSSEC_ENABLED=true</code>
|
||||
on authority workers, zones are dual-sign ready for ZSK rollover.
|
||||
</p>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Status:
|
||||
|
|
@ -2047,6 +2109,62 @@ alias Elektrine.DNS.TsigKey
|
|||
>
|
||||
Keys are present but no KSK DS could be derived.
|
||||
</p>
|
||||
|
||||
<div class="rounded-box border border-base-content/10 bg-base-100/40 px-3 py-3">
|
||||
<p class="text-2xs font-semibold uppercase tracking-[0.14em] text-base-content/50">
|
||||
ZSK rollover
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-base-content/55">
|
||||
Pre-publication dual-sign: introduce a new ZSK, wait for caches, then
|
||||
retire the old key. Recommended hold-down:
|
||||
<span class="font-mono">
|
||||
{@dnssec_status.zsk_rollover.recommended_hold_down_seconds}s
|
||||
</span>
|
||||
· active ZSKs:
|
||||
<span class="font-mono">
|
||||
{@dnssec_status.zsk_rollover.active_zsk_count}
|
||||
</span>
|
||||
<span :if={@dnssec_status.zsk_rollover.active}>
|
||||
· remaining:
|
||||
<span class="font-mono">
|
||||
{@dnssec_status.zsk_rollover.remaining_seconds}s
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<.button
|
||||
:if={!@dnssec_status.zsk_rollover.active}
|
||||
type="button"
|
||||
phx-click="dnssec_zsk_rollover_start"
|
||||
class="self-start"
|
||||
>
|
||||
Start ZSK rollover
|
||||
</.button>
|
||||
<.button
|
||||
:if={
|
||||
@dnssec_status.zsk_rollover.active and
|
||||
@dnssec_status.zsk_rollover.can_complete
|
||||
}
|
||||
type="button"
|
||||
phx-click="dnssec_zsk_rollover_complete"
|
||||
class="self-start"
|
||||
>
|
||||
Complete ZSK rollover
|
||||
</.button>
|
||||
<.button
|
||||
:if={
|
||||
@dnssec_status.zsk_rollover.active and
|
||||
not @dnssec_status.zsk_rollover.can_complete
|
||||
}
|
||||
type="button"
|
||||
phx-click="dnssec_zsk_rollover_complete"
|
||||
phx-value-force="true"
|
||||
class="self-start"
|
||||
>
|
||||
Force complete early (unsafe)
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 space-y-1">
|
||||
<h4 class="text-sm font-semibold">TSIG keys</h4>
|
||||
|
|
|
|||
|
|
@ -162,6 +162,46 @@ defmodule Elektrine.DNS.Dnssec.SignerTest do
|
|||
assert Signer.rrsigs_for(prepared, "sign-example.com", :a) != []
|
||||
end
|
||||
|
||||
test "dual-sign publishes two verifiable RRSIGs per data RRset during ZSK rollover", %{
|
||||
zone: zone
|
||||
} do
|
||||
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||
assert {:ok, rolling} = Dnssec.start_zsk_rollover(enabled)
|
||||
|
||||
keys = Dnssec.list_active_keys(rolling)
|
||||
zsks = Enum.filter(keys, &(&1.key_role == "zsk"))
|
||||
assert length(zsks) == 2
|
||||
|
||||
prepared = Signer.prepare_zone(%{rolling | dnssec_keys: keys})
|
||||
assert Signer.prepared?(prepared)
|
||||
|
||||
dnskeys = Signer.dnskey_records(prepared)
|
||||
# KSK + two ZSKs
|
||||
assert length(dnskeys) == 3
|
||||
|
||||
a_records = [
|
||||
%{host: "sign-example.com", type: :a, content: "203.0.113.9", ttl: 300}
|
||||
]
|
||||
|
||||
rrsigs = Signer.rrsigs_for(prepared, "sign-example.com", :a)
|
||||
assert length(rrsigs) == 2
|
||||
|
||||
key_tags = rrsigs |> Enum.map(& &1.key_tag) |> Enum.sort()
|
||||
zsk_tags = zsks |> Enum.map(& &1.key_tag) |> Enum.sort()
|
||||
assert key_tags == zsk_tags
|
||||
|
||||
Enum.each(zsks, fn zsk ->
|
||||
sig = Enum.find(rrsigs, &(&1.key_tag == zsk.key_tag))
|
||||
assert Signer.verify_rrsig(sig, a_records, zsk.public_key)
|
||||
end)
|
||||
|
||||
# DNSKEY still single-signed by the KSK only.
|
||||
[dnskey_rrsig] = Signer.rrsigs_for(prepared, "sign-example.com", :dnskey)
|
||||
ksk = Enum.find(keys, &(&1.key_role == "ksk"))
|
||||
assert dnskey_rrsig.key_tag == ksk.key_tag
|
||||
assert Signer.verify_rrsig(dnskey_rrsig, dnskeys, ksk.public_key)
|
||||
end
|
||||
|
||||
test "Packet encodes RRSIG rdata for algorithm 13" do
|
||||
rrsig = %{
|
||||
host: "example.com",
|
||||
|
|
|
|||
|
|
@ -151,6 +151,102 @@ defmodule Elektrine.DNS.DnssecTest do
|
|||
<<7, "example", 3, "com", 0>>
|
||||
end
|
||||
|
||||
test "ZSK rollover dual-activates then retires the old ZSK", %{zone: zone} do
|
||||
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||
[old_zsk] = Enum.filter(Dnssec.list_active_keys(enabled), &(&1.key_role == "zsk"))
|
||||
|
||||
assert {:ok, rolling} = Dnssec.start_zsk_rollover(enabled)
|
||||
assert rolling.dnssec_status == "zsk_rollover"
|
||||
|
||||
active = Dnssec.list_active_keys(rolling)
|
||||
zsks = Enum.filter(active, &(&1.key_role == "zsk"))
|
||||
ksks = Enum.filter(active, &(&1.key_role == "ksk"))
|
||||
assert length(zsks) == 2
|
||||
assert length(ksks) == 1
|
||||
assert Enum.any?(zsks, &(&1.id == old_zsk.id))
|
||||
|
||||
status = Dnssec.status(rolling)
|
||||
assert status.zsk_rollover.active
|
||||
assert status.zsk_rollover.active_zsk_count == 2
|
||||
assert status.zsk_rollover.recommended_hold_down_seconds >= 86_400
|
||||
refute status.zsk_rollover.can_complete
|
||||
assert status.zsk_rollover.remaining_seconds > 0
|
||||
assert status.zsk_rollover.started_at
|
||||
assert status.zsk_rollover.earliest_complete_at
|
||||
|
||||
# Hold-down gate blocks early complete without force.
|
||||
assert {:error, :hold_down_active} = Dnssec.complete_zsk_rollover(rolling)
|
||||
|
||||
# Idempotent start while already rolling.
|
||||
assert {:ok, again} = Dnssec.start_zsk_rollover(rolling)
|
||||
assert length(Enum.filter(Dnssec.list_active_keys(again), &(&1.key_role == "zsk"))) == 2
|
||||
|
||||
assert {:ok, completed} = Dnssec.complete_zsk_rollover(again, force: true)
|
||||
assert completed.dnssec_status == "keys_generated"
|
||||
|
||||
final_zsks =
|
||||
completed
|
||||
|> Dnssec.list_active_keys()
|
||||
|> Enum.filter(&(&1.key_role == "zsk"))
|
||||
|
||||
assert length(final_zsks) == 1
|
||||
assert hd(final_zsks).id != old_zsk.id
|
||||
|
||||
revoked = Repo.get!(DnssecKey, old_zsk.id)
|
||||
refute revoked.active
|
||||
assert revoked.revoked_at
|
||||
end
|
||||
|
||||
test "ZSK rollover rejects zones without DNSSEC", %{zone: zone} do
|
||||
assert {:error, :not_enabled} = Dnssec.start_zsk_rollover(zone)
|
||||
assert {:error, :not_enabled} = Dnssec.complete_zsk_rollover(zone)
|
||||
end
|
||||
|
||||
test "context wrappers schedule complete and finish rollover", %{zone: zone} do
|
||||
assert {:ok, enabled} = DNS.enable_zone_dnssec(zone)
|
||||
|
||||
assert {:ok, rolling} =
|
||||
DNS.start_zone_zsk_rollover(enabled,
|
||||
schedule_complete: false,
|
||||
hold_down_seconds: 60
|
||||
)
|
||||
|
||||
assert rolling.dnssec_status == "zsk_rollover"
|
||||
assert length(Enum.filter(Dnssec.list_active_keys(rolling), &(&1.key_role == "zsk"))) == 2
|
||||
|
||||
assert {:error, :hold_down_active} = DNS.complete_zone_zsk_rollover(rolling)
|
||||
|
||||
assert {:ok, completed} = DNS.complete_zone_zsk_rollover(rolling, force: true)
|
||||
assert completed.dnssec_status == "keys_generated"
|
||||
assert length(Enum.filter(Dnssec.list_active_keys(completed), &(&1.key_role == "zsk"))) == 1
|
||||
end
|
||||
|
||||
test "reactivate_zsk restores a retired ZSK for dual-sign recovery", %{zone: zone} do
|
||||
assert {:ok, enabled} = Dnssec.enable_zone(zone)
|
||||
[old_zsk] = Enum.filter(Dnssec.list_active_keys(enabled), &(&1.key_role == "zsk"))
|
||||
|
||||
assert {:ok, rolling} = Dnssec.start_zsk_rollover(enabled)
|
||||
assert {:ok, completed} = Dnssec.complete_zsk_rollover(rolling, force: true)
|
||||
|
||||
revoked = Repo.get!(DnssecKey, old_zsk.id)
|
||||
refute revoked.active
|
||||
|
||||
assert {:ok, recovered} = DNS.reactivate_zone_zsk(completed, old_zsk.id)
|
||||
assert recovered.dnssec_status == "zsk_rollover"
|
||||
|
||||
zsks = Enum.filter(Dnssec.list_active_keys(recovered), &(&1.key_role == "zsk"))
|
||||
assert length(zsks) == 2
|
||||
assert Enum.any?(zsks, &(&1.id == old_zsk.id))
|
||||
end
|
||||
|
||||
test "recommended hold-down respects TTL basis and floor", %{zone: zone} do
|
||||
short = %{zone | default_ttl: 60, soa_minimum: 60}
|
||||
assert Dnssec.recommended_zsk_hold_down_seconds(short) == 86_400
|
||||
|
||||
long = %{zone | default_ttl: 100_000, soa_minimum: 50_000}
|
||||
assert Dnssec.recommended_zsk_hold_down_seconds(long) == 200_000
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
defmodule Elektrine.DNS.Dnssec.ZskRolloverWorkerTest do
|
||||
use Elektrine.DataCase, async: false
|
||||
|
||||
alias Elektrine.AccountsFixtures
|
||||
alias Elektrine.DNS
|
||||
alias Elektrine.DNS.Dnssec
|
||||
alias Elektrine.DNS.Dnssec.ZskRolloverWorker
|
||||
|
||||
setup do
|
||||
old_master = Application.get_env(:elektrine, :encryption_master_secret)
|
||||
old_salt = Application.get_env(:elektrine, :encryption_key_salt)
|
||||
|
||||
Application.put_env(:elektrine, :encryption_master_secret, "test-master-secret-dnssec-012345")
|
||||
Application.put_env(:elektrine, :encryption_key_salt, "test-key-salt-dnssec-0123456789")
|
||||
|
||||
on_exit(fn ->
|
||||
restore_env(:encryption_master_secret, old_master)
|
||||
restore_env(:encryption_key_salt, old_salt)
|
||||
end)
|
||||
|
||||
user = AccountsFixtures.user_fixture()
|
||||
{:ok, zone} = DNS.create_zone(user, %{"domain" => "rollover-worker.example"})
|
||||
%{zone: zone}
|
||||
end
|
||||
|
||||
test "start action dual-activates ZSKs without scheduling complete", %{zone: zone} do
|
||||
assert {:ok, enabled} = DNS.enable_zone_dnssec(zone)
|
||||
|
||||
assert :ok =
|
||||
ZskRolloverWorker.perform(%Oban.Job{
|
||||
args: %{
|
||||
"zone_id" => enabled.id,
|
||||
"action" => "start",
|
||||
"schedule_complete" => false
|
||||
}
|
||||
})
|
||||
|
||||
rolling = DNS.get_zone(enabled.id, enabled.user_id)
|
||||
assert rolling.dnssec_status == "zsk_rollover"
|
||||
assert length(Enum.filter(Dnssec.list_active_keys(rolling), &(&1.key_role == "zsk"))) == 2
|
||||
end
|
||||
|
||||
test "complete action retires the old ZSK when force is set", %{zone: zone} do
|
||||
assert {:ok, enabled} = DNS.enable_zone_dnssec(zone)
|
||||
|
||||
assert {:ok, rolling} =
|
||||
DNS.start_zone_zsk_rollover(enabled, schedule_complete: false)
|
||||
|
||||
assert :ok =
|
||||
ZskRolloverWorker.perform(%Oban.Job{
|
||||
args: %{"zone_id" => rolling.id, "action" => "complete", "force" => true}
|
||||
})
|
||||
|
||||
done = DNS.get_zone(rolling.id, rolling.user_id)
|
||||
assert done.dnssec_status == "keys_generated"
|
||||
assert length(Enum.filter(Dnssec.list_active_keys(done), &(&1.key_role == "zsk"))) == 1
|
||||
end
|
||||
|
||||
test "complete action snoozes while hold-down is active", %{zone: zone} do
|
||||
assert {:ok, enabled} = DNS.enable_zone_dnssec(zone)
|
||||
|
||||
assert {:ok, rolling} =
|
||||
DNS.start_zone_zsk_rollover(enabled, schedule_complete: false)
|
||||
|
||||
assert {:snooze, seconds} =
|
||||
ZskRolloverWorker.perform(%Oban.Job{
|
||||
args: %{"zone_id" => rolling.id, "action" => "complete"}
|
||||
})
|
||||
|
||||
assert seconds >= 60
|
||||
|
||||
still = DNS.get_zone(rolling.id, rolling.user_id)
|
||||
assert still.dnssec_status == "zsk_rollover"
|
||||
assert length(Enum.filter(Dnssec.list_active_keys(still), &(&1.key_role == "zsk"))) == 2
|
||||
end
|
||||
|
||||
test "start is a no-op when DNSSEC is disabled", %{zone: zone} do
|
||||
assert :ok =
|
||||
ZskRolloverWorker.perform(%Oban.Job{
|
||||
args: %{"zone_id" => zone.id, "action" => "start", "schedule_complete" => false}
|
||||
})
|
||||
end
|
||||
|
||||
test "unknown action returns error" do
|
||||
assert {:error, :invalid_args} =
|
||||
ZskRolloverWorker.perform(%Oban.Job{args: %{"zone_id" => 1, "action" => "nope"}})
|
||||
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
|
||||
|
|
@ -23,6 +23,18 @@ defmodule ElektrineWeb.Routes.DNS do
|
|||
post("/zones/:id/verify", ElektrineDNSWeb.API.DNSController, :verify)
|
||||
post("/zones/:id/dnssec/enable", ElektrineDNSWeb.API.DNSController, :enable_dnssec)
|
||||
|
||||
post(
|
||||
"/zones/:id/dnssec/zsk/rollover",
|
||||
ElektrineDNSWeb.API.DNSController,
|
||||
:start_zsk_rollover
|
||||
)
|
||||
|
||||
post(
|
||||
"/zones/:id/dnssec/zsk/rollover/complete",
|
||||
ElektrineDNSWeb.API.DNSController,
|
||||
:complete_zsk_rollover
|
||||
)
|
||||
|
||||
post(
|
||||
"/zones/:id/services/:service/apply",
|
||||
ElektrineDNSWeb.API.DNSController,
|
||||
|
|
|
|||
|
|
@ -359,12 +359,80 @@ defmodule ElektrineDNSWeb.API.DNSControllerTest do
|
|||
|> with_pat(user.id, ["read:dns"])
|
||||
|> get("/api/ext/v1/dns/zones/#{zone.id}/dnssec/ds")
|
||||
|
||||
assert %{"data" => %{"ds" => [ds], "keys" => keys}} = json_response(ds_conn, 200)
|
||||
assert %{"data" => %{"ds" => [ds], "keys" => keys, "zsk_rollover" => rollover}} =
|
||||
json_response(ds_conn, 200)
|
||||
|
||||
assert ds["algorithm"] == 13
|
||||
assert ds["digest_type"] == 2
|
||||
assert is_binary(ds["presentation"])
|
||||
assert length(keys) == 2
|
||||
refute Enum.any?(keys, &Map.has_key?(&1, "private_key"))
|
||||
assert rollover["active"] == false
|
||||
assert rollover["active_zsk_count"] == 1
|
||||
assert is_integer(rollover["recommended_hold_down_seconds"])
|
||||
end
|
||||
|
||||
test "starts and completes ZSK rollover over the external API", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
{:ok, zone} = DNS.create_zone(user, %{"domain" => "dnssec-rollover-api.example"})
|
||||
|
||||
_ =
|
||||
conn
|
||||
|> with_pat(user.id, ["write:dns"])
|
||||
|> post("/api/ext/v1/dns/zones/#{zone.id}/dnssec/enable", %{})
|
||||
|
||||
start_conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> with_pat(user.id, ["write:dns"])
|
||||
|> post("/api/ext/v1/dns/zones/#{zone.id}/dnssec/zsk/rollover", %{
|
||||
"schedule_complete" => false
|
||||
})
|
||||
|
||||
assert %{
|
||||
"data" => %{
|
||||
"zone" => api_zone,
|
||||
"dnssec" => dnssec
|
||||
}
|
||||
} = json_response(start_conn, 200)
|
||||
|
||||
assert api_zone["dnssec_status"] == "zsk_rollover"
|
||||
assert dnssec["status"] == "zsk_rollover"
|
||||
assert dnssec["zsk_rollover"]["active"] == true
|
||||
assert dnssec["zsk_rollover"]["active_zsk_count"] == 2
|
||||
assert dnssec["zsk_rollover"]["can_complete"] == false
|
||||
assert length(Enum.filter(dnssec["keys"], &(&1["key_role"] == "zsk"))) == 2
|
||||
|
||||
early_conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> with_pat(user.id, ["write:dns"])
|
||||
|> post("/api/ext/v1/dns/zones/#{zone.id}/dnssec/zsk/rollover/complete", %{})
|
||||
|
||||
assert %{
|
||||
"error" => %{
|
||||
"code" => "dnssec_hold_down_active"
|
||||
}
|
||||
} = json_response(early_conn, 422)
|
||||
|
||||
complete_conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> with_pat(user.id, ["write:dns"])
|
||||
|> post("/api/ext/v1/dns/zones/#{zone.id}/dnssec/zsk/rollover/complete", %{
|
||||
"force" => true
|
||||
})
|
||||
|
||||
assert %{
|
||||
"data" => %{
|
||||
"zone" => done_zone,
|
||||
"dnssec" => done_dnssec
|
||||
}
|
||||
} = json_response(complete_conn, 200)
|
||||
|
||||
assert done_zone["dnssec_status"] == "keys_generated"
|
||||
assert done_dnssec["zsk_rollover"]["active"] == false
|
||||
assert done_dnssec["zsk_rollover"]["active_zsk_count"] == 1
|
||||
end
|
||||
|
||||
test "rejects dns endpoints without dns scopes", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -312,6 +312,9 @@ config :elektrine, :dns,
|
|||
# Inbound secondary AXFR ingest (kind=secondary zones). Off by default.
|
||||
secondary_ingest_enabled: false,
|
||||
secondary_ingest_poll_interval_ms: 60_000,
|
||||
# Minimum dual-sign window (seconds) before automated ZSK rollover complete.
|
||||
# Effective hold-down is max(2 * max(default_ttl, soa_minimum), this floor).
|
||||
dnssec_zsk_hold_down_seconds: 86_400,
|
||||
zone_cache_refresh_interval_ms: 300_000,
|
||||
udp_port: 5300,
|
||||
tcp_port: 5300,
|
||||
|
|
|
|||
|
|
@ -206,6 +206,11 @@ secondary_axfr_enabled:
|
|||
"DNS_SECONDARY_INGEST_POLL_INTERVAL_MS",
|
||||
Keyword.get(dns_config, :secondary_ingest_poll_interval_ms, 60_000)
|
||||
),
|
||||
dnssec_zsk_hold_down_seconds:
|
||||
parse_int_env.(
|
||||
"DNS_DNSSEC_ZSK_HOLD_DOWN_SECONDS",
|
||||
Keyword.get(dns_config, :dnssec_zsk_hold_down_seconds, 86_400)
|
||||
),
|
||||
edge_proxy_enabled:
|
||||
parse_bool_env.(
|
||||
"DNS_EDGE_PROXY_ENABLED",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,10 @@ Current PAT endpoints:
|
|||
- `PUT /api/ext/v1/dns/zones/:id`
|
||||
- `DELETE /api/ext/v1/dns/zones/:id`
|
||||
- `POST /api/ext/v1/dns/zones/:id/verify`
|
||||
- `POST /api/ext/v1/dns/zones/:id/dnssec/enable`
|
||||
- `GET /api/ext/v1/dns/zones/:id/dnssec/ds`
|
||||
- `POST /api/ext/v1/dns/zones/:id/dnssec/zsk/rollover`
|
||||
- `POST /api/ext/v1/dns/zones/:id/dnssec/zsk/rollover/complete`
|
||||
- `POST /api/ext/v1/dns/zones/:id/services/:service/apply`
|
||||
- `DELETE /api/ext/v1/dns/zones/:id/services/:service`
|
||||
- `POST /api/ext/v1/dns/zones/:zone_id/records`
|
||||
|
|
@ -310,3 +314,19 @@ Two distinct HA models:
|
|||
|
||||
DNSSEC remains deferred pending operator UX and failure-mode design beyond
|
||||
keys/export phases.
|
||||
DNSSEC (phased):
|
||||
|
||||
- Keys + DS export (`Elektrine.DNS.Dnssec`, `dns_dnssec_keys`)
|
||||
- Sign-on-publish + serve DNSKEY/RRSIG behind `DNS_DNSSEC_ENABLED` (`Dnssec.Signer`, ZoneCache)
|
||||
- ZSK pre-publication dual-sign rollover (`start_zone_zsk_rollover/2`,
|
||||
`complete_zone_zsk_rollover/1`, `Dnssec.ZskRolloverWorker`) — operator runbook:
|
||||
`docs/self-hosting/dnssec-zsk-rollover.md`
|
||||
|
||||
Not implemented yet:
|
||||
|
||||
- Automated KSK/DS rollover (parent registrar / CDS/CDNSKEY)
|
||||
- NSEC/NSEC3 denial of existence
|
||||
- AXFR/IXFR secondary DNS with TSIG
|
||||
|
||||
Secondary transfer and KSK automation remain deferred for operator UX and
|
||||
failure-mode design beyond packet support.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ binds, and TLS modes.
|
|||
- `mail.md`
|
||||
- `../architecture/dns-module.md`
|
||||
- `../architecture/edge-platform.md`
|
||||
- `dnssec-zsk-rollover.md`
|
||||
- `turn.md`
|
||||
- `vpn.md`
|
||||
- `../addons/onion.md`
|
||||
|
|
|
|||
220
docs/self-hosting/dnssec-zsk-rollover.md
Normal file
220
docs/self-hosting/dnssec-zsk-rollover.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# DNSSEC ZSK rollover runbook (no resolution outage)
|
||||
|
||||
Pre-publication ZSK rollover for Elektrine authoritative DNS. Goal: replace the
|
||||
zone-signing key **without** a window where validators see signatures they
|
||||
cannot verify.
|
||||
|
||||
KSK/DS rollover (parent registrar) is **out of scope** here — this runbook only
|
||||
covers ZSK dual-sign.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Zone has DNSSEC enabled (KSK + ZSK generated; DS published at the parent if
|
||||
you already serve signed answers to the public).
|
||||
2. Authority workers run with `DNS_DNSSEC_ENABLED=true` so ZoneCache pre-signs
|
||||
and Query serves DNSKEY/RRSIG.
|
||||
3. Oban is running on a worker/app role that can execute jobs (for automated
|
||||
complete). Manual complete via API/UI works without waiting for the job.
|
||||
|
||||
## How dual-sign works
|
||||
|
||||
| Phase | Active keys | What is signed |
|
||||
| --- | --- | --- |
|
||||
| Steady state | 1 KSK + 1 ZSK | DNSKEY by KSK; data RRsets by ZSK |
|
||||
| Dual-sign window | 1 KSK + **2 ZSKs** | DNSKEY still by KSK; **each data RRset gets two RRSIGs** (old + new ZSK); both ZSKs appear in DNSKEY |
|
||||
| After complete | 1 KSK + **newest** ZSK only | Back to single ZSK signatures |
|
||||
|
||||
Validators that cached RRSIGs from the old ZSK still find that ZSK in DNSKEY
|
||||
during the dual-sign window. Validators that prefer the new key already see its
|
||||
signatures. Completing too early can produce BOGUS answers for resolvers that
|
||||
still hold old RRSIGs **without** the old DNSKEY — hence the hold-down.
|
||||
|
||||
Start and complete both refresh the local ZoneCache **and** emit
|
||||
`ZoneChangeListener` `pg_notify` so a separate (unclustered) DNS authority node
|
||||
picks up the dual-sign set without waiting for the periodic cache refresh
|
||||
interval.
|
||||
|
||||
## Recommended hold-down
|
||||
|
||||
Effective wait before complete is **enforced server-side**:
|
||||
|
||||
```text
|
||||
max(2 × max(zone.default_ttl, zone.soa_minimum), DNS_DNSSEC_ZSK_HOLD_DOWN_SECONDS)
|
||||
```
|
||||
|
||||
Default floor: **86400** seconds (24h). Override with
|
||||
`DNS_DNSSEC_ZSK_HOLD_DOWN_SECONDS` (see `env/core.env.example`).
|
||||
|
||||
Complete before `newest_zsk.activated_at + hold_down` returns
|
||||
`hold_down_active` unless `force: true` / `force=true` is passed. The default
|
||||
start path schedules the complete worker after the recommended hold-down so the
|
||||
automated path does not need force. Manual “complete now” is intentionally
|
||||
gated; UI offers **Force complete early (unsafe)** only while remaining > 0.
|
||||
|
||||
If any public RRset uses a longer TTL than the zone defaults, wait at least
|
||||
`2 × that TTL` (or bump those TTLs down before rolling). Code does not scan
|
||||
per-record TTLs for the recommendation.
|
||||
|
||||
## Operator steps (UI)
|
||||
|
||||
1. DNS LiveView → zone → **Settings** → DNSSEC.
|
||||
2. Confirm status is not already `zsk_rollover`.
|
||||
3. Click **Start ZSK rollover**.
|
||||
- A second ZSK is minted and kept active.
|
||||
- Authority cache refreshes + notifies other NS nodes; dual RRSIGs are
|
||||
published when the serve flag is on.
|
||||
- A complete job is scheduled for the recommended hold-down.
|
||||
4. Wait the hold-down (status shows **remaining** seconds; verify dual
|
||||
DNSKEY/RRSIG externally — see checks).
|
||||
5. When remaining is 0, click **Complete ZSK rollover** (or wait for the job).
|
||||
During hold-down the UI only offers **Force complete early (unsafe)**.
|
||||
6. Confirm only one active ZSK remains and answers still validate.
|
||||
|
||||
## Operator steps (API)
|
||||
|
||||
Scopes: `write:dns` for start/complete; `read:dns` for status/DS.
|
||||
|
||||
```bash
|
||||
# Start (schedules complete after hold-down by default)
|
||||
curl -X POST -H "Authorization: Bearer $PAT" \
|
||||
"$BASE/api/ext/v1/dns/zones/$ZONE_ID/dnssec/zsk/rollover" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"schedule_complete": true}'
|
||||
|
||||
# Optional: start without auto-complete, custom hold-down for a later manual complete
|
||||
curl -X POST -H "Authorization: Bearer $PAT" \
|
||||
"$BASE/api/ext/v1/dns/zones/$ZONE_ID/dnssec/zsk/rollover" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"schedule_complete": false, "hold_down_seconds": 3600}'
|
||||
|
||||
# Status (active ZSK count, remaining hold-down, can_complete)
|
||||
curl -H "Authorization: Bearer $PAT" \
|
||||
"$BASE/api/ext/v1/dns/zones/$ZONE_ID/dnssec/ds"
|
||||
|
||||
# Complete after hold-down (fails with dnssec_hold_down_active if early)
|
||||
curl -X POST -H "Authorization: Bearer $PAT" \
|
||||
"$BASE/api/ext/v1/dns/zones/$ZONE_ID/dnssec/zsk/rollover/complete"
|
||||
|
||||
# Emergency early complete (may cause validating SERVFAIL)
|
||||
curl -X POST -H "Authorization: Bearer $PAT" \
|
||||
"$BASE/api/ext/v1/dns/zones/$ZONE_ID/dnssec/zsk/rollover/complete" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"force": true}'
|
||||
```
|
||||
|
||||
## Operator steps (IEx / Oban)
|
||||
|
||||
```elixir
|
||||
zone = Elektrine.DNS.get_zone!(zone_id)
|
||||
|
||||
# Start dual-sign + schedule complete (also pg_notify's authority nodes)
|
||||
{:ok, rolling} = Elektrine.DNS.start_zone_zsk_rollover(zone)
|
||||
|
||||
# Or start without scheduling
|
||||
{:ok, rolling} =
|
||||
Elektrine.DNS.start_zone_zsk_rollover(zone, schedule_complete: false)
|
||||
|
||||
# Enqueue complete after N seconds
|
||||
Elektrine.DNS.Dnssec.ZskRolloverWorker.enqueue_complete(rolling, schedule_in: 3_600)
|
||||
|
||||
# Complete after hold-down (default)
|
||||
{:ok, _} = Elektrine.DNS.complete_zone_zsk_rollover(rolling)
|
||||
|
||||
# Emergency early complete
|
||||
{:ok, _} = Elektrine.DNS.complete_zone_zsk_rollover(rolling, force: true)
|
||||
```
|
||||
|
||||
Worker args: `%{"zone_id" => id, "action" => "start" | "complete"}`.
|
||||
If complete runs while hold-down is still active, the worker **snoozes** until
|
||||
remaining time elapses (unless `"force" => true`).
|
||||
|
||||
## Verification checklist (no outage)
|
||||
|
||||
Run these **during** the dual-sign window and **after** complete. Replace
|
||||
`example.com` and point dig at an authority that serves the zone.
|
||||
|
||||
```bash
|
||||
# Both ZSKs published as DNSKEY during dual-sign
|
||||
dig @ns1 +dnssec example.com DNSKEY
|
||||
|
||||
# Data RRset carries two RRSIGs (two key tags) during dual-sign
|
||||
dig @ns1 +dnssec example.com A
|
||||
|
||||
# After complete: one ZSK DNSKEY; one RRSIG key tag for data
|
||||
dig @ns1 +dnssec example.com DNSKEY
|
||||
dig @ns1 +dnssec example.com A
|
||||
|
||||
# Optional: unbound-host / delv against a validating resolver
|
||||
delv @127.0.0.1 example.com A
|
||||
```
|
||||
|
||||
Expect:
|
||||
|
||||
| Window | DNSKEY ZSK count | RRSIG(A) count (typical) |
|
||||
| --- | --- | --- |
|
||||
| Dual-sign | 2 (+ 1 KSK) | 2 |
|
||||
| After complete | 1 (+ 1 KSK) | 1 |
|
||||
|
||||
## Recovery after early complete (BOGUS / SERVFAIL)
|
||||
|
||||
If dig shows `SERVFAIL` from a **validating** recursive after complete, you
|
||||
likely completed before caches expired. **Do not** only “start rollover again”:
|
||||
`start_zsk_rollover` mints a *new* ZSK dual-signing with the current (newest)
|
||||
key — it does **not** re-publish the **retired** ZSK that still covers cached
|
||||
RRSIGs.
|
||||
|
||||
Correct recovery:
|
||||
|
||||
1. **Re-activate the retired ZSK** so dual-sign resumes with the key validators
|
||||
still need:
|
||||
|
||||
```elixir
|
||||
zone = Elektrine.DNS.get_zone!(zone_id)
|
||||
|
||||
# Find the revoked ZSK that signed the still-cached RRSIGs (by key_tag or id)
|
||||
revoked =
|
||||
Elektrine.Repo.all(
|
||||
from k in Elektrine.DNS.DnssecKey,
|
||||
where: k.zone_id == ^zone.id and k.key_role == "zsk" and k.active == false,
|
||||
order_by: [desc: k.revoked_at]
|
||||
)
|
||||
|
||||
retired = hd(revoked)
|
||||
{:ok, rolling} = Elektrine.DNS.reactivate_zone_zsk(zone, retired.id)
|
||||
# status -> zsk_rollover; dual-sign again; authority notified
|
||||
```
|
||||
|
||||
2. Wait a **full** hold-down (and verify dual DNSKEY on the public NS).
|
||||
3. Complete carefully (without force) after remaining hits 0.
|
||||
4. **Or** wait out remaining RRSIG/data TTLs until old signatures expire (zone
|
||||
may SERVFAIL until then).
|
||||
|
||||
Private material stays encrypted in `dns_dnssec_keys` after revoke
|
||||
(`active=false`); rows are not deleted on complete.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
| --- | --- | --- |
|
||||
| Start returns `dnssec_not_enabled` | Zone never enabled | `POST .../dnssec/enable` first |
|
||||
| Start returns `dnssec_missing_keys` | Corrupt/missing key rows | Re-enable only after deliberate re-key (enable is idempotent and will **not** mint if keys exist) |
|
||||
| Complete returns `dnssec_hold_down_active` | Dual-sign window not finished | Wait `remaining_seconds` from status, or pass `force=true` (unsafe) |
|
||||
| Complete with one ZSK | Already steady state | No-op; status returns to `keys_generated` |
|
||||
| BOGUS after complete | Hold-down too short / forced early | Re-activate the **retired** ZSK (see recovery); do not only start a new rollover |
|
||||
| Jobs not completing | Oban not on this node | Run complete via API/UI or enqueue on a worker role |
|
||||
| dig on public NS lags after start | Rare if notify failed | Check logs for zone-change notify warnings; wait refresh interval |
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- Automated KSK rollover / parent DS swap (CDS/CDNSKEY later).
|
||||
- Multi-algorithm or NSEC3 opt-out changes.
|
||||
- Emergency “revoke this ZSK immediately” without dual-sign (that **can** cause
|
||||
outages — do not skip dual-sign for routine rotation).
|
||||
|
||||
## Related code
|
||||
|
||||
- `Elektrine.DNS.Dnssec` — start/complete/reactivate, hold-down gate + math
|
||||
- `Elektrine.DNS.Dnssec.Signer` — dual-sign all active ZSKs/KSKs
|
||||
- `Elektrine.DNS.Dnssec.ZskRolloverWorker` — delayed complete (snooze if early)
|
||||
- `Elektrine.DNS.start_zone_zsk_rollover/2`, `complete_zone_zsk_rollover/2`,
|
||||
`reactivate_zone_zsk/2` (refresh + `ZoneChangeListener.notify/0`)
|
||||
2
env/core.env.example
vendored
2
env/core.env.example
vendored
|
|
@ -125,6 +125,8 @@ MESSAGING_FEDERATION_PEERS_JSON='[]'
|
|||
# DNS_RECURSIVE_ENABLED=true
|
||||
# Serve DNSKEY/RRSIG for zones with DNSSEC keys (default off).
|
||||
# DNS_DNSSEC_ENABLED=false
|
||||
# Minimum dual-sign window (seconds) before automated ZSK rollover complete.
|
||||
# DNS_DNSSEC_ZSK_HOLD_DOWN_SECONDS=86400
|
||||
# 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