Feature Specification: Effects registrar-*

Created: 2026-10-07 Status: Implemented Input: Reconstructed from the code at 093e68b6f (core/effects/tf/registrars.nix, core/effects/tf/common/provider-common.nix, core/effects/tf/porkbun/, core/effects/tf/inwx/, core/effects/tf/openprovider/, core/effects/common/standalone-env.nix, core/render/catalog.nix, core/setups/hosts-common.nix).

The registrar effects publish the delegation of the group apex at the registrar. They are registrar-domain, registrar-nameservers, registrar-dnssec and one registrar-glue-<label> per DNS node label. Each effect applies one OpenTofu module with one provider and one resource. The records inside the zone are the job of octodns.md. The effects deploy no nodes. The tf-incus-hosts setup builds them as setup.registrar.<step> (see ../009-deployment-methods/tf-incus.md). The family behavior of standalone effects is in spec.md. The program grammar, the verb names and the state rules are in ../004-deployment-programs/spec.md.

The dispatch table core/effects/tf/registrars.nix names, per registrar, the steps it offers, the effect for each step, and the coordinates each step needs:

Registrar domain nameservers glue dnssec
porkbun tf-porkbun-domain (max cost) tf-porkbun-domain-nameservers tf-porkbun-glue-record tf-porkbun-dnssec-record (DS)
inwx none none tf-inwx-glue-record tf-inwx-dnssec-key (DNSKEY)
openprovider tf-openprovider-domain (max cost, owner handle) tf-openprovider-nsgroup tf-openprovider-glue-record none (dnssec_keys on the registration)

User Scenarios & Testing (mandatory)

User Story 1 - Delegate the apex to the group nameservers (Priority: P1)

An operator deploys a group that runs its own nameservers. The operator applies registrar-glue-<label> for each DNS node and then registrar-nameservers. The parent zone then sends resolvers to the group nameservers.

Why this priority: Without the delegation, no resolver finds the zone that octodns-zone publishes.

Independent Test: core/setups/registrar-test.nix (suite nix-unit-setups) asserts the leaf arguments and state keys. effects-tf-registrar-render (suite nix-unit-effects) forces each generated module to evaluate. No integration test applies the glue or nameserver effects.

Acceptance Scenarios:

  1. Given REGISTRAR, REGISTRAR_STATE and a group with DNS nodes, When the catalog lists the group effects, Then it lists registrar-glue-<label> for each DNS node label of that group.
  2. Given a nameserver inside the zone it serves, When the operator applies registrar-nameservers at openprovider, Then the nameserver group carries the node IPv4 and IPv6 addresses.
  3. Given a DNS node that this deployment does not stand up, When the setup evaluates, Then it makes no glue leaf for that node.

User Story 2 - Register the apex under a spending bound (Priority: P1)

The operator states REGISTRAR_MAX_COST. The operator applies registrar-domain. The provider asks the registrar for a live quote and registers the domain only at or below the bound.

Why this priority: A registration spends money and holds the name that all other records depend on.

Independent Test: core/setups/registrar-test.nix (suite nix-unit-setups) asserts that the registration is absent until its coordinates are stated. integrations-registrar-guards applies the porkbun and openprovider registration against a fake registrar API: a quote above the bound fails the apply and orders nothing, a quote at the bound registers, and a destroy plan fails because of prevent_destroy. The build of the openprovider provider runs its unit tests, which also refuse a quote in another currency, a zero quote and a failed quote.

Acceptance Scenarios:

  1. Given no REGISTRAR_MAX_COST, When the catalog lists the group effects, Then it does not list registrar-domain.
  2. Given a quote above the bound, in another currency, or no quote, When the operator applies registrar-domain, Then the apply fails and names the numbers.
  3. Given a registered domain, When the operator runs a destroy, Then OpenTofu refuses because of prevent_destroy.

User Story 3 - Close the chain of trust (Priority: P2)

The apex node mints its signing key at run time and writes the DS to /var/lib/named/<domain>.ds. The operator copies that value into REGISTRAR_DS or REGISTRAR_DNSKEY and applies registrar-dnssec.

Why this priority: The zone is signed, but validating resolvers trust it only when the parent holds the DS.

Independent Test: core/setups/registrar-test.nix (suite nix-unit-setups) asserts that the DS reads the same as four fields or as a whole record.

Acceptance Scenarios:

  1. Given no DS coordinate, When the catalog lists the group effects, Then it does not list registrar-dnssec.
  2. Given REGISTRAR_DS as a whole DS record, When the setup evaluates, Then the leaf takes the key tag, algorithm, digest type and digest from it.
  3. Given REGISTRAR_DS that does not parse, When the setup evaluates, Then the eval fails and prints the value.

Edge Cases

  • REGISTRAR names a registrar that the table does not hold: the eval fails and lists the known registrars.
  • REGISTRAR_STATE is not set: the whole registrar surface is absent. No local state backend exists.
  • The group DNS is external: the whole registrar surface is absent, because that apex is not ours to delegate.
  • The registrar does not offer a step: that effect is absent. inwx offers no domain and no nameservers; openprovider offers no dnssec.
  • A DS digest wrapped across lines: the setup joins the parts.
  • openprovider takes no dots in a nameserver group name: the setup derives the group name from the domain without dots.
  • A teardown at openprovider: the nameserver group has allow_deletion = true, so a later bootstrap can create it again.

Requirements (mandatory)

Functional Requirements

  • FR-001: The catalog MUST offer a registrar effect only when REGISTRAR and REGISTRAR_STATE are set, the group DNS is not external, and the group has DNS nodes.
  • FR-002: The catalog MUST offer registrar-domain, registrar-nameservers and registrar-dnssec only when the registrar offers that step and every coordinate that the step needs is set.
  • FR-003: The catalog MUST offer registrar-glue-<label> only for groups that run a DNS node with that label.
  • FR-004: Each effect MUST read the env keys of the tf-incus method plus REGISTRAR, REGISTRAR_MAX_COST, REGISTRAR_DS, REGISTRAR_DNSKEY, REGISTRAR_OWNER_HANDLE and REGISTRAR_STATE.
  • FR-005: Each effect MUST offer the OpenTofu verbs, and run MUST be the apply.
  • FR-006: Each leaf MUST keep its state in the HTTP backend at REGISTRAR_STATE plus its own suffix: -domain, -nameservers, -dnssec or -glue-<label>.
  • FR-007: The registration MUST carry prevent_destroy = true.
  • FR-008: The registration MUST refuse to apply when the live quote is above REGISTRAR_MAX_COST (US cents), in another currency, or missing.
  • FR-009: The glue for a node MUST name the node and hold all addresses of that node, IPv4 and IPv6.
  • FR-010: The nameservers step MUST name the group apex NS set as hostnames, and MUST be absent when that set is empty.
  • FR-011: The setup MUST accept REGISTRAR_DS as four fields or as a whole DS record, and MUST fail the eval on any other form.
  • FR-012: The setup MUST accept REGISTRAR_DNSKEY as four fields, as a whole DNSKEY record, or as JSON, and MUST fail the eval on any other form.
  • FR-013: At openprovider, the registration MUST set ns_group only when the apex NS set is not empty, and dnssec_keys only when REGISTRAR_DNSKEY is set.
  • FR-014: Each provider module MUST use the committed provider-schema.json, a single provider, and the exact provider version.
  • FR-015: Each provider module MUST expose only the resource types that the registrar steps use, and MUST NOT expose zone records, contacts, customers or certificate orders.

Key Entities

  • Registrar: the account at which the group apex is registered. One of porkbun, inwx or openprovider.
  • Step: one of domain, nameservers, glue or dnssec, each with one effect and its own state.
  • Dispatch table: core/effects/tf/registrars.nix, read by the catalog for availability and by the setup for the leaves.
  • Apex DS / DNSKEY: the public key data that the apex node mints, given back to the effect as a coordinate.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: After all applies, a query for NS at the parent zone returns the group apex NS set.
  • SC-002: After registrar-dnssec, the parent zone serves the DS that the apex node wrote.
  • SC-003: No registration costs more than REGISTRAR_MAX_COST.
  • SC-004: A second plan of each leaf after its apply shows no change.

Assumptions

  • The operator applies the steps in order. At openprovider: register without ns_group, then glue for each nameserver, then the nameserver group, then the registration with ns_group, then the registration with dnssec_keys. At porkbun: glue before the delegation.
  • netbox-pinning.nix pins the DNS node addresses, so the glue changes only on a full rebootstrap.
  • The cost bound and the openprovider glue resource come from the providers that npins/sources.json pins and core/effects/tf/common/providers.nix builds. The openprovider pin is a fork branch until an upstream release contains its fix, see npins/README.md.
  • The registrar credentials reach the provider through the env of the machine that runs the apply.

Known Deviations

  • core/effects/default.nix defines tf-inwx-domain, but registrars.nix gives inwx no domain step, so no registrar effect reaches it (#2131).