#!/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 [args] Inspection (always safe): list list configured zones status [domain] zone status; all zones if omitted check knotc zone-check -- the running server's own load path serials compare this node's serials against PEERS path print the zone file path conf-check validate the configuration Apply: reload [domain] zone-reload ; full config reload if omitted Break-glass (writes to this server; must be reconciled into git): add --class dnssec|public remove edit 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 --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 " manifest=$(find_in_manifests "$domain") || die "$domain is not configured" zf=$(find_zone_file "$domain") || warn "no zone file found for $domain" cat >&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 " knotc -b zone-check "$1" ;; conf-check) knotc -b conf-check ;; serials) cmd_serials ;; path) shift; [[ -n ${1:-} ]] || die "usage: knsctl path " find_zone_file "$1" || die "no zone file for $1" ;; edit) shift; [[ -n ${1:-} ]] || die "usage: knsctl edit " 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