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==