From 0c151ce79b758f4b7104cce996779e680e4d56f4 Mon Sep 17 00:00:00 2001 From: William Gill Date: Wed, 16 Sep 2026 14:32:45 -0500 Subject: [PATCH] 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 --- automations.sh | 15 +- deployments/knot-dns/.env.example | 36 ++ deployments/knot-dns/.gitignore | 5 + deployments/knot-dns/README.md | 33 ++ deployments/knot-dns/build.sh | 45 +++ deployments/knot-dns/cloud-init.yml | 55 +++ deployments/knot-dns/deploy.sh | 551 ++++++++++++++++++++++++++++ 7 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 deployments/knot-dns/.env.example create mode 100644 deployments/knot-dns/.gitignore create mode 100644 deployments/knot-dns/build.sh create mode 100644 deployments/knot-dns/cloud-init.yml create mode 100644 deployments/knot-dns/deploy.sh diff --git a/automations.sh b/automations.sh index 4f0f290..ecc2489 100644 --- a/automations.sh +++ b/automations.sh @@ -118,7 +118,7 @@ set -euo pipefail . "$ROOT/scripts/lib.sh" load_globals -DEPLOYMENTS=(pocket-id beszel headscale webfinger squid copyparty simplex openbao ergo) +DEPLOYMENTS=(pocket-id beszel headscale webfinger squid copyparty simplex openbao ergo knot-dns) SCRIPTS=(setup-host harden-ssh harden-jumphost sshuser auto-update) # ---------------------------------------------------------------------------- @@ -184,6 +184,19 @@ ask_deployment_vars() { openbao) ask OPENBAO_ADDR "LAN address the Kanrisha tape host reaches the vault at (IP or DNS)" ask OPENBAO_BIND "Host IP to bind the API on (blank = 0.0.0.0)" optional ;; + knot-dns) + ask NODE_ID "Node ID for server.identity / NSID (e.g. ANYCAST-DNS-3)" + ask ROLE "Role: primary | secondary" + ask LISTEN "Listen addresses (blank = 0.0.0.0@53, ::@53)" optional + # A secondary's TSIG keys must byte-match the primary's, so they are + # copied from it rather than generated here. + if [[ "${ROLE:-}" == secondary ]]; then + ask PRIMARY_ADDR "Address of the primary this node transfers from" + ask TSIG_AUTHORITIVE "TSIG authortive-tsig secret (from the primary)" password + ask TSIG_ADMIN "TSIG admin-tsig secret (from the primary)" password + fi + ask DATACENTER "Data centre label for the MOTD" optional + ask PEERS "Peer addresses for 'knsctl serials' (space-separated)" optional ;; ergo) ask ERGO_DOMAIN "IRC server hostname (e.g. irc.example.com)" ask ACME_EMAIL "Let's Encrypt email" diff --git a/deployments/knot-dns/.env.example b/deployments/knot-dns/.env.example new file mode 100644 index 0000000..54e5b26 --- /dev/null +++ b/deployments/knot-dns/.env.example @@ -0,0 +1,36 @@ +# Copy to /etc/knot/.env (0640 root:knot). deploy.sh seeds it on first run and, +# on a re-run, updates only the keys you passed that run. Never commit it. +# +# knot.conf is re-rendered from these values on every deploy, so correcting one +# here (or passing it to a re-run) actually takes effect. + +# ─── Identity ─────────────────────────────────────────────────────────────── +# Shown as server.identity and server.nsid, and in the MOTD. Use the estate's +# node naming, e.g. ANYCAST-DNS-3. +NODE_ID= + +# primary | secondary +# primary -- holds zone files, signs with automatic DNSSEC, owns the KASP +# database, notifies the secondaries. Exactly one node. +# secondary -- receives zones by AXFR/IXFR with TSIG and signs nothing. +ROLE=secondary + +# ─── Listening ────────────────────────────────────────────────────────────── +# Substituted into knot.conf's `listen:`. Comma-separated addr@port entries. +# Narrow this to the node's service address if it should not answer on every +# interface (an anycast node usually should not). +LISTEN=0.0.0.0@53, ::@53 + +# ─── Replication ──────────────────────────────────────────────────────────── +# secondary only: the primary this node transfers from. deploy.sh seeds a +# minimal remotes.conf from it so the node can bootstrap by AXFR before the +# `dns` repo's pipeline has ever run. The pipeline overwrites that file later. +PRIMARY_ADDR= + +# ─── MOTD ─────────────────────────────────────────────────────────────────── +# Data centre label for the login banner. Cosmetic. +DATACENTER= + +# Peers for `knsctl serials` / the knsers alias -- space-separated addresses of +# the other nodes in the estate. Written to /etc/profile.d/knotdns-peers.sh. +PEERS= diff --git a/deployments/knot-dns/.gitignore b/deployments/knot-dns/.gitignore new file mode 100644 index 0000000..9fa20f1 --- /dev/null +++ b/deployments/knot-dns/.gitignore @@ -0,0 +1,5 @@ +# Never commit the rendered TSIG key material or a populated .env. secrets.conf +# holds the shared secrets every node's AXFR/NOTIFY authenticates with; leaking +# it lets anyone transfer -- or spoof a NOTIFY for -- every zone in the estate. +secrets.conf +.env diff --git a/deployments/knot-dns/README.md b/deployments/knot-dns/README.md index 2370a66..062caed 100644 --- a/deployments/knot-dns/README.md +++ b/deployments/knot-dns/README.md @@ -30,6 +30,9 @@ domain inventory, the zone files — arrives from the `dns` repo. | 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. | @@ -37,6 +40,36 @@ domain inventory, the zone files — arrives from the `dns` repo. | `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: diff --git a/deployments/knot-dns/build.sh b/deployments/knot-dns/build.sh new file mode 100644 index 0000000..b6c5e74 --- /dev/null +++ b/deployments/knot-dns/build.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# build.sh -- (re)embed knot.conf, secrets.conf.example, knsctl, zone.tmpl, +# knotdns-aliases.sh, knot-dns.motd and .env.example into deploy.sh as a +# base64-encoded tar.gz payload after __ARCHIVE_BELOW__. Idempotent: strips any +# existing payload first. + +set -euo pipefail + +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +SCRIPT="$DIR/deploy.sh" +MARKER="__ARCHIVE_BELOW__" + +FILES=( + knot.conf + secrets.conf.example + knsctl + zone.tmpl + knotdns-aliases.sh + knot-dns.motd + .env.example +) + +[[ -f "$SCRIPT" ]] || { echo "deploy.sh not found at $SCRIPT" >&2; exit 1; } +for f in "${FILES[@]}"; do + [[ -f "$DIR/$f" ]] || { echo "Missing $DIR/$f" >&2; exit 1; } +done + +PAYLOAD=$(tar -czf - -C "$DIR" "${FILES[@]}" | base64) + +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +sed "/^${MARKER}\$/,\$d" "$SCRIPT" > "$TMP" +{ + echo "$MARKER" + echo "$PAYLOAD" +} >> "$TMP" + +mv "$TMP" "$SCRIPT" +chmod +x "$SCRIPT" +trap - EXIT + +size=$(wc -c < "$SCRIPT") +echo "Built $SCRIPT (${size} bytes)" diff --git a/deployments/knot-dns/cloud-init.yml b/deployments/knot-dns/cloud-init.yml new file mode 100644 index 0000000..92b8d05 --- /dev/null +++ b/deployments/knot-dns/cloud-init.yml @@ -0,0 +1,55 @@ +#cloud-config +# +# Authoritative Knot DNS node — harden SSH, then deploy, on a fresh Alpine host. +# Native (no container): Knot binds :53 directly. Alpine only. +# +# Fill in REPO_URL and the values in the runcmd block, then paste this as the +# instance user-data. +# +# A SECONDARY needs the primary's TSIG secrets, byte for byte, or nothing will +# ever transfer — take them from the primary's deploy summary (or its +# /etc/knot/secrets.conf) and paste them below. A PRIMARY generates its own and +# prints them once; capture them from the console before they scroll away. + +packages: + - git + +runcmd: + - hostnamectl set-hostname anycast-dns-3 || true + - | + set -e + REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git + REPO_BRANCH=main + HARDEN_SSH=1 # harden SSH on this fresh VM (set 0 to skip) + SSH_PORT=22 + ALLOWED_IP= # optional: whitelist your client IP in sshguard + git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" /opt/automations + cd /opt/automations + + # Harden SSH: PQ KEX, key-only auth, sshguard. Seeds root from + # globals/authorized_keys (or SSH_KEYS_URL). + if [ "$HARDEN_SSH" = 1 ]; then + SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" SKIP_PROMPTS=1 FORCE=1 \ + bash scripts/harden-ssh.sh + fi + + # Host firewall, so deploy.sh's 53/tcp+udp drop-in has somewhere to register. + SKIP_PROMPTS=1 FORCE=1 bash scripts/harden-firewall.sh || true + + # Deploy the node. ROLE=primary holds and signs the zones; ROLE=secondary + # transfers them in and signs nothing. NODE_ID becomes server.identity and + # NSID, so make it the estate's node name. + ROLE=secondary \ + NODE_ID=ANYCAST-DNS-3 \ + PRIMARY_ADDR=10.1.24.64 \ + LISTEN='0.0.0.0@53, ::@53' \ + DATACENTER='Stockholm SE' \ + PEERS='10.1.24.64 10.1.24.68' \ + TSIG_AUTHORITIVE=REPLACE_WITH_THE_PRIMARYS_SECRET \ + TSIG_ADMIN=REPLACE_WITH_THE_PRIMARYS_SECRET \ + SKIP_PROMPTS=1 \ + bash deployments/knot-dns/deploy.sh + + # The node now serves NO zones — DNS policy and zone data are delivered by + # the `dns` repo's pipeline. Add this node there (and to the primary's + # remotes.conf, unless a catalog zone picks it up) to put it in rotation. diff --git a/deployments/knot-dns/deploy.sh b/deployments/knot-dns/deploy.sh new file mode 100644 index 0000000..21b3eb1 --- /dev/null +++ b/deployments/knot-dns/deploy.sh @@ -0,0 +1,551 @@ +#!/usr/bin/env bash +# +# deploy.sh -- deploy an authoritative Knot DNS node on Alpine, native (no +# container). Single-purpose box, runs as root. +# +# What this does: +# 1. Installs knot + knot-utils and creates the knot user/directories. +# 2. Seeds /etc/knot/.env on first run; on a re-run updates only the values +# passed that run, and re-renders knot.conf from it either way. +# 3. Renders /etc/knot/knot.conf from the skeleton (@NODE_ID@, @LISTEN@). +# 4. TSIG: ROLE=primary generates secrets.conf and prints the keys once; +# ROLE=secondary REQUIRES them to be supplied, because they must byte-match +# the primary's or no transfer will ever authenticate. +# 5. Stubs the seven policy files knot.conf includes, so conf-check passes and +# knotd starts clean on a box the `dns` repo has never delivered to. +# 6. Installs knsctl, zone.tmpl, the login aliases and the MOTD. +# 7. Opens 53/tcp + 53/udp on the host firewall. +# 8. conf-check, then enables and starts knot. +# +# What this does NOT do: manage zones or DNS policy. Those come from the `dns` +# repo's pipeline -- see the README's split table. This script never overwrites +# a policy file the pipeline owns, nor an existing secrets.conf. +# +# Idempotent: re-run to change identity/listen/peers or pull a newer knot. +# +# Self-contained: knot.conf, secrets.conf.example, knsctl, zone.tmpl, +# knotdns-aliases.sh, knot-dns.motd and .env.example are embedded as a base64 +# tar.gz at the bottom. Rebuild with build.sh after editing the loose files. +# +# Usage: +# ROLE=primary NODE_ID=ANYCAST-DNS-1 bash deploy.sh +# ROLE=secondary NODE_ID=ANYCAST-DNS-3 PRIMARY_ADDR=10.1.24.64 \ +# TSIG_AUTHORITIVE=... TSIG_ADMIN=... SKIP_PROMPTS=1 bash deploy.sh + +set -euo pipefail + +: "${CONF_DIR:=/etc/knot}" +: "${DATA_DIR:=/var/lib/knot}" +: "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts +: "${FORCE:=0}" +[[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1 + +ENV_FILE="$CONF_DIR/.env" + +# Which keys arrived in this script's ENVIRONMENT has to be recorded BEFORE the +# ":=" defaults below, which make an unset variable look like an empty one -- +# and a blank written over a live TSIG secret would break every transfer. +RUNTIME_KEYS=(NODE_ID ROLE LISTEN PRIMARY_ADDR DATACENTER PEERS) +KEYS_FROM_ENV=() +for _k in "${RUNTIME_KEYS[@]}"; do + if [[ -n "${!_k+x}" ]]; then KEYS_FROM_ENV+=("$_k"); fi +done + +: "${NODE_ID:=}" +: "${ROLE:=}" +: "${LISTEN:=}" +: "${PRIMARY_ADDR:=}" +: "${DATACENTER:=}" +: "${PEERS:=}" +: "${TSIG_AUTHORITIVE:=}" # secondary: must match the primary's +: "${TSIG_ADMIN:=}" + +LISTEN_DEFAULT='0.0.0.0@53, ::@53' + +log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "Run as root." + +# Alpine only, deliberately: Knot binds :53 directly and keeps its DNSSEC key +# store on the host filesystem, and this estate's nodes are Alpine. The RHEL +# packaging needs EPEL, which nothing in this repo sets up, so fail loudly +# rather than half-install somewhere untested. +_osid="$( [[ -r /etc/os-release ]] && . /etc/os-release 2>/dev/null && echo "${ID:-}" )" +[[ "$_osid" == alpine ]] || \ + die "This deployment is Alpine-only (detected: ${_osid:-unknown}). Knot on other distros is not packaged or tested here." + +# --------------------------------------------------------------------------- +# Extract embedded archive (falls back to the source dir before build.sh runs) +# --------------------------------------------------------------------------- +SCRIPT_DIR=$(mktemp -d -t knot-deploy.XXXXXX) +trap 'rm -rf "$SCRIPT_DIR"' EXIT + +grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \ + | tail -n +2 \ + | base64 -d \ + | tar -xz -C "$SCRIPT_DIR" 2>/dev/null || true + +if [[ ! -f "$SCRIPT_DIR/knot.conf" ]]; then + SRC=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + for f in knot.conf secrets.conf.example knsctl zone.tmpl knotdns-aliases.sh knot-dns.motd .env.example; do + [[ -f "$SRC/$f" ]] || die "Missing $f (no embedded payload and not in $SRC -- run build.sh)." + cp "$SRC/$f" "$SCRIPT_DIR/$f" + done +fi + +# --------------------------------------------------------------------------- +# .env: seed once, then update only what this run passed +# --------------------------------------------------------------------------- +set_env() { # : update KEY in .env, or append if absent + # The value goes through the ENVIRONMENT, never interpolated into a sed + # script: a TSIG secret or a listen list containing & or the s||| delimiter + # would otherwise be silently corrupted or abort the run. + local key="$1" val="$2" tmp + if [[ ! -f "$ENV_FILE" ]]; then + printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + return 0 + fi + tmp="$(mktemp)" + _SE_KEY="$key" _SE_VAL="$val" awk ' + BEGIN { k = ENVIRON["_SE_KEY"]; v = ENVIRON["_SE_VAL"]; seen = 0 } + !seen && index($0, k "=") == 1 { print k "=" v; seen = 1; next } + { print } + END { if (!seen) print k "=" v } + ' "$ENV_FILE" > "$tmp" + cat "$tmp" > "$ENV_FILE" # rewrite in place: keeps the original mode/owner + rm -f "$tmp" +} + +install -d -m 0755 "$CONF_DIR" + +# On a re-run the deployed values live in .env. Load them into any key NOT +# passed this run, so the prompts below do not demand a full retype and +# SKIP_PROMPTS=1 does not die over values the node already has. +if [[ -f "$ENV_FILE" ]]; then + _preloaded=() + for _k in "${RUNTIME_KEYS[@]}"; do + if [[ " ${KEYS_FROM_ENV[*]-} " == *" ${_k} "* ]]; then continue; fi + _v=$(sed -n "s/^${_k}=//p" "$ENV_FILE" | tail -n1) + if [[ -z "$_v" ]]; then continue; fi + printf -v "$_k" '%s' "$_v" + _preloaded+=("$_k") + done + if (( ${#_preloaded[@]} > 0 )); then + log "Reusing ${ENV_FILE} values for: ${_preloaded[*]}" + log " (pass VAR=... to change one; it is then written back to .env)" + fi +fi + +prompt() { + local varname="$1" message="$2" + local -n ref="$varname" + if [[ -z "${ref:-}" ]]; then + [[ "$SKIP_PROMPTS" == "1" ]] && die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)." + read -r -p "$message: " ref + [[ -n "$ref" ]] || die "$varname required." + fi +} + +prompt NODE_ID "Node ID for server.identity / NSID (e.g. ANYCAST-DNS-3)" +prompt ROLE "Role: primary or secondary" +case "$ROLE" in + primary|secondary) ;; + *) die "ROLE must be 'primary' or 'secondary' (got '$ROLE')." ;; +esac +[[ -n "$LISTEN" ]] || LISTEN="$LISTEN_DEFAULT" + +if [[ "$ROLE" == secondary ]]; then + prompt PRIMARY_ADDR "Address of the primary this node transfers from" +fi + +# --------------------------------------------------------------------------- +# Packages, user, directories +# --------------------------------------------------------------------------- +log "Installing Knot DNS..." +apk add -q knot knot-utils + +# The package normally creates knot:knot; make that certain before any chown. +getent group knot >/dev/null 2>&1 || addgroup -S knot +getent passwd knot >/dev/null 2>&1 || adduser -S -D -H -G knot -s /sbin/nologin knot + +# storage/journal/kasp/timer/catalog as declared in knot.conf's database: +# section, plus the zone root knsctl writes into. +for d in database journal keys timer catalog zones; do + install -d -m 0750 -o knot -g knot "$DATA_DIR/$d" +done +install -d -m 0750 -o knot -g knot /run/knot + +# --------------------------------------------------------------------------- +# knot.conf -- re-rendered every run, since it is derived purely from .env +# --------------------------------------------------------------------------- +log "Rendering $CONF_DIR/knot.conf (NODE_ID=$NODE_ID, ROLE=$ROLE)..." +_KC_NODE_ID="$NODE_ID" _KC_LISTEN="$LISTEN" awk ' + { gsub(/@NODE_ID@/, ENVIRON["_KC_NODE_ID"]); gsub(/@LISTEN@/, ENVIRON["_KC_LISTEN"]); print } +' "$SCRIPT_DIR/knot.conf" > "$CONF_DIR/knot.conf.new" +install -m 0640 -o root -g knot "$CONF_DIR/knot.conf.new" "$CONF_DIR/knot.conf" +rm -f "$CONF_DIR/knot.conf.new" + +# --------------------------------------------------------------------------- +# TSIG. A secondary's keys must byte-match the primary's, so they can never be +# generated here -- a generated key would authenticate nothing. +# --------------------------------------------------------------------------- +gen_tsig() { # gen_tsig -> base64 secret on stdout + local id="$1" out="" + if command -v keymgr >/dev/null 2>&1; then + # keymgr prints a whole key: block; take just the secret. + out="$(keymgr -t "$id" hmac-sha256 2>/dev/null \ + | awk '/secret:/ { sub(/^[[:space:]]*secret:[[:space:]]*/, ""); print; exit }' || true)" + fi + [[ -n "$out" ]] || out="$(openssl rand -base64 32)" + printf '%s' "$out" +} + +SECRETS="$CONF_DIR/secrets.conf" +TSIG_PRINTED=0 +if [[ -f "$SECRETS" ]]; then + log "$SECRETS exists; leaving it alone (TSIG keys are never rotated by a re-run)." +elif [[ "$ROLE" == primary ]]; then + log "Generating TSIG keys for $SECRETS..." + [[ -n "$TSIG_AUTHORITIVE" ]] || TSIG_AUTHORITIVE="$(gen_tsig authortive-tsig)" + [[ -n "$TSIG_ADMIN" ]] || TSIG_ADMIN="$(gen_tsig admin-tsig)" + TSIG_PRINTED=1 +else + [[ -n "$TSIG_AUTHORITIVE" && -n "$TSIG_ADMIN" ]] || die \ +"A secondary's TSIG keys must match the primary's byte for byte, so they cannot be generated here. + Copy them off the primary ($CONF_DIR/secrets.conf) and re-run with: + TSIG_AUTHORITIVE= TSIG_ADMIN= bash deploy.sh" +fi + +if [[ ! -f "$SECRETS" ]]; then + # Increment only on a MATCHING line: as a gsub argument, `++n` would be + # evaluated on every line of the file, so both keys would get the second + # secret -- making the read-only admin key identical to the replication key. + _TS_AUTH="$TSIG_AUTHORITIVE" _TS_ADMIN="$TSIG_ADMIN" awk ' + /REPLACE_ME/ { n++; sub(/REPLACE_ME/, n == 1 ? ENVIRON["_TS_AUTH"] : ENVIRON["_TS_ADMIN"]) } + { print } + ' "$SCRIPT_DIR/secrets.conf.example" > "$SECRETS.new" + install -m 0640 -o root -g knot "$SECRETS.new" "$SECRETS" + rm -f "$SECRETS.new" +fi + +# --------------------------------------------------------------------------- +# The seven policy files knot.conf includes. The `dns` repo owns them; a fresh +# node has none, and Knot treats a missing include as a config error -- so +# conf-check would fail and knotd would never start. Stub only what is absent; +# never touch a file the pipeline has already delivered. +# --------------------------------------------------------------------------- +_stubbed=() +for f in remotes policy modules templates arpa dnssec public; do + [[ -f "$CONF_DIR/$f.conf" ]] && continue + cat > "$CONF_DIR/$f.conf" < "$CONF_DIR/remotes.conf" < /etc/profile.d/knotdns-peers.sh + chmod 0644 /etc/profile.d/knotdns-peers.sh +fi + +_FQDN="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "$NODE_ID")" +# The MOTD is a pre-drawn box, so a value wider or narrower than its @TOKEN@ +# would shift the right-hand border on every login. Replace "@TOKEN@ + the run +# of spaces after it" with the value padded to that same span, which keeps the +# border put. That span is pure ASCII, so byte length == display width and this +# stays correct under busybox awk in the C locale (the box-drawing characters +# themselves are never measured). A value longer than its span is left to +# overflow rather than truncated: a clipped hostname in an ops banner is worse +# than a ragged edge. +_MD_NODE_ID="$NODE_ID" _MD_ROLE="$ROLE" _MD_DC="${DATACENTER:-unset}" _MD_FQDN="$_FQDN" awk ' + function fit(line, tok, val, n, span, pad) { + while (match(line, tok " *")) { + span = RLENGTH + pad = val + while (length(pad) < span) pad = pad " " + line = substr(line, 1, RSTART - 1) pad substr(line, RSTART + RLENGTH) + } + return line + } + { + $0 = fit($0, "@NODE_ID@", ENVIRON["_MD_NODE_ID"]) + $0 = fit($0, "@ROLE@", ENVIRON["_MD_ROLE"]) + $0 = fit($0, "@DATACENTER@", ENVIRON["_MD_DC"]) + $0 = fit($0, "@FQDN@", ENVIRON["_MD_FQDN"]) + print + } +' "$SCRIPT_DIR/knot-dns.motd" > /etc/motd +chmod 0644 /etc/motd + +# --------------------------------------------------------------------------- +# .env: seed on first run, else update only what was passed this run +# --------------------------------------------------------------------------- +if [[ ! -f "$ENV_FILE" ]]; then + log "Seeding $ENV_FILE..." + install -m 0640 -o root -g knot "$SCRIPT_DIR/.env.example" "$ENV_FILE" + for _k in "${RUNTIME_KEYS[@]}"; do set_env "$_k" "${!_k}"; done +else + # knot.conf is re-rendered from .env above, so letting .env drift from what + # was passed would silently redeploy the old identity on the next run. + log ".env exists; keeping it (only values passed this run are updated)." + for _k in ${KEYS_FROM_ENV[@]+"${KEYS_FROM_ENV[@]}"}; do + _cur=$(sed -n "s/^${_k}=//p" "$ENV_FILE" | tail -n1) + if [[ "$_cur" == "${!_k}" ]]; then continue; fi + set_env "$_k" "${!_k}" + log " ${_k}: ${_cur:-} -> ${!_k:-}" + done +fi + +# --------------------------------------------------------------------------- +# Firewall: authoritative DNS answers on 53, TCP and UDP both. +# --------------------------------------------------------------------------- +if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then + log "Registering 53/tcp + 53/udp with the host firewall..." + printf '53/tcp\n53/udp\n' > /etc/firewall/ports.d/knot-dns.rule + /usr/local/sbin/firewall-apply +else + warn "No host firewall found (scripts/harden-firewall.sh not installed); 53/tcp+udp not registered." +fi + +# --------------------------------------------------------------------------- +# Validate, then start. -b so a rejected config fails here, not silently. +# --------------------------------------------------------------------------- +log "Validating configuration..." +knotc -b conf-check || die "knotc conf-check failed -- not starting. Fix $CONF_DIR/knot.conf and re-run." + +rc-update add knot default >/dev/null 2>&1 || true +if rc-service knot status >/dev/null 2>&1; then + log "Reloading knot..." + knotc -b reload || warn "Reload failed; check 'rc-service knot status' and the syslog." +else + log "Starting knot..." + rc-service knot start || die "knot failed to start -- check the syslog (log target is syslog, zone at info)." +fi + +cat <} with +authortive-tsig. Add this node's address to the PRIMARY's remotes.conf (or let +the catalog zone do it) before it can transfer. +EOF +fi + +if [[ "$TSIG_PRINTED" == "1" ]]; then + cat <> TSIG KEYS -- SHOWN ONCE. Every secondary needs these byte for byte: + TSIG_AUTHORITIVE=${TSIG_AUTHORITIVE} + TSIG_ADMIN=${TSIG_ADMIN} + Store them out of band. They are in ${SECRETS} on this node. +EOF +fi + +cat <> BACK UP THE KASP DATABASE (${DATA_DIR}/keys). It replicates nowhere, and + losing it makes every signed domain go bogus until each registrar is updated + by hand: + knotc -b zone-backup +backupdir /var/backups/knot/\$(date -u +%FT%H) +journal +EOF +__ARCHIVE_BELOW__ +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 +ohWtaP8r7Xext/XSAHgAAA==