merge: pr-13-featdns-dnssec-rollover

This commit is contained in:
maxfield 2026-08-02 01:54:37 -04:00
commit d892ce951f
18 changed files with 1522 additions and 24 deletions

View file

@ -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`).

View file

@ -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})

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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>

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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",

View file

@ -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.

View file

@ -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`

View 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`)

View file

@ -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=