Native Alpine deployment for the anycast DNS estate -- no Docker, no Caddy, alongside squid and openbao as an exception to the repo norm. Knot binds :53 directly, needs real client addresses for RRL and DNS cookies, and its DNSSEC key store must live on the host filesystem. Deploys a NODE. Zone data lives in the separate dns repo and arrives from its pipeline. The split is /etc/knot/knot.conf: written here once as a skeleton of include: lines covering only what belongs to a box (identity, NSID, storage paths, listen, logging, control socket); everything that is DNS policy -- templates, dnssec policy, remotes/ACLs, modules, the domain inventory and the zone files -- is delivered by the dns repo. knsctl replaces adddns.pl and adddnssec.pl, fixing four defects: - the duplicate check searched for the domain in BIND named.conf double-quote syntax (/"$domain"/) against unquoted YAML, so it could never match; only the -f zone-file test ever caught anything - neither script consulted the other class's manifest, so a domain already in public.conf could be appended to dnssec.conf and fail the reload AFTER both files had been written - nothing validated before reloading - the reload was non-blocking, so a rejected config reported success Its manifest matching is anchored on the YAML key and escapes the dot, so barsrvno.de and srvnoXde no longer false-positive against srvno.de. Aliases preserve the existing muscle memory with three corrections: -b on every triggering knotc command (without it knotc returns OK when the command was SENT, not when it succeeded); knzr (zone-reload) added alongside knrl (reload), since reloading one zone's data is the right verb for a record change and a full reload is only needed when a zone is added or removed; and serial/NSID helpers that query unicast addresses, because asking the anycast service address reaches whichever node is nearest and says nothing about which node is stale. Break-glass writes (add/remove/edit) warn and audit-log: they are overwritten by the next pipeline deploy unless the change also lands in git. Removal refuses to purge DNSSEC keys -- zone-purge +keys is irreversible on Knot 3.5.x, the key trash bin having arrived in 3.6.0 -- and prints the ordering requirement, since removing a signed zone before the parent DS is withdrawn is an outage for validating resolvers rather than a graceful shutdown. deploy.sh, build.sh and cloud-init.yml are deliberately not included yet; they are blocked on the Knot version decision, which sets the apk pin and feature availability. See the Status section in the README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
9.9 KiB
Bash
257 lines
9.9 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# knsctl -- Knot DNS admin CLI. Replaces adddns.pl / adddnssec.pl.
|
|
#
|
|
# Fixes four real defects in the Perl scripts it replaces:
|
|
# 1. Their duplicate check searched for the domain wrapped in double quotes
|
|
# (/"$domain"/) -- BIND named.conf syntax. What they wrote is unquoted
|
|
# YAML (` - domain: example.com`), so the check could never match. Only
|
|
# the -f zone-file test ever caught a duplicate.
|
|
# 2. Neither script consulted the OTHER class's manifest, so a domain already
|
|
# in public.conf could be appended to dnssec.conf; the following
|
|
# `knotc reload` then failed on a duplicate zone -- after both files had
|
|
# already been written, leaving the server misconfigured.
|
|
# 3. Nothing validated before reloading.
|
|
# 4. The reload was non-blocking, so a rejected config reported success.
|
|
#
|
|
# NORMAL CHANGES GO THROUGH GIT. add/remove/edit here are BREAK-GLASS: they
|
|
# write directly to this server and the next pipeline deploy will overwrite
|
|
# them unless the change is also made in the `dns` repo. They warn and they
|
|
# audit-log.
|
|
set -euo pipefail
|
|
|
|
ZONES_ROOT=${ZONES_ROOT:-/var/lib/knot/zones}
|
|
CONF_DIR=${CONF_DIR:-/etc/knot}
|
|
SKELETON=${SKELETON:-$CONF_DIR/zone.tmpl}
|
|
AUDIT=${AUDIT:-/var/log/knsctl-audit.log}
|
|
KNOT_OWNER=${KNOT_OWNER:-knot:knot}
|
|
|
|
die() { printf 'knsctl: %s\n' "$*" >&2; exit 1; }
|
|
warn() { printf 'knsctl: %s\n' "$*" >&2; }
|
|
audit() {
|
|
printf '%s %s %s\n' "$(date -u +%FT%TZ)" "${SUDO_USER:-${USER:-root}}" "$*" \
|
|
>>"$AUDIT" 2>/dev/null || true
|
|
}
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
knsctl <command> [args]
|
|
|
|
Inspection (always safe):
|
|
list list configured zones
|
|
status [domain] zone status; all zones if omitted
|
|
check <domain> knotc zone-check -- the running server's own load path
|
|
serials compare this node's serials against PEERS
|
|
path <domain> print the zone file path
|
|
conf-check validate the configuration
|
|
|
|
Apply:
|
|
reload [domain] zone-reload <domain>; full config reload if omitted
|
|
|
|
Break-glass (writes to this server; must be reconciled into git):
|
|
add <domain> --class dnssec|public
|
|
remove <domain>
|
|
edit <domain> open the zone file in $EDITOR
|
|
|
|
Environment:
|
|
PEERS="10.1.24.68 10.1.24.69" peers for `serials`
|
|
USAGE
|
|
}
|
|
|
|
# Escape a domain for use as a literal in a POSIX ERE. valid_domain() already
|
|
# restricts input to [a-z0-9.-], so the dot is the only metacharacter that can
|
|
# appear. (Do NOT reach for a general bracket expression here: one starting
|
|
# "[." opens a POSIX collating symbol and silently breaks the pattern.)
|
|
ere_quote() { printf '%s' "$1" | sed 's/\./\\./g'; }
|
|
|
|
# Which manifest, if any, already lists this domain? Checks ALL of them --
|
|
# looking at only one was the Perl scripts' second bug.
|
|
find_in_manifests() {
|
|
local d rx f
|
|
d=$1; rx=$(ere_quote "$d")
|
|
for f in "$CONF_DIR"/dnssec.conf "$CONF_DIR"/public.conf "$CONF_DIR"/arpa.conf; do
|
|
[[ -f $f ]] || continue
|
|
# Anchored on the YAML key so `foo.net` cannot match `barfoo.net`.
|
|
if grep -qE "^[[:space:]]*-[[:space:]]*domain:[[:space:]]*${rx}[[:space:]]*\$" "$f"; then
|
|
printf '%s\n' "${f##*/}"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
find_zone_file() {
|
|
local d c
|
|
d=$1
|
|
for c in dnssec public arpa; do
|
|
if [[ -f "$ZONES_ROOT/$c/$d.zone" ]]; then
|
|
printf '%s\n' "$ZONES_ROOT/$c/$d.zone"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
valid_domain() {
|
|
[[ $1 =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]]
|
|
}
|
|
|
|
zone_names() {
|
|
knotc zone-status 2>/dev/null | awk '/^\[/{gsub(/[][]/, "", $1); print $1}'
|
|
}
|
|
|
|
cmd_add() {
|
|
local domain class serial zf rx
|
|
domain=${1:-}; class=""
|
|
shift || true
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--class) class=${2:-}; shift 2 ;;
|
|
*) die "unknown option: $1" ;;
|
|
esac
|
|
done
|
|
[[ -n $domain ]] || die "usage: knsctl add <domain> --class dnssec|public"
|
|
valid_domain "$domain" || die "invalid domain: $domain"
|
|
case $class in
|
|
dnssec|public) ;;
|
|
*) die "--class must be dnssec or public" ;;
|
|
esac
|
|
|
|
local existing
|
|
if existing=$(find_in_manifests "$domain"); then
|
|
die "$domain is already configured in $existing"
|
|
fi
|
|
zf="$ZONES_ROOT/$class/$domain.zone"
|
|
[[ -e $zf ]] && die "$zf already exists -- refusing to overwrite"
|
|
[[ -f $SKELETON ]] || die "skeleton not found at $SKELETON"
|
|
|
|
warn "BREAK-GLASS: adding $domain directly on this server."
|
|
warn " The next pipeline deploy will remove it unless you also add it to"
|
|
warn " the dns repo (zones/$class/$domain.zone + ci/gen-manifest.sh)."
|
|
|
|
# The serial is only ever a cold-start seed: Knot assigns the live serial
|
|
# under zonefile-load: difference-no-serial. It must still be large and
|
|
# RFC 1982-sane, so seed with today's dateserial rather than 1.
|
|
serial="$(date -u +%Y%m%d)01"
|
|
sed -e "s/@DOMAIN@/$domain/g" -e "s/@SERIAL@/$serial/g" "$SKELETON" >"$zf.tmp"
|
|
mv "$zf.tmp" "$zf"
|
|
chown "$KNOT_OWNER" "$zf"
|
|
chmod 0644 "$zf"
|
|
|
|
# Append to the manifest only after the zone file is in place, so a failure
|
|
# never leaves config referencing a file that does not exist.
|
|
printf ' - domain: %s\n template: %s-records\n' "$domain" "$class" \
|
|
>>"$CONF_DIR/$class.conf"
|
|
chown "$KNOT_OWNER" "$CONF_DIR/$class.conf"
|
|
|
|
# Validate BEFORE reloading -- the Perl scripts reloaded blind.
|
|
if ! knotc -b conf-check; then
|
|
warn "conf-check failed; rolling back"
|
|
rx=$(ere_quote "$domain")
|
|
sed -i "/^[[:space:]]*-[[:space:]]*domain:[[:space:]]*${rx}[[:space:]]*\$/,+1d" \
|
|
"$CONF_DIR/$class.conf"
|
|
rm -f "$zf"
|
|
die "configuration invalid; no changes applied"
|
|
fi
|
|
|
|
# A NEW zone needs a full config reload. zone-reload only reloads data for
|
|
# an already-configured zone and would not see this one.
|
|
knotc -b reload || die "reload failed"
|
|
knotc -b zone-check "$domain" || warn "zone-check reported problems for $domain"
|
|
|
|
audit "add $domain class=$class serial=$serial"
|
|
printf 'added %s (%s), cold-start seed %s\n' "$domain" "$class" "$serial"
|
|
if [[ $class == dnssec ]]; then
|
|
printf 'DS submission to the registrar is manual: keymgr %s ds\n' "$domain"
|
|
fi
|
|
}
|
|
|
|
cmd_remove() {
|
|
local domain manifest zf rx confirm
|
|
domain=${1:-}
|
|
[[ -n $domain ]] || die "usage: knsctl remove <domain>"
|
|
manifest=$(find_in_manifests "$domain") || die "$domain is not configured"
|
|
zf=$(find_zone_file "$domain") || warn "no zone file found for $domain"
|
|
|
|
cat >&2 <<EOF
|
|
knsctl: BREAK-GLASS removal of $domain
|
|
|
|
ORDER MATTERS. For a signed zone, removing it here before the parent's DS
|
|
record is withdrawn causes SERVFAIL for every validating resolver -- that is
|
|
an outage, not a graceful shutdown. The correct sequence is:
|
|
|
|
1. Publish the RFC 8078 delete signal, or remove the DS at the registrar.
|
|
2. Wait out the parent DS TTL (commonly 86400 at gTLDs).
|
|
3. Remove the NS delegation; wait out the NS TTL.
|
|
4. Only then remove the zone here.
|
|
|
|
This command performs step 4 only.
|
|
|
|
DNSSEC keys are deliberately NOT purged: 'zone-purge +keys' is irreversible
|
|
on Knot 3.5.x (the key trash bin arrived in 3.6.0). Orphaned keys are left
|
|
in place for a later, deliberate cleanup.
|
|
EOF
|
|
read -r -p "Type the domain to confirm: " confirm
|
|
[[ $confirm == "$domain" ]] || die "aborted"
|
|
|
|
rx=$(ere_quote "$domain")
|
|
sed -i "/^[[:space:]]*-[[:space:]]*domain:[[:space:]]*${rx}[[:space:]]*\$/,+1d" \
|
|
"$CONF_DIR/$manifest"
|
|
knotc -b conf-check || die "conf-check failed after edit -- inspect $CONF_DIR/$manifest"
|
|
knotc -b reload || die "reload failed"
|
|
if [[ -n ${zf:-} ]]; then
|
|
mv "$zf" "$zf.removed-$(date -u +%Y%m%d)"
|
|
warn "zone file retained as $zf.removed-$(date -u +%Y%m%d)"
|
|
fi
|
|
audit "remove $domain manifest=$manifest"
|
|
printf 'removed %s from %s. DNSSEC keys retained.\n' "$domain" "$manifest"
|
|
}
|
|
|
|
cmd_serials() {
|
|
local peers d local_serial p
|
|
peers=${PEERS:-}
|
|
[[ -n $peers ]] || warn "set PEERS='10.1.24.68 ...' to compare across nodes"
|
|
# Query each node's UNICAST address. Querying the anycast service address
|
|
# reaches whichever node is nearest, which tells you nothing about which
|
|
# node is stale.
|
|
while read -r d; do
|
|
[[ -n $d ]] || continue
|
|
local_serial=$(kdig +short @127.0.0.1 SOA "$d" 2>/dev/null | awk '{print $3}')
|
|
printf '%-34s local=%-12s' "$d" "${local_serial:-?}"
|
|
for p in $peers; do
|
|
printf ' %s=%-12s' "$p" \
|
|
"$(kdig +short "@$p" SOA "$d" 2>/dev/null | awk '{print $3}')"
|
|
done
|
|
printf '\n'
|
|
done < <(zone_names)
|
|
}
|
|
|
|
case ${1:-} in
|
|
add) shift; cmd_add "$@" ;;
|
|
remove) shift; cmd_remove "$@" ;;
|
|
list) zone_names ;;
|
|
status) shift; knotc zone-status "$@" ;;
|
|
check) shift; [[ -n ${1:-} ]] || die "usage: knsctl check <domain>"
|
|
knotc -b zone-check "$1" ;;
|
|
conf-check) knotc -b conf-check ;;
|
|
serials) cmd_serials ;;
|
|
path) shift; [[ -n ${1:-} ]] || die "usage: knsctl path <domain>"
|
|
find_zone_file "$1" || die "no zone file for $1" ;;
|
|
edit) shift; [[ -n ${1:-} ]] || die "usage: knsctl edit <domain>"
|
|
f=$(find_zone_file "$1") || die "no zone file for $1"
|
|
warn "BREAK-GLASS: edits here are overwritten by the next deploy"
|
|
warn " unless the same change is made in the dns repo."
|
|
audit "edit $1"
|
|
"${EDITOR:-vi}" "$f" ;;
|
|
reload) shift
|
|
if [[ -n ${1:-} ]]; then
|
|
# zone-reload for content; full reload only for config
|
|
# changes (a new or removed zone).
|
|
knotc -b zone-reload "$1"
|
|
else
|
|
knotc -b reload
|
|
fi ;;
|
|
''|-h|--help|help) usage ;;
|
|
*) die "unknown command: $1 (try: knsctl help)" ;;
|
|
esac
|