Skip to content

handle deSEC canonicalization of record values #26

Description

@sshine

plan::build compares desired against stored on deSEC's wire form. convert.rs reproduces two
axes of deSEC's normalization: embedded domain names (rdata_name_field) and TXT quoting
(is_text_type). deSEC normalizes more than that:

Record values that are not given in canonical form, such as 0:0000::1 for an IPv6 address, will
be converted by the API into canonical form (here: ::1). [...] exact validation and
canonicalization depend on the record type.

deSEC docs, RRsets

Where deSEC canonicalizes a value we do not reproduce, the comparison reports a difference that is
not one, and we write. Every cycle, against a budget of 300 per zone per day. That is the write
loop this provider exists to prevent, arriving through the storage path instead of the adjust path.

Why nothing is currently on fire

The two obvious instances are absorbed, both for reasons that are load-bearing and recorded nowhere.

Long TXT. deSEC splits: "Token strings longer than 255 characters are automatically split into
several token strings." We send one chunk and get several back, so the internal comparison genuinely
does differ. It never fires because external-dns never asks: /records reports txt_unquote, which
joins the chunks back into one target.

IPv6 compression. We do nothing here at all. external-dns absorbs it: Targets.Same falls back
to netip.ParseAddr and compares string forms when EqualFold fails.

What is left

Types where deSEC canonicalizes, external-dns's Same does not absorb it (not case-only, not an
IP), and our egress does not map it back. LOC, SVCB/HTTPS parameter ordering and quoting, and
whatever comes next. Narrow, possibly empty in practice, and currently invisible: nothing notices
that an RRset was written and came back different.

adjusting_storing_and_reading_back_is_a_fixpoint cannot catch it, because it round-trips through
our own records_for rather than through a canonicalizing server. docs/testing.md claims the two
property tests make the no-op write loop structurally impossible; that holds for the adjust path
only.

Two claims to correct either way

  • docs/testing.md calls the TXT split "the open question only the live layer can answer" and argues
    "we emit a single chunk and store whatever the server returns, so the split should never matter".
    deSEC documents the split, so the question is answered; and the argument is wrong, since storing
    the server's form is exactly what makes the internal comparison differ. The reason it does not
    matter is the egress join.
  • txt_quote's doc comment says letting deSEC do the splitting "is what keeps our idea of the split
    from ever disagreeing with its own". The two do disagree; nothing ever compares them.

Options

  1. Detect it. write_zone already holds both sides, plan.patches and confirmed. Comparing them
    costs almost nothing and turns the whole open-ended class into one signal, say
    webhook_write_normalized_total{record_type}, plus a log line.
  2. Suppress it. Remember, per RRset, which desired form produced the current stored form; a desired
    form we have already written is identical even when the stored bytes differ. General and
    self-teaching, carried through store::publish.
  3. Record the external-dns dependency in docs/protocol.md, with a test, so a future change to
    Targets.Same becomes a known risk rather than a mystery.
  4. Extend just live-test with long TXT, LOC and HTTPS round trips.

Building 2 before 1 would be writing a mechanism for a failure not yet observed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocsImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions