info@altius-group.ch
Froideville, Vaud
IT

A DNS zone you can only write whole

/ 12 min di lettura / aggiornato 04.09.2026

Some APIs have one property that determines your entire design. The OpenSRS DNS service has this one:

SET_DNS_ZONE replaces the whole zone. There is no call that writes a single record.

No PATCH /zones/{id}/records/{record}. No “add an A record”. You send the complete set of records for a domain, and whatever was there before is gone.

That is not a limitation to work around. It is a fact that decides where truth lives, how you delete something, what “idempotent” means here, and which record gets published in which order. This is the domain layer behind names.legal, where each customer gets a domain, a website and a mailbox — and every one of those needs DNS records that must not fight each other.

The consequence: something has to be authoritative

If you cannot write one record, you cannot treat the remote zone as the store. Every write is a read-modify-write of the whole thing, and read-modify-write against a system anyone else can also edit is a lost-update bug with extra steps.

So the decision is made for you, and the only useful thing is to make it loudly:

"""
Modèle de fonctionnement — À LIRE AVANT DE TOUCHER À CE FICHIER :

`SET_DNS_ZONE` **remplace la zone entière** côté OpenSRS ; il n'existe pas
d'écriture d'un enregistrement isolé. Par conséquent :

  - `DnsRecord` (base locale) fait autorité ;
  - toute modification = écrire en base, puis `push_zone()` qui reconstruit et
    envoie la zone COMPLÈTE ;
  - une zone éditée à la main dans le RCP web sera écrasée au prochain push —
    `pull_zone()` permet de la réimporter d'abord.
"""

That docstring is the most valuable thing in the module, and it is at the top for a reason. The dangerous property of this design is that it is invisible from the call site. push_zone(domain) looks like a sync. It is a replacement, and the person who discovers that empirically discovers it by deleting a customer’s records.

There is a general rule here worth stating: when a module’s correctness depends on a fact about a remote system, that fact goes at the top of the module, in prose, before the imports. Not in the wiki. Not in the commit message. The person about to break it is reading this file.

Deleting a record means sending an empty array

The first non-obvious consequence shows up in the payload builder.

def build_zone_payload(tenant_domain) -> dict:
    """Reconstruit le dict `records` complet attendu par SET_DNS_ZONE.

    Groupé par type. Les types sans aucun enregistrement sont envoyés en
    tableau vide plutôt qu'omis : c'est ce qui permet de SUPPRIMER le dernier
    enregistrement d'un type (un type absent laisserait l'ancien contenu en
    place chez certains registrars — on ne parie pas là-dessus).
    """
    payload = {t: [] for t in _TYPE_ORDER}
    for record in tenant_domain.dns_records.all():
        if record.record_type not in payload:
            raise ValueError(
                f"Type DNS inattendu en base : {record.record_type} "
                f"(domaine {tenant_domain.domain_name})"
            )
        payload[record.record_type].append(record.to_opensrs())
    return payload

payload = {t: [] for t in _TYPE_ORDER} — every type is initialised, and types with no records go out as [] rather than being left out of the dict.

The difference matters exactly once per type, and it is the case you will hit: the customer deletes their last TXT record. If the payload omits TXT, you are relying on the remote system to interpret absent as empty. Some do. Some interpret absent as unchanged — which is a defensible reading of a partial-update API, except this is not one. The record stays, the customer’s screen says it is gone, and the two disagree until someone reports it.

absent and empty are different words, and an API that replaces everything should never be given the chance to guess which one you meant.

The raise ValueError on an unknown type is the same instinct in the other direction. A record type in the database that the payload builder does not recognise means the row would be silently dropped from the zone — a record that exists in your interface and does not exist in DNS. Failing the whole push is much better: nothing is published, the log names the domain, and the state stays consistent instead of half-applied.

Two kinds of record, and a partial unique index

The platform puts records into a customer’s zone: the A records that point the domain at the server, and the mail block. The customer also puts records in theirs. These need to be different things.

class Managed(models.TextChoices):
    """Enregistrements posés et maintenus par la plateforme.

    Ils sont réaffirmés à chaque push : un tenant ne doit pas pouvoir
    casser l'accès à son site ou sa messagerie depuis l'écran DNS.
    `NONE` = enregistrement libre, créé par le tenant.
    """
    NONE       = "", gettext_lazy("Yours (free)")
    APEX       = "apex", gettext_lazy("Site — apex")
    WWW        = "www", gettext_lazy("Site — www")
    MX         = "mx", gettext_lazy("Mail — MX")
    SPF        = "spf", gettext_lazy("Mail — SPF")
    DKIM       = "dkim", gettext_lazy("Mail — DKIM")
    DMARC      = "dmarc", gettext_lazy("Mail — DMARC")
    IMAPS      = "imaps", "Mail — SRV IMAPS"
    SUBMISSION = "submission", "Mail — SRV soumission"

managed is not a boolean. It names the role the record plays, and that is what makes the next thing possible:

constraints = [
    models.UniqueConstraint(
        fields=["tenant_domain", "managed"],
        condition=~models.Q(managed=""),
        name="uniq_managed_dns_record_per_domain",
    ),
]

A partial unique index: one record per (domain, role), except for free records, of which a customer may have as many as they like.

This is the constraint that makes synchronisation replayable. Every seeding function goes through:

def _upsert_managed(tenant_domain, managed, **fields):
    """Pose ou met à jour l'enregistrement géré d'un rôle donné.

    Clé sur (domaine, rôle) — cf. la contrainte d'unicité du modèle : rejouer
    une synchronisation ne doit jamais empiler deux SPF ou deux apex.
    """
    record, _ = DnsRecord.objects.update_or_create(
        tenant_domain=tenant_domain, managed=managed, defaults=fields,
    )
    return record

Re-run the provisioning after a failure and nothing duplicates. Without the role column, “the SPF record” has to be identified by its value — and the moment you change the SPF value, the old one is no longer findable, so you get two, and a domain with two SPF records fails SPF for every message. Not degrades: fails, by specification, permerror. Identity by role rather than by content is what makes the value editable.

A note on NONE = "" rather than NULL. Both would work with a partial index — Postgres treats NULLs as distinct, so a plain unique constraint would also allow many free records. The empty string is chosen because it makes the condition explicit and readable (~Q(managed="") says “the managed ones”), and because managed is then a CharField with no nullable branch anywhere in the code that reads it. Three-valued logic in a column that is queried on every page is a cost with no benefit here.

Publishing in the wrong order breaks mail

The nominal path deliberately does less than it could:

def provision_domain_dns(tenant_domain, api=None):
    """Chemin nominal après enregistrement : zone + enregistrements site + push.

    Volontairement SANS le bloc mail : celui-ci n'est posé qu'à la création
    effective d'une boîte (cf. `seed_mail_records`), pour ne pas publier un MX
    vers un serveur qui ne connaît pas encore le domaine — ce qui ferait
    rebondir les messages entrants.
    """
    api = api or OpenSRSClient()
    ensure_zone(tenant_domain, api=api)
    seed_site_records(tenant_domain)
    return push_zone(tenant_domain, api=api)

It would be tidier to seed everything at once. It would also be wrong, and the failure is nasty.

An MX record is a public promise that a server accepts mail for this domain. Publish it before the mail server has been configured for that domain, and the world starts delivering: the sending server resolves the MX, connects, and gets 550 relay not permitted — a permanent failure. The message does not queue and retry. It bounces, back to the sender, saying this address does not exist.

The window between “domain registered” and “customer creates their first mailbox” can be weeks. Every message sent to them during that window is permanently rejected, and the people who sent them have been told, by the protocol, that the address is invalid.

So the ordering rule is: publish a record that makes a promise only once the thing it promises is true. The A records are safe early — a domain resolving to a server that serves a holding page is fine. The MX is not.

The same instinct, one level down:

if dkim_txt:
    records.append(_upsert_managed(
        tenant_domain, DnsRecord.Managed.DKIM,
        record_type=DnsRecord.Type.TXT,
        subdomain=f"{settings.MAIL_DKIM_SELECTOR}._domainkey",
        value=dkim_txt,
    ))
else:
    logger.warning(
        "DKIM non posé pour %s : aucune clé fournie par Mailcow. "
        "La messagerie fonctionnera mais sans signature.",
        tenant_domain.domain_name,
    )

A DKIM selector published with an empty or placeholder key is worse than no DKIM at all. No DKIM record means receivers do not check a signature. A present selector with the wrong key means every signature fails validation — and a failed DKIM signature is a much stronger spam signal than an absent one. The else branch is not defensive coding; it is the difference between “unsigned” and “forged”.

SRV instead of the CNAME everyone uses

The mail block includes something most setups do differently, and the reason is worth the paragraph:

# Autoconfiguration des clients de messagerie par SRV (et non par
# CNAME autodiscover) : le client se connecte à l'hôte cible et valide
# le certificat contre CE nom, donc pas de dépendance à un certificat
# couvrant le domaine du tenant.
_upsert_managed(
    tenant_domain, DnsRecord.Managed.IMAPS,
    record_type=DnsRecord.Type.SRV, subdomain="_imaps._tcp",
    value=host, port=993, priority=0, weight=1,
),
_upsert_managed(
    tenant_domain, DnsRecord.Managed.SUBMISSION,
    record_type=DnsRecord.Type.SRV, subdomain="_submission._tcp",
    value=host, port=587, priority=0, weight=1,
),

The common approach is autodiscover.customer.example as a CNAME to the mail host. It works, and it drags a certificate problem behind it: the client connects to a hostname in the customer’s domain, so TLS validation requires a certificate covering autodiscover.customer.example. With one shared mail server and a few hundred customer domains, that is a few hundred names on a certificate you now have to keep issued and renewed as customers come and go.

_imaps._tcp and _submission._tcp SRV records (RFC 6186) point at the mail host by its own name. The client is told “for this domain, connect to mail.altiusmail.ch:993”, connects there, and validates the certificate against mail.altiusmail.ch — a name the platform owns, with one certificate, forever.

One record type change removes an entire class of operational work. It is worth looking for these: a certificate problem that looks structural is often a naming decision two layers away.

Reclaiming a zone somebody edited by hand

Because the local database is authoritative, anything done elsewhere is destroyed by the next push. That is correct and it is also a trap, so the opposite direction exists:

def pull_zone(tenant_domain, api=None, *, replace=False):
    """Importe la zone telle qu'elle est chez OpenSRS vers la base.

    `replace=True` remplace les enregistrements LIBRES par ceux du distant ;
    les enregistrements gérés par la plateforme ne sont jamais touchés, ils
    sont réaffirmés par leurs fonctions dédiées.
    """
    ...
    if replace:
        with transaction.atomic():
            tenant_domain.dns_records.filter(managed="").delete()
            DnsRecord.objects.bulk_create(parsed)
    return parsed

Three things are doing work in those five lines.

replace is keyword-only and defaults to False. pull_zone(domain) reads. pull_zone(domain, replace=True) writes. The destructive behaviour cannot be switched on by a positional argument that someone passes by accident, and it cannot be switched on without naming it at the call site.

filter(managed="") — only free records are cleared. The managed ones are the platform’s, re-asserted by their own functions; letting an import overwrite them would let a hand-edited zone silently un-manage a customer’s MX.

transaction.atomic() around delete-then-create. A failure between the two statements leaves the customer with no free DNS records and no way to know what they were. This is the smallest possible transaction and it is not optional.

The parser is deliberately forgiving in one direction and strict in the other:

for record_type, entries in remote.items():
    if record_type not in _TYPE_ORDER:
        continue
    for entry in entries or []:
        if not isinstance(entry, dict):
            continue
        value = (
            entry.get("ip_address") or entry.get("ipv6_address")
            or entry.get("hostname") or entry.get("text") or ""
        )
        if not value:
            continue

Skip types we do not model, skip malformed entries, skip valueless records — because this is reading someone else’s data, and someone else’s data will eventually contain a shape you did not anticipate. Contrast with build_zone_payload, which raises on an unknown type. Same codebase, opposite policies, and both are right: be permissive about what you read from a system you do not control, and strict about what you publish under your customer’s name.

The one honest fragility

def ensure_zone(tenant_domain, api=None) -> bool:
    api = api or OpenSRSClient()
    try:
        api.create_dns_zone(tenant_domain.domain_name)
        return True
    except OpenSRSError as exc:
        if _looks_like_already_exists(exc):
            logger.info("Zone DNS déjà présente pour %s — reprise.",
                        tenant_domain.domain_name)
            return True
        raise


def _looks_like_already_exists(exc) -> bool:
    text = str(exc).lower()
    return "already" in text and ("exist" in text or "zone" in text)

This is string-matching on an error message from a third party, and the function name says so — _looks_like_already_exists, not is_already_exists.

It is there because the operation has to be idempotent. Provisioning gets retried: a register replayed after a timeout, an operator re-running a sync, a worker restarted mid-job. “The zone already exists” is the goal state, not a failure, and treating it as one turns every recovery into a manual ticket.

It is fragile because the vendor can reword the message in a release note nobody reads, at which point retries start failing again — loudly, at least, since the fallback is raise.

The right shape when a vendor gives you a stable error code is to match the code. When they do not, the honest engineering is: match the text, name the function so the next reader knows it is a heuristic, make the failure mode noisy rather than silent, and put a test around it. What you must not do is pretend that except OpenSRSError: pass is the same thing — that swallows the case where the zone genuinely could not be created, and you find out when the domain does not resolve.

What to take away

  1. Find the one property of the API that decides the design, and write it at the top of the module. Here it is “no partial writes”; everything else follows from it.
  2. When you replace a collection, send empty containers for empty categories. Absent and empty are different words, and only one of them deletes.
  3. Identify platform-owned rows by role, not by value, and enforce it with a partial unique index. It is what makes the value editable and the sync replayable.
  4. Publish a record that makes a promise only after the promise is true. An early MX bounces real mail, permanently.
  5. Be permissive reading, strict publishing — and let the two directions of the same module have opposite error policies on purpose.
Pronto a cominciare?

Parliamo del suo progetto

Ci racconti le sue esigenze in IoT, GIS o sviluppo su misura — le rispondiamo entro 24 ore.