Files
automations/deployments/knot-dns/README.md
T
57_WolveandClaude Opus 5 0c151ce79b feat(knot-dns): add the missing host-level installer
The deployment shipped its payload but not the thing that installs it.
54a5c09 added README, knot.conf, knsctl, zone.tmpl, secrets.conf.example,
the aliases and the MOTD -- but no deploy.sh, no cloud-init.yml, and no
entry in automations.sh's DEPLOYMENTS. `git log --all` confirms deploy.sh
was never committed and it is not gitignored, yet README.md:96 and :100
tell the operator to run it. So the documented install path did not exist.

Alpine only, native, matching the README: Knot binds :53 directly, needs
real client addresses for RRL and cookies, and keeps its DNSSEC key store on
the host filesystem. The RHEL packaging needs EPEL, which nothing here sets
up, so anything that is not Alpine dies with a clear message rather than
half-installing somewhere untested.

Three decisions worth recording:

The include chain is stubbed. knot.conf include:s seven files the `dns` repo
owns; Knot treats a missing include as a config error, so a node the pipeline
has never delivered to would fail conf-check and never start. deploy.sh
writes a placeholder for each one that is ABSENT -- never over a delivered
file -- so the node comes up healthy serving no zones until the pipeline
lands.

TSIG is generated on a primary and required on a secondary. The keys must
match byte for byte, so a secondary that generated its own would
authenticate nothing; it now refuses to deploy without TSIG_AUTHORITIVE and
TSIG_ADMIN. A primary generates both and prints them once. An existing
secrets.conf is never rewritten, so a re-run cannot rotate a key out from
under a running estate.

PRIMARY_ADDR seeds a minimal remotes.conf on a secondary so it can bootstrap
by AXFR before the pipeline runs -- written only when remotes.conf was
absent, verified by re-running against a delivered file and confirming it is
left untouched.

Re-runs apply changes rather than freezing at first deploy, per the pattern
this repo just adopted elsewhere: knot.conf is re-rendered from .env every
run, env-presence is captured before the ":=" defaults, and values passed to
a re-run are written back to .env with the awk-based set_env from 947c899 --
which matters here because a TSIG secret can contain the characters that
broke the sed-based one.

Two bugs caught while testing this, before it shipped:
- the secrets.conf renderer used `++n` as a gsub argument, which awk
  evaluates on every line, not just matching ones -- both keys would have
  received the SAME secret, making the read-only admin key identical to the
  replication key. Increments on a matching line only now.
- the MOTD is a pre-drawn box, so substituting values of a different width
  than their @TOKEN@ shifted the right border on every login. Values are now
  padded to the token's span, measured over an ASCII-only region so it holds
  under busybox awk in the C locale; an over-long value overflows rather than
  being truncated.

Verified: knot.conf renders identity/NSID/listen and leaves the control
socket alone; all seven stubs are created on a fresh node and skipped on a
re-run; the remotes.conf seed fires only for a fresh secondary; .env seeds
every runtime key; the MOTD renders with no leftover tokens and an aligned
border. Not verified: apk, knotc and the service start, which need an actual
Alpine host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:32:45 -05:00

135 lines
6.5 KiB
Markdown

# knot-dns
Authoritative DNS node — [Knot DNS](https://www.knot-dns.cz/) on Alpine, native
(no container), for a global anycast estate with automatic DNSSEC.
**Exception to the repo norm**, alongside `squid` and `openbao`: no Docker, no
Caddy, no Let's Encrypt. Knot binds :53 directly, needs real client addresses
for RRL and DNS cookies, and its DNSSEC key store must live on the host
filesystem. Containerising it buys nothing and costs the host firewall.
## What this deploys, and what it does not
This stands up **a node**. It does not manage zones — that is the
[`dns`](../../../dns) repo, and its pipeline delivers zone data here.
The split point is `/etc/knot/knot.conf`. This deployment writes it once as a
skeleton of `include:` lines covering only what belongs to a *box*: identity,
NSID, storage paths, listen addresses, logging, control socket. Everything that
belongs to *DNS policy* — templates, DNSSEC policy, remotes/ACLs, modules, the
domain inventory, the zone files — arrives from the `dns` repo.
| Deployed here, once | Delivered by the `dns` repo, continuously |
|---|---|
| `knot.conf` skeleton | `templates.conf`, `policy.conf`, `remotes.conf`, `modules.conf` |
| `secrets.conf` (TSIG, rendered locally, never in git) | `dnssec.conf`, `public.conf`, `arpa.conf` |
| `knsctl`, aliases, MOTD, `zone.tmpl` | `zones/**/*.zone` |
| packages, users, directories, firewall | |
## Files
| File | Purpose |
|---|---|
| `deploy.sh` | The installer. Alpine only, native. Self-contained: the files below are embedded: rebuild with `build.sh` after editing them. |
| `.env.example` | Seeds `/etc/knot/.env`. `knot.conf` is re-rendered from it every run. |
| `cloud-init.yml` | Fresh-VM user-data: harden SSH, host firewall, then deploy. |
| `knot.conf` | Bootstrap skeleton. `@NODE_ID@` / `@LISTEN@` substituted from `.env`. |
| `secrets.conf.example` | TSIG `key:` block. Rendered to `/etc/knot/secrets.conf`, `0640 root:knot`. Never committed. |
| `knsctl` | Admin CLI. Replaces `adddns.pl` / `adddnssec.pl` and fixes four defects in them. |
| `knotdns-aliases.sh` | `/etc/profile.d/` — the existing muscle memory, with `-b` added everywhere. |
| `knot-dns.motd` | The cheat-sheet MOTD. |
| `zone.tmpl` | New-zone skeleton (mirrors `dns/skeleton/zone.tmpl`). |
## What deploy.sh does on a fresh node
Two things are worth knowing before the first run.
**The include chain is stubbed.** `knot.conf` `include:`s seven files this
deployment does not own (`remotes`, `policy`, `modules`, `templates`, `arpa`,
`dnssec`, `public`). Knot treats a missing include as a config error, so a node
the `dns` pipeline has never delivered to would fail `conf-check` and never
start. `deploy.sh` writes a placeholder for each one that is absent — and only
for those, so a delivered file is never clobbered. The node therefore comes up
healthy, serving no zones, until the pipeline lands.
**TSIG keys are generated on a primary and copied to a secondary.** They have to
match byte for byte or nothing authenticates, so a secondary cannot invent its
own. `ROLE=primary` generates both keys, writes `secrets.conf` and prints them
once at the end of the run — capture them. `ROLE=secondary` requires
`TSIG_AUTHORITIVE` and `TSIG_ADMIN` and refuses to deploy without them. An
existing `secrets.conf` is never rewritten, so a re-run cannot rotate a key out
from under a running estate.
A secondary also gets a minimal `remotes.conf` seeded from `PRIMARY_ADDR`, so it
can bootstrap by AXFR before the pipeline has ever run. That seed is written
only when `remotes.conf` was absent; once the pipeline owns the file, re-running
leaves it alone.
Re-running is safe and is how you change things: `knot.conf` is re-rendered from
`.env` every time, and values passed to the re-run are written back to `.env`
first, so a corrected `NODE_ID` or `LISTEN` actually takes effect. Values you do
not pass are left as they are.
## Roles
`ROLE` in `.env` selects what the node is:
- **`primary`** — holds zone files, signs with automatic DNSSEC, owns the KASP
database, notifies the secondaries. Exactly one node. Not necessarily
public-facing: a hidden (stealth) primary is the recommended shape.
- **`secondary`** — receives zones by AXFR/IXFR with TSIG, serves queries,
signs nothing. `dnssec-signing` must be **off**; a secondary serves data that
is already signed.
Adding a public node is a `secondary` deploy plus one address in the primary's
`remotes.conf`. With catalog zones configured, the new node self-populates.
## What survives from the previous setup
Everything an operator types. `knrl`, `knsc`, `knzc`, `knpbz`, `knsec`,
`knarpa`, `ozf` all still work, from the same paths, on the same box. Three
corrections were folded in:
1. **`-b` on every triggering `knotc` command.** Without it `knotc` returns OK
when the command was *sent*, not when it succeeded, so a rejected zone file
reports green. `knrl` was a bare `knotc reload`.
2. **`knzr` (`zone-reload`) added** next to `knrl` (`reload`). Reloading one
zone's data is the right verb for a record change — smaller blast radius,
and a parse error in one zone cannot disturb the others. `knrl` reloads
configuration and is only needed when a zone is added or removed.
3. **`knsctl` replaces the Perl scripts**, whose duplicate check searched for
the domain in BIND `named.conf` double-quote syntax against unquoted YAML
and therefore never matched; which never consulted the other class's
manifest, so a domain in `public.conf` could be appended to `dnssec.conf`
and fail the reload after both files were already written; and which
reloaded without validating.
## Key material
`/var/lib/knot/keys` (the KASP LMDB) is node-local to the primary and
replicates nowhere. Losing it is the one unrecoverable failure in this system:
Knot will happily generate fresh KSKs, every published DS will point at keys
that no longer exist, and every signed domain goes bogus until each registrar
is updated by hand.
```sh
knotc -b zone-backup +backupdir /var/backups/knot/$(date -u +%FT%H) +journal
```
**`+journal` is not the default** — the documented default filter set excludes
it, and in this configuration the journal holds the only durable copy of the
signed zone and the last real SOA serial. A default `zone-backup` captures your
keys and loses your zone data. Restore order is config → KASP + journal → *then*
start `knotd`, never the reverse.
## Usage
```sh
# interactive
bash deploy.sh
# non-interactive (this is what cloud-init.yml does)
ROLE=secondary NODE_ID=ANYCAST-DNS-3 PRIMARY_ADDR=10.1.24.64 \
SKIP_PROMPTS=1 bash deploy.sh
```