From 728c2fb94f5f3c67d330353fa86d47a69df3bee9 Mon Sep 17 00:00:00 2001 From: Hamed Date: Tue, 3 Nov 2026 18:22:14 +0330 Subject: [PATCH] fix(install): handle yes responses and validate panel ports (#6992) The panel-port prompt accepted only y/Y, so yes picked a random port; a custom port was never validated (empty, non-numeric, out-of-range, busy, or leading-zero octal), is_port_in_use never detected a listener, and the installer printed success even when applying the settings failed. Answers are now case-insensitive, custom ports are normalized and checked, the port check is fixed, and the install aborts unless the CLI reports every setting applied. A bash suite runs in shell-lint. --- .github/workflows/ci.yml | 8 +- deploy/test/installer-panel-port.sh | 304 ++++++++++++++++++++++++++++ install.sh | 104 ++++++++-- scripts_base_path_test.go | 16 +- 4 files changed, 407 insertions(+), 25 deletions(-) create mode 100644 deploy/test/installer-panel-port.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e4e129ac..c2d4a6deb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,8 @@ on: - "frontend/**" - ".nvmrc" - "Makefile" + - "install.sh" + - "deploy/test/installer-panel-port.sh" - ".github/workflows/ci.yml" push: branches: @@ -20,6 +22,8 @@ on: - "frontend/**" - ".nvmrc" - "Makefile" + - "install.sh" + - "deploy/test/installer-panel-port.sh" - ".github/workflows/ci.yml" permissions: @@ -323,8 +327,10 @@ jobs: run: sudo apt-get update && sudo apt-get install -y shellcheck - name: shellcheck run: | - shellcheck --severity=style DockerInit.sh DockerEntrypoint.sh deploy/test/smoke-noninteractive.sh + shellcheck --severity=style DockerInit.sh DockerEntrypoint.sh deploy/test/smoke-noninteractive.sh deploy/test/installer-panel-port.sh shellcheck --severity=error install.sh update.sh x-ui.sh + - name: Installer panel port regression tests + run: bash deploy/test/installer-panel-port.sh frontend: runs-on: ubuntu-latest diff --git a/deploy/test/installer-panel-port.sh b/deploy/test/installer-panel-port.sh new file mode 100644 index 000000000..ba4b96132 --- /dev/null +++ b/deploy/test/installer-panel-port.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash +# Exercise only installer function definitions, never its installation entry point. +set -euo pipefail + +repo_root=$(cd "$(dirname "$0")/../.." && pwd) +installer=${INSTALLER_UNDER_TEST:-"$repo_root/install.sh"} +test_dir=$(mktemp -d) +trap 'rm -rf -- "$test_dir"' EXIT + +load_function() { + local definition + definition=$(awk -v name="$1" ' + $0 == name "() {" {copy = 1} + copy {print} + copy && /^}$/ {exit} + ' "$installer") + [[ -n "$definition" ]] || { echo "Missing installer function: $1" >&2; return 1; } + eval "$definition" +} + +load_function is_port_in_use +load_function config_after_install +# The original installer has no selection helpers; its integration tests still run. +if grep -q '^select_panel_port() {' "$installer"; then + load_function normalize_panel_port + load_function select_panel_port +fi + +# These commands are invoked by the extracted installer functions in a subshell. +# shellcheck disable=SC2317,SC2329 +curl() { printf '192.0.2.1\n200\n'; } +# shellcheck disable=SC2317,SC2329 +gen_random_string() { printf 'test-credential-%s\n' "$1"; } +# shellcheck disable=SC2317,SC2329 +prompt_and_setup_ssl() { + echo ssl >> "$CASE_DIR/events" + export SSL_SCHEME=http SSL_HOST=192.0.2.1 +} +# shellcheck disable=SC2317,SC2329 +write_install_result() { printf '%s\n' "$@" > "$CASE_DIR/result"; } +# shellcheck disable=SC2317,SC2329 +read() { + echo read >> "$CASE_DIR/events" + [[ "$NONINTERACTIVE" != 1 ]] || return 90 + builtin read -r "$@" +} +# shellcheck disable=SC2317,SC2329 +shuf() { + echo random >> "$CASE_DIR/events" + local count=0 + [[ ! -f "$CASE_DIR/random-count" ]] || count=$(cat "$CASE_DIR/random-count") + echo "$((count + 1))" > "$CASE_DIR/random-count" + [[ "${SHUF_FAIL:-0}" == 0 ]] || return 1 + if ((count > 0)) && [[ -n "${RANDOM_NEXT:-}" ]]; then + printf '%s\n' "$RANDOM_NEXT" + else + printf '54321\n' + fi +} +# Select the availability backend deterministically, regardless of host packages. +# shellcheck disable=SC2317,SC2329 +command() { + if [[ "${1:-}" == -v && "${2:-}" =~ ^(ss|netstat|lsof)$ ]]; then + [[ "$2" == "${PORT_BACKEND:-ss}" ]] + else + builtin command "$@" + fi +} +# shellcheck disable=SC2317,SC2329 +ss() { + printf 'State Recv-Q Send-Q Local\ Address:Port Peer\ Address:Port\n' + if [[ -n "${BUSY_PORT:-}" ]]; then + printf 'LISTEN 0 128 0.0.0.0:%s 0.0.0.0:*\n' "$BUSY_PORT" + printf 'LISTEN 0 128 [::]:%s [::]:*\n' "$BUSY_PORT" + fi +} +# shellcheck disable=SC2317,SC2329 +netstat() { + if [[ -n "${BUSY_PORT:-}" ]]; then + printf 'tcp 0 0 0.0.0.0:%s 0.0.0.0:* LISTEN\n' "$BUSY_PORT" + printf 'tcp6 0 0 :::%s :::* LISTEN\n' "$BUSY_PORT" + fi +} +# shellcheck disable=SC2317,SC2329 +lsof() { [[ -n "${BUSY_PORT:-}" && "$*" == *"-iTCP:$BUSY_PORT "* ]]; } + +new_case() { + CASE_DIR=$(mktemp -d "$test_dir/case.XXXXXXXX") + export CASE_DIR + : > "$CASE_DIR/events" + xui_folder="$CASE_DIR/bin" + mkdir -p "$xui_folder" + cat > "$xui_folder/x-ui" <<'MOCK' +#!/usr/bin/env bash +set -eu +if [[ "${1:-}" == migrate ]]; then + echo migrate >> "$CASE_DIR/events" + exit 0 +fi +case "${2:-}" in + -show) printf 'hasDefaultCredential: true\nwebBasePath: /\nport: 2053\n' ;; + -getCert) printf 'cert: \n' ;; + -getApiToken) printf 'apiToken: test-token\n' ;; + -username) + printf '%s\n' "$@" > "$CASE_DIR/settings-args" + case "${SETTING_FAILURE:-}" in + exit) echo 'controlled settings failure' >&2; exit 23 ;; + zero) echo 'Database initialization failed: controlled failure'; exit 0 ;; + esac + while (($#)); do + case "$1" in -port) port=$2 ;; esac + shift + done + printf 'Port set successfully: %s\n' "$port" + [[ "${SETTING_FAILURE:-}" != partial ]] || exit 0 + printf 'Username and password updated successfully\nBase URI path set successfully\n' + ;; + *) echo 'Unexpected configuration command' >&2; exit 1 ;; +esac +MOCK + chmod +x "$xui_folder/x-ui" + export red='' green='' blue='' yellow='' plain='' release=ubuntu + export XUI_USERNAME=test-user XUI_PASSWORD=test-pass XUI_WEB_BASE_PATH=test-path + unset XUI_PANEL_PORT SETTING_FAILURE BUSY_PORT RANDOM_NEXT SHUF_FAIL + PORT_BACKEND=ss + NONINTERACTIVE=0 +} + +run_config() { + local input=$1 + # The initial newline selects the existing SQLite default. All later input + # belongs to the port prompts; SSL and persistence are side-effect-free mocks. + printf '\n%s' "$input" > "$CASE_DIR/input" + status=0 + (config_after_install) < "$CASE_DIR/input" > "$CASE_DIR/output" 2>&1 || status=$? +} + +expect_port() { + [[ "$status" == 0 ]] || { cat "$CASE_DIR/output"; return 1; } + local actual + actual=$(awk 'last == "-port" {print; exit} {last = $0}' "$CASE_DIR/settings-args") + [[ "$actual" == "$1" ]] || { echo "Port $actual, expected $1"; return 1; } + grep -Fxq 'Panel Installation Complete!' < <(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' "$CASE_DIR/output") + [[ -f "$CASE_DIR/result" ]] || return 1 + [[ $(sed -n '3p' "$CASE_DIR/result") == "$1" ]] || return 1 + [[ $(sed -n '1p' "$CASE_DIR/result") == test-user ]] || return 1 + [[ $(sed -n '2p' "$CASE_DIR/result") == test-pass ]] || return 1 + [[ $(sed -n '4p' "$CASE_DIR/result") == test-path ]] || return 1 +} + +expect_failure() { + [[ "$status" != 0 ]] || { echo 'Configuration unexpectedly succeeded'; return 1; } + [[ ! -f "$CASE_DIR/result" ]] || return 1 + ! grep -q 'Panel Installation Complete!\|SSL Certificate Setup\|^ssl$' "$CASE_DIR/output" "$CASE_DIR/events" +} + +affirmative() { + new_case + run_config "$1"$'\n2053\n' + expect_port 2053 + ! grep -q '^random$' "$CASE_DIR/events" +} +negative() { + new_case + run_config "$1"$'\n' + expect_port 54321 + [[ $(grep -c '^random$' "$CASE_DIR/events") == 1 ]] +} +invalid_port() { + new_case + run_config $'y\n'"$1"$'\n2053\n' + expect_port 2053 + grep -q 'Invalid port' "$CASE_DIR/output" + ! grep -q '^random$' "$CASE_DIR/events" +} +valid_port() { + new_case + run_config $'y\n'"$1"$'\n' + expect_port "$2" +} +confirmation_retry() { + new_case + run_config $'maybe\nyes please\nYES\n2053\n' + expect_port 2053 + grep -q 'Please answer yes or no' "$CASE_DIR/output" + ! grep -q '^random$' "$CASE_DIR/events" +} +busy_port() { + new_case + BUSY_PORT=2053 + PORT_BACKEND=$1 + run_config $'y\n2053\n2054\n' + expect_port 2054 + grep -q 'already in use' "$CASE_DIR/output" +} +random_retry() { + new_case + BUSY_PORT=54321 RANDOM_NEXT=54322 + run_config $'no\n' + expect_port 54322 + [[ $(grep -c '^random$' "$CASE_DIR/events") == 2 ]] +} +random_failure() { + new_case + SHUF_FAIL=1 + run_config $'no\n' + expect_failure + [[ ! -f "$CASE_DIR/settings-args" ]] +} +random_exhaustion() { + new_case + BUSY_PORT=54321 + run_config $'no\n' + expect_failure + [[ ! -f "$CASE_DIR/settings-args" ]] + [[ $(grep -c '^random$' "$CASE_DIR/events") == 100 ]] +} +noninteractive() { + new_case + NONINTERACTIVE=1 + case "$1" in unset) ;; *) export XUI_PANEL_PORT=$1 ;; esac + run_config '' + expect_port "$2" + ! grep -q '^read$' "$CASE_DIR/events" +} +noninteractive_invalid() { + new_case + NONINTERACTIVE=1 + export XUI_PANEL_PORT=$1 + run_config '' + expect_failure + [[ ! -f "$CASE_DIR/settings-args" ]] + ! grep -q '^read$\|^random$' "$CASE_DIR/events" +} +noninteractive_busy() { + new_case + NONINTERACTIVE=1 BUSY_PORT=2053 + export XUI_PANEL_PORT=2053 + run_config '' + expect_failure + [[ ! -f "$CASE_DIR/settings-args" ]] + ! grep -q '^read$\|^random$' "$CASE_DIR/events" +} +settings_failure() { + new_case + export SETTING_FAILURE=$1 + run_config $'y\n2053\n' + expect_failure + [[ -f "$CASE_DIR/settings-args" ]] + grep -q 'Failed to configure panel settings' "$CASE_DIR/output" +} +input_eof() { + new_case + run_config "$1" + expect_failure + [[ ! -f "$CASE_DIR/settings-args" ]] +} + +passed=0 failed=0 +check() { + local name=$1 + shift + # Keep the test body out of an if/|| condition: those disable errexit + # throughout called functions and can hide a failed intermediate assertion. + set +e + (set -e; "$@") + local result=$? + set -e + if [[ "$result" == 0 ]]; then + printf 'PASS %s\n' "$name" + passed=$((passed + 1)) + else + printf 'FAIL %s\n' "$name" >&2 + failed=$((failed + 1)) + fi +} + +for answer in y Y yes Yes YES ' yEs ' $'\tYES\t'; do check "affirmative [$answer]" affirmative "$answer"; done +for answer in n N no No NO '' ' nO ' $' \t \r '; do check "negative [$answer]" negative "$answer"; done +check 'invalid confirmation retries' confirmation_retry +for port in '' abc 0 -1 65536 00065536 70000 1.5 +2053 '20 53' 0x805 999999999999999999999999 00000 '1+2' 'a[0]'; do + check "invalid port [$port]" invalid_port "$port" +done +for port in 1 2053 65535; do check "valid port [$port]" valid_port "$port" "$port"; done +check 'leading zeros use decimal' valid_port 00002053 2053 +check 'leading zeros containing eight use decimal' valid_port 008080 8080 +check 'leading-zero boundary uses decimal' valid_port 065535 65535 +for backend in ss netstat lsof; do check "busy port via $backend" busy_port "$backend"; done +check 'busy random port retried' random_retry +check 'random generator failure' random_failure +check 'random search exhaustion' random_exhaustion +check 'noninteractive supplied port' noninteractive 2053 2053 +check 'noninteractive decimal normalization' noninteractive 00002053 2053 +check 'noninteractive random default' noninteractive unset 54321 +for port in '' abc 0 -1 65536 1.5 999999999999999999999; do + check "noninteractive invalid [$port]" noninteractive_invalid "$port" +done +check 'noninteractive busy port fails' noninteractive_busy +for failure in exit zero partial; do check "settings failure [$failure]" settings_failure "$failure"; done +check 'EOF at confirmation' input_eof '' +check 'EOF at custom port' input_eof $'y\n' + +printf '%s passed, %s failed\n' "$passed" "$failed" +[[ "$failed" == 0 ]] diff --git a/install.sh b/install.sh index 4ea1ea82a..4d7c50e88 100644 --- a/install.sh +++ b/install.sh @@ -80,11 +80,11 @@ acme_listen_flag() { is_port_in_use() { local port="$1" if command -v ss > /dev/null 2>&1; then - ss -ltn 2> /dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}' + ss -ltn 2> /dev/null | awk -v p=":${port}$" '$4 ~ p {found = 1} END {exit !found}' return fi if command -v netstat > /dev/null 2>&1; then - netstat -lnt 2> /dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}' + netstat -lnt 2> /dev/null | awk -v p=":${port}$" '$4 ~ p {found = 1} END {exit !found}' return fi if command -v lsof > /dev/null 2>&1; then @@ -93,6 +93,74 @@ is_port_in_use() { return 1 } +normalize_panel_port() { + local port="$1" + [[ "$port" =~ ^[0-9]+$ ]] || return 1 + # Strip leading zeros before bounding the length and doing arithmetic: + # Bash and the Go flag parser otherwise interpret leading zeros as octal. + port="${port#"${port%%[!0]*}"}" + [[ -n "$port" && ${#port} -le 5 ]] || return 1 + ((10#$port <= 65535)) || return 1 + printf '%s\n' "$port" +} + +# Print only the selected decimal port to stdout; prompts/notices use stderr. +select_panel_port() { + local confirm=n port attempt + if [[ "$NONINTERACTIVE" == 1 ]]; then + [[ -z "${XUI_PANEL_PORT+x}" ]] || confirm=y + else + while true; do + if ! IFS= read -rp "Would you like to customize the Panel Port settings? (If not, a random port will be applied) [y/N]: " confirm; then + echo 'Cannot read the panel port confirmation; aborting.' >&2 + return 1 + fi + confirm="${confirm#"${confirm%%[![:space:]]*}"}" + confirm="${confirm%"${confirm##*[![:space:]]}"}" + case "${confirm,,}" in + y | yes) break ;; + n | no | '') confirm=n; break ;; + *) echo 'Please answer yes or no (Enter defaults to No).' >&2 ;; + esac + done + fi + + if [[ "$confirm" == n ]]; then + for ((attempt = 0; attempt < 100; attempt++)); do + if ! port=$(shuf -i 1024-62000 -n 1); then + echo 'Failed to generate a random panel port.' >&2 + return 1 + fi + if port=$(normalize_panel_port "$port") && ! is_port_in_use "$port"; then + echo "Generated random port: $port" >&2 + printf '%s\n' "$port" + return 0 + fi + done + echo 'Could not find an available random panel port; aborting.' >&2 + return 1 + fi + + while true; do + if [[ "$NONINTERACTIVE" == 1 ]]; then + port="$XUI_PANEL_PORT" + elif ! read -rp 'Please set up the panel port: ' port; then + echo 'Cannot read the custom panel port; aborting.' >&2 + return 1 + fi + if ! port=$(normalize_panel_port "$port"); then + echo 'Invalid port. Enter a number between 1 and 65535.' >&2 + elif is_port_in_use "$port"; then + echo "Port $port is already in use. Choose another panel port." >&2 + else + echo "Your Panel Port is: $port" >&2 + printf '%s\n' "$port" + return 0 + fi + [[ "$NONINTERACTIVE" != 1 ]] || return 1 + done +} + install_base() { case "${release}" in ubuntu | debian | armbian) @@ -1269,26 +1337,20 @@ EOF fi fi - if [[ "$NONINTERACTIVE" == "1" ]]; then - if [[ -n "${XUI_PANEL_PORT:-}" ]]; then - config_port="${XUI_PANEL_PORT}" - echo -e "${yellow}Your Panel Port is: ${config_port}${plain}" - else - config_port=$(shuf -i 1024-62000 -n 1) - echo -e "${yellow}Generated random port: ${config_port}${plain}" - fi - else - read -rp "Would you like to customize the Panel Port settings? (If not, a random port will be applied) [y/n]: " config_confirm - if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then - read -rp "Please set up the panel port: " config_port - echo -e "${yellow}Your Panel Port is: ${config_port}${plain}" - else - config_port=$(shuf -i 1024-62000 -n 1) - echo -e "${yellow}Generated random port: ${config_port}${plain}" - fi - fi + config_port=$(select_panel_port) || exit 1 - ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}" + local settings_output settings_status=0 + settings_output=$("${xui_folder}/x-ui" setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}" 2>&1) || settings_status=$? + printf '%s\n' "$settings_output" + # Released CLIs may print a settings/database error and still exit 0. + # Require all three existing success messages before claiming success. + if [[ "$settings_status" != 0 ]] || + ! grep -Fxq "Port set successfully: ${config_port}" <<< "$settings_output" || + ! grep -Fxq 'Username and password updated successfully' <<< "$settings_output" || + ! grep -Fxq 'Base URI path set successfully' <<< "$settings_output"; then + echo 'Failed to configure panel settings; installation aborted.' >&2 + exit 1 + fi echo "" echo -e "${green}═══════════════════════════════════════════${plain}" diff --git a/scripts_base_path_test.go b/scripts_base_path_test.go index 54d15fc91..a5d6df4d2 100644 --- a/scripts_base_path_test.go +++ b/scripts_base_path_test.go @@ -56,24 +56,29 @@ case "$1" in -getApiToken) echo 'apiToken: test-token'; exit 0 ;; esac printf 'write\n' >> "$state/writes" +said_port= said_user= said_path= while [[ $# -gt 0 ]]; do key=$1 value=$2 shift 2 case "$key" in -webBasePath) + said_path=1 printf '%s\n' "$value" >> "$state/path-writes" [[ "$value" == /* ]] || value="/$value" [[ "$value" == */ ]] || value="$value/" printf '%s' "$value" > "$state/path" ;; - -username) printf '%s' "$value" > "$state/username" ;; - -password) printf '%s' "$value" > "$state/password" ;; - -port) printf '%s' "$value" > "$state/port" ;; + -username) said_user=1; printf '%s' "$value" > "$state/username" ;; + -password) said_user=1; printf '%s' "$value" > "$state/password" ;; + -port) said_port=$value; printf '%s' "$value" > "$state/port" ;; *) echo "unexpected setting: $key" >&2; exit 98 ;; esac done if [[ "$(cat "$state/username")" != admin && "$(cat "$state/password")" != admin ]]; then echo false > "$state/default" fi +[[ -z "$said_port" ]] || echo "Port set successfully: $said_port" +[[ -z "$said_user" ]] || echo 'Username and password updated successfully' +[[ -z "$said_path" ]] || echo 'Base URI path set successfully' ` func runScriptConfig(t *testing.T, script, path string, defaults bool, mode string, runs int, continueSetup bool) (string, string, error) { @@ -126,6 +131,11 @@ _install_xui_service_unit() { printf 'unit\n' >> "$TEST_SETTINGS_DIR/lifecycle"; setup_fail2ban() { printf 'fail2ban\n' >> "$TEST_SETTINGS_DIR/lifecycle"; } ` driver += scriptFunction(t, script, "gen_random_string") + scriptFunction(t, script, function) + if script == "install.sh" { + for _, helper := range []string{"is_port_in_use", "normalize_panel_port", "select_panel_port"} { + driver += scriptFunction(t, script, helper) + } + } if continueSetup { parent := "update_x-ui" if script == "install.sh" {