#!/usr/bin/env bash
# =============================================================================
#  start-dns.sh - install (offline) and start the BIND DNS server (named)
#                 on RHEL 9.6, ready for performance testing
# =============================================================================
#
#  USAGE (as root)
#      ./start-dns.sh            install if needed, configure, start
#      ./start-dns.sh start      same as above
#      ./start-dns.sh stop       stop the named daemon
#      ./start-dns.sh restart    stop, then start again
#      ./start-dns.sh status     show whether the DNS server is running
#      ./start-dns.sh cleanup    stop named and remove every test file
#
#  WHAT "start" DOES, STEP BY STEP
#      1. Checks the operating system (RHEL 9 expected).
#      2. Installs bind + bind-utils if missing, WITHOUT internet:
#           a) from RPM files in ./rpms/  (see download-rpms.sh), or
#           b) from a local dnf repository already set up on the host
#              (for example the mounted RHEL 9.6 DVD / ISO).
#      3. Makes sure nothing else uses DNS port 53 on 127.0.0.1.
#      4. Creates two test zones in /var/named/perf-test/:
#           perf.test       N hosts, each with an IPv4 and an IPv6 address
#           upstream.test   a "wildcard" zone: every name in it exists
#      5. Writes the test configuration /etc/named/perf-test.conf.
#      6. Tells named.service to use that file (one systemd drop-in file).
#      7. Checks the configuration and zones with "named-checkconf -z".
#      8. Starts the daemon, then waits until it answers DNS queries.
#
#  /etc/named.conf is never changed. "cleanup" removes everything again.
#
#  HOW THE TEST SERVER IS BUILT (two "views" in one named process)
#
#      test-dns.sh ---query---> named, view "perf-main"   (answers all normal clients)
#                                 |  perf.test      : answered from the zone file
#                                 |  upstream.test  : forwarded, then cached
#                                 v
#                               named, view "perf-upstream"  (only for signed queries)
#                                    upstream.test  : answered from the zone file
#
#      The "upstream" view plays the part of the internet, so the answer cache
#      can be tested on an offline machine. named signs its forwarded queries
#      with a key (TSIG); only signed queries reach the upstream view.
# =============================================================================

set -euo pipefail
source "$(dirname -- "${BASH_SOURCE[0]}")/lib/common.sh"


# ----------------------------------------------------------------------------
#  Step 1 - operating system check
# ----------------------------------------------------------------------------
check_operating_system() {
    # /etc/os-release defines NAME, VERSION_ID, ...
    source /etc/os-release

    if [[ ${VERSION_ID%%.*} != 9 ]]; then
        die "This kit is made for RHEL 9. Found: $PRETTY_NAME"
    fi
    if [[ $ID != rhel || $VERSION_ID != 9.6 ]]; then
        warn "Target is RHEL 9.6; this host is '$PRETTY_NAME'. Continuing."
    fi
    log "Operating system: $PRETTY_NAME"
}


# ----------------------------------------------------------------------------
#  Step 2 - install BIND without internet access
# ----------------------------------------------------------------------------
install_bind_offline() {
    if rpm -q bind bind-utils >/dev/null 2>&1; then
        log "BIND is already installed: $(rpm -q bind)"
        return
    fi

    local rpm_dir="$KIT_DIR/rpms"

    if compgen -G "$rpm_dir/*.rpm" >/dev/null; then
        # a) RPM files shipped next to this script. All other repositories
        #    are disabled so dnf never tries to reach the internet.
        log "Installing BIND from RPM files in $rpm_dir"
        dnf install -y --disablerepo='*' "$rpm_dir"/*.rpm
    else
        # b) A local repository already configured on this host (RHEL DVD).
        log "Installing BIND from the local dnf repositories"
        if ! dnf install -y bind bind-utils; then
            die "Could not install BIND offline.
       Either copy RPMs into $rpm_dir (see download-rpms.sh),
       or mount the RHEL 9.6 DVD and configure it as a local repository."
        fi
    fi
    ok "Installed $(rpm -q bind) and $(rpm -q bind-utils)"
}


# ----------------------------------------------------------------------------
#  Step 3 - is the DNS port free?
# ----------------------------------------------------------------------------
#  Another DNS program (dnsmasq, unbound, systemd-resolved ...) listening on
#  the same address and port would receive our test queries instead of named.
check_port_is_free() {
    local owner
    # Column 4 of "ss" is the local address, e.g. 127.0.0.1:53 or 0.0.0.0:53.
    # The program name is in the last column: users:(("dnsmasq",pid=...
    owner="$(ss -Hlnup "sport = :$DNS_PORT" 2>/dev/null |
             awk -v port=":$DNS_PORT" -v address="$DNS_SERVER" \
                 '$4 == address port || $4 == "0.0.0.0" port || $4 == "*" port' |
             grep -o 'users:(("[^"]*"' | cut -d'"' -f2 | sort -u | grep -vx named || true)"

    if [[ -n $owner ]]; then
        die "UDP port $DNS_PORT on $DNS_SERVER is already used by: $owner
       Stop that program first, or set DNS_PORT in settings.conf."
    fi
}


# ----------------------------------------------------------------------------
#  Step 4 - test zones
# ----------------------------------------------------------------------------
create_zones() {
    local serial
    serial="$(date +%s)"    # a new serial on every run, so reloads always apply
    mkdir -p "$ZONE_DIR"

    log "Creating zone $TEST_ZONE with $ZONE_RECORDS hosts (A + AAAA records)"
    {
        cat <<EOF
; Test zone created by start-dns.sh. Every host has an IPv4 and an IPv6 address.
\$TTL 3600
@       IN SOA  ns1.$TEST_ZONE. hostmaster.$TEST_ZONE. ( $serial 3600 600 86400 300 )
@       IN NS   ns1
@       IN MX   10 mail
@       IN TXT  "v=spf1 -all"
ns1     IN A    $DNS_SERVER
mail    IN A    10.255.0.25
www     IN CNAME host1
EOF
        # host<N>  A 10.x.y.z   and   AAAA fd00::x:y   (N spread over the address)
        awk -v count="$ZONE_RECORDS" 'BEGIN {
            for (i = 1; i <= count; i++) {
                printf "host%d IN A 10.%d.%d.%d\n", i, int(i / 65536) % 256, int(i / 256) % 256, i % 256
                printf "host%d IN AAAA fd00::%x:%x\n", i, int(i / 65536), i % 65536
            }
        }'
    } > "$ZONE_DIR/$TEST_ZONE.zone"

    log "Creating zone $UPSTREAM_ZONE (wildcard: any name answers 192.0.2.1)"
    cat > "$ZONE_DIR/$UPSTREAM_ZONE.zone" <<EOF
; "Internet" stand-in for the cache test, created by start-dns.sh.
; The wildcard (*) makes every name below $UPSTREAM_ZONE exist.
\$TTL 3600
@       IN SOA  ns1.$UPSTREAM_ZONE. hostmaster.$UPSTREAM_ZONE. ( $serial 3600 600 86400 300 )
@       IN NS   ns1
ns1     IN A    $DNS_SERVER
*       IN A    192.0.2.1
EOF

    # named runs as user "named" and must be able to read the zones.
    chown -R root:named "$ZONE_DIR"
    chmod 750 "$ZONE_DIR"
    chmod 640 "$ZONE_DIR"/*.zone
    if command -v restorecon >/dev/null; then
        restorecon -R "$ZONE_DIR"      # SELinux label: named_zone_t
    fi
}


# ----------------------------------------------------------------------------
#  Step 5 - named configuration for the test
# ----------------------------------------------------------------------------
create_keys() {
    # rndc key: lets the "rndc" command control named (reload, flush, stop).
    # On a normal RHEL host named-setup-rndc.service creates it; in a
    # container we create it ourselves.
    if [[ ! -s /etc/rndc.key ]]; then
        log "Creating /etc/rndc.key"
        rndc-confgen -a -A hmac-sha256 >/dev/null 2>&1
    fi

    # TSIG key: named signs its forwarded queries with it, which is how the
    # "perf-upstream" view recognises them (see the picture at the top).
    if [[ ! -s $TSIG_KEY_FILE ]]; then
        log "Creating TSIG key $TSIG_KEY_FILE"
        tsig-keygen -a hmac-sha256 perf-upstream-key > "$TSIG_KEY_FILE"
    fi

    chown root:named /etc/rndc.key "$TSIG_KEY_FILE"
    chmod 640 /etc/rndc.key "$TSIG_KEY_FILE"
}

write_test_config() {
    log "Writing $TEST_CONF"
    cat > "$TEST_CONF" <<EOF
// =============================================================================
//  Created by $KIT_DIR/start-dns.sh
//  Values come from settings.conf. Run "./start-dns.sh cleanup" to remove.
// =============================================================================

include "/etc/rndc.key";             // key "rndc-key", used by the rndc command
include "$TSIG_KEY_FILE";   // key "perf-upstream-key", see the views below

// Only this machine may send queries (test traffic never leaves the host).
acl "test-clients" { 127.0.0.0/8; };

// rndc (reload, flush, status, stop) - local only.
controls {
    inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; };
};

// Server counters as JSON, read by test-dns.sh - local only.
statistics-channels {
    inet 127.0.0.1 port $STATS_PORT allow { 127.0.0.1; };
};

options {
    // Standard RHEL locations (correct SELinux labels already exist).
    directory               "/var/named";
    managed-keys-directory  "/var/named/dynamic";
    dump-file               "/var/named/data/cache_dump.db";
    statistics-file         "/var/named/data/named_stats.txt";
    memstatistics-file      "/var/named/data/named_mem_stats.txt";
    pid-file                "/run/named/named.pid";
    session-keyfile         "/run/named/session.key";

    listen-on port $DNS_PORT { $DNS_SERVER; };
    listen-on-v6 { none; };
    allow-query  { test-clients; };

    // Offline test: no DNSSEC trust anchors can be refreshed from the internet.
    dnssec-validation no;
    notify no;                      // no other servers to tell about changes

    max-cache-size    $MAX_CACHE_SIZE;
    recursive-clients $RECURSIVE_CLIENTS;
    tcp-clients       $TCP_CLIENTS;
};

// -----------------------------------------------------------------------------
//  View 1: the "internet" stand-in. Only queries signed with the TSIG key
//  arrive here - that is, only the queries named forwards to itself.
// -----------------------------------------------------------------------------
view "perf-upstream" {
    match-clients { key perf-upstream-key; };
    recursion no;

    zone "$UPSTREAM_ZONE" {
        type primary;
        file "$ZONE_DIR/$UPSTREAM_ZONE.zone";
    };
};

// -----------------------------------------------------------------------------
//  View 2: the DNS server under test. All normal queries arrive here.
// -----------------------------------------------------------------------------
view "perf-main" {
    match-clients { test-clients; };
    recursion yes;
    allow-recursion { test-clients; };

    // Sign every query this view sends to $DNS_SERVER, so it reaches view 1.
    server $DNS_SERVER { keys { perf-upstream-key; }; };

    // Authoritative zone: answers come straight from the zone file.
    zone "$TEST_ZONE" {
        type primary;
        file "$ZONE_DIR/$TEST_ZONE.zone";
        allow-transfer { test-clients; };     // for the zone transfer (AXFR) test
    };

    // Forwarded zone: the first answer is fetched from view 1, then cached.
    zone "$UPSTREAM_ZONE" {
        type forward;
        forward only;
        forwarders { $DNS_SERVER port $DNS_PORT; };
    };
};
EOF

    chown root:named "$TEST_CONF"
    chmod 640 "$TEST_CONF"
    if command -v restorecon >/dev/null; then
        restorecon "$TEST_CONF" "$TSIG_KEY_FILE" /etc/rndc.key   # label: named_conf_t
    fi
}


# ----------------------------------------------------------------------------
#  Step 6 - point named.service at the test configuration
# ----------------------------------------------------------------------------
#  named.service reads the config file name from the variable NAMEDCONF.
#  A drop-in file overrides it, without touching the packaged unit file.
write_systemd_dropin() {
    has_systemd || return 0

    # /etc/sysconfig/named is read after our drop-in and would win.
    if grep -qE '^[[:space:]]*NAMEDCONF=' /etc/sysconfig/named 2>/dev/null; then
        die "/etc/sysconfig/named sets NAMEDCONF, which would override the test config.
       Comment that line out while testing."
    fi
    if grep -qE '^[[:space:]]*OPTIONS=' /etc/sysconfig/named 2>/dev/null && [[ -n $NAMED_THREADS ]]; then
        warn "/etc/sysconfig/named sets OPTIONS; NAMED_THREADS from settings.conf is ignored."
    fi

    log "Writing $SYSTEMD_DROPIN"
    mkdir -p "$(dirname "$SYSTEMD_DROPIN")"
    {
        echo "# Created by $KIT_DIR/start-dns.sh"
        echo "# Makes named.service load the performance-test configuration."
        echo "# Remove with \"./start-dns.sh cleanup\" to go back to /etc/named.conf."
        echo "[Service]"
        echo "Environment=NAMEDCONF=$TEST_CONF"
        if [[ -n $NAMED_THREADS ]]; then
            echo "Environment=\"OPTIONS=-n $NAMED_THREADS\""
        fi
    } > "$SYSTEMD_DROPIN"
    systemctl daemon-reload
}


# ----------------------------------------------------------------------------
#  Step 7 - configuration check
# ----------------------------------------------------------------------------
check_config() {
    log "Checking configuration and zones (named-checkconf -z)"
    if ! named-checkconf -z "$TEST_CONF" >/dev/null; then
        named-checkconf -z "$TEST_CONF" 2>&1 | head -20 || true   # show the first messages
        die "The named configuration has errors (see messages above)."
    fi
}


# ----------------------------------------------------------------------------
#  Step 8 - start the daemon
# ----------------------------------------------------------------------------
start_daemon() {
    if named_is_running; then
        local running_command
        running_command="$(ps -o args= -p "$(named_pid)")"
        if [[ $running_command != *"$TEST_CONF"* ]]; then
            warn "named is running with another configuration ($running_command)."
            warn "It is restarted with the test configuration; /etc/named.conf is not changed."
        fi
        log "named is running - restarting it to apply the test configuration"
        named_restart
    else
        log "Starting the named daemon"
        named_start
    fi

    if ! wait_until_answering 60; then
        die "named did not answer within 60 s.
       Look at:  journalctl -u named"
    fi
}

print_summary() {
    local version threads selinux
    version="$(named -v)"
    threads="$(rndc status 2>/dev/null | awk -F': ' '/worker threads/ {print $2}')"
    selinux="$(getenforce 2>/dev/null || echo 'not available')"

    echo
    ok "The DNS server is running and answering queries."
    echo "    Version      : $version"
    echo "    Worker threads: ${threads:-?}"
    echo "    Main PID     : $(named_pid)"
    echo "    Listening on : $DNS_SERVER port $DNS_PORT (UDP and TCP)"
    echo "    Test zones   : $TEST_ZONE ($ZONE_RECORDS hosts), $UPSTREAM_ZONE (forwarded + cached)"
    echo "    Config file  : $TEST_CONF"
    echo "    Statistics   : $STATS_URL"
    echo "    SELinux      : $selinux"
    if has_systemd; then
        echo "    Managed by   : systemd  (systemctl status named)"
    else
        echo "    Managed by   : direct start (no systemd found, e.g. inside a container)"
    fi
    echo
    echo "    Try it       : dig @$DNS_SERVER -p $DNS_PORT host1.$TEST_ZONE"
    echo "    Next step    : ./test-dns.sh"
}


# ----------------------------------------------------------------------------
#  Other actions
# ----------------------------------------------------------------------------
show_status() {
    if dns_is_answering; then
        ok "named is running (PID $(named_pid)) and answering on $DNS_SERVER port $DNS_PORT"
        rndc status 2>/dev/null |
            grep -E '^(version|boot time|last configured|number of zones|worker threads|recursive clients|tcp clients|server is up)' |
            sed 's/^/    /' || true      # extra details; never fail on them
    elif named_is_running; then
        warn "named is running, but does not answer for $TEST_ZONE. Run: ./start-dns.sh"
        exit 1
    else
        warn "named is not running."
        exit 1
    fi
}

cleanup_test_setup() {
    log "Stopping named and removing the test files"
    named_is_running && named_stop

    rm -f "$SYSTEMD_DROPIN"
    rmdir --ignore-fail-on-non-empty "$(dirname "$SYSTEMD_DROPIN")" 2>/dev/null || true
    has_systemd && systemctl daemon-reload

    rm -f "$TEST_CONF" "$TSIG_KEY_FILE"
    rm -rf "$ZONE_DIR"
    # Trust-anchor files that named created for the two test views.
    rm -f /var/named/dynamic/perf-main.mkeys* /var/named/dynamic/perf-upstream.mkeys*

    ok "Cleanup finished. named is stopped; 'systemctl start named' uses /etc/named.conf again."
}


# ----------------------------------------------------------------------------
#  Main
# ----------------------------------------------------------------------------
main() {
    local action="${1:-start}"
    require_root "$@"

    case "$action" in
        start)
            check_operating_system
            install_bind_offline
            check_port_is_free
            create_zones
            create_keys
            write_test_config
            write_systemd_dropin
            check_config
            start_daemon
            print_summary
            ;;
        stop)
            named_stop
            ok "named stopped."
            ;;
        restart)
            named_restart
            wait_until_answering 60 || die "named did not come back. See: journalctl -u named"
            ok "named restarted."
            ;;
        status)
            show_status
            ;;
        cleanup)
            cleanup_test_setup
            ;;
        *)
            echo "Usage: $0 [start|stop|restart|status|cleanup]"
            exit 2
            ;;
    esac
}

main "$@"
