Skip to content

Examples

Focused, runnable scripts for common real-world setups. Each is a complete, standalone script - copy it and adapt it. The full source lives under examples/.

For an exhaustive list of every field on a given model, see the API Reference instead - it's generated from the models themselves, so it never drifts out of date.

Split-horizon DNS (multiple views)

Internal clients see private records, everyone else sees public ones. View declaration order matters: BIND applies the first view whose match-clients/match-destinations accepts the client.

"""Split-horizon DNS: internal clients see private records, everyone else sees public ones.

A single zone name (`example.com`) is served differently depending on where the query
comes from - the classic use case for BIND9 `view` blocks.
"""

from __future__ import annotations

from bindantic import (
    AclBlock,
    ARecord,
    NamedConfig,
    NSRecord,
    OptionsBlock,
    SOARecord,
    ViewBlock,
    ZoneBlock,
    ZoneTypeEnum,
)

internal_networks = AclBlock(name="internal_networks", addresses=["10.0.0.0/8", "192.168.0.0/16"])

soa = SOARecord(
    mname="ns1.example.com",
    rname="admin.example.com",
    serial=2026010101,
    refresh=10800,
    retry=3600,
    expire=604800,
    minimum=3600,
)

internal_view = ViewBlock(
    name="internal",
    match_clients=["internal_networks"],
    recursion=True,
    view_zones=[
        ZoneBlock(
            name="example.com",
            zone_type=ZoneTypeEnum.PRIMARY,
            file="zones/internal/example.com.zone",
            resource_records=[
                soa,
                NSRecord(nsdname="ns1.example.com"),
                ARecord(name="@", address="10.0.0.1"),
                ARecord(name="ns1", address="10.0.0.1"),
                ARecord(name="intranet", address="10.0.0.50"),
            ],
        )
    ],
)

external_view = ViewBlock(
    name="external",
    match_clients=["any"],
    recursion=False,
    view_zones=[
        ZoneBlock(
            name="example.com",
            zone_type=ZoneTypeEnum.PRIMARY,
            file="zones/external/example.com.zone",
            resource_records=[
                soa,
                NSRecord(nsdname="ns1.example.com"),
                ARecord(name="@", address="203.0.113.10"),
                ARecord(name="ns1", address="203.0.113.10"),
            ],
        )
    ],
)

config = NamedConfig(
    acl_blocks=[internal_networks],
    options_block=OptionsBlock(directory="/etc/bind", listen_on=["any"], listen_on_v6=["any"]),
    # View order matters: BIND matches the first view whose match-clients accepts the
    # client, so the more specific "internal" view must come before "external".
    view_blocks=[internal_view, external_view],
)

if __name__ == "__main__":
    print(config.model_bind_syntax())

Secondary zone with TSIG-authenticated transfers

Both sides of the relationship: a primary that only allows transfers signed with a shared key, and a secondary that pulls the zone using that same key.

"""Primary/secondary zone transfer authenticated with a TSIG key.

Shows both sides of the relationship: the primary server that serves `example.com`
and only allows transfers signed with the shared key, and the secondary server that
pulls the zone from the primary using that same key.
"""

from __future__ import annotations

from bindantic import (
    ARecord,
    KeyBlock,
    NamedConfig,
    NSRecord,
    OptionsBlock,
    SOARecord,
    ZoneBlock,
    ZoneTypeEnum,
)

transfer_key = KeyBlock(
    name="example-transfer-key",
    algorithm="hmac-sha256",
    secret="ZXhhbXBsZS10c2lnLXNoYXJlZC1zZWNyZXQ=",
)

primary_config = NamedConfig(
    key_blocks=[transfer_key],
    options_block=OptionsBlock(directory="/etc/bind", listen_on=["any"], listen_on_v6=["any"]),
    zone_blocks=[
        ZoneBlock(
            name="example.com",
            zone_type=ZoneTypeEnum.PRIMARY,
            file="zones/example.com.zone",
            # Only a client that can prove it holds the key may AXFR/IXFR this zone.
            allow_transfer=["key example-transfer-key"],
            # Push a NOTIFY to the secondary as soon as the SOA serial changes,
            # instead of waiting for it to poll on the SOA refresh interval.
            also_notify=[("198.51.100.2", 53)],
            resource_records=[
                SOARecord(
                    mname="ns1.example.com",
                    rname="admin.example.com",
                    serial=2026010101,
                    refresh=10800,
                    retry=3600,
                    expire=604800,
                    minimum=3600,
                ),
                NSRecord(nsdname="ns1.example.com"),
                NSRecord(nsdname="ns2.example.com"),
                ARecord(name="@", address="198.51.100.1"),
                ARecord(name="ns1", address="198.51.100.1"),
                ARecord(name="ns2", address="198.51.100.2"),
            ],
        )
    ],
)

secondary_config = NamedConfig(
    key_blocks=[transfer_key],
    options_block=OptionsBlock(directory="/etc/bind", listen_on=["any"], listen_on_v6=["any"]),
    zone_blocks=[
        ZoneBlock(
            name="example.com",
            zone_type=ZoneTypeEnum.SECONDARY,
            file="secondaries/example.com.zone",
            # Pull the zone from the primary, authenticated with the same TSIG key.
            primaries=["198.51.100.1 key example-transfer-key"],
        )
    ],
)

if __name__ == "__main__":
    print("# --- primary (198.51.100.1) ---")
    print(primary_config.model_bind_syntax())
    print("\n# --- secondary (198.51.100.2) ---")
    print(secondary_config.model_bind_syntax())

DNSSEC-signed zone, end to end

A key-store, a dnssec-policy (KSK+ZSK), and a zone that uses that policy with inline signing.

"""DNSSEC-signed zone, end to end: key-store -> dnssec-policy (KSK+ZSK) -> zone.

BIND9's "dnssec-policy" (KASP) framework generates and rotates the signing keys
itself once the server is running; bindantic's job is to describe *where* keys live
and *how* they should be generated/rotated, then attach that policy to a zone.
"""

from __future__ import annotations

from bindantic import (
    ARecord,
    DnssecAlgorithmEnum,
    DnssecKeyEntry,
    DnssecPolicyBlock,
    KeyRoleEnum,
    KeyStorageEnum,
    KeyStoreBlock,
    NamedConfig,
    NSRecord,
    OptionsBlock,
    SOARecord,
    ZoneBlock,
    ZoneTypeEnum,
)

# Where BIND persists the generated private keys.
key_store = KeyStoreBlock(name="local-keys", directory="/etc/bind/keys")

policy = DnssecPolicyBlock(
    name="example-policy",
    keys=[
        DnssecKeyEntry(
            role=KeyRoleEnum.KSK,
            storage_type=KeyStorageEnum.KEY_STORE,
            key_store_name="local-keys",
            lifetime="unlimited",
            algorithm=DnssecAlgorithmEnum.ECDSAP256SHA256,
        ),
        DnssecKeyEntry(
            role=KeyRoleEnum.ZSK,
            storage_type=KeyStorageEnum.KEY_STORE,
            key_store_name="local-keys",
            lifetime="P30D",  # rotate the zone-signing key every 30 days
            algorithm=DnssecAlgorithmEnum.ECDSAP256SHA256,
        ),
    ],
    # Keep an unsigned copy of the zone file on disk and a separately-signed one -
    # lets you edit records without dealing with signatures directly.
    inline_signing=True,
)

zone = ZoneBlock(
    name="example.com",
    zone_type=ZoneTypeEnum.PRIMARY,
    file="zones/example.com.zone",
    dnssec_policy="example-policy",
    inline_signing=True,
    resource_records=[
        SOARecord(
            mname="ns1.example.com",
            rname="admin.example.com",
            serial=2026010101,
            refresh=10800,
            retry=3600,
            expire=604800,
            minimum=3600,
        ),
        NSRecord(nsdname="ns1.example.com"),
        ARecord(name="@", address="192.168.1.1"),
        # ns1.example.com is in-bailiwick - it needs a glue A record in this
        # same zone, or BIND rejects the zone as unloadable.
        ARecord(name="ns1", address="192.168.1.1"),
    ],
)

config = NamedConfig(
    key_store_blocks=[key_store],
    dnssec_policy_blocks=[policy],
    options_block=OptionsBlock(directory="/etc/bind", listen_on=["any"], listen_on_v6=["any"]),
    zone_blocks=[zone],
)

if __name__ == "__main__":
    print(config.model_bind_syntax())

Operational visibility: logging and a statistics channel

Split log output by purpose (rotating file for queries, syslog for security events), and expose a statistics channel for monitoring tools to scrape.

"""Operational visibility: structured logging plus a statistics channel to scrape.

Splits log output by purpose - a rotating file for day-to-day queries, syslog for
security-relevant events an ops team should be paged on - and exposes an HTTP
statistics channel that monitoring tools (e.g. Prometheus's bind_exporter) can poll.
"""

from __future__ import annotations

from bindantic import (
    InetChannel,
    LogCategory,
    LogCategoryEnum,
    LogChannel,
    LoggingBlock,
    LogSeverityEnum,
    NamedConfig,
    OptionsBlock,
    StatisticsChannelsBlock,
    SyslogFacilityEnum,
)

query_log = LogChannel(
    name="query_log",
    file="/var/log/named/query.log",
    versions=10,
    size="50M",
    print_time="iso8601-utc",
)

security_log = LogChannel(
    name="security_log",
    syslog=SyslogFacilityEnum.AUTHPRIV,
    severity=LogSeverityEnum.NOTICE,
)

logging_block = LoggingBlock(
    channels=[query_log, security_log],
    categories=[
        LogCategory(name=LogCategoryEnum.QUERIES, channels=["query_log"]),
        LogCategory(name=LogCategoryEnum.SECURITY, channels=["security_log"]),
        # "default" without an explicit channel falls back to named's built-in
        # default_syslog/default_debug - only override the categories you care about.
    ],
)

statistics_channels = StatisticsChannelsBlock(
    channels=[
        # Restrict to loopback - this endpoint has no authentication of its own.
        InetChannel(address="127.0.0.1", port=8053, allow=["localhost"]),
    ],
)

config = NamedConfig(
    logging_block=logging_block,
    statistics_channels_blocks=[statistics_channels],
    options_block=OptionsBlock(directory="/etc/bind", listen_on=["any"], listen_on_v6=["any"]),
)

if __name__ == "__main__":
    print(config.model_bind_syntax())

Remote management with rndc

An explicit controls channel secured with its own key, restricted to loopback.

"""Remote management: an `rndc` control channel secured with its own key.

Without an explicit `controls` block, BIND already listens for `rndc` on loopback with
no authentication beyond the default key BIND generates itself. This shows the
explicit form: a dedicated key, restricted to loopback, so the same key can be handed
to a monitoring/automation user without also granting it zone-transfer access.
"""

from __future__ import annotations

from bindantic import ControlsBlock, InetControl, KeyBlock, NamedConfig, OptionsBlock

rndc_key = KeyBlock(
    name="rndc-key",
    algorithm="hmac-sha256",
    secret="k7h0uSRGCcVdKfLQfCJgCA==",  # generate your own with `tsig-keygen -a hmac-sha256`
)

controls = ControlsBlock(
    controls=[
        InetControl(ip_address="127.0.0.1", allow=["127.0.0.1"], keys=["rndc-key"]),
        InetControl(ip_address="::1", allow=["::1"], keys=["rndc-key"]),
    ],
)

config = NamedConfig(
    key_blocks=[rndc_key],
    controls_block=controls,
    options_block=OptionsBlock(directory="/etc/bind", listen_on=["any"], listen_on_v6=["any"]),
)

if __name__ == "__main__":
    print(config.model_bind_syntax())

Forwarding over DNS-over-TLS (DoT)

Forward recursive queries to upstream resolvers over TLS - one opportunistic (tls ephemeral), one with a pinned CA and hostname for real authentication.

"""Forward recursive queries to upstream resolvers over DNS-over-TLS (DoT).

Two forwarders, two trust models: one uses "ephemeral" TLS (opportunistic - encrypts
the query but doesn't verify who's on the other end), the other pins a specific
certificate authority and hostname for real authentication.

Note: `tls_id` values like "ephemeral" and "none" are BIND9-reserved - they're passed
as plain strings wherever a TLS configuration name is expected, never declared as
their own `tls { ... };` block.
"""

from __future__ import annotations

from bindantic import ForwardersBlock, NamedConfig, OptionsBlock, ServerSpecifier, TlsBlock

pinned_resolver_tls = TlsBlock(
    name="pinned-resolver-tls",
    ca_file="/etc/ssl/certs/upstream-ca.pem",
    remote_hostname="dot.upstream.example.com",
)

config = NamedConfig(
    tls_blocks=[pinned_resolver_tls],
    options_block=OptionsBlock(
        directory="/etc/bind",
        forward="only",
        forwarders=ForwardersBlock(
            servers=[
                ServerSpecifier(address="9.9.9.9", port=853, tls="ephemeral"),
                ServerSpecifier(address="198.51.100.53", port=853, tls="pinned-resolver-tls"),
            ],
        ),
    ),
)

if __name__ == "__main__":
    print(config.model_bind_syntax())

A reusable remote-servers list, plus trust-anchors

Name a group of primaries once and reference it from any zone's primaries, and pin trust anchors for the root zone and for a private zone outside the public DNSSEC chain of trust.

"""A reusable `remote-servers` list, and both trust-anchor formats for DNSSEC.

`remote-servers` names a group of servers once so it can be referenced from any
number of zones' `primaries`/`parental-agents`/`also-notify`, instead of repeating the
same IP list everywhere. Pairs it here with `trust-anchors`: the built-in root key
(DNSKEY format) plus a manually pinned DS record for a private, non-root zone that
isn't in the public DNSSEC chain of trust.
"""

from __future__ import annotations

from bindantic import (
    AnchorTypeEnum,
    DSTrustAnchor,
    KeyTrustAnchor,
    NamedConfig,
    OptionsBlock,
    RemoteServerEntry,
    RemoteServersBlock,
    TrustAnchorsBlock,
    ZoneBlock,
    ZoneTypeEnum,
)

primaries = RemoteServersBlock(
    name="corp-primaries",
    servers=[
        RemoteServerEntry(server="198.51.100.10"),
        RemoteServerEntry(server="198.51.100.11"),
    ],
)

trust_anchors = TrustAnchorsBlock(
    anchors=[
        # See https://www.iana.org/dnssec/files for the actual current root key -
        # this key_data is a placeholder, not the live key.
        KeyTrustAnchor(
            domain=".",
            anchor_type=AnchorTypeEnum.INITIAL_KEY,
            flags=257,
            protocol=3,
            algorithm=8,
            key_data=(
                "GUY9l5iZ0/FXh/drrhfId2e4blNVOlmUYffdgnt/mAzWP5MW9gMvp/xTwbtmxg88"
                "Nq3c5TIH1bV1rv9L+xt7GBlGPZeYmdPxV4f3a64XyHdnuG5TVTpZlGH33YJ7f5gM"
                "1j+TFvYDL6f8U8G7ZsYPPDat3OUyB9W1da7/S/sbexg="
            ),
        ),
        DSTrustAnchor(
            domain="internal.example.com",
            anchor_type=AnchorTypeEnum.STATIC_DS,
            key_tag=54321,
            algorithm=13,
            digest_type=2,
            digest="ab" * 32,  # placeholder - use the real SHA-256 digest for your zone
        ),
    ],
)

config = NamedConfig(
    remote_servers_blocks=[primaries],
    trust_anchors_blocks=[trust_anchors],
    options_block=OptionsBlock(directory="/etc/bind", listen_on=["any"], listen_on_v6=["any"]),
    zone_blocks=[
        ZoneBlock(
            name="secondary.example.com",
            zone_type=ZoneTypeEnum.SECONDARY,
            primaries=["corp-primaries"],
        ),
    ],
)

if __name__ == "__main__":
    print(config.model_bind_syntax())

Response Policy Zone (RPZ) and catalog zones

Block or redirect malicious domains at the resolver with an RPZ, and distribute a fleet's zone list via a catalog zone.

"""Block malicious domains at the resolver with a Response Policy Zone (RPZ).

`rpz.example.com` is an ordinary primary zone whose records happen to describe
policy actions (NXDOMAIN a known-bad domain, redirect another to a walled-garden
page) rather than real answers. `response-policy` tells the resolver to consult it
before answering. `catalog-zones` shows the companion feature: a zone whose member
list (of *other* zones to serve) is itself distributed via zone transfer, so adding a
zone to the catalog is enough to provision it across a whole secondary fleet.
"""

from __future__ import annotations

from bindantic import (
    ARecord,
    CatalogZoneBlock,
    CNAMERecord,
    NamedConfig,
    NSRecord,
    OptionsBlock,
    ResponsePolicyBlock,
    ResponsePolicyZone,
    SOARecord,
    ZoneBlock,
    ZoneTypeEnum,
)

rpz_zone = ZoneBlock(
    name="rpz.example.com",
    zone_type=ZoneTypeEnum.PRIMARY,
    allow_transfer=["none"],
    resource_records=[
        SOARecord(
            mname="ns1.rpz.example.com",
            rname="admin.rpz.example.com",
            serial=2026010101,
            refresh=3600,
            retry=900,
            expire=604800,
            minimum=3600,
        ),
        NSRecord(nsdname="ns1.rpz.example.com"),
        ARecord(name="ns1", address="192.0.2.1"),
        # A "*" wildcard NXDOMAIN's the whole malware.example. subtree.
        CNAMERecord(name="*.malware.example.com", canonical_name="."),
        # A specific host gets redirected to a walled-garden landing page instead.
        CNAMERecord(name="phishing.example.com", canonical_name="walled-garden.example.com"),
    ],
)

catalog_zone = ZoneBlock(
    name="catalog.example.com",
    zone_type=ZoneTypeEnum.PRIMARY,
    allow_transfer=["none"],
    resource_records=[
        SOARecord(
            mname="ns1.catalog.example.com",
            rname="admin.catalog.example.com",
            serial=2026010101,
            refresh=3600,
            retry=900,
            expire=604800,
            minimum=3600,
        ),
        NSRecord(nsdname="ns1.catalog.example.com"),
        ARecord(name="ns1", address="192.0.2.1"),
    ],
)

config = NamedConfig(
    options_block=OptionsBlock(
        directory="/etc/bind",
        listen_on=["any"],
        listen_on_v6=["any"],
        response_policy=ResponsePolicyBlock(
            zones=[ResponsePolicyZone(zone="rpz.example.com", log=True)],
        ),
        catalog_zones=[CatalogZoneBlock(zone="catalog.example.com")],
    ),
    zone_blocks=[rpz_zone, catalog_zone],
)

if __name__ == "__main__":
    print(config.model_bind_syntax())