Architecture¶
This page is for developers integrating with the DNS module from another module, or extending it via record-type plugins. It assumes familiarity with Drupal's content entity API, plugin system, and Views.
Module structure¶
Single module, no submodules. Everything lives under dns/src/:
dns/
├── composer.json
├── dns.info.yml
├── dns.module # hooks (help, ENTITY_TYPE_view, extra fields)
├── dns.permissions.yml
├── dns.routing.yml
├── dns.links.menu.yml
├── dns.links.action.yml
├── dns.links.task.yml
├── dns.services.yml # plugin manager service definitions
├── dns.views.inc # views integration (rdata field handler)
├── config/install/ # default config (dns.settings, the dns_records view)
└── src/
├── Attribute/ # plugin attributes
├── Entity/ # DnsZone, DnsRecord
├── Form/ # zone form, record form, settings form
├── Plugin/ # local-action, RecordType plugins, validation constraints, views handlers
├── Routing/ # custom route providers
├── Utility/ # ZoneNameTransformer (static helpers)
└── *.php # interfaces, access handlers, list builders, plugin managers
The D7 split (dns + dns_zone + dns_record) is gone. Zones
without records is too niche to justify the maintenance burden, and
records without zones doesn't make sense. One module, one upgrade
path.
Entity model¶
DnsZone¶
Content entity, base table dns_zone. Two notable design decisions:
- The
namefield is the entity ID.entity_keys.id = "name", string PK. Punycode form. Stable URL slug, cache-tag identifier, and what DNS itself sees on the wire.setReadOnly(TRUE)so storage rejects rename attempts after first save. - The
labelfield is the Unicode display form.entity_keys.label = "label". ASCII zones havename === label; IDN zones differ.$entity->label()returns Unicode; every Drupal subsystem that asks for a label (page titles, autocomplete, Views' default Label handler, JSON:API) renders Unicode for free.
Other base fields: uid (owner), created, changed, attrs
(string_long JSON for arbitrary key/value attributes — currently
hidden from form/view, kept for forward compat).
Zone names round-trip through
Drupal\dns\Utility\ZoneNameTransformer::derivePair(), which accepts
either Unicode or already-Punycode input and returns the canonical
[label, name] pair. The transformer is a static utility class —
zero state, zero deps, deterministic. Aligned with Drupal core's
Component/Utility/* convention.
Validation lives on the label field as
Drupal\dns\Plugin\Validation\Constraint\ZoneNameConstraint /
ZoneNameConstraintValidator. The validator normalizes (trim → strip
trailing dot → idn_to_ascii UTS-46 → lowercase) and then checks
RFC 1035 structure plus uniqueness against the canonical Punycode
form (so münchen.de and xn--mnchen-3ya.de collide correctly).
DnsRecord¶
Content entity, base table dns_record. entity_keys.id =
"record_id" (integer autoincrement). Records are nested under their
zone in the URL space (/dns/{dns_zone}/records/{dns_record}) via a
custom route provider, Drupal\dns\Routing\DnsRecordRouteProvider,
which adds the dns_zone parameter converter to all routes generated
from the entity link templates.
Base fields:
| Field | Type | Used by |
|---|---|---|
record_id |
integer (entity ID) | always |
uuid |
uuid | always |
zone |
entity_reference (DnsZone) | always (required) |
prefix |
string | always |
record_type |
list_string (plugin id) | always |
ttl |
integer (unsigned) | always |
uid |
entity_reference (User) | always |
ip_address |
ipaddress (field_ipaddress) |
A, AAAA |
target |
string (max 253) | CNAME, NS, MX, PTR, SRV (and future DNAME) |
rdata |
string_long (JSON) | type-specific tail |
created, changed |
created / changed timestamps | always |
The shared base fields (ip_address, target) are each used by
multiple record types and earn their column slot. Per-type data
that's narrow or rarely queried (priority, weight, port, content,
SSHFP fingerprints, etc.) goes in the rdata JSON catch-all. The
threshold for promotion is shared across at least two record types
AND the cross-type query is operationally meaningful. priority
(MX + SRV) doesn't clear the bar because the queries are always
within a single type.
The record_type field uses allowed_values_function pointing at
DnsRecord::recordTypeAllowedValues(), which queries the RecordType
plugin manager — so adding a new record-type plugin automatically
adds it to the dropdown without further config.
The ipaddress field is configured with allow_family =
IP_FAMILY_ALL and allow_range = FALSE at the storage level. The
field's widget enforces parse-validity and single-only. Family is a
plugin-level concern (only the active record-type plugin knows
whether IPv4 or IPv6 is required), so SingleAddressRecordTypeBase
performs that check in its validateForm().
RecordType plugin system¶
Plugins live under Drupal\dns\Plugin\RecordType\, discovered via
the #[\Drupal\dns\Attribute\RecordType] PHP attribute. The manager
is Drupal\dns\RecordTypeManager (service id
plugin.manager.dns_record_type).
The interface, Drupal\dns\Plugin\RecordType\RecordTypeInterface,
has five methods:
public function usedFields(): array;
public function buildForm(array $form, FormStateInterface $form_state, DnsRecordInterface $record): array;
public function validateForm(array &$form, FormStateInterface $form_state, DnsRecordInterface $record): void;
public function applyToRecord(DnsRecordInterface $record, array &$form, FormStateInterface $form_state): void;
public function renderSummary(DnsRecordInterface $record): string;
Drupal\dns\Plugin\RecordType\RecordTypeBase provides no-op defaults
for the optional contract methods. SingleAddressRecordTypeBase
specializes for record types that store one IP and is the parent of
A and AAAA. CName extends RecordTypeBase directly since it
claims target instead of ip_address.
For step-by-step instructions on writing a new record-type plugin, see Writing a record type.
Cross-type entity constraints¶
Some DNS rules can't live inside a single plugin because they cross
record types. They're attached as constraints in the DnsRecord
entity annotation and fire on every record's validation.
DnsRecordCnameExclusivity(RFC 2181 §10.1) — a name with a CNAME cannot have any other record. The validator queries siblings at the same(zone, prefix)(apex-equivalence applied: empty string and@prefix collapse) and rejects two cases: saving a CNAME alongside any existing record, and saving any non-CNAME record alongside an existing CNAME.
When future cross-type rules surface (NS-at-zone-cut requirements, DNSKEY/DS pairing, etc.) they follow the same shape: an attribute- discovered constraint plugin attached to the entity, with a validator that loads siblings via storage.
Target hostname normalization¶
The target shared field stores values in canonical form (lowercase
Punycode) for any record type that uses it. Normalization happens in
DnsRecord::preSave() — entity-level so it applies regardless of
write path (form, Migrate, REST/JSON:API, programmatic). Display
helpers convert back to Unicode at render time via
ZoneNameTransformer::toUnicode().
Plugin form-validate of the user-typed value uses
ZoneNameTransformer::derivePair() followed by
validateLabels() — both pure functions, reusable across all
target-using record types.
The LabelValidationError enum returned by validateLabels() is
the typed contract between the structural validator and its
consumers; plugins map enum cases to their own localized messages.
Form flow on record submit¶
The record form (Drupal\dns\Form\DnsRecordForm) auto-renders all
configurable base fields via the standard form display, then:
- Sets
#access = FALSEon shared fields the active plugin doesn't claim (e.g. an A record hidestarget). - If the plugin contributes additional rdata fields via
buildForm(), places them under a fieldset keyedRecordTypeBase::FORM_KEY_RDATA. - Wraps the whole form in a wrapper div and attaches
#ajaxto therecord_typewidget — changing the type rebuilds the entire form so the toggleable widgets appear in their new state.
On submit:
parent::validateForm()runs entity-level validation (constraints, field-level required, etc.).- The form re-runs
buildEntity()to get the form-populated entity (parent'svalidateFormworks on a clone but doesn't update$this->entity— that happens insubmitForm()). The plugin'svalidateForm()runs against the populated entity. parent::submitForm()updates$this->entityviabuildEntity().save()clears any shared fields the active plugin doesn't claim (so switching A → CNAME doesn't leave a stale IP), callsapplyToRecord()for any plugin-side persistence work, and delegates toparent::save().
Views integration¶
A default view views.view.dns_records ships in config/install
with three displays:
- default — master.
- page_records — page display at
/admin/content/dns/records, replaces the old hand-rolledEntityListBuilderfor the records collection. Admins can customize columns, filters, sorts, exposed filters via the Views UI. - embed_zone — embed display, takes the zone id as the contextual
filter, used by
dns.module'shook_dns_zone_viewto render the records list inline on the zone canonical page. Surfaced as a pseudo-field "DNS records" on the zone view display so admins can hide/move/disable it via Manage Display.
For surfacing rdata JSON values as Views columns,
Drupal\dns\Plugin\views\field\RdataValue is a configurable field
handler — admins add it to a view, set the JSON key in the field
options, and it renders that key for each row. No filter or sort
handlers; the use case is "show me the full picture as a table." See
Customizing the records view.
JSON:API resource URLs¶
jsonapi (when enabled) addresses resources by UUID, not by the
entity's primary id. So zone resources live at
/jsonapi/dns_zone/dns_zone/{uuid} rather than
/jsonapi/dns_zone/dns_zone/{name}. The Punycode name is exposed
as attributes.drupal_internal__name in the response payload, and
record-to-zone relationships expose the target zone's id as
attributes.drupal_internal__target_id.
This is core JSON:API behavior; no module-level code influences it. Consumers wanting to look up a zone by name do so via filtering:
GET /jsonapi/dns_zone/dns_zone?filter[drupal_internal__name]=example.com
Field-level serialization of ip_address exposes all three
properties — ip_start, ip_end (both binary blobs), and value
(the human-readable IP). Use value. The binary properties are the
storage shape and shouldn't be relied on as a stable API surface;
consider them implementation detail.
Shipped record types¶
The main module ships the record types that cover the vast majority of
real zones — A, AAAA, CNAME, NS, MX, TXT, CAA, SRV, PTR — plus the
modern service-binding pair HTTPS and SVCB (RFC 9460) once alpha2
lands. Targets-only types (CNAME / NS / PTR / SRV / HTTPS / SVCB) all
share the TargetHostnameTrait so the BIND-format rules don't drift
between plugins. Cross-type rules (currently CNAME exclusivity) live
on the entity, not the plugin.
The dns_extras submodule (see roadmap) carries the smaller-audience
types that share core's plugin contract but don't earn shelf space in
the average install — DNAME plus the hash-digest family (SSHFP, TLSA,
SMIMEA, OPENPGPKEY, DS, DNSKEY).
External providers (dns_external)¶
The optional dns_external submodule is the framework half of a
two-sided design: it provides the plugin manager, value objects,
binding entity, importer, and pusher; concrete provider integrations
(dns_cloudns, future dns_route53, …) ship as separate modules and
register Provider plugins via #[\Drupal\dns_external\Attribute\Provider].
Splitting concrete providers out of the framework keeps each one's
SDK / Composer dependency surface scoped to that integration.
modules/dns_external/
├── src/
│ ├── Attribute/Provider.php # plugin attribute
│ ├── Plugin/Provider/ # contract + base class
│ │ ├── ProviderInterface.php
│ │ └── ProviderBase.php
│ ├── Provider/ # value objects
│ │ ├── RemoteZone.php
│ │ ├── RemoteRecord.php
│ │ ├── ProviderConfigInterface.php
│ │ └── Capability.php
│ ├── Entity/
│ │ ├── ProviderConfig.php # config entity (account)
│ │ └── DnsExternalBinding.php # content entity (zone↔account)
│ ├── Importer/Importer.php # discovery + materialize
│ ├── Pusher/Pusher.php # local → remote
│ └── ProviderManager.php
└── tests/modules/dns_external_test/ # FixtureProvider for tests
Storage. provider_config is a config entity (one row per
provider account, settings blob). dns_external_binding is a
content entity (one row per local zone, unique on zone, carrying the
provider's id for the zone plus a free-form provider_data map).
Records carry their own provider_remote_id directly as a base
field added to dns_record via
hook_entity_base_field_info() — one nullable column per row, no
per-record pivot entity. Multi-provider mirroring isn't a v1 goal;
the user explicitly opted into the column over a pivot entity.
Credentials. The Key module is a hard dependency. Provider
plugins store credential references as Key entity ids in their
settings blob, never raw secrets. Plugins resolve the key at API
call time via the key.repository service.
RemoteZone / RemoteRecord are the normalization target: every provider plugin's discovery / fetch methods convert upstream responses into these readonly value objects, and the importer consumes only those. A foreign API's representation never leaks past the plugin boundary, so adding a new provider doesn't touch the import pipeline.
Importer (dns_external.importer) is synchronous and
per-zone. The full materialize phase runs inside a database
transaction; mid-flight failures roll back so a half-imported zone
never lingers. Pre-flight refuses when a local zone with the same
name already exists (re-import / merge ships with bidirectional
sync). Per-record skips for unsupported types or validation failures
are non-fatal — they accumulate into ImportResult and log to the
dns_external channel rather than aborting the zone.
Pusher (dns_external.pusher) walks the local zone state and
dispatches pushRecord (record has no provider_remote_id) or
pushRecordUpdate (it does). The first push of an unbound zone
fires pushZone once and stamps the returned id on the binding;
subsequent pushes only touch records. No transaction — remote API
calls aren't rollback-able, and the partial-failure mode is real:
records that succeeded keep their stamps, failed ones don't, and a
re-run picks up where the previous attempt stopped.
Access. Provider configurations and binding mutation are
admin-only (administer dns providers). Binding view defers to
the parent zone's view records capability so zone owners and
granted collaborators see whether their zone is bound, even though
they can't change the binding. Per-zone push/import remains admin-
only in this version because credentials and remote-API
responsibility are admin concerns.
See the plugin-author guide for the full contract and normalization rules.
What's not yet built (and where it goes when it is)¶
| Feature | Where it'll live |
|---|---|
| Bidirectional sync (record-level diff, conflict resolution, deletes propagated either direction) | Service alongside dns_external.importer and dns_external.pusher. The unidirectional importer (refuses on local-existing) and pusher (no delete propagation) shipped first to keep the contract small; merge / sync semantics are a separate code path with its own UX. |
| Live DNS drift detector | Service Drupal\dns\DriftDetector resolves each stored record against the live authoritative answer (PHP's dns_get_record() or a shell-out to dig +short for richer types) and reports three categories: missing-at-live (in our DB, not resolving), extra-at-live (resolving, not in our DB — pulled via AXFR or per-type queries), and value-mismatch. Surfaced as a "Compare with live DNS" action on the zone canonical, plus an optional scheduled queue worker that flags drift in the admin overview. Treats CNAME/target rendering, TTL deltas, and the BIND apex/relative resolution rules consistently with how we store them. |
dns_extras submodule for less-common record types: DNAME and the hash-digest family (SSHFP, TLSA, SMIMEA, OPENPGPKEY, DS, DNSKEY) |
Submodule under modules/dns_extras/. Same plugin contract as core. The hash-digest types share a HashRecordTypeBase so each concrete plugin is ~30 lines (header range + payload encoding + label). Shipped separately because the average install never enables DNSSEC self-host or DANE / S/MIME / SSH fingerprint publishing. |
| Tier-C specialty types (LOC, NAPTR, CERT) | Deferred; community-driven. Each is an irregular one-off with its own grammar (LOC's coords, NAPTR's regex/replacement, CERT's superseded-by-TLSA-and-SMIMEA semantics). Anyone with a real use case can contribute via the existing plugin contract. |
Provider-quirks third-party modules (e.g. dns_dnsimple for ALIAS, redirect-style records) |
Out of tree. ALIAS and "Web Redirect"-style records are provider-specific synthetic types, not on-wire DNS — they belong to a provider integration module, not core DNS. |
| SOA auto-generation per zone | Zone-level metadata (serial / refresh / retry / expire / minimum-ttl) emitted at export time; no SOA plugin in the record dropdown — your provider's UI shouldn't either. |
| SPF builder UI for TXT records | A guided composer that produces SPF-valid TXT content from a mechanism list (include / a / mx / ip4 / ip6 / exists / redirect / all + qualifiers). Likely a TXT-plugin form variant or alter, with optional client-side preview matching the BIND-target preview pattern. |
| IANA TLD list validation | New service Drupal\dns\IanaTldList (cached, refreshable). New setting + checkbox in zone-name validator so zone creation rejects names whose effective TLD isn't on the IANA list. |
| Provider access constraints (read-only zones, records-only-editable, etc.) | Provider modules ship their own access policy in the dns_zone scope (see extension-points) that revokes capabilities on provider-managed zones. The local-grant policy ships in alpha3; concrete provider constraint policies layer on top of the dns_external binding entity once a provider needs them. |
Migration from 7.x-1.x (full d7_user mapping) |
Post-1.0. The current shipped migration covers the data side (zones + records, all six D7 types) and defaults zone owners to admin. Wiring user-owner mapping through the standard d7_user migration with a robust fallback when d7_user hasn't been run is the planned follow-up. |
Native JSON column for rdata |
3.x branch only. Replaces string_long field with json schema column; reuses everything else. |
These are roadmap items, not promises — they ship when they ship.