Files
automations/deployments/knot-dns/deploy.sh
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

552 lines
28 KiB
Bash

#!/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() { # <KEY> <value>: 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 <key-id> -> 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=<secret> TSIG_ADMIN=<secret> 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" <<EOF
# $f.conf -- PLACEHOLDER written by deploy.sh.
#
# This file is owned by the \`dns\` repo and is overwritten by its pipeline.
# It exists only so knot.conf's include: chain resolves and knotd can start on
# a node the pipeline has not delivered to yet. Do not edit it here.
EOF
chown root:knot "$CONF_DIR/$f.conf"; chmod 0640 "$CONF_DIR/$f.conf"
_stubbed+=("$f.conf")
done
(( ${#_stubbed[@]} == 0 )) || log "Stubbed policy files (the dns pipeline overwrites these): ${_stubbed[*]}"
# A secondary otherwise cannot transfer anything until the pipeline first runs.
# Seed remotes.conf from PRIMARY_ADDR so the node can bootstrap by AXFR -- but
# only when we just stubbed it, so a delivered remotes.conf is never clobbered.
if [[ "$ROLE" == secondary && -n "$PRIMARY_ADDR" ]] \
&& printf '%s\n' "${_stubbed[@]-}" | grep -qx 'remotes.conf'; then
log "Seeding $CONF_DIR/remotes.conf with the primary ($PRIMARY_ADDR)..."
cat > "$CONF_DIR/remotes.conf" <<EOF
# remotes.conf -- BOOTSTRAP seed written by deploy.sh from PRIMARY_ADDR.
#
# Enough for this node to AXFR from the primary before the \`dns\` repo's
# pipeline has ever delivered here. The pipeline owns this file and will
# overwrite it; do not add remotes by hand.
remote:
- id: primary
address: ${PRIMARY_ADDR}
key: authortive-tsig
acl:
- id: acl_primary
address: ${PRIMARY_ADDR}
key: authortive-tsig
action: [ transfer, notify ]
EOF
chown root:knot "$CONF_DIR/remotes.conf"; chmod 0640 "$CONF_DIR/remotes.conf"
fi
# ---------------------------------------------------------------------------
# Operator tooling
# ---------------------------------------------------------------------------
install -m 0755 "$SCRIPT_DIR/knsctl" /usr/local/sbin/knsctl
install -m 0644 -o root -g knot "$SCRIPT_DIR/zone.tmpl" "$CONF_DIR/zone.tmpl"
install -m 0644 "$SCRIPT_DIR/knotdns-aliases.sh" /etc/profile.d/knotdns-aliases.sh
# PEERS drives `knsctl serials` / the knsers alias.
if [[ -n "$PEERS" ]]; then
printf 'export PEERS="%s"\n' "$PEERS" > /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:-<unset>} -> ${!_k:-<empty>}"
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 <<EOF
================================================================
DEPLOYED ${NODE_ID} (${ROLE})
Config: ${CONF_DIR}/knot.conf (re-rendered from ${ENV_FILE} every run)
Secrets: ${SECRETS} (0640 root:knot, never in git)
Data: ${DATA_DIR}
Admin: knsctl -- 'knsctl list', 'knsctl status'; aliases in a login shell
This node serves NO ZONES yet. Zone data and DNS policy come from the \`dns\`
repo's pipeline; the policy files here are placeholders until it delivers.
EOF
if [[ "$ROLE" == secondary ]]; then
cat <<EOF
Secondary bootstrap: remotes.conf points at ${PRIMARY_ADDR:-<unset>} 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 <<EOF
>> 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 <<EOF
>> 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==