#!/usr/bin/env bash
# =============================================================================
#  start-chrony.sh - install (offline) and start a Chrony NTP server on
#                    RHEL 9.6, ready for performance testing
# =============================================================================
#
#  USAGE (as root)
#      ./start-chrony.sh            install if needed, configure, start
#      ./start-chrony.sh start      same as above
#      ./start-chrony.sh stop       stop the test server
#      ./start-chrony.sh restart    stop, then start again
#      ./start-chrony.sh status     show the test server and what it answers
#      ./start-chrony.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 chrony 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. Checks that the test port (UDP 11123) is free.
#      4. Writes the test configuration /etc/chrony-perf-test.conf and
#         checks it with "chronyd -p".
#      5. SELinux: allows chronyd to use the test port (label ntp_port_t).
#      6. Creates the systemd service chronyd-perf-test.service.
#      7. Starts the test chronyd, then waits until it answers NTP requests
#         with synchronized time.
#
#  SAFE FOR A HOST THAT ALREADY RUNS CHRONY
#      The normal chronyd.service and /etc/chrony.conf are not changed, and
#      the test chronyd is started with "-x": it never changes the clock.
#      It answers only on 127.0.0.1, so no other machine can reach it.
#      "cleanup" removes everything from steps 4-7.
# =============================================================================

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 chrony without internet access
# ----------------------------------------------------------------------------
#  chrony                         the NTP server and client (chronyd, chronyc)
#  policycoreutils-python-utils   "semanage", only needed when SELinux is on
install_packages_offline() {
    local packages=(chrony)
    selinux_is_on && packages+=(policycoreutils-python-utils)

    local missing=() package
    for package in "${packages[@]}"; do
        rpm -q "$package" >/dev/null 2>&1 || missing+=("$package")
    done

    if (( ${#missing[@]} == 0 )); then
        log "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 ${missing[*]} 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).
            #    Both packages are in BaseOS.
            log "Installing ${missing[*]} from the local dnf repositories"
            if ! dnf install -y "${missing[@]}"; then
                die "Could not install ${missing[*]} offline.
       Either copy RPMs into $rpm_dir (see download-rpms.sh),
       or mount the RHEL 9.6 DVD and configure BaseOS as a local repository."
            fi
        fi
        ok "Installed $(rpm -q "${packages[@]}" | tr '\n' ' ')"
    fi

    # The load generator needs Python 3, part of every RHEL 9 installation.
    command -v python3 >/dev/null || die "python3 not found (package python3)."
}


# ----------------------------------------------------------------------------
#  Step 3 - is the test port free?
# ----------------------------------------------------------------------------
check_port_is_free() {
    # Our own test server may already hold the port; that is fine.
    test_chronyd_is_running && return 0

    local user
    user="$(ss -Hulpn "sport = :$NTP_PORT" 2>/dev/null || true)"
    if [[ -n $user ]]; then
        die "UDP port $NTP_PORT is already in use:
       $user
       Choose another port, for example:  NTP_PORT=11124 ./start-chrony.sh
       (and use the same NTP_PORT for test-chrony.sh, or change settings.conf)"
    fi
    log "UDP port $NTP_PORT is free"
}


# ----------------------------------------------------------------------------
#  Step 4 - the test configuration
# ----------------------------------------------------------------------------
write_and_check_config() {
    log "Writing $TEST_CONF"
    write_test_config

    # "chronyd -p" reads the configuration, prints it and exits. It fails
    # on a mistake, with the line number.
    if ! chronyd -p -f "$TEST_CONF" >/dev/null 2>"$KIT_DIR/.config-check.txt"; then
        cat "$KIT_DIR/.config-check.txt" >&2
        rm -f "$KIT_DIR/.config-check.txt"
        die "chronyd does not accept $TEST_CONF (see the message above)."
    fi
    rm -f "$KIT_DIR/.config-check.txt"
    log "Configuration checked with 'chronyd -p'"
}


# ----------------------------------------------------------------------------
#  Step 5 - SELinux
# ----------------------------------------------------------------------------
#  SELinux lets chronyd use only the NTP ports (UDP 123, 323 ...). The test
#  port gets the same label, "ntp_port_t". "cleanup" removes the label again,
#  but only if the kit added it (remembered in the state file).
configure_selinux() {
    if ! selinux_is_on; then
        log "SELinux is disabled - nothing to do"
        return
    fi

    if sepolicy network -p "$NTP_PORT" 2>/dev/null | grep -q "udp ntp_port_t"; then
        log "SELinux: UDP port $NTP_PORT is already an NTP port (ntp_port_t)"
    else
        log "SELinux: labelling UDP port $NTP_PORT as ntp_port_t (takes a few seconds)"
        if ! semanage port -a -t ntp_port_t -p udp "$NTP_PORT"; then
            die "Could not label UDP port $NTP_PORT for chronyd.
       It may belong to another service already:  sepolicy network -p $NTP_PORT
       Choose another port with NTP_PORT=... in settings.conf."
        fi
        echo "port_label_added=$NTP_PORT" >> "$TEST_STATE_FILE"
    fi
    log "SELinux mode: $(getenforce)"
}


# ----------------------------------------------------------------------------
#  Step 6 - systemd service
# ----------------------------------------------------------------------------
#  Based on RHEL's own chronyd.service, with the same security settings,
#  but with the test configuration and "-x". It has no [Install] section on
#  purpose: the test server is not started at boot.
#  Not copied from chronyd.service:
#    - "Conflicts=": the test server runs NEXT TO the normal chronyd.
#    - "ConditionCapability=CAP_SYS_TIME": not needed, the clock is not changed.
write_systemd_unit() {
    has_systemd || return 0

    log "Writing systemd service $TEST_UNIT_FILE"
    cat > "$TEST_UNIT_FILE" <<EOF
# Created by $KIT_DIR/start-chrony.sh - "./start-chrony.sh cleanup" removes it.
[Unit]
Description=Chrony NTP server for performance testing (Chrony perf kit, never changes the clock)
Documentation=man:chronyd(8) man:chrony.conf(5)
After=network.target

[Service]
Type=forking
PIDFile=$TEST_PIDFILE
ExecStart=$(chronyd_command)

# The same protection as RHEL's chronyd.service.
CapabilityBoundingSet=~CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE
CapabilityBoundingSet=~CAP_BLOCK_SUSPEND CAP_KILL CAP_LEASE CAP_LINUX_IMMUTABLE
CapabilityBoundingSet=~CAP_MAC_ADMIN CAP_MAC_OVERRIDE CAP_MKNOD CAP_SYS_ADMIN
CapabilityBoundingSet=~CAP_SYS_BOOT CAP_SYS_CHROOT CAP_SYS_MODULE CAP_SYS_PACCT
CapabilityBoundingSet=~CAP_SYS_PTRACE CAP_SYS_RAWIO CAP_SYS_TTY_CONFIG CAP_WAKE_ALARM
DevicePolicy=closed
LockPersonality=yes
MemoryDenyWriteExecute=yes
NoNewPrivileges=yes
PrivateTmp=yes
ProtectControlGroups=yes
ProtectHome=yes
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectProc=invisible
ProtectSystem=full
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
SystemCallFilter=~@cpu-emulation @debug @module @mount @obsolete @raw-io @reboot @swap
EOF

    command -v restorecon >/dev/null && restorecon "$TEST_UNIT_FILE"
    systemctl daemon-reload
}


# ----------------------------------------------------------------------------
#  Step 7 - start the test server
# ----------------------------------------------------------------------------
start_server() {
    if test_chronyd_is_running; then
        log "The test chronyd is running - restarting it to apply the configuration"
        chronyd_restart
    else
        log "Starting the test chronyd"
        chronyd_start
    fi

    if ! wait_until_ntp_answers 30; then
        if has_systemd; then
            die "The test server does not answer within 30 s.
       Look at:  journalctl -u $TEST_SERVICE      and, with SELinux:  ausearch -m avc -ts recent"
        else
            die "The test server does not answer within 30 s.
       Try it in the foreground:  $(chronyd_command) -d"
        fi
    fi
    log "The test server answers NTP requests on $LISTEN_ADDRESS:$NTP_PORT"
}

print_summary() {
    local answer
    answer="$(ntp_probe || true)"
    field() { awk -v key="$1" '$1 == key { print $3 }' <<< "$answer"; }

    echo
    ok "The Chrony test server is running."
    echo "    Version        : $(chrony_version)"
    echo "    Answers on     : $LISTEN_ADDRESS, UDP port $NTP_PORT (this machine only)"
    echo "    Serves         : stratum $(field stratum), leap status '$(awk -F' = ' '$1 ~ /^leap_text/ { print $2 }' <<< "$answer")'"
    echo "    Time source    : this machine's clock (local stratum $LOCAL_STRATUM); the clock is NOT changed (-x)"
    echo "    Configuration  : $TEST_CONF"
    echo "    chronyc        : chronyc -n -h $TEST_SOCKET serverstats"
    echo "    SELinux        : $(getenforce 2>/dev/null || echo 'not available')"
    if has_systemd; then
        echo "    Managed by     : systemd  (systemctl status $TEST_SERVICE)"
    else
        echo "    Managed by     : PID file $TEST_PIDFILE (no systemd, e.g. inside a container)"
    fi
    echo "    Normal chronyd : $(normal_chronyd_state) (not touched by this kit)"
    echo
    echo "    Try it         : python3 lib/ntpload.py probe --server $LISTEN_ADDRESS --port $NTP_PORT"
    echo "    Next step      : ./test-chrony.sh"
}

# State of the host's normal chronyd.service, for information only.
normal_chronyd_state() {
    if has_systemd; then
        systemctl is-active chronyd 2>/dev/null || true
    else
        echo "unknown (no systemd)"
    fi
}


# ----------------------------------------------------------------------------
#  Other actions
# ----------------------------------------------------------------------------
stop_server() {
    if test_chronyd_is_running; then
        chronyd_stop
        ok "The test chronyd is stopped."
    else
        log "The test chronyd is not running."
    fi
}

show_status() {
    if ! test_chronyd_is_running; then
        warn "The test chronyd is not running. Run: ./start-chrony.sh"
        exit 1
    fi
    local answer
    if ! answer="$(ntp_probe)"; then
        warn "The test chronyd runs (PID $(test_chronyd_pid)) but does not answer. Run: ./start-chrony.sh restart"
        exit 1
    fi

    ok "The test chronyd is running (PID $(test_chronyd_pid), $(chronyd_rss_kb) kB memory)"
    echo "    One NTP request, answered:"
    sed 's/^/        /' <<< "$answer"
    echo "    Server statistics (chronyc serverstats):"
    test_chronyc serverstats | sed 's/^/        /'
}

cleanup_test_setup() {
    stop_server

    log "Removing $TEST_CONF and the systemd service"
    rm -f "$TEST_CONF"
    if has_systemd && [[ -f $TEST_UNIT_FILE ]]; then
        rm -f "$TEST_UNIT_FILE"
        systemctl daemon-reload
    fi

    # Remove the SELinux port label, if the kit added it.
    local port
    port="$(awk -F= '$1 == "port_label_added" { print $2 }' "$TEST_STATE_FILE" 2>/dev/null || true)"
    for port in $port; do
        log "SELinux: removing the ntp_port_t label from UDP port $port"
        semanage port -d -t ntp_port_t -p udp "$port" || warn "Could not remove the label of port $port."
    done
    rm -f "$TEST_STATE_FILE"

    ok "Cleanup finished. (The chrony package stays installed; the normal chronyd was not touched.)"
}


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

    case "$action" in
        start)
            check_operating_system
            install_packages_offline
            check_port_is_free
            write_and_check_config
            configure_selinux
            write_systemd_unit
            start_server
            print_summary
            ;;
        stop)
            stop_server
            ;;
        restart)
            stop_server
            "$0" start
            ;;
        status)
            show_status
            ;;
        cleanup)
            cleanup_test_setup
            ;;
        *)
            echo "Usage: $0 [start|stop|restart|status|cleanup]"
            exit 2
            ;;
    esac
}

main "$@"
