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())