diff --git a/install.sh b/install.sh index dd67e6c09..4ea1ea82a 100644 --- a/install.sh +++ b/install.sh @@ -1078,9 +1078,23 @@ prompt_and_setup_ssl() { } config_after_install() { - local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}') - local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##') - local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') + local settings existing_webBasePath + if ! settings=$("${xui_folder}/x-ui" setting -show true) || + ! existing_webBasePath=$(printf '%s\n' "$settings" | awk ' + /^get .* failed, error info:/ { failed = 1 } + /^hasDefaultCredential: (true|false)$/ { credentials++ } + /^port: [0-9]+$/ { ports++ } + /^webBasePath:/ { paths++; sub(/^webBasePath: ?/, ""); path = $0 } + END { + if (failed || credentials != 1 || ports != 1 || paths != 1 || (path != "" && path !~ /^\//)) exit 1 + print path + }'); then + echo -e "${red}Unable to read webBasePath; leaving existing settings unchanged.${plain}" >&2 + return 1 + fi + existing_webBasePath=${existing_webBasePath#/} + local existing_hasDefaultCredential=$(printf '%s\n' "$settings" | awk '/^hasDefaultCredential: / {print $2}') + local existing_port=$(printf '%s\n' "$settings" | awk '/^port: / {print $2}') # Properly detect empty cert by checking if cert: line exists and has content after it local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') local URL_lists=( @@ -1120,7 +1134,7 @@ config_after_install() { fi fi - if [[ ${#existing_webBasePath} -lt 4 ]]; then + if [[ -z "$existing_webBasePath" ]]; then if [[ "$existing_hasDefaultCredential" == "true" ]]; then local config_webBasePath="${XUI_WEB_BASE_PATH:-$(gen_random_string 18)}" local config_username="${XUI_USERNAME:-$(gen_random_string 10)}" @@ -1348,7 +1362,7 @@ EOF "${config_webBasePath}" "${SSL_SCHEME}" "${SSL_HOST}" "${config_apiToken}" "${db_type_out}" else local config_webBasePath=$(gen_random_string 18) - echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}" + echo -e "${yellow}WebBasePath is missing or set to root. Generating a new one...${plain}" ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}" diff --git a/scripts_base_path_test.go b/scripts_base_path_test.go new file mode 100644 index 000000000..54d15fc91 --- /dev/null +++ b/scripts_base_path_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func scriptFunction(t *testing.T, script, name string) string { + t.Helper() + source, err := os.ReadFile(script) + if err != nil { + t.Fatal(err) + } + _, body, ok := strings.Cut("\n"+string(source), "\n"+name+"() {\n") + if !ok { + t.Fatalf("%s: missing function %s", script, name) + } + body, _, ok = strings.Cut(body, "\n}\n") + if !ok { + t.Fatalf("%s: missing closing brace for %s", script, name) + } + return name + "() {\n" + body + "\n}\n" +} + +const settingsCLIMock = `#!/bin/bash +set -eu +state=$TEST_SETTINGS_DIR +if [[ "$1" == migrate ]]; then exit 0; fi +shift +case "$1" in + -show) + case "$TEST_READ_MODE" in + failure) exit 1 ;; + empty) exit 0 ;; + diagnostic) echo 'get webBasePath failed, error info: database unavailable' ;; + esac + echo 'current panel settings as follows:' + echo "hasDefaultCredential: $(cat "$state/default")" + echo "port: $(cat "$state/port")" + case "$TEST_READ_MODE" in + missing_field) ;; + malformed) echo 'webBasePath: not-an-absolute-path' ;; + duplicate) printf 'webBasePath: /\nwebBasePath: /a/\n' ;; + *) + path=$(cat "$state/path") + printf 'webBasePath: %s\n' "${path:-/}" ;; + esac + exit 0 ;; + -getCert) echo 'cert: /tmp/panel.example/fullchain.pem'; exit 0 ;; + -getApiToken) echo 'apiToken: test-token'; exit 0 ;; +esac +printf 'write\n' >> "$state/writes" +while [[ $# -gt 0 ]]; do + key=$1 value=$2 + shift 2 + case "$key" in + -webBasePath) + 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" ;; + *) echo "unexpected setting: $key" >&2; exit 98 ;; + esac +done +if [[ "$(cat "$state/username")" != admin && "$(cat "$state/password")" != admin ]]; then + echo false > "$state/default" +fi +` + +func runScriptConfig(t *testing.T, script, path string, defaults bool, mode string, runs int, continueSetup bool) (string, string, error) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("installer regressions require a POSIX environment; run them under WSL") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is required for the isolated installer regression tests") + } + dir := t.TempDir() + username, password := "custom-user", "custom-password" + if defaults { + username, password = "admin", "admin" + } + for name, value := range map[string]string{ + "path": path, "username": username, "password": password, + "default": fmt.Sprint(defaults), "port": "54321", "x-ui": settingsCLIMock, + "writes": "", "path-writes": "", "restarts": "", "lifecycle": "", "x-ui.service": "test unit", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(value), 0o700); err != nil { + t.Fatal(err) + } + } + function := "config_after_update" + if script == "install.sh" { + function = "config_after_install" + } + driver := `set -u +red= green= yellow= blue= plain= +xui_folder=$TEST_SETTINGS_DIR +xui_service=$TEST_SETTINGS_DIR +tag_version=test +script_ref=main +NONINTERACTIVE=1 +release=ubuntu +SSL_SCHEME=https +SSL_HOST=panel.example +curl() { printf '203.0.113.10\n200\n'; } +systemctl() { printf '%s\n' "$*" >> "$TEST_SETTINGS_DIR/restarts"; } +rc-service() { printf '%s\n' "$*" >> "$TEST_SETTINGS_DIR/restarts"; } +prompt_and_setup_ssl() { printf '%s\n' "$*" > "$TEST_SETTINGS_DIR/ssl"; } +write_install_result() { printf '%s\n' "$@" > "$TEST_SETTINGS_DIR/result"; } +install_postgres_local() { echo 'unexpected PostgreSQL installation' >&2; exit 97; } +chown() { :; } +chmod() { :; } +[() { if [[ "${2:-}" == /etc/* ]]; then return 1; fi; builtin [ "$@"; } +_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 continueSetup { + parent := "update_x-ui" + if script == "install.sh" { + parent = "install_x-ui" + } + body := scriptFunction(t, script, parent) + start := strings.Index(body, "\n "+function) + if start < 0 { + t.Fatalf("%s: missing configuration call in %s", script, parent) + } + driver += "finish_setup() {" + body[start:] + "finish_setup\n" + } else { + driver += fmt.Sprintf("for ((i=0; i<%d; i++)); do %s || exit $?; done\n", runs, function) + } + cmd := exec.Command(bash, "-c", driver) + cmd.Dir = dir + for _, value := range os.Environ() { + if !strings.HasPrefix(value, "XUI_") && !strings.HasPrefix(value, "TEST_") { + cmd.Env = append(cmd.Env, value) + } + } + cmd.Env = append(cmd.Env, "TEST_SETTINGS_DIR="+dir, "TEST_READ_MODE="+mode, + "XUI_DB_TYPE=sqlite", "XUI_PANEL_PORT=41234") + out, err := cmd.CombinedOutput() + return dir, string(out), err +} + +func readScriptState(t *testing.T, dir, name string) string { + t.Helper() + value, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + return string(value) +} + +func TestScriptConfigPreservesCustomBasePaths(t *testing.T) { + for _, script := range []string{"install.sh", "update.sh"} { + for _, path := range []string{"/", "", "/a/", "/ab/", "/abc/", "/mypanel/", "/failed/", "/my panel/"} { + for _, defaults := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/path=%q/defaults=%t", script, path, defaults), func(t *testing.T) { + dir, out, err := runScriptConfig(t, script, path, defaults, "", 2, false) + if err != nil { + t.Fatalf("configure twice: %v\n%s", err, out) + } + actualPath := readScriptState(t, dir, "path") + initialize := path == "" || path == "/" + if initialize { + if !regexp.MustCompile(`^/[A-Za-z0-9]{18}/$`).MatchString(actualPath) { + t.Errorf("initialized path = %q, want secure 18-character path", actualPath) + } + if n := strings.Count(readScriptState(t, dir, "path-writes"), "\n"); n != 1 { + t.Errorf("path initialized %d times across two runs, want once", n) + } + } else if actualPath != path || readScriptState(t, dir, "path-writes") != "" { + t.Errorf("custom path %q became %q; writes=%q", path, actualPath, readScriptState(t, dir, "path-writes")) + } + for _, field := range []string{"username", "password"} { + actual := readScriptState(t, dir, field) + if defaults && script == "install.sh" { + if !regexp.MustCompile(`^[A-Za-z0-9]{10}$`).MatchString(actual) || actual == "admin" { + t.Errorf("%s was not securely initialized: %q", field, actual) + } + } else { + want := "admin" + if !defaults { + want = "custom-" + field + if field == "username" { + want = "custom-user" + } + } + if actual != want { + t.Errorf("%s = %q, want preserved %q", field, actual, want) + } + } + } + wantRestarts := "" + if initialize && script == "update.sh" { + wantRestarts = "restart x-ui\n" + } + if actual := readScriptState(t, dir, "restarts"); actual != wantRestarts { + t.Errorf("path-related restarts = %q, want %q", actual, wantRestarts) + } + wantWrites := 0 + if initialize || (defaults && script == "install.sh") { + wantWrites = 1 + } + if n := strings.Count(readScriptState(t, dir, "writes"), "\n"); n != wantWrites { + t.Errorf("configuration writes = %d, want %d across two runs", n, wantWrites) + } + wantPort := "54321" + if initialize && defaults && script == "install.sh" { + wantPort = "41234" + } + if actual := readScriptState(t, dir, "port"); actual != wantPort { + t.Errorf("port = %q, want %q", actual, wantPort) + } + if !initialize { + if _, err := os.Stat(filepath.Join(dir, "ssl")); !os.IsNotExist(err) { + t.Errorf("configured TLS should not be reinitialized: %v", err) + } + } + }) + } + } + } +} + +func TestScriptConfigRejectsUnreadableSettings(t *testing.T) { + for _, script := range []string{"install.sh", "update.sh"} { + for _, mode := range []string{"failure", "empty", "missing_field", "malformed", "duplicate", "diagnostic"} { + t.Run(script+"/"+mode, func(t *testing.T) { + dir, out, err := runScriptConfig(t, script, "/", true, mode, 1, false) + if err == nil || !strings.Contains(out, "Unable to read webBasePath") { + t.Errorf("settings read should stop with an actionable error; got %v\n%s", err, out) + } + for name, want := range map[string]string{"path": "/", "username": "admin", "password": "admin", "writes": "", "restarts": ""} { + if actual := readScriptState(t, dir, name); actual != want { + t.Errorf("failed settings read changed %s to %q, want %q", name, actual, want) + } + } + }) + } + } +} + +func TestScriptConfigReadFailureKeepsServiceSetup(t *testing.T) { + for _, script := range []string{"install.sh", "update.sh"} { + t.Run(script, func(t *testing.T) { + dir, out, err := runScriptConfig(t, script, "/a/", true, "failure", 1, true) + if err != nil || !strings.Contains(out, "Unable to read webBasePath") { + t.Errorf("service setup should continue after the settings warning: %v\n%s", err, out) + } + wantLifecycle, wantServices := "fail2ban\n", "" + if script == "install.sh" { + wantLifecycle = "unit\nfail2ban\n" + wantServices = "daemon-reload\nenable x-ui\nstart x-ui\n" + } + for name, want := range map[string]string{ + "path": "/a/", "username": "admin", "password": "admin", "writes": "", + "lifecycle": wantLifecycle, "restarts": wantServices, + } { + if actual := readScriptState(t, dir, name); actual != want { + t.Errorf("%s = %q, want %q", name, actual, want) + } + } + }) + } +} diff --git a/update.sh b/update.sh index a4377a719..ae1781cbb 100755 --- a/update.sh +++ b/update.sh @@ -176,8 +176,22 @@ config_after_update() { # Properly detect empty cert by checking if cert: line exists and has content after it local existing_cert=$(${xui_folder}/x-ui setting -getCert true 2> /dev/null | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]') - local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') - local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##') + local settings existing_webBasePath + if ! settings=$("${xui_folder}/x-ui" setting -show true) || + ! existing_webBasePath=$(printf '%s\n' "$settings" | awk ' + /^get .* failed, error info:/ { failed = 1 } + /^hasDefaultCredential: (true|false)$/ { credentials++ } + /^port: [0-9]+$/ { ports++ } + /^webBasePath:/ { paths++; sub(/^webBasePath: ?/, ""); path = $0 } + END { + if (failed || credentials != 1 || ports != 1 || paths != 1 || (path != "" && path !~ /^\//)) exit 1 + print path + }'); then + echo -e "${red}Unable to read webBasePath; leaving existing settings unchanged.${plain}" >&2 + return 1 + fi + existing_webBasePath=${existing_webBasePath#/} + local existing_port=$(printf '%s\n' "$settings" | awk '/^port: / {print $2}') # Get server IP local URL_lists=( @@ -205,9 +219,9 @@ config_after_update() { server_ip="${server_ip:-}" fi - # Handle missing/short webBasePath - if [[ ${#existing_webBasePath} -lt 4 ]]; then - echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}" + # Only an unset or default root path needs initialization. + if [[ -z "$existing_webBasePath" ]]; then + echo -e "${yellow}WebBasePath is missing or set to root. Generating a new one...${plain}" local config_webBasePath=$(gen_random_string 18) ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}" existing_webBasePath="${config_webBasePath}"