#!/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
