#!/usr/bin/env bash
# =============================================================================
#  start-samba.sh - install (offline) and start a Samba server (smbd) on
#                   RHEL 9.6, with a test share mounted and ready for
#                   performance testing
# =============================================================================
#
#  USAGE (as root)
#      ./start-samba.sh            install if needed, configure, start, mount
#      ./start-samba.sh start      same as above
#      ./start-samba.sh stop       unmount the test share, stop the test smbd
#      ./start-samba.sh restart    stop, then start again
#      ./start-samba.sh status     show the server, its connections and the mount
#      ./start-samba.sh reset      delete the test data files on the share
#      ./start-samba.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 samba, samba-client, cifs-utils, python3-samba, fio and
#         nftables 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. Writes the test server's smb.conf and its systemd service.
#      4. Creates the test user "smbperf" (no shell, no home folder) and
#         the shared folder /srv/samba-perf-test.
#      5. Sets up SELinux: the port and the shared folder get Samba labels.
#      6. Starts the test smbd on 127.0.0.1:4450, forwards 127.0.0.2:445 to
#         it (clients use the normal SMB port), and checks that a login works.
#      7. Mounts //127.0.0.2/perftest on /mnt/samba-perf-test and checks a write.
#
#  SAFE FOR A HOST THAT ALREADY RUNS SAMBA
#      The kit runs its OWN smbd (service smbd-perf-test), with its own
#      configuration (/etc/samba/perf-test/smb.conf), its own port (4450
#      instead of 445), its own user database and its own folders. The
#      host's smb.service and /etc/samba/smb.conf are not changed. The test
#      server listens on 127.0.0.1 only, so it cannot be reached from the
#      network. "cleanup" removes everything from steps 3-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 the packages without internet access
# ----------------------------------------------------------------------------
#  samba           the Samba server (smbd)
#  samba-client    smbclient, used to check that a login works
#  cifs-utils      mount.cifs, to mount the test share (kernel SMB client)
#  python3-samba   Samba's client library for Python, used by lib/smb-load.py
#  fio             the standard Linux storage benchmark, used by test-samba.sh
#  nftables        "nft", for the port forwarding 127.0.0.2:445 -> 127.0.0.1:4450
#  policycoreutils-python-utils   "semanage", only needed with SELinux
install_packages_offline() {
    local packages=(samba samba-client cifs-utils python3-samba fio nftables)
    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 samba samba-client cifs-utils python3-samba fio nftables | 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).
            #    samba, cifs-utils are in BaseOS; fio, python3-samba in AppStream.
            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 + AppStream as local repositories."
            fi
        fi
        ok "Installed $(rpm -q "${packages[@]}" | tr '\n' ' ')"
    fi

    command -v python3 >/dev/null || die "python3 not found (package python3)."
    python3 -c 'import samba.samba3.libsmb_samba_internal' 2>/dev/null ||
        die "Python cannot load Samba's client library (package python3-samba)."
}


# ----------------------------------------------------------------------------
#  Step 3 - configuration of the test server
# ----------------------------------------------------------------------------
write_server_config() {
    mkdir -p "$CONF_DIR" "$DATA_BASE_DIR"/{lock,state,cache,private} "$RUN_DIR" "$LOG_DIR"
    chmod 700 "$DATA_BASE_DIR/private"            # holds the password database

    # "yes"/"no" from settings.conf, turned into smb.conf values.
    local encrypt="default"
    [[ $ENCRYPTION == yes ]] && encrypt="required"

    log "Writing $TEST_CONF"
    cat > "$TEST_CONF" <<EOF
# Created by $KIT_DIR/start-samba.sh
# "./start-samba.sh cleanup" removes it. Change values in settings.conf,
# not here: start-samba.sh writes this file again every time.
# All parameters are explained in:  man smb.conf

[global]
    # --- A stand-alone file server with its own users -------------------
    server role = standalone server
    workgroup = PERFTEST
    netbios name = PERFTEST
    server string = Samba performance test kit
    passdb backend = tdbsam
    map to guest = never

    # --- Reachable only from this machine, on its own port ---------------
    # (The normal SMB port 445 stays free for the host's own Samba.)
    smb ports = $SAMBA_PORT
    interfaces = $SAMBA_ADDRESS
    bind interfaces only = yes
    disable netbios = yes

    # --- Own folders, so nothing is shared with the host's Samba ---------
    lock directory  = $DATA_BASE_DIR/lock
    state directory = $DATA_BASE_DIR/state
    cache directory = $DATA_BASE_DIR/cache
    private dir     = $DATA_BASE_DIR/private
    pid directory   = $RUN_DIR
    ncalrpc dir     = $RUN_DIR/ncalrpc
    log file        = $LOG_DIR/log.smbd
    max log size    = 10000
    log level       = 0

    # --- No printers ----------------------------------------------------
    load printers = no
    printing = bsd
    printcap name = /dev/null
    disable spoolss = yes

    # --- Settings that change performance (settings.conf, section 2) ----
    server min protocol = SMB2_10
    server smb encrypt  = $encrypt
    server signing      = $SIGNING
    strict sync         = $STRICT_SYNC
    use sendfile        = $USE_SENDFILE

[$SHARE_NAME]
    comment = Test share of the Samba performance kit
    path = $SHARE_DIR
    read only = no
    valid users = $TEST_USER
EOF
    chmod 644 "$TEST_CONF"

    # testparm reads the file the way smbd will, and reports mistakes.
    if ! testparm -s "$TEST_CONF" >/dev/null 2>"$CONF_DIR/testparm.log"; then
        die "testparm found an error in $TEST_CONF:
$(cat "$CONF_DIR/testparm.log")"
    fi
    rm -f "$CONF_DIR/testparm.log"

    has_systemd && write_systemd_service
    return 0
}

# A service of its own, next to (not instead of) RHEL's smb.service.
# The lines are the same as in /usr/lib/systemd/system/smb.service.
write_systemd_service() {
    log "Writing $UNIT_FILE"
    cat > "$UNIT_FILE" <<EOF
# Created by $KIT_DIR/start-samba.sh - "./start-samba.sh cleanup" removes it.
[Unit]
Description=Samba SMB server of the performance test kit ($SAMBA_ADDRESS:$SAMBA_PORT)
Documentation=file://$KIT_DIR/README.md man:smbd(8)
After=network.target

[Service]
Type=notify
NotifyAccess=all
PIDFile=$SMBD_PID_FILE
RuntimeDirectory=${RUN_DIR#/run/}
LimitNOFILE=16384
ExecStart=/usr/sbin/smbd --foreground --no-process-group --configfile=$TEST_CONF
ExecReload=/bin/kill -HUP \$MAINPID
EOF
    systemctl daemon-reload
}


# ----------------------------------------------------------------------------
#  Step 4 - test user and shared folder
# ----------------------------------------------------------------------------
#  The test user exists on the system (Samba needs a Linux user to own the
#  files) but cannot log in to a shell. Its Samba password is random and is
#  stored in the credentials file, which only root can read. The password
#  is known only to the TEST server's user database, not to the host's Samba.
create_user_and_share() {
    if ! id "$TEST_USER" >/dev/null 2>&1; then
        log "Creating the system user $TEST_USER (no shell, no home folder)"
        useradd --system --no-create-home --shell /sbin/nologin \
                --comment "Samba performance test kit" "$TEST_USER"
    fi

    if [[ ! -s $CREDENTIALS_FILE ]]; then
        local password
        password="$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24 || true)"
        # The same format works for mount.cifs, smbclient -A and smb-load.py.
        ( umask 077; printf 'username=%s\npassword=%s\n' "$TEST_USER" "$password" > "$CREDENTIALS_FILE" )
    fi

    # (Re)set the Samba password of the test user in the TEST server's
    # user database. smbpasswd -s reads the password twice from stdin.
    local password
    password="$(awk -F= '$1 == "password" { print $2 }' "$CREDENTIALS_FILE")"
    printf '%s\n%s\n' "$password" "$password" |
        smbpasswd -c "$TEST_CONF" -a -s "$TEST_USER" >/dev/null ||
        die "smbpasswd could not add $TEST_USER to the test server's user database."

    install -d -o "$TEST_USER" -g "$TEST_USER" -m 755 "$SHARE_DIR"

    local free_mb need_mb
    free_mb="$(df --output=avail -m "$SHARE_DIR" | tail -1 | tr -d ' ')"
    need_mb=$(( DATA_FILE_MB * SEQ_STREAMS + 1024 ))
    if (( free_mb < need_mb )); then
        die "Only $free_mb MB free in $SHARE_DIR; the tests need about $need_mb MB.
       Lower DATA_FILE_MB or choose another SHARE_DIR in settings.conf."
    fi
    log "Shared folder: $SHARE_DIR ($(df --output=fstype "$SHARE_DIR" | tail -1), $free_mb MB free)"
}


# ----------------------------------------------------------------------------
#  Step 5 - SELinux
# ----------------------------------------------------------------------------
#  With SELinux, smbd may only
#    - listen on ports labelled smbd_port_t (445, 137-139 by default), so
#      port 4450 gets that label too, and
#    - share folders labelled samba_share_t.
#  The kit notes what it changed in kit.state, so "cleanup" can undo it.
setup_selinux() {
    if ! selinux_is_on; then
        log "SELinux is disabled - nothing to set up"
        return
    fi

    local port_type
    port_type="$(semanage port -l | awk -v port="$SAMBA_PORT" \
        '$2 == "tcp" { for (i = 3; i <= NF; i++) { gsub(",", "", $i); if ($i == port) print $1 } }')"
    if [[ -z $port_type ]]; then
        log "SELinux: labelling TCP port $SAMBA_PORT as smbd_port_t (takes a few seconds)"
        semanage port -a -t smbd_port_t -p tcp "$SAMBA_PORT"
        echo "selinux_port_added=yes" >> "$KIT_STATE_FILE"
    elif [[ $port_type != smbd_port_t ]]; then
        die "SELinux: port $SAMBA_PORT is already labelled $port_type.
       Choose another SAMBA_PORT in settings.conf."
    fi

    # Label the shared folder and the kit's Samba folders. chcon changes
    # only these folders (the label is lost after a full relabel of the
    # system; start-samba.sh sets it again).
    chcon -R -t samba_share_t "$SHARE_DIR"
    restorecon -R "$CONF_DIR" "$DATA_BASE_DIR" "$RUN_DIR" "$LOG_DIR"
    [[ -e $UNIT_FILE ]] && restorecon "$UNIT_FILE"
    log "SELinux mode: $(getenforce); port $SAMBA_PORT = smbd_port_t; share = samba_share_t"
}


# ----------------------------------------------------------------------------
#  Step 6 - start the test smbd, and forward the normal SMB port to it
# ----------------------------------------------------------------------------
#  Clients connect to 127.0.0.2 on the normal SMB port 445; one nftables
#  rule forwards these connections to the test smbd on 127.0.0.1:4450.
#  This is needed because Samba's client library in Samba 4.21 (RHEL 9.6),
#  and the kernel's SMB client when it reconnects, always use port 445.
#  Connections to other addresses (the host's own Samba) are not touched.
start_server() {
    # Start again every time, so changed settings are used.
    if smbd_is_running; then
        log "Restarting the test smbd (to load the new settings)"
        unmount_test_share
        samba_stop
    else
        log "Starting the test smbd on $SAMBA_ADDRESS:$SAMBA_PORT"
    fi

    if port_is_open; then
        die "Another program already uses $SAMBA_ADDRESS:$SAMBA_PORT:
$(ss -ltnp "sport = :$SAMBA_PORT" | tail -n +2)
       Choose another SAMBA_PORT in settings.conf."
    fi

    samba_start

    log "Forwarding $CONNECT_ADDRESS:445 to $SAMBA_ADDRESS:$SAMBA_PORT (nftables table $NFT_TABLE)"
    add_port_forwarding ||
        die "Could not add the nftables rule. Is the nftables package installed?"

    if ! wait_until_login_works 30; then
        if has_systemd; then
            die "No login to the test smbd within 30 s.
       Look at:  journalctl -u $SERVICE_NAME ;  $LOG_DIR/log.smbd"
        else
            die "No login to the test smbd within 30 s. Look at: $LOG_DIR/log.smbd"
        fi
    fi
    log "Login as $TEST_USER to $SHARE_UNC works"
}


# ----------------------------------------------------------------------------
#  Step 7 - mount the test share (as a client on this same machine)
# ----------------------------------------------------------------------------
mount_test_share() {
    unmount_test_share

    log "Mounting $SHARE_UNC on $MOUNT_POINT"
    if ! mount_share; then
        die "Mounting the test share failed.
       Try it by hand:  mount -v -t cifs -o $(mount_options) $SHARE_UNC $MOUNT_POINT
       and look at:     dmesg | tail"
    fi

    # Write, read and delete one file, to be sure the whole setup works.
    local probe="$MOUNT_POINT/.start-samba-probe"
    if ! { echo "hello" > "$probe" && grep -q hello "$probe" && rm -f "$probe"; }; then
        die "The share is mounted, but a file cannot be written.
       Check that $SHARE_DIR belongs to $TEST_USER, and with SELinux:
       ausearch -m avc -ts recent"
    fi
    log "Test write through SMB works"
}

unmount_test_share() {
    if test_mount_is_mounted; then
        log "Unmounting $MOUNT_POINT"
        unmount_share
    fi
}

print_summary() {
    local selinux
    selinux="$(getenforce 2>/dev/null || echo 'not available')"

    echo
    ok "The test Samba server is running and the test share is mounted."
    echo "    Version        : $(samba_version)"
    echo "    Listens on     : $SAMBA_ADDRESS:$SAMBA_PORT (only this machine can connect)"
    echo "    Clients use    : $CONNECT_ADDRESS:445, forwarded to $SAMBA_ADDRESS:$SAMBA_PORT (nft list table ip $NFT_TABLE)"
    echo "    Share          : $SHARE_UNC  ->  $SHARE_DIR"
    echo "    Test user      : $TEST_USER  (password in $CREDENTIALS_FILE)"
    echo "    Encryption     : $ENCRYPTION    Signing: $SIGNING"
    echo "    Mounted on     : $MOUNT_POINT"
    echo "    Mount options  : $(active_mount_options)"
    echo "    Configuration  : $TEST_CONF"
    echo "    Logs           : $LOG_DIR/"
    echo "    SELinux        : $selinux"
    if has_systemd; then
        echo "    Managed by     : systemd  (systemctl status $SERVICE_NAME)"
    else
        echo "    Managed by     : this script (no systemd, e.g. inside a container)"
    fi
    echo
    echo "    Try it         : ls -l $MOUNT_POINT"
    echo "                     smbclient -s $TEST_CONF -A $CREDENTIALS_FILE $SHARE_UNC"
    echo "    Next step      : ./test-samba.sh"
}


# ----------------------------------------------------------------------------
#  Other actions
# ----------------------------------------------------------------------------
stop_server() {
    unmount_test_share
    samba_stop
    remove_port_forwarding
    ok "The test smbd is stopped. (The host's own Samba, if any, was not touched.)"
}

show_status() {
    if ! smbd_is_running; then
        warn "The test smbd is not running. Run: ./start-samba.sh"
        exit 1
    fi
    if ! login_works; then
        warn "The test smbd runs, but a login does not work. Run: ./start-samba.sh restart"
        exit 1
    fi

    ok "The test smbd is running (main process $(smbd_main_pid)) and a login works"
    echo "    Listens on              : $SAMBA_ADDRESS:$SAMBA_PORT"
    if port_forwarding_is_active; then
        echo "    Port forwarding         : $CONNECT_ADDRESS:445 -> $SAMBA_ADDRESS:$SAMBA_PORT (active)"
    else
        echo "    Port forwarding         : MISSING (run ./start-samba.sh)"
    fi
    echo "    Client connections      : $(client_process_pids | wc -l) (one smbd process each)"
    echo "    Sessions and open files (smbstatus):"
    smbstatus -s "$TEST_CONF" 2>/dev/null | sed 's/^/        /'
    if test_mount_is_mounted; then
        echo "    Test mount              : $MOUNT_POINT"
        echo "    Mount options           : $(active_mount_options)"
    else
        echo "    Test mount              : NOT mounted (run ./start-samba.sh)"
    fi
    echo "    Test data on the share  : $(du -sh "$SHARE_DIR" 2>/dev/null | cut -f1)"
}

# Delete the test data files (they are created again by the next test run).
reset_test_data() {
    log "Deleting the test data in $SHARE_DIR"
    find "$SHARE_DIR" -mindepth 1 -delete 2>/dev/null || true
    ok "Test data deleted."
}

cleanup_test_setup() {
    unmount_test_share
    rmdir "$MOUNT_POINT" 2>/dev/null || true

    log "Stopping and removing the test smbd and the port forwarding"
    samba_stop
    remove_port_forwarding
    if has_systemd && [[ -e $UNIT_FILE ]]; then
        rm -f "$UNIT_FILE"
        systemctl daemon-reload
    fi

    # Undo the SELinux port label, but only if the kit added it.
    if grep -q '^selinux_port_added=yes' "$KIT_STATE_FILE" 2>/dev/null; then
        log "SELinux: removing the smbd_port_t label from TCP port $SAMBA_PORT"
        semanage port -d -t smbd_port_t -p tcp "$SAMBA_PORT" || true
    fi

    log "Removing the configuration, databases, logs and the shared folder"
    rm -rf "$CONF_DIR" "$DATA_BASE_DIR" "$RUN_DIR" "$LOG_DIR"
    find "$SHARE_DIR" -mindepth 1 -delete 2>/dev/null || true
    rmdir "$SHARE_DIR" 2>/dev/null || true       # stays if it is a mount point

    if id "$TEST_USER" >/dev/null 2>&1; then
        log "Removing the system user $TEST_USER"
        userdel "$TEST_USER" 2>/dev/null || true
    fi

    ok "Cleanup finished. (The RPMs stay installed; remove them with: dnf remove fio cifs-utils python3-samba)"
}


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

    # Every action except "start" works on the server as it was started
    # (its port, share and options), not on today's settings.conf.
    [[ $action != start ]] && read_settings_of_running_server

    case "$action" in
        start)
            check_operating_system
            install_packages_offline
            write_server_config
            create_user_and_share
            setup_selinux
            start_server
            mount_test_share
            print_summary
            ;;
        stop)
            stop_server
            ;;
        restart)
            stop_server
            "$0" start
            ;;
        status)
            show_status
            ;;
        reset)
            reset_test_data
            ;;
        cleanup)
            cleanup_test_setup
            ;;
        *)
            echo "Usage: $0 [start|stop|restart|status|reset|cleanup]"
            exit 2
            ;;
    esac
}

main "$@"
