#!/usr/bin/env bash
# =============================================================================
#  start-kerberos.sh - install (offline) and start an MIT Kerberos server
#                      (KDC + kadmind) on RHEL 9.6, ready for performance testing
# =============================================================================
#
#  USAGE (as root)
#      ./start-kerberos.sh            install if needed, configure, start
#      ./start-kerberos.sh start      same as above
#      ./start-kerberos.sh stop       stop both test daemons
#      ./start-kerberos.sh restart    stop, then start again
#      ./start-kerberos.sh status     show whether the Kerberos server is running
#      ./start-kerberos.sh rebuild    delete the test database and create it again
#      ./start-kerberos.sh cleanup    stop and remove everything the kit created
#
#  WHAT "start" DOES, STEP BY STEP
#      1. Checks the operating system (RHEL 9 expected).
#      2. Installs krb5-server + krb5-workstation 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 the Kerberos ports on 127.0.0.1.
#      4. Writes the configuration files of the test realm PERF.TEST.
#      5. Creates the principal database with the test users, services and
#         "filler" principals, plus keytab files with the users' keys.
#         (Only the first time, or when settings.conf changed the database.)
#      6. Creates two systemd services: krb5kdc-perf-test, kadmind-perf-test.
#      7. Starts both daemons, then waits until they answer.
#
#  THE TWO DAEMONS
#      krb5kdc   the KDC ("Key Distribution Center"). It hands out tickets:
#                  AS request  = a login; the user gets a ticket-granting ticket (TGT)
#                  TGS request = with the TGT, a ticket for one service (web, NFS, ...)
#      kadmind   the administration server: add/change/delete principals,
#                change passwords. Used by the "kadmin" test.
#
#  SAFE FOR A HOST THAT ALREADY RUNS KERBEROS
#      /etc/krb5.conf, /var/kerberos/krb5kdc/kdc.conf and the normal krb5kdc /
#      kadmin services are never changed. All test files carry "perf-test" in
#      their name. "cleanup" removes every one of them.
#      A real KDC that listens on port 88 must be stopped first (step 3 checks).
# =============================================================================

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

# Unit files of the kit's own systemd services.
KDC_UNIT_FILE="/etc/systemd/system/$KDC_SERVICE.service"
KADMIN_UNIT_FILE="/etc/systemd/system/$KADMIN_SERVICE.service"


# ----------------------------------------------------------------------------
#  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 the Kerberos server without internet access
# ----------------------------------------------------------------------------
install_kerberos_offline() {
    local packages=(krb5-server krb5-workstation)

    if rpm -q "${packages[@]}" >/dev/null 2>&1; then
        log "Kerberos is already installed: $(rpm -q "${packages[@]}" | tr '\n' ' ')"
    else
        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 ${packages[*]} 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 ${packages[*]} from the local dnf repositories"
            if ! dnf install -y "${packages[@]}"; then
                die "Could not install ${packages[*]} 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 "${packages[@]}" | tr '\n' ' ')"
    fi

    # The load generator is written in Python 3 and uses the Kerberos library
    # of the system (krb5-libs). Both are part of every RHEL 9 installation.
    command -v python3 >/dev/null || die "python3 not found (package python3)."
    ldconfig -p | grep -q 'libkrb5\.so\.3' || die "libkrb5.so.3 not found (package krb5-libs)."
}


# ----------------------------------------------------------------------------
#  Step 3 - the Kerberos ports must be free
# ----------------------------------------------------------------------------
#  Lists who listens on a port, for example:  port_users 88
#  Prints one "protocol program" line per listener; the kit's own daemons are left out.
port_users() {
    local port="$1"
    local our_pids
    our_pids=" $(daemon_pids krb5kdc | tr '\n' ' ') $(daemon_pids kadmind | tr '\n' ' ') "

    ss -Hlnptu "( sport = :$port )" 2>/dev/null |
        while read -r protocol _ _ _ local_address _ process; do
            # Only listeners that 127.0.0.1 traffic would reach.
            case "$local_address" in
                127.0.0.1:*|0.0.0.0:*|\*:*|\[::\]:*) ;;
                *) continue ;;
            esac
            local pid
            pid="$(grep -o 'pid=[0-9]*' <<< "$process" | head -1 | cut -d= -f2)"
            [[ $our_pids == *" $pid "* ]] && continue
            echo "$protocol ${process:-unknown program}"
        done
}

check_ports_are_free() {
    local port users problem=no
    for port in "$KDC_PORT" "$KADMIN_PORT" "$KPASSWD_PORT"; do
        users="$(port_users "$port")"
        if [[ -n $users ]]; then
            warn "Port $port is already in use by: $users"
            problem=yes
        fi
    done
    if [[ $problem == yes ]]; then
        die "The Kerberos ports are in use, probably by a real KDC on this machine.
       Stop it first (for example: systemctl stop krb5kdc kadmin), or use a test machine."
    fi
}


# ----------------------------------------------------------------------------
#  Step 4 - configuration files
# ----------------------------------------------------------------------------
write_config_files() {
    local db_library=db2
    [[ $DB_BACKEND == lmdb ]] && db_library=klmdb

    local spake_kdc="" spake_client=""
    if [[ $SPAKE_PREAUTH == yes ]]; then
        spake_kdc="spake_preauth_kdc_challenge = edwards25519"
        spake_client="spake_preauth_groups = edwards25519"
    fi

    # --- The KDC configuration (the kit's own "kdc.conf") --------------------
    log "Writing $KDC_CONF"
    cat > "$KDC_CONF" <<EOF
# =============================================================================
#  Created by $KIT_DIR/start-kerberos.sh
#  Values come from settings.conf. "./start-kerberos.sh cleanup" removes this file.
#  Used only by the $KDC_SERVICE and $KADMIN_SERVICE services.
# =============================================================================

[kdcdefaults]
    # Listen only on the loopback address: the test never uses the real network.
    kdc_listen     = $KDC_ADDRESS:$KDC_PORT
    kdc_tcp_listen = $KDC_ADDRESS:$KDC_PORT
    # SPAKE pre-authentication (RHEL 9 default: on). Empty when switched off.
    $spake_kdc

[realms]
    $REALM = {
        database_module  = perf_test_db
        key_stash_file   = $STASH_FILE
        acl_file         = $ACL_FILE
        kadmind_listen   = $KDC_ADDRESS:$KADMIN_PORT
        kpasswd_listen   = $KDC_ADDRESS:$KPASSWD_PORT
        master_key_type  = $MASTER_KEY_TYPE
        supported_enctypes = $SUPPORTED_ENCTYPES
        # Every principal must prove its identity before it gets a ticket
        # ("pre-authentication"), exactly like RHEL's default kdc.conf.
        default_principal_flags = +preauth
        max_life           = 10h
        max_renewable_life = 7d
    }

[dbmodules]
    perf_test_db = {
        # db2 = classic Berkeley DB file, klmdb = LMDB
        db_library    = $db_library
        database_name = $DATABASE
        # false (default): write the time of each successful login to the database
        disable_last_success = $DISABLE_LAST_SUCCESS
    }

[logging]
    # The KDC writes one line per request here, like a normal RHEL KDC.
    kdc          = FILE:$KDC_LOG
    admin_server = FILE:$KADMIN_LOG
EOF

    # --- The client configuration ---------------------------------------------
    # kinit, kadmin and the load generator read this instead of /etc/krb5.conf.
    # It still includes /etc/krb5.conf.d/, where RHEL's system-wide crypto
    # policy (allowed encryption types, FIPS) is configured.
    log "Writing $CLIENT_CONF"
    local include_line=""
    [[ -d /etc/krb5.conf.d ]] && include_line="includedir /etc/krb5.conf.d/"
    cat > "$CLIENT_CONF" <<EOF
# Created by $KIT_DIR/start-kerberos.sh - client settings for the test realm.
$include_line

[libdefaults]
    default_realm      = $REALM
    # No DNS lookups: the KDC address is written below.
    dns_lookup_realm   = false
    dns_lookup_kdc     = false
    rdns               = false
    dns_canonicalize_hostname = false
    qualify_shortname  = ""
    ticket_lifetime    = 10h
    # SPAKE pre-authentication, as in RHEL's /etc/krb5.conf. Empty when switched off.
    $spake_client

[realms]
    $REALM = {
        kdc            = $KDC_ADDRESS:$KDC_PORT
        admin_server   = $KDC_ADDRESS:$KADMIN_PORT
        kpasswd_server = $KDC_ADDRESS:$KPASSWD_PORT
    }

[domain_realm]
    .perf.test = $REALM
EOF

    # --- Add-on for the TCP test ---------------------------------------------
    # Clients normally send small requests over UDP. With this file placed in
    # front of the client configuration (KRB5_CONFIG="tcp-file:client-file"),
    # every request goes over TCP instead.
    cat > "$TCP_CLIENT_CONF" <<EOF
# Created by $KIT_DIR/start-kerberos.sh - makes clients always use TCP.
[libdefaults]
    udp_preference_limit = 1
EOF

    # --- Who may administer the test realm -----------------------------------
    log "Writing $ACL_FILE"
    cat > "$ACL_FILE" <<EOF
# Created by $KIT_DIR/start-kerberos.sh
# Every principal ending in "/admin" may do everything in the test realm.
*/admin@$REALM    *
EOF

    # Empty log files, created now so that they get the right SELinux label.
    touch "$KDC_LOG" "$KADMIN_LOG"
    chmod 600 "$KDC_LOG" "$KADMIN_LOG"
    chmod 644 "$KDC_CONF" "$CLIENT_CONF" "$TCP_CLIENT_CONF"
    chmod 600 "$ACL_FILE"
}


# ----------------------------------------------------------------------------
#  Step 5 - the principal database
# ----------------------------------------------------------------------------
#  A short text that describes the database. If settings.conf changes it (more
#  users, another back end ...), the database is built again.
database_description() {
    echo "backend=$DB_BACKEND users=$TEST_USERS services=$TEST_SERVICES" \
         "filler=$FILLER_PRINCIPALS enctypes=$SUPPORTED_ENCTYPES master=$MASTER_KEY_TYPE"
}

database_exists() {
    [[ -e $DATABASE || -e $DATABASE.mdb ]]
}

delete_database() {
    kdb5_util -r "$REALM" destroy -f >/dev/null 2>&1 || true
    rm -f "$DATABASE" "$DATABASE".* "$STASH_FILE" "$USER_KEYTAB" "$ADMIN_KEYTAB" "$DB_INFO_FILE"
}

# kadmin.local works directly on the database files (no kadmind needed).
# It reads its commands from standard input, one per line.
run_kadmin_local() {
    kadmin.local -r "$REALM" > /dev/null
}

create_database_if_needed() {
    if database_exists && [[ -f $DB_INFO_FILE && $(< "$DB_INFO_FILE") == "$(database_description)" ]]; then
        log "Using the existing test database ($(database_description | cut -d' ' -f1-4))"
        return
    fi

    if database_exists; then
        log "settings.conf changed the database - building it again"
        kdc_stop
        kadmind_stop
        delete_database
    fi

    # The master key encrypts the database. It is stored in the "stash file"
    # (-s) so the KDC can start without anyone typing it. It is random and
    # never needed again, so it is not shown anywhere.
    log "Creating the principal database ($DB_BACKEND) for realm $REALM"
    local master_password
    master_password="$(head -c 32 /dev/urandom | base64)"
    kdb5_util -r "$REALM" create -s -P "$master_password" > /dev/null

    # Principals get random keys (-randkey), as services and machines do.
    # The test users log in with the keys from a keytab file, like "kinit -k".
    log "Adding the administrator and $TEST_USERS test users"
    {
        echo "addprinc -randkey -clearpolicy $ADMIN_PRINCIPAL"
        for (( i = 1; i <= TEST_USERS; i++ )); do
            printf 'addprinc -randkey -clearpolicy perfuser%05d\n' "$i"
        done
    } | run_kadmin_local

    log "Adding $TEST_SERVICES test services (HTTP/web0001.perf.test ...)"
    for (( i = 1; i <= TEST_SERVICES; i++ )); do
        printf 'addprinc -randkey -clearpolicy HTTP/web%04d.perf.test\n' "$i"
    done | run_kadmin_local

    log "Adding $FILLER_PRINCIPALS filler principals, so the database has a realistic size"
    for (( i = 1; i <= FILLER_PRINCIPALS; i++ )); do
        printf 'addprinc -randkey -clearpolicy perffill%06d\n' "$i"
    done | run_kadmin_local

    # Keytab files hold copies of the keys; -norandkey keeps the keys unchanged.
    log "Writing the keytab files"
    echo "ktadd -k $ADMIN_KEYTAB -norandkey $ADMIN_PRINCIPAL" | run_kadmin_local
    for (( i = 1; i <= TEST_USERS; i++ )); do
        printf 'ktadd -k %s -norandkey perfuser%05d\n' "$USER_KEYTAB" "$i"
    done | run_kadmin_local
    chmod 600 "$USER_KEYTAB" "$ADMIN_KEYTAB"

    database_description > "$DB_INFO_FILE"
    ok "Database ready: $(count_principals) principals"
}

count_principals() {
    kadmin.local -r "$REALM" -q listprincs 2>/dev/null | grep -c "@$REALM"
}

# Give every file the SELinux label the daemons are allowed to use
# (for example the database must be "krb5kdc_principal_t").
fix_selinux_labels() {
    command -v restorecon >/dev/null || return 0
    restorecon -F "$KDC_DIR"/perf-test-* "$DATABASE"* "$STASH_FILE" "$KDC_LOG" "$KADMIN_LOG" 2>/dev/null || true
}


# ----------------------------------------------------------------------------
#  Step 6 - systemd services
# ----------------------------------------------------------------------------
#  Like RHEL's own krb5kdc.service and kadmin.service, but with the kit's
#  configuration. The daemons stay in the foreground (-n, -nofork), so systemd
#  knows them directly and no PID file is needed. There is no [Install]
#  section: the test services never start at boot.
write_systemd_units() {
    has_systemd || return 0

    log "Writing systemd services $KDC_SERVICE and $KADMIN_SERVICE"
    cat > "$KDC_UNIT_FILE" <<EOF
# Created by $KIT_DIR/start-kerberos.sh - "./start-kerberos.sh cleanup" removes it.
[Unit]
Description=Kerberos 5 KDC for performance testing (Kerberos perf kit, realm $REALM)
Documentation=man:krb5kdc(8) man:kdc.conf(5)
After=network.target

[Service]
Type=simple
Environment=KRB5_KDC_PROFILE=$KDC_CONF KRB5_CONFIG=$CLIENT_CONF
ExecStart=$(kdc_command) -n
EOF

    cat > "$KADMIN_UNIT_FILE" <<EOF
# Created by $KIT_DIR/start-kerberos.sh - "./start-kerberos.sh cleanup" removes it.
[Unit]
Description=Kerberos 5 admin server for performance testing (Kerberos perf kit, realm $REALM)
Documentation=man:kadmind(8) man:kdc.conf(5)
After=network.target $KDC_SERVICE.service

[Service]
Type=simple
Environment=KRB5_KDC_PROFILE=$KDC_CONF KRB5_CONFIG=$CLIENT_CONF
ExecStart=$(kadmind_command) -nofork
EOF

    if command -v restorecon >/dev/null; then
        restorecon "$KDC_UNIT_FILE" "$KADMIN_UNIT_FILE"
    fi
    systemctl daemon-reload
}


# ----------------------------------------------------------------------------
#  Step 7 - start the daemons
# ----------------------------------------------------------------------------
start_daemons() {
    # Always restart, so that configuration changes are applied.
    kdc_stop
    kadmind_stop

    log "Starting the KDC (krb5kdc)"
    kdc_start
    if ! wait_until kdc_is_answering 30; then
        explain_start_failure krb5kdc "$KDC_SERVICE" "$KDC_LOG"
    fi

    log "Starting the admin server (kadmind)"
    kadmind_start
    if ! wait_until kadmind_is_answering 30; then
        explain_start_failure kadmind "$KADMIN_SERVICE" "$KADMIN_LOG"
    fi
}

explain_start_failure() {
    local program="$1" service="$2" log_file="$3"
    echo "---- last lines of $log_file ----" >&2
    tail -n 15 "$log_file" >&2 || true
    if has_systemd; then
        die "$program does not answer within 30 s. Look at:  journalctl -u $service"
    else
        die "$program does not answer within 30 s. See the log lines above."
    fi
}

print_summary() {
    local selinux workers="1 (single process)"
    selinux="$(getenforce 2>/dev/null || echo 'not available')"
    (( KDC_WORKERS > 0 )) && workers="$KDC_WORKERS worker processes"

    echo
    ok "The Kerberos server is running and hands out tickets."
    echo "    Version      : MIT Kerberos $(krb5_version)"
    echo "    Realm        : $REALM"
    echo "    KDC          : $KDC_ADDRESS port $KDC_PORT (UDP+TCP), PID $(kdc_main_pid), $workers"
    echo "    kadmind      : $KDC_ADDRESS port $KADMIN_PORT (admin), $KPASSWD_PORT (passwords)"
    echo "    Database     : $DATABASE ($DB_BACKEND, $(count_principals) principals)"
    echo "    Pre-auth     : $([[ $SPAKE_PREAUTH == yes ]] && echo SPAKE || echo 'encrypted timestamp')"
    echo "    Config files : $KDC_CONF, $CLIENT_CONF"
    echo "    Logs         : $KDC_LOG, $KADMIN_LOG"
    echo "    SELinux      : $selinux"
    if has_systemd; then
        echo "    Managed by   : systemd  (systemctl status $KDC_SERVICE $KADMIN_SERVICE)"
    else
        echo "    Managed by   : started directly (no systemd, e.g. inside a container)"
    fi
    echo
    echo "    Try it       : KRB5_CONFIG=$CLIENT_CONF KRB5CCNAME=MEMORY: kinit -k -t $USER_KEYTAB $FIRST_USER"
    echo "    Next step    : ./test-kerberos.sh"
}


# ----------------------------------------------------------------------------
#  Other actions
# ----------------------------------------------------------------------------
show_status() {
    local healthy=yes

    if kdc_is_answering; then
        ok "KDC is running (PID $(kdc_main_pid)) and hands out tickets on $KDC_ADDRESS:$KDC_PORT"
    elif kdc_is_running; then
        warn "krb5kdc is running, but hands out no tickets. Run: ./start-kerberos.sh"
        healthy=no
    else
        warn "The test KDC is not running."
        healthy=no
    fi

    if kadmind_is_answering; then
        ok "kadmind is running (PID $(daemon_pids kadmind | head -1)) and answers on $KDC_ADDRESS:$KADMIN_PORT"
    elif kadmind_is_running; then
        warn "kadmind is running, but does not answer. Run: ./start-kerberos.sh"
        healthy=no
    else
        warn "The test kadmind is not running."
        healthy=no
    fi

    if database_exists; then
        echo "    Database : $DATABASE ($DB_BACKEND, $(count_principals) principals)"
        echo "    KDC log  : $KDC_LOG ($(du -h "$KDC_LOG" | cut -f1))"
    fi
    [[ $healthy == yes ]] || exit 1
}

cleanup_test_setup() {
    log "Stopping the test daemons"
    kdc_stop
    kadmind_stop

    log "Removing the test database, keytabs and configuration files"
    delete_database
    rm -f "$KDC_CONF" "$CLIENT_CONF" "$TCP_CLIENT_CONF" "$ACL_FILE"

    log "Removing the log files $KDC_LOG and $KADMIN_LOG"
    rm -f "$KDC_LOG"* "$KADMIN_LOG"*

    if [[ -f $KDC_UNIT_FILE || -f $KADMIN_UNIT_FILE ]]; then
        log "Removing the systemd services $KDC_SERVICE and $KADMIN_SERVICE"
        rm -f "$KDC_UNIT_FILE" "$KADMIN_UNIT_FILE"
        has_systemd && systemctl daemon-reload
    fi

    ok "Cleanup finished. (The Kerberos RPMs stay installed; remove them with: dnf remove krb5-server krb5-workstation)"
}


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

    case "$action" in
        start)
            check_operating_system
            install_kerberos_offline
            check_ports_are_free
            write_config_files
            create_database_if_needed
            fix_selinux_labels
            write_systemd_units
            start_daemons
            print_summary
            ;;
        stop)
            kdc_stop
            kadmind_stop
            ok "The test KDC and kadmind are stopped."
            ;;
        restart)
            kdc_stop
            kadmind_stop
            kdc_start
            kadmind_start
            wait_until kdc_is_answering 30     || die "The KDC did not come back. Run ./start-kerberos.sh"
            wait_until kadmind_is_answering 30 || die "kadmind did not come back. Run ./start-kerberos.sh"
            ok "The test KDC and kadmind restarted."
            ;;
        status)
            show_status
            ;;
        rebuild)
            log "Deleting the test database"
            kdc_stop
            kadmind_stop
            delete_database
            main start
            ;;
        cleanup)
            cleanup_test_setup
            ;;
        *)
            echo "Usage: $0 [start|stop|restart|status|rebuild|cleanup]"
            exit 2
            ;;
    esac
}

main "$@"
